From 906578df394423e65f8de0ad190464290ff42d54 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 10 Nov 2026 10:52:27 -0800 Subject: [PATCH 01/86] test(integration): recognize the model list's lock in the held write helper (#50974) The registry lock fix (#50933) makes add_deployment and set_model_list wait for a held delete on the same lock, taken inside HostedModelList as self._lock. The helper only knew lines spelled with self._deployment_index_lock, so it never saw those two writes reach the lock and timed out after 10 seconds. --- ...est_router_concurrent_deployment_delete_sdk.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/integration/sdk/test_router_concurrent_deployment_delete_sdk.py b/tests/integration/sdk/test_router_concurrent_deployment_delete_sdk.py index 9f8aa966335..4cc6aca5a03 100644 --- a/tests/integration/sdk/test_router_concurrent_deployment_delete_sdk.py +++ b/tests/integration/sdk/test_router_concurrent_deployment_delete_sdk.py @@ -39,15 +39,16 @@ def _router_source_files() -> frozenset[str]: return frozenset({_HAND_CHANGES.co_filename, *(path for path in facade_files if path is not None)}) -def _lock_lines(path: str) -> frozenset[int]: - return frozenset( - number - for number, line in enumerate(linecache.getlines(path), start=1) - if "with self._deployment_index_lock" in line - ) +def _lock_lines(path: str, statement: str) -> frozenset[int]: + return frozenset(number for number, line in enumerate(linecache.getlines(path), start=1) if statement in line) -_INDEX_LOCK_LINES: Final = MappingProxyType({path: _lock_lines(path) for path in _router_source_files()}) +_INDEX_LOCK_LINES: Final = MappingProxyType( + { + **{path: _lock_lines(path, "with self._deployment_index_lock") for path in _router_source_files()}, + _LIST_REFRESH.co_filename: _lock_lines(_LIST_REFRESH.co_filename, "with self._lock"), + } +) T = TypeVar("T") From 59a3e75368a6923076bd728bb13ee68b22f9b29f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:53:04 +0000 Subject: [PATCH 02/86] test(anthropic): scope structured output captures to inference routes (#50983) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../providers/test_vertex_anthropic_structured_output_wire.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py index edecdf9e785..c0964478da6 100644 --- a/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py +++ b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py @@ -605,6 +605,7 @@ def _delivered(wire: Wire) -> tuple[_Delivery, ...]: return tuple( _Delivery(request.target, request.headers.get("anthropic-beta"), _JSON_OBJECT.validate_json(request.body)) for request in wire.drain() + if _scripted_target(request) ) From 9ba551b871691bb7f9aa3eb59310ac5eb60b5ac0 Mon Sep 17 00:00:00 2001 From: mrinal-berri Date: Tue, 10 Nov 2026 11:06:17 -0800 Subject: [PATCH 03/86] feat(proxy): let teams use their own allowed S3 buckets for batch files (#50571) * feat(proxy): let teams send allowed S3 buckets for batch files and read them back A team's metadata can now list allowed_s3_buckets. Keys on that team may send one of those buckets as s3_bucket_name on POST /v1/files and as s3_output_bucket_name on POST /v1/batches, and the upload and batch-create handlers check the bucket against the team list plus general_settings.allowed_client_side_s3_buckets. The early auth body check still rejects bucket fields on every other route and method Reads of managed files now trust the bucket LiteLLM recorded in the stored s3:// uri through an internal stored_s3_bucket_name param, so content, retrieve, delete and the batch cost job can read files written to a request-chosen bucket. Callers can never send that param, and files in the model bucket resolve exactly as before * test(proxy): assert outcomes in team bucket tests and type the managed content read Gives the admit-only tests a real assertion, has the refresh test check what the caller gets back, and narrows the provider file id before reading its bucket so the type gate stays at its base count * fix(proxy): keep team S3 bucket lists proxy-admin only and read prefixed request buckets back A team admin allowed to edit team metadata could add any bucket to the team's allowed_s3_buckets, so the key now sits with the other proxy-controlled team metadata keys. The stored bucket for a managed S3 file now keeps any prefix that comes before LiteLLM's own path, so a file uploaded to a request bucket like team-bucket/tenant reads, refreshes and deletes from that prefix * fix(proxy): keep the request's approved S3 bucket when merging deployment credentials Model-routed uploads and batch creates merge the deployment's credentials into the request, and that merge let a deployment's s3_bucket_name or s3_output_bucket_name replace the bucket the request had already been approved for, so Bedrock wrote to the deployment bucket. The merge now keeps any bucket the request carries and fills it from the deployment only when the request has none * fix(proxy): keep the request bucket only on upload and batch create, not on reads The previous commit let a request's S3 bucket win over the deployment's on every credential merge, which undid #47496 on reads, where the deployment's bucket bounds which buckets a model-routed file id may point at. Deployment credentials win again by default, and only model-routed and provider-only upload and batch create opt in to keep the bucket the request was approved for * fix(proxy): only proxy admins can set a team's allowed_s3_buckets Org admins and users allowed to create teams could put any bucket on a team's allowed_s3_buckets through /team/new or a team update, which grants that team the proxy's AWS access to the bucket. The key now goes through the same proxy-admin-only metadata check as the batch limits, so only a proxy admin can add, change or remove it, and resending the stored value still passes --- .../proxy/common_utils/check_batch_cost.py | 3 +- .../proxy/hooks/managed_files.py | 22 +- litellm/batches/batch_utils.py | 3 +- .../cloud_storage_security.py | 22 ++ .../litellm_core_utils/get_litellm_params.py | 1 + litellm/llms/bedrock/files/transformation.py | 13 +- litellm/proxy/auth/auth_utils.py | 115 ++++++++--- litellm/proxy/batches_endpoints/endpoints.py | 13 ++ .../team_admin_field_permissions.py | 2 + .../openai_files_endpoints/common_utils.py | 24 ++- .../openai_files_endpoints/files_endpoints.py | 12 ++ tests/unit/batches/test_batch_utils.py | 22 ++ .../proxy/hooks/test_managed_files.py | 119 +++++++++++ .../test_bedrock_files_transformation.py | 82 ++++++++ tests/unit/proxy/auth/test_auth_utils.py | 190 +++++++++++++++++- .../proxy/batches_endpoints/test_endpoints.py | 83 ++++++++ .../common_utils/test_check_batch_cost.py | 20 +- .../test_team_admin_field_permissions.py | 5 +- .../test_team_endpoints.py | 61 ++++++ .../test_files_common_utils.py | 17 ++ .../test_files_endpoint.py | 39 ++++ .../test_endpoints_characterization_batch.py | 26 +++ 22 files changed, 850 insertions(+), 44 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 4cfc6d4e081..abc2487a4a5 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -20,6 +20,7 @@ from litellm_enterprise.proxy.common_utils.managed_object_attribution import ( import litellm from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid +from litellm.litellm_core_utils.cloud_storage_security import stored_s3_bucket_kwargs from litellm.constants import ( BATCH_LINE_ITEMS_MAX_PART_BYTES, MANAGED_OBJECT_STALENESS_CUTOFF_DAYS, @@ -765,7 +766,7 @@ class CheckBatchCost: credentials: Final = self.llm_router.registry().credentials(model_id, form="with_provider") or {} _file_content = await afile_content( file_id=cast(str, raw_output_file_id), - **cast(FileContentCallOptions, credentials), + **cast(FileContentCallOptions, {**credentials, **stored_s3_bucket_kwargs(cast(str, raw_output_file_id))}), ) content_bytes: Final = await _file_content_bytes(_file_content) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 626a3b0bb83..bbb09e75b47 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -49,6 +49,7 @@ from litellm.constants import ( ) from litellm.files.types import FileRetrieveCallOptions, FileRetrieveProvider from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.cloud_storage_security import stored_s3_bucket_kwargs from litellm.litellm_core_utils.core_helpers import require_attributes from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR from litellm.litellm_core_utils.internal_call_metadata import requests_background @@ -396,6 +397,7 @@ class _CursorPageArgs(TypedDict, total=False): class _RouterFileCallKwargs(TypedDict, total=False): client: ReadOnly[AsyncOpenAI | None] custom_llm_provider: ReadOnly[str | None] + stored_s3_bucket_name: ReadOnly[str] _MODEL_FILE_ID_MAPPING: Final = TypeAdapter(dict[str, str]) @@ -463,7 +465,7 @@ async def _delete_provider_file( await llm_router.afile_delete( model=model_id, file_id=provider_file_id, - **cast(_RouterFileCallKwargs, delete_data), + **cast(_RouterFileCallKwargs, {**delete_data, **stored_s3_bucket_kwargs(provider_file_id)}), ) @@ -1157,7 +1159,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): retrieve_file: Final = cast(_ManagedFileRetrieve, litellm.afile_retrieve) retrieve_options: Final = cast( FileRetrieveCallOptions, - {**call_options, "max_retries": 0}, + {**call_options, **stored_s3_bucket_kwargs(provider_file_id), "max_retries": 0}, ) for attempt in range(len(_PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS) + 1): if attempt > 0: @@ -1795,8 +1797,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): if potential_file_id and "llm_output_file_id," in potential_file_id: model_id = self.get_model_id_from_unified_file_id(potential_file_id) if model_id: + provider_file_id: Final = self.get_output_file_id_from_unified_file_id(potential_file_id) data["model"] = model_id - data["file_id"] = self.get_output_file_id_from_unified_file_id(potential_file_id) + data["file_id"] = provider_file_id + data.update(stored_s3_bucket_kwargs(provider_file_id)) elif call_type == CallTypes.acreate_batch.value: input_file_id = cast(Optional[str], data.get("input_file_id")) if input_file_id: @@ -2894,7 +2898,17 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): await llm_router.afile_content( model=model_id, file_id=provider_file_id, - **cast(_RouterFileCallKwargs, data), + **cast( + _RouterFileCallKwargs, + { + **data, + **( + stored_s3_bucket_kwargs(provider_file_id) + if isinstance(provider_file_id, str) + else {} + ), + }, + ), ), ) except Exception as e: diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index ca072368061..cdae3de47c8 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -11,6 +11,7 @@ from pydantic import JsonValue, TypeAdapter import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils import batch_utils as core_batch_utils +from litellm.litellm_core_utils.cloud_storage_security import stored_s3_bucket_kwargs from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.litellm_core_utils.llm_cost_calc.utils import parse_prompt_tokens_details from litellm.llms.base_llm.ocr.transformation import OCRUsageInfo @@ -551,7 +552,7 @@ async def fetch_batch_managed_file_content( "file_id": provider_output_file_id, "custom_llm_provider": custom_llm_provider, **cast( # cast-ok: preserve dynamic provider credentials without validation - FileContentCallOptions, credentials + FileContentCallOptions, {**credentials, **stored_s3_bucket_kwargs(provider_output_file_id)} ), } diff --git a/litellm/litellm_core_utils/cloud_storage_security.py b/litellm/litellm_core_utils/cloud_storage_security.py index 477775bb423..d78fbd58a57 100644 --- a/litellm/litellm_core_utils/cloud_storage_security.py +++ b/litellm/litellm_core_utils/cloud_storage_security.py @@ -17,6 +17,28 @@ BEDROCK_MANAGED_S3_PREFIXES: Final = ( BEDROCK_MANAGED_S3_OUTPUT_PREFIX, ) MANAGED_CLOUD_STORAGE_SCHEMES: Final = ("s3://", "gs://") +STORED_S3_BUCKET_PARAM: Final = "stored_s3_bucket_name" +REQUEST_S3_BUCKET_PARAMS: Final = frozenset({"s3_bucket_name", "s3_output_bucket_name"}) + + +def stored_s3_bucket_kwargs(stored_file_id: str) -> Mapping[str, str]: + """The bucket (and any prefix before LiteLLM's own path) of an ``s3://`` file id LiteLLM recorded itself, + as the param that lets Bedrock read it back. + + Only pass ids from LiteLLM's own records (managed file rows, provider batch responses), never one a caller sent. + """ + if not stored_file_id.startswith("s3://"): + return {} + bucket, _, object_key = stored_file_id.removeprefix("s3://").partition("/") + if not bucket: + return {} + segments: Final = object_key.split("/") + prefix_depth: Final = next( + (depth for depth in range(len(segments)) if "/".join(segments[depth:]).startswith(BEDROCK_MANAGED_S3_PREFIXES)), + 0, + ) + prefix: Final = "/".join(segments[:prefix_depth]) + return {STORED_S3_BUCKET_PARAM: f"{bucket}/{prefix}" if prefix else bucket} MAX_FILE_ID_DECODE_PASSES: Final = 8 diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 41c1baffab8..bbbbe9635c9 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -62,6 +62,7 @@ OPTIONAL_KWARGS_KEYS: Final = ( "bucket_name", "s3_bucket_name", "s3_output_bucket_name", + "stored_s3_bucket_name", "allow_legacy_cloud_file_ids", "s3_endpoint_url", "s3_region_name", diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 476d1395321..b19ad776051 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -32,6 +32,7 @@ from litellm.litellm_core_utils.cloud_storage_security import ( BEDROCK_MANAGED_S3_OUTPUT_PREFIX, BEDROCK_MANAGED_S3_PREFIXES, BEDROCK_MANAGED_S3_UPLOAD_PREFIX, + STORED_S3_BUCKET_PARAM, build_managed_cloud_object_name, encode_s3_object_key_for_url, sanitize_cloud_object_component, @@ -313,11 +314,19 @@ def get_configured_s3_bucket_names(litellm_params: Mapping[str, object]) -> tupl Resolve the S3 buckets a Bedrock file id may live in. Bedrock batch outputs land in ``s3_output_bucket_name`` when it differs from - the input bucket, so retrieval validates against both. + the input bucket, so retrieval validates against both, plus the bucket LiteLLM + recorded for a managed file uploaded or written to a request-chosen bucket. """ input_bucket: Final = _input_bucket_name(litellm_params) output_bucket: Final = _output_bucket_name(litellm_params) - buckets: Final = tuple(dict.fromkeys(bucket for bucket in (input_bucket, output_bucket) if bucket)) + stored_bucket: Final = litellm_params.get(STORED_S3_BUCKET_PARAM) + buckets: Final = tuple( + dict.fromkeys( + bucket + for bucket in (input_bucket, output_bucket, stored_bucket if isinstance(stored_bucket, str) else None) + if bucket + ) + ) if not buckets: raise ValueError(_S3_BUCKET_REQUIRED_ERROR) return buckets diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index d3978a58c43..715b98a441a 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -3,7 +3,7 @@ import os import re import sys from collections.abc import Collection, Iterator, Mapping, Sequence -from functools import lru_cache +from functools import lru_cache, partial from itertools import accumulate, chain, repeat from logging import Logger from types import MappingProxyType @@ -22,6 +22,7 @@ from litellm.constants import ( MINIMUM_CUSTOM_KEY_LENGTH, STANDARD_CUSTOMER_ID_HEADERS, ) +from litellm.litellm_core_utils.cloud_storage_security import REQUEST_S3_BUCKET_PARAMS, STORED_S3_BUCKET_PARAM from litellm.litellm_core_utils.credential_accessor import CredentialAccessor, reject_api_key_secret_reference from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.url_utils import ( @@ -444,6 +445,7 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "s3_endpoint_url", "s3_bucket_name", "s3_output_bucket_name", + STORED_S3_BUCKET_PARAM, "allow_legacy_cloud_file_ids", "sagemaker_base_url", "deployment_url", @@ -494,6 +496,9 @@ _PROXY_ALLOWLIST_SETTING_BY_PARAM: Final[Mapping[str, _ClientSideAllowlistSettin } ) +TEAM_S3_BUCKET_ALLOWLIST_METADATA_KEY: Final = "allowed_s3_buckets" +_BUCKET_NAMES: Final = TypeAdapter(tuple[str, ...]) + class _ClientSideStorageAllowlist(BaseModel): model_config = ConfigDict(frozen=True) @@ -511,8 +516,15 @@ class _ClientSideStorageAllowlist(BaseModel): def _banned_param_error(param: str) -> ValueError: setting: Final = _PROXY_ALLOWLIST_SETTING_BY_PARAM.get(param) + team_hint: Final = ( + f" or in the team's `metadata.{TEAM_S3_BUCKET_ALLOWLIST_METADATA_KEY}`" + if param in REQUEST_S3_BUCKET_PARAMS + else "" + ) allowlist_hint: Final = ( - "" if setting is None else f" To allow specific values only, list them in `general_settings.{setting}`." + "" + if setting is None + else f" To allow specific values only, list them in `general_settings.{setting}`{team_hint}." ) return ValueError( f"Rejected Request: {param} is not allowed in request body. " @@ -532,11 +544,13 @@ def _check_banned_params( *, manages_deployments: bool = False, mcp_server_fields_allowed: bool = False, + defer_s3_buckets: bool = False, ) -> None: """Raise ``ValueError`` if ``body`` carries a banned param without admin opt-in. Shared between the root-level check and the nested-config check so a - new banned param only needs to be added in one place. + new banned param only needs to be added in one place. ``defer_s3_buckets`` lets + bucket params through for handlers that check them against the caller team's allowlist. """ wif_body: Final[Mapping[str, object]] = ( MappingProxyType({key: value for key, value in body.items() if key not in oauth_token_exchange_litellm_params}) @@ -557,6 +571,8 @@ def _check_banned_params( setting := _PROXY_ALLOWLIST_SETTING_BY_PARAM.get(param) ) is not None and _ClientSideStorageAllowlist.model_validate(general_settings).admits(setting, body[param]): continue + if defer_s3_buckets and param in REQUEST_S3_BUCKET_PARAMS: + continue if ( _allow_model_level_clientside_configurable_parameters( model=model, @@ -704,6 +720,7 @@ def is_request_body_safe( model: str, *, route: str | None = None, + defer_s3_buckets: bool = False, ) -> bool: """ Check if the request body is safe. @@ -733,33 +750,28 @@ def is_request_body_safe( if "model_list" in request_body: raise ValueError("Rejected Request: model_list is not allowed in the request body.") manages_deployments: Final = route_manages_deployments(route) - _check_banned_params( - request_body, - general_settings, - llm_router, - model, + check_banned_params: Final = partial( + _check_banned_params, + general_settings=general_settings, + llm_router=llm_router, + model=model, manages_deployments=manages_deployments, - mcp_server_fields_allowed=route_manages_mcp_servers(route), + defer_s3_buckets=defer_s3_buckets, ) + check_banned_params(request_body, mcp_server_fields_allowed=route_manages_mcp_servers(route)) for nested_key in _NESTED_CONFIG_KEYS: nested = _coerce_metadata_to_dict(request_body.get(nested_key)) if nested is not None: - _check_banned_params(nested, general_settings, llm_router, model, manages_deployments=manages_deployments) + check_banned_params(nested) for metadata_key in _NESTED_METADATA_KEYS: metadata = _coerce_metadata_to_dict(request_body.get(metadata_key)) if metadata is not None: - _check_banned_params(metadata, general_settings, llm_router, model, manages_deployments=manages_deployments) + check_banned_params(metadata) if any(isinstance(key, str) and key.startswith(f"{metadata_key}[") for key in request_body): - _check_banned_params( - extract_nested_form_metadata(form_data=request_body, prefix=f"{metadata_key}["), - general_settings, - llm_router, - model, - manages_deployments=manages_deployments, - ) + check_banned_params(extract_nested_form_metadata(form_data=request_body, prefix=f"{metadata_key}[")) for target in iter_request_fallback_targets(request_body): if isinstance(target, dict): - _check_banned_params(target, general_settings, llm_router, model, manages_deployments=manages_deployments) + check_banned_params(target) target_model = target.get("model") if isinstance(target_model, str): _reject_url_valued_fallback_target(target_model) @@ -772,9 +784,7 @@ def is_request_body_safe( _reject_request_credential_params(litellm_params, manages_deployments=manages_deployments) litellm_params_metadata: Final = _coerce_metadata_to_dict(litellm_params.get("metadata")) if litellm_params_metadata is not None: - _check_banned_params( - litellm_params_metadata, general_settings, llm_router, model, manages_deployments=manages_deployments - ) + check_banned_params(litellm_params_metadata) for user_config_params in _user_config_litellm_params(request_body): # pyright: ignore[reportUnknownArgumentType] # request_body is an untyped dict reject_api_key_secret_reference(user_config_params) return True @@ -846,6 +856,48 @@ def _coerce_metadata_to_dict(value: object) -> dict[str, object] | None: return None +def _team_s3_buckets(team_metadata: Mapping[str, object] | None) -> tuple[str, ...]: + if team_metadata is None: + return () + try: + return _BUCKET_NAMES.validate_python(team_metadata.get(TEAM_S3_BUCKET_ALLOWLIST_METADATA_KEY) or ()) + except ValidationError: + return () + + +_TEAM_S3_BUCKET_ROUTE_TEMPLATES: Final = frozenset( + {"/v1/files", "/files", "/{provider}/v1/files", "/v1/batches", "/batches", "/{provider}/v1/batches"} +) + + +def checks_team_s3_buckets_in_handler(request: Request) -> bool: + """File upload and batch create check request buckets against the caller team's allowlist themselves.""" + return get_request_route_template(request) in _TEAM_S3_BUCKET_ROUTE_TEMPLATES and request.method == "POST" + + +def enforce_team_s3_bucket_allowlist( + request_body: Mapping[str, object], + general_settings: Mapping[str, object], + llm_router: Router | None, + team_metadata: Mapping[str, object] | None, +) -> None: + """Re-run the request body check that auth relaxed for bucket params, now admitting the caller team's buckets.""" + proxy_buckets: Final = _ClientSideStorageAllowlist.model_validate(general_settings).allowed_client_side_s3_buckets + model: Final = request_body.get("model") + try: + is_request_body_safe( + request_body=dict(request_body), + general_settings={ + **general_settings, + "allowed_client_side_s3_buckets": (*(proxy_buckets or ()), *_team_s3_buckets(team_metadata)), + }, + llm_router=llm_router, + model=model if isinstance(model, str) else "", + ) + except ValueError as rejection: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Authentication Error, {rejection}") + + async def pre_db_read_auth_checks( request: Request, request_data: dict, @@ -875,6 +927,7 @@ async def pre_db_read_auth_checks( llm_router=llm_router, model=request_data.get("model", ""), # [TODO] use model passed in url as well (azure openai routes) route=route, + defer_s3_buckets=checks_team_s3_buckets_in_handler(request), ) # Check 3. Check if IP address is allowed @@ -1522,6 +1575,9 @@ class BatchLimitRequest(Protocol): def model_fields_set(self) -> Collection[str]: ... +_ADMIN_ONLY_METADATA_KEYS: Final = (*ADMIN_ONLY_BATCH_LIMIT_METADATA_KEYS, TEAM_S3_BUCKET_ALLOWLIST_METADATA_KEY) + + def enforce_batch_limits_are_admin_only( data: BatchLimitRequest, existing_metadata: Mapping[str, object] | None, @@ -1532,7 +1588,8 @@ def enforce_batch_limits_are_admin_only( Every key in ``ADMIN_ONLY_BATCH_LIMIT_METADATA_KEYS`` caps what the holder may do with batches, so a holder-writable copy would let a caller lift their - own quota. Gated on the resulting value rather than on presence, so a form + own quota, and the S3 bucket list grants access to buckets with the proxy's + own credentials. Gated on the resulting value rather than on presence, so a form resending the stored value stays a no-op. """ if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: @@ -1542,17 +1599,19 @@ def enforce_batch_limits_are_admin_only( (data.metadata or EMPTY_MAPPING) if "metadata" in data.model_fields_set else stored ) changed: Final = next( - (key for key in ADMIN_ONLY_BATCH_LIMIT_METADATA_KEYS if requested.get(key) != stored.get(key)), + (key for key in _ADMIN_ONLY_METADATA_KEYS if requested.get(key) != stored.get(key)), None, ) if changed is None: return + reason: Final = ( + "It lets the holder read and write those buckets with the proxy's AWS credentials." + if changed == TEAM_S3_BUCKET_ALLOWLIST_METADATA_KEY + else "It limits what the holder can do with batches, so the holder cannot raise it." + ) raise HTTPException( status_code=403, - detail={ - "error": f"Only proxy admins can set {changed} on a {entity}. " - "It limits what the holder can do with batches, so the holder cannot raise it." - }, + detail={"error": f"Only proxy admins can set {changed} on a {entity}. {reason}"}, ) diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 512a9166e31..00fa7f3c33e 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -12,6 +12,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Protocol, cast from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_proxy_logger @@ -20,6 +21,7 @@ from litellm.litellm_core_utils.core_helpers import attribute_or_none, has_attri from litellm.litellm_core_utils.hidden_params import get_hidden_params, set_hidden_param from litellm.llms.openai.workload_identity import resolve_openai_workload_identity_config from litellm.proxy._types import * +from litellm.proxy.auth.auth_utils import enforce_team_s3_bucket_allowlist from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.batches_endpoints.common_utils import validate_batch_list_limit from litellm.proxy.batches_endpoints.litellm_executed_batches import ( @@ -81,6 +83,7 @@ if TYPE_CHECKING: from prisma.models import LiteLLM_ManagedObjectTable router: Final = APIRouter() +_REQUEST_BODY_ADAPTER: Final = TypeAdapter(Mapping[str, object]) class _UserBatchLister(Protocol): @@ -285,6 +288,7 @@ async def create_batch( """ from litellm.proxy.proxy_server import ( general_settings, + general_settings_view, llm_router, proxy_config, proxy_logging_obj, @@ -294,6 +298,12 @@ async def create_batch( data: dict = {} try: data = await read_request_body(request=request) # rebind-ok: pre-existing rebinding on a rename-only line + enforce_team_s3_bucket_allowlist( + request_body=_REQUEST_BODY_ADAPTER.validate_python(data), + general_settings=general_settings_view(), + llm_router=llm_router, + team_metadata=user_api_key_dict.team_metadata, + ) _raise_when_routing_headers_disagree(request, data.get("model")) verbose_proxy_logger.debug( "Request received by LiteLLM:\n%s", @@ -384,6 +394,7 @@ async def create_batch( prepare_data_with_credentials( data=_create_batch_data, credentials=credentials, + keep_request_buckets=True, ) # Create batch using model credentials @@ -488,6 +499,7 @@ async def create_batch( prepare_data_with_credentials( data=_create_batch_data, credentials=credentials, + keep_request_buckets=True, ) # Create batch using model credentials @@ -506,6 +518,7 @@ async def create_batch( llm_router=llm_router, user_api_key_dict=user_api_key_dict, custom_llm_provider=custom_llm_provider, + keep_request_buckets=True, ) _raise_not_found_when_openai_fallback_unservable( requested_provider=requested_provider, diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py index 0ece14eb90a..08a84ca91c3 100644 --- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py +++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py @@ -31,6 +31,7 @@ from litellm.proxy._types import ( UpdateKeyRequest, UpdateTeamRequest, ) +from litellm.proxy.auth.auth_utils import TEAM_S3_BUCKET_ALLOWLIST_METADATA_KEY from litellm.types.proxy.management_endpoints.scim_v2 import ( SCIM_MANAGED_TEAM_METADATA_KEY, SCIM_TEAM_DATA_METADATA_KEY, @@ -97,6 +98,7 @@ PROXY_CONTROLLED_TEAM_METADATA_KEYS: Final[frozenset[str]] = frozenset( "priority", "zguard_policy_id", "allowed_tools", + TEAM_S3_BUCKET_ALLOWLIST_METADATA_KEY, "allow_client_mock_response", "allow_client_message_redaction_opt_out", "allow_client_pricing_override", diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 9a044f1dee3..e5e68a930c9 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -24,6 +24,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_FILE_LIST_LIMIT from litellm.litellm_core_utils.batch_utils import batch_cost_is_final +from litellm.litellm_core_utils.cloud_storage_security import REQUEST_S3_BUCKET_PARAMS from litellm.litellm_core_utils.core_helpers import as_str_mapping from litellm.llms.base_llm.managed_resources.utils import is_base64_encoded_unified_id from litellm.proxy._types import ProxyException @@ -884,6 +885,8 @@ def apply_team_provider_credentials( llm_router: Optional["Router"], user_api_key_dict: "UserAPIKeyAuth", custom_llm_provider: str, + *, + keep_request_buckets: bool = False, ) -> None: """ Resolve credentials for a provider-only request (no model pinned) via @@ -901,7 +904,11 @@ def apply_team_provider_credentials( ) if credentials is None: return - prepare_data_with_credentials(data=data, credentials={**credentials, **explicit_request_credentials(data)}) + prepare_data_with_credentials( + data=data, + credentials={**credentials, **explicit_request_credentials(data)}, + keep_request_buckets=keep_request_buckets, + ) def add_deployment_model_info( @@ -925,10 +932,16 @@ def add_deployment_model_info( } +_REQUEST_FIELDS: Final = TypeAdapter(Mapping[str, object]) +_NO_REQUEST_FIELDS: Final[Mapping[str, object]] = MappingProxyType({}) + + def prepare_data_with_credentials( data: dict, credentials: dict, file_id: str | None = None, + *, + keep_request_buckets: bool = False, ) -> None: """ Update data dictionary with model credentials (in-place). @@ -937,8 +950,15 @@ def prepare_data_with_credentials( data: Data dictionary to update credentials: Credentials from router file_id: Optional original file_id to set (for decoded file IDs) + keep_request_buckets: Keep an S3 bucket the request already carries over the deployment's. Only + file upload and batch create set it, after checking the bucket against the caller's allowlist. + Reads keep the deployment's bucket so a request cannot widen which buckets a file id may point at. """ - data.update(credentials) + request_fields: Final = _REQUEST_FIELDS.validate_python(data) if keep_request_buckets else _NO_REQUEST_FIELDS + request_buckets: Final = MappingProxyType( + {key: request_fields[key] for key in REQUEST_S3_BUCKET_PARAMS if key in request_fields} + ) + data.update({**credentials, **request_buckets}) data.pop("custom_llm_provider", None) if file_id is not None: diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 5ce0ed8b493..ca1470bc94e 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -40,6 +40,7 @@ from litellm.llms.base_llm.files.litellm_db_storage_backend import LITELLM_DB_ST from litellm.llms.base_llm.files.transformation import BaseFileEndpoints, FileListModelScope from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.proxy._types import * +from litellm.proxy.auth.auth_utils import enforce_team_s3_bucket_allowlist from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.batches_endpoints.litellm_executed_batches import ( litellm_executed_provider_of, @@ -181,6 +182,7 @@ async def _litellm_executed_batch_input_model( _MAX_BATCH_FILE_SIZE_MB_ADAPTER: Final = TypeAdapter(int | None) _LISTED_FILES_ADAPTER: Final = TypeAdapter(list[OpenAIFileObject]) +_REQUEST_BODY_ADAPTER: Final = TypeAdapter(Mapping[str, object]) class UploadedFileInfo(TypedDict): @@ -465,6 +467,7 @@ async def route_create_file( prepare_data_with_credentials( data=_create_file_request, credentials={**credentials, **deployment_drop_params(llm_router, model, _create_file_request)}, + keep_request_buckets=True, ) # Create the file with model credentials @@ -528,6 +531,7 @@ async def route_create_file( llm_router=llm_router, user_api_key_dict=user_api_key_dict, custom_llm_provider=custom_llm_provider, + keep_request_buckets=True, ) # get configs for custom_llm_provider llm_provider_config: Final = get_files_provider_config(custom_llm_provider=custom_llm_provider) @@ -630,6 +634,12 @@ async def create_file( # Extract file creation parameters using utility function request_body: Final = await read_request_body(request=request) or {} + enforce_team_s3_bucket_allowlist( + request_body=_REQUEST_BODY_ADAPTER.validate_python(request_body), + general_settings=general_settings_view(), + llm_router=llm_router, + team_metadata=user_api_key_dict.team_metadata, + ) file_params: Final = await extract_file_creation_params( request=request, request_body=request_body, @@ -707,6 +717,8 @@ async def create_file( # Add litellm_metadata to data if provided (from form field) if extracted_litellm_metadata is not None: data["litellm_metadata"] = extracted_litellm_metadata + if isinstance(s3_bucket_name := form_data_raw.get("s3_bucket_name"), str) and s3_bucket_name: + data["s3_bucket_name"] = s3_bucket_name if expires_after_anchor is not None or expires_after_seconds_str is not None: if expires_after_anchor is None or expires_after_seconds_str is None: diff --git a/tests/unit/batches/test_batch_utils.py b/tests/unit/batches/test_batch_utils.py index 896fc9d975a..bef78554a36 100644 --- a/tests/unit/batches/test_batch_utils.py +++ b/tests/unit/batches/test_batch_utils.py @@ -1072,6 +1072,28 @@ def respx_interceptable_httpx_client(monkeypatch): litellm.in_memory_llm_clients_cache.flush_cache() +@pytest.mark.asyncio +@respx.mock +async def test_bedrock_output_written_to_a_request_bucket_is_read_from_that_bucket(respx_interceptable_httpx_client): + route: Final = respx.get( + "https://s3.us-west-2.amazonaws.com/team-a-bucket/litellm-batch-outputs/job-1/input.jsonl.out" + ).mock(return_value=httpx.Response(200, content=b'{"recordId": "1"}\n')) + + file_content: Final = await bu.fetch_batch_managed_file_content( + "s3://team-a-bucket/litellm-batch-outputs/job-1/input.jsonl.out", + custom_llm_provider="bedrock", # pyright: ignore[reportArgumentType] # the cost job passes bedrock at runtime + litellm_params={ + "s3_bucket_name": "model-bucket", + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + }, + ) + + assert route.call_count == 1 + assert file_content == b'{"recordId": "1"}\n' + + @pytest.mark.asyncio @respx.mock async def test_output_file_content_vertex_managed_uri_accepted_by_real_validation(respx_interceptable_httpx_client): diff --git a/tests/unit/enterprise/proxy/hooks/test_managed_files.py b/tests/unit/enterprise/proxy/hooks/test_managed_files.py index 8c8e970b80d..d8a647fda27 100644 --- a/tests/unit/enterprise/proxy/hooks/test_managed_files.py +++ b/tests/unit/enterprise/proxy/hooks/test_managed_files.py @@ -3520,6 +3520,87 @@ async def test_afile_retrieve_fetches_from_provider_when_file_object_none(): assert managed_file_table.upsert_calls == [] +TEAM_BUCKET_FILE_ID: Final = "s3://team-a-bucket/litellm-batch-outputs/job-1/input.jsonl.out" + + +def _managed_files_holding_a_team_bucket_file() -> PROXY_LiteLLMManagedFiles: + internal_usage_cache: Final = MagicMock() + internal_usage_cache.async_get_cache = AsyncMock( + return_value={ + "unified_file_id": _managed_output_file_id(TEAM_BUCKET_FILE_ID), + "model_mappings": {"model-1": TEAM_BUCKET_FILE_ID}, + "flat_model_file_ids": [TEAM_BUCKET_FILE_ID], + "file_object": None, + "created_by": "user-a", + "updated_by": "user-a", + } + ) + internal_usage_cache.async_set_cache = AsyncMock() + prisma_client: Final = AsyncMock() + prisma_client.db.litellm_managedfiletable.find_first = AsyncMock(return_value=None) + managed_files: Final = PROXY_LiteLLMManagedFiles( + internal_usage_cache=internal_usage_cache, prisma_client=prisma_client + ) + managed_files.delete_unified_file_id = AsyncMock(return_value=None) + return managed_files + + +@pytest.mark.asyncio +async def test_deleting_a_managed_file_in_a_team_bucket_names_that_bucket_to_the_provider(): + router: Final = MagicMock() + router.afile_delete = AsyncMock(return_value=MagicMock()) + + await _managed_files_holding_a_team_bucket_file().afile_delete( + file_id=_managed_output_file_id(TEAM_BUCKET_FILE_ID), litellm_parent_otel_span=None, llm_router=router + ) + + assert router.afile_delete.call_args.kwargs["file_id"] == TEAM_BUCKET_FILE_ID + assert router.afile_delete.call_args.kwargs["stored_s3_bucket_name"] == "team-a-bucket" + + +@pytest.mark.asyncio +async def test_reading_a_managed_file_in_a_team_bucket_names_that_bucket_to_the_provider(): + router: Final = MagicMock() + router.afile_content = AsyncMock(return_value=MagicMock()) + + await _managed_files_holding_a_team_bucket_file().afile_content( + file_id=_managed_output_file_id(TEAM_BUCKET_FILE_ID), litellm_parent_otel_span=None, llm_router=router + ) + + assert router.afile_content.call_args.kwargs["file_id"] == TEAM_BUCKET_FILE_ID + assert router.afile_content.call_args.kwargs["stored_s3_bucket_name"] == "team-a-bucket" + + +@pytest.mark.asyncio +async def test_refreshing_a_managed_file_in_a_team_bucket_names_that_bucket_to_the_provider(): + from litellm.types.llms.openai import OpenAIFileObject + + stored_file: Final = LiteLLM_ManagedFileTable( + unified_file_id="unified-team-file", + file_object=None, + model_mappings={"model-1": TEAM_BUCKET_FILE_ID}, + flat_model_file_ids=[TEAM_BUCKET_FILE_ID], + created_by="user-a", + ) + proxy_managed_files, _ = _managed_files_with_fake_prisma(stored_file) + router: Final = MagicMock() + router.registry.return_value.credentials = MagicMock( + return_value={"custom_llm_provider": "bedrock", "s3_bucket_name": "model-bucket"} + ) + provider_file: Final = OpenAIFileObject( + id=TEAM_BUCKET_FILE_ID, object="file", bytes=1, created_at=1, filename="input.jsonl.out", purpose="batch_output" + ) + + with patch("litellm.afile_retrieve", new_callable=AsyncMock, return_value=provider_file) as provider_retrieve: + refreshed: Final = await proxy_managed_files.afile_retrieve( + file_id="unified-team-file", litellm_parent_otel_span=None, llm_router=router + ) + + assert (refreshed.id, refreshed.filename) == ("unified-team-file", "input.jsonl.out") + assert provider_retrieve.call_args.kwargs["file_id"] == TEAM_BUCKET_FILE_ID + assert provider_retrieve.call_args.kwargs["stored_s3_bucket_name"] == "team-a-bucket" + + @pytest.mark.asyncio async def test_afile_retrieve_raises_error_when_no_router_and_file_object_none(): """ @@ -5665,6 +5746,44 @@ async def test_provider_format_file_without_ownership_row_stays_accessible(): assert result["file_id"] == RAW_PROVIDER_FILE_ID +def _managed_output_file_id(provider_file_id: str) -> str: + unified: Final = ( + "litellm_proxy:application/json;unified_id,u-1;target_model_names,claude-batch;" + f"llm_output_file_id,{provider_file_id};llm_output_file_model_id,model-1" + ) + return base64.urlsafe_b64encode(unified.encode()).decode().rstrip("=") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider_file_id,stored_bucket", + [("s3://team-a-bucket/litellm-batch-outputs/job-1/input.jsonl.out", "team-a-bucket"), ("file-openai-1", None)], +) +async def test_owned_managed_file_content_is_read_from_the_bucket_litellm_recorded( + provider_file_id: str, stored_bucket: str | None +): + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedfiletable.find_first.return_value = _owned_record( + created_by="user_a", team_id="team_a" + ) + proxy_managed_files = PROXY_LiteLLMManagedFiles(MagicMock(), prisma_client=prisma_client) + + result = await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(user_id="user_a", team_id="team_a", parent_otel_span=MagicMock()), + cache=MagicMock(), + data={"file_id": _managed_output_file_id(provider_file_id)}, + call_type="afile_content", + ) + + assert (result["model"], result["file_id"], result.get("stored_s3_bucket_name")) == ( + "model-1", + provider_file_id, + stored_bucket, + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("batch_id", [MODEL_ENCODED_BATCH_ID, RAW_PROVIDER_BATCH_ID]) async def test_post_call_batch_create_stores_ownership_row(batch_id): diff --git a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py index fdd6d5e61bc..f56910ec103 100644 --- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py @@ -2928,6 +2928,88 @@ class TestBedrockFileContentTransformation: assert response.content == b'{"recordId": "x"}' +class TestBedrockFileContentFromTheStoredBucket: + TEAM_OUTPUT_URI: Final = "s3://team-a-bucket/litellm-batch-outputs/job-1/input.jsonl.out" + TEAM_OUTPUT_URL: Final = "https://s3.us-west-2.amazonaws.com/team-a-bucket/litellm-batch-outputs/job-1/input.jsonl.out" + CREDENTIALS: Final = { + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + } + + def test_file_in_another_bucket_is_refused_without_the_stored_bucket(self) -> None: + with pytest.raises(Exception, match="does not match the configured storage bucket"): + litellm.file_content( + file_id=self.TEAM_OUTPUT_URI, + custom_llm_provider="bedrock", + s3_bucket_name="model-bucket", + **self.CREDENTIALS, + ) + + def test_file_in_the_stored_bucket_is_read_from_that_bucket(self) -> None: + import httpx + import respx + + with respx.mock: + route: Final = respx.get(self.TEAM_OUTPUT_URL).mock(return_value=httpx.Response(200, content=b"{}")) + response: Final = litellm.file_content( + file_id=self.TEAM_OUTPUT_URI, + custom_llm_provider="bedrock", + s3_bucket_name="model-bucket", + stored_s3_bucket_name="team-a-bucket", + **self.CREDENTIALS, + ) + + assert route.call_count == 1 + assert response.content == b"{}" + + @pytest.mark.parametrize( + "stored_file_id", + [ + "s3://team-a-bucket/tenant/litellm-batch-outputs/job-1/input.jsonl.out", + "s3://team-a-bucket/tenant/litellm-bedrock-files-nova-1.jsonl", + "s3://team-a-bucket/tenant/deeper/litellm-bedrock-files/nova-1.jsonl", + ], + ) + def test_file_written_under_a_prefixed_request_bucket_is_read_back_from_it(self, stored_file_id: str) -> None: + import httpx + import respx + + from litellm.litellm_core_utils.cloud_storage_security import stored_s3_bucket_kwargs + + url: Final = f"https://s3.us-west-2.amazonaws.com/{stored_file_id.removeprefix('s3://')}" + with respx.mock: + route: Final = respx.get(url).mock(return_value=httpx.Response(200, content=b"{}")) + litellm.file_content( + file_id=stored_file_id, + custom_llm_provider="bedrock", + s3_bucket_name="model-bucket", + **stored_s3_bucket_kwargs(stored_file_id), + **self.CREDENTIALS, + ) + + assert route.call_count == 1 + + def test_file_under_the_model_bucket_prefix_keeps_its_url_when_a_stored_bucket_is_passed(self) -> None: + import httpx + import respx + + prefixed_url: Final = ( + "https://s3.us-west-2.amazonaws.com/model-bucket/tenant/litellm-batch-outputs/job-1/input.jsonl.out" + ) + with respx.mock: + route: Final = respx.get(prefixed_url).mock(return_value=httpx.Response(200, content=b"{}")) + litellm.file_content( + file_id="s3://model-bucket/tenant/litellm-batch-outputs/job-1/input.jsonl.out", + custom_llm_provider="bedrock", + s3_bucket_name="model-bucket/tenant", + stored_s3_bucket_name="model-bucket", + **self.CREDENTIALS, + ) + + assert route.call_count == 1 + + class TestBedrockFilesS3SignatureEncoding: """ S3 rebuilds the canonical request from the wire path with single percent-encoding, diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 8651d522ac8..82b80ad8da9 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -4,7 +4,8 @@ Unit tests for auth_utils functions related to rate limiting and customer ID ext import base64 import logging -from collections.abc import Callable +from collections.abc import Callable, Mapping +from types import SimpleNamespace from typing import Final, Optional from unittest.mock import MagicMock, patch @@ -12,7 +13,7 @@ import pytest from fastapi import HTTPException, Request import litellm -from litellm.proxy._types import ProxyException, UserAPIKeyAuth +from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth from litellm.proxy.auth.auth_utils import ( _allow_model_level_clientside_configurable_parameters, _get_customer_id_from_standard_headers, @@ -33,7 +34,9 @@ from litellm.proxy.auth.auth_utils import ( get_project_model_rpm_limit, get_project_model_tpm_limit, get_request_route_template, + enforce_team_s3_bucket_allowlist, is_request_body_safe, + pre_db_read_auth_checks, ) from litellm.router import Router from litellm.types.utils import oauth_token_exchange_litellm_params, server_owned_wif_litellm_params @@ -5429,6 +5432,189 @@ def test_other_blocked_fields_do_not_point_at_the_storage_allowlist() -> None: assert "allowed_client_side" not in str(error.value) +_TEAM_A_METADATA: Final = {"allowed_s3_buckets": ["team-a-bucket"]} + + +def _enforce_for_team( + body: Mapping[str, object], team_metadata: Mapping[str, object] | None, **general_settings: object +) -> None: + enforce_team_s3_bucket_allowlist( + request_body=body, + general_settings=general_settings, + llm_router=None, + team_metadata=team_metadata, + ) + + +def _rejection_for_team(body: Mapping[str, object], team_metadata: Mapping[str, object] | None) -> HTTPException: + with pytest.raises(HTTPException) as rejection: + _enforce_for_team(body, team_metadata) + assert rejection.value.status_code == 401, rejection.value.detail + return rejection.value + + +@pytest.mark.parametrize("field", ["s3_bucket_name", "s3_output_bucket_name"]) +def test_team_allowlist_admits_its_own_bucket_and_rejects_another_teams(field: str) -> None: + _enforce_for_team({field: "team-a-bucket"}, _TEAM_A_METADATA) + rejection: Final = _rejection_for_team({field: "team-b-bucket"}, _TEAM_A_METADATA) + assert f"{field} is not allowed in request body" in str(rejection.detail) + assert "metadata.allowed_s3_buckets" in str(rejection.detail) + + +@pytest.mark.parametrize( + "team_metadata", + [None, {}, {"allowed_s3_buckets": None}, {"allowed_s3_buckets": "team-a-bucket"}, {"allowed_s3_buckets": [1]}], +) +def test_team_without_a_usable_allowlist_cannot_pick_a_bucket(team_metadata: Mapping[str, object] | None) -> None: + _rejection_for_team({"s3_bucket_name": "team-a-bucket"}, team_metadata) + + +def _team_bucket_admitted( + body: Mapping[str, object], team_metadata: Mapping[str, object] | None, **general_settings: object +) -> bool: + try: + _enforce_for_team(body, team_metadata, **general_settings) + except HTTPException: + return False + return True + + +def test_proxy_wide_allowlist_still_applies_alongside_the_team_allowlist() -> None: + assert _team_bucket_admitted( + {"s3_bucket_name": "team-a-bucket", "s3_output_bucket_name": "shared-bucket"}, + _TEAM_A_METADATA, + allowed_client_side_s3_buckets=["shared-bucket"], + ) + assert not _team_bucket_admitted({"s3_output_bucket_name": "shared-bucket"}, _TEAM_A_METADATA) + + +def test_team_allowlist_is_checked_for_buckets_nested_in_the_body() -> None: + _enforce_for_team({"extra_body": {"s3_bucket_name": "team-a-bucket"}}, _TEAM_A_METADATA) + _rejection_for_team({"litellm_metadata": {"s3_bucket_name": "team-b-bucket"}}, _TEAM_A_METADATA) + + +def test_team_allowlist_admits_no_other_blocked_field() -> None: + rejection: Final = _rejection_for_team({"s3_endpoint_url": "team-a-bucket"}, _TEAM_A_METADATA) + assert "s3_endpoint_url" in str(rejection.detail) + + +def _routed_request(method: str, route_template: str | None, path: str) -> Request: + scope: Final[dict[str, object]] = {"type": "http", "method": method, "path": path, "headers": [], "query_string": b""} + return Request(scope if route_template is None else {**scope, "route": SimpleNamespace(path=route_template)}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "route_template,path", + [ + ("/v1/files", "/v1/files"), + ("/files", "/files"), + ("/{provider}/v1/files", "/bedrock/v1/files"), + ("/v1/batches", "/v1/batches"), + ("/batches", "/batches"), + ("/{provider}/v1/batches", "/bedrock/v1/batches"), + ], +) +async def test_auth_leaves_request_buckets_to_the_upload_and_batch_create_handlers( + route_template: str, path: str +) -> None: + assert await _auth_admits( + _routed_request("POST", route_template, path), + {"s3_bucket_name": "team-b-bucket", "s3_output_bucket_name": "team-b-bucket"}, + path, + ) + + +async def _auth_admits(request: Request, request_data: dict[str, object], route: str) -> bool: + try: + await pre_db_read_auth_checks(request=request, request_data=request_data, route=route) + except ValueError: + return False + return True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method,route_template,path", + [ + ("GET", "/v1/files", "/v1/files"), + ("POST", "/v1/files/{file_id}/content", "/v1/files/file-1/content"), + ("POST", "/v1/chat/completions", "/v1/chat/completions"), + ("POST", None, "/v1/batches"), + ], +) +async def test_auth_still_rejects_request_buckets_on_every_other_route( + method: str, route_template: str | None, path: str +) -> None: + with pytest.raises(ValueError, match="s3_bucket_name is not allowed"): + await pre_db_read_auth_checks( + request=_routed_request(method, route_template, path), + request_data={"s3_bucket_name": "team-b-bucket"}, + route=path, + ) + + +@pytest.mark.asyncio +async def test_a_caller_can_never_send_the_stored_bucket_param() -> None: + with pytest.raises(ValueError, match="stored_s3_bucket_name is not allowed"): + await pre_db_read_auth_checks( + request=_routed_request("POST", "/v1/batches", "/v1/batches"), + request_data={"stored_s3_bucket_name": "team-b-bucket"}, + route="/v1/batches", + ) + rejection: Final = _rejection_for_team( + {"stored_s3_bucket_name": "team-a-bucket"}, {"allowed_s3_buckets": ["team-a-bucket"]} + ) + assert "stored_s3_bucket_name" in str(rejection.detail) + with pytest.raises(ValueError, match="stored_s3_bucket_name"): + _batches_body_is_safe({"stored_s3_bucket_name": "team-a-bucket"}, _BUCKET_ALLOWLIST) + + +@pytest.mark.asyncio +async def test_auth_still_rejects_other_blocked_fields_on_the_upload_and_batch_create_routes() -> None: + with pytest.raises(ValueError, match="api_base"): + await pre_db_read_auth_checks( + request=_routed_request("POST", "/v1/batches", "/v1/batches"), + request_data={"s3_bucket_name": "team-b-bucket", "api_base": "https://attacker.example"}, + route="/v1/batches", + ) + + +@pytest.mark.parametrize( + "role,stored,sent,refused", + [ + (LitellmUserRoles.PROXY_ADMIN, None, ["any-bucket"], False), + (LitellmUserRoles.INTERNAL_USER, None, ["any-bucket"], True), + (LitellmUserRoles.INTERNAL_USER, ["team-a-bucket"], ["team-a-bucket", "other"], True), + (LitellmUserRoles.INTERNAL_USER, ["team-a-bucket"], None, True), + (LitellmUserRoles.INTERNAL_USER, ["team-a-bucket"], ["team-a-bucket"], False), + ], +) +def test_only_proxy_admins_change_a_teams_s3_bucket_list( + role: LitellmUserRoles, stored: list[str] | None, sent: list[str] | None, refused: bool +) -> None: + from litellm.proxy._types import UpdateTeamRequest + from litellm.proxy.auth.auth_utils import enforce_batch_limits_are_admin_only + + existing: Final = None if stored is None else {"allowed_s3_buckets": stored, "project": "x"} + request: Final = UpdateTeamRequest( + team_id="t1", metadata={"project": "x"} if sent is None else {"allowed_s3_buckets": sent, "project": "x"} + ) + caller: Final = UserAPIKeyAuth(user_role=role, user_id="u1") + + if not refused: + enforce_batch_limits_are_admin_only( + data=request, existing_metadata=existing, user_api_key_dict=caller, entity="team" + ) + return + with pytest.raises(HTTPException) as rejection: + enforce_batch_limits_are_admin_only( + data=request, existing_metadata=existing, user_api_key_dict=caller, entity="team" + ) + assert rejection.value.status_code == 403 + assert "Only proxy admins can set allowed_s3_buckets on a team." in str(rejection.value.detail) + + def _request_with_content_length(content_length: int) -> Request: return Request( { diff --git a/tests/unit/proxy/batches_endpoints/test_endpoints.py b/tests/unit/proxy/batches_endpoints/test_endpoints.py index b5da6f31eb4..cee8a129172 100644 --- a/tests/unit/proxy/batches_endpoints/test_endpoints.py +++ b/tests/unit/proxy/batches_endpoints/test_endpoints.py @@ -85,6 +85,12 @@ CREDS: Dict[str, Dict[str, str]] = { "api_base": "http://vllm.test/v1", "model": "hosted_vllm/qwen", }, + "bedrock-batch": { + "custom_llm_provider": "bedrock", + "model": "bedrock/us.amazon.nova-lite-v1:0", + "s3_bucket_name": "model-bucket", + "s3_output_bucket_name": "model-bucket", + }, } # A real model-encoded file id: decodes to "azure/gpt-4o", strips to "file-original123". @@ -426,6 +432,83 @@ async def test_create__model_from_body(harness): assert resp.id == encode_file_id_with_model("batch-provider-id", "vertex-model", id_type="batch") +_TEAM_A_KEY: Final = UserAPIKeyAuth( + api_key="sk-team-a", team_id="team-a", team_metadata={"allowed_s3_buckets": ["team-a-bucket"]} +) + + +def _batch_body_with_output_bucket(bucket: str) -> dict[str, object]: + return { + "input_file_id": "file-plain", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "model": "vertex-model", + "s3_output_bucket_name": bucket, + } + + +@pytest.mark.asyncio +async def test_create__output_bucket_on_the_team_allowlist_reaches_the_provider(harness): + set_body(harness, _batch_body_with_output_bucket("team-a-bucket")) + + await call_create(harness, user=_TEAM_A_KEY) + + assert harness.acreate_kwargs()["s3_output_bucket_name"] == "team-a-bucket" + + +@pytest.mark.asyncio +async def test_create__team_with_an_allowlist_and_no_request_bucket_leaves_the_bucket_to_the_model(harness): + set_body( + harness, + { + "input_file_id": "file-plain", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "model": "vertex-model", + }, + ) + + await call_create(harness, user=_TEAM_A_KEY) + + assert "s3_output_bucket_name" not in harness.acreate_kwargs() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "request_bucket,expected_bucket", [("team-a-bucket", "team-a-bucket"), (None, "model-bucket")] +) +@pytest.mark.parametrize( + "routing", + [ + {"input_file_id": "file-plain", "model": "bedrock-batch"}, + {"input_file_id": encode_file_id_with_model("file-plain", "bedrock-batch")}, + ], + ids=["model_in_body", "model_encoded_file_id"], +) +async def test_create__model_routed_batch_keeps_the_team_bucket_over_the_deployment_bucket( + harness, routing: dict[str, str], request_bucket: str | None, expected_bucket: str +): + body: Final = {"endpoint": "/v1/chat/completions", "completion_window": "24h", **routing} + set_body(harness, body if request_bucket is None else {**body, "s3_output_bucket_name": request_bucket}) + + await call_create(harness, user=_TEAM_A_KEY) + + assert harness.acreate_kwargs()["s3_output_bucket_name"] == expected_bucket + + +@pytest.mark.asyncio +async def test_create__output_bucket_off_the_team_allowlist_is_rejected_before_the_provider(harness): + set_body(harness, _batch_body_with_output_bucket("team-b-bucket")) + + with pytest.raises(ProxyException) as rejection: + await call_create(harness, user=_TEAM_A_KEY) + + assert str(rejection.value.code) == "401" + assert "s3_output_bucket_name is not allowed in request body" in rejection.value.message + harness.litellm_acreate.assert_not_called() + harness.router_acreate.assert_not_called() + + @pytest.mark.asyncio async def test_create__model_from_header(harness): set_body( diff --git a/tests/unit/proxy/common_utils/test_check_batch_cost.py b/tests/unit/proxy/common_utils/test_check_batch_cost.py index 09c76b2dd65..79524cfeda9 100644 --- a/tests/unit/proxy/common_utils/test_check_batch_cost.py +++ b/tests/unit/proxy/common_utils/test_check_batch_cost.py @@ -2297,6 +2297,10 @@ class TestManagedOutputFileIdEncodesPublicModelGroup: return job async def _run(self, job: MagicMock) -> str: + output_file_id, _ = await self._run_capturing(job, self._RAW_OUTPUT_FILE_ID) + return output_file_id + + async def _run_capturing(self, job: MagicMock, raw_output_file_id: str) -> tuple[str, Mapping[str, object]]: from litellm_enterprise.proxy.common_utils.check_batch_cost import ( CheckBatchCost, ) @@ -2342,7 +2346,7 @@ class TestManagedOutputFileIdEncodesPublicModelGroup: object="batch", status="completed", ) - response.output_file_id = self._RAW_OUTPUT_FILE_ID + response.output_file_id = raw_output_file_id file_content = MagicMock() file_content.content = b'{"id":"req-1"}' @@ -2352,7 +2356,7 @@ class TestManagedOutputFileIdEncodesPublicModelGroup: "litellm.files.main.afile_content", new_callable=AsyncMock, return_value=file_content, - ), + ) as provider_file_content, patch( "litellm.batches.batch_utils.get_file_content_as_dictionary", return_value=[{"id": "req-1"}], @@ -2376,7 +2380,17 @@ class TestManagedOutputFileIdEncodesPublicModelGroup: prom_logger=None, ) - return response.output_file_id + return response.output_file_id, provider_file_content.call_args.kwargs + + @pytest.mark.asyncio + async def test_output_written_to_a_team_bucket_is_billed_from_that_bucket(self): + team_output: Final = "s3://team-a-bucket/litellm-batch-outputs/job-1/input.jsonl.out" + + _, download = await self._run_capturing( + self._job(self._managed_input_file_id(self._PUBLIC_MODEL_GROUP)), team_output + ) + + assert (download["file_id"], download["stored_s3_bucket_name"]) == (team_output, "team-a-bucket") @pytest.mark.asyncio async def test_target_model_names_comes_from_input_file_not_provider_model(self): diff --git a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py index 4af57a83395..88b02ed1aa1 100644 --- a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py +++ b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py @@ -322,6 +322,7 @@ _PROXY_READ_TEAM_METADATA_KEYS: Final = ( "allowed_vector_store_indexes", "enforced_file_expires_after", "enforced_batch_output_expires_after", + "allowed_s3_buckets", "require_trace_id", "allowed_passthrough_routes", "denied_passthrough_routes", @@ -384,7 +385,9 @@ class TestTeamMetadataEdits: verdict: Final = _metadata_verdict(_STORED_METADATA, {**_STORED_METADATA, key: "set-by-team-admin"}) assert verdict == TeamAdminMetadataKeyProxyControlled(key=key) - @pytest.mark.parametrize("key", ["opted_out_global_guardrails", "callback_settings", "priority", "guardrails"]) + @pytest.mark.parametrize( + "key", ["opted_out_global_guardrails", "callback_settings", "priority", "guardrails", "allowed_s3_buckets"] + ) def test_changing_or_removing_a_stored_proxy_controlled_key_names_it(self, key: str): stored: Final = {**_STORED_METADATA, key: ["stored"]} changed: Final = _metadata_verdict(stored, {**stored, key: ["changed"]}) diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index 7e54f0893c0..83a121da21a 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -13062,6 +13062,67 @@ async def test_update_team_blocks_org_admin_require_trace_id_change(mock_db_clie assert "Only proxy admins can set `require_trace_id` on a team." in str(exc.value.message) +@pytest.mark.asyncio +async def test_new_team_blocks_org_admin_granting_s3_buckets(mock_db_client: MagicMock) -> None: + mock_db_client.db.litellm_teamtable.count = AsyncMock(return_value=0) + + with ( + _org_admins(("u-team-admin", "org-1")), + patch( + "litellm.proxy.management_endpoints.team_endpoints._should_auto_add_team_creator", + return_value=False, + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=MagicMock()), + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints._check_org_team_limits", + new=AsyncMock(return_value=None), + ), + ): + exc: Final[pytest.ExceptionInfo[ProxyException]] + with pytest.raises(ProxyException) as exc: + await new_team( + data=NewTeamRequest( + team_alias="t", + organization_id="org-1", + metadata={"allowed_s3_buckets": ["another-orgs-bucket"]}, + ), + http_request=MagicMock(spec=Request), + user_api_key_dict=_non_admin_auth(), + ) + + assert str(exc.value.code) == "403" + assert "Only proxy admins can set allowed_s3_buckets on a team." in str(exc.value.message) + + +@pytest.mark.asyncio +async def test_update_team_blocks_org_admin_widening_s3_buckets(mock_db_client: MagicMock) -> None: + existing: Final[MagicMock] = MagicMock() + existing.metadata = {"allowed_s3_buckets": ["team-a-bucket"]} + existing.model_dump.return_value = { + "team_id": "t1", + "organization_id": "org-1", + "metadata": {"allowed_s3_buckets": ["team-a-bucket"]}, + } + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing) + + with _org_admins(("u-team-admin", "org-1")): + exc: Final[pytest.ExceptionInfo[ProxyException]] + with pytest.raises(ProxyException) as exc: + await update_team( + data=UpdateTeamRequest( + team_id="t1", metadata={"allowed_s3_buckets": ["team-a-bucket", "another-orgs-bucket"]} + ), + http_request=MagicMock(spec=Request), + user_api_key_dict=_non_admin_auth(), + ) + + assert str(exc.value.code) == "403" + assert "Only proxy admins can set allowed_s3_buckets on a team." in str(exc.value.message) + + @pytest.mark.asyncio async def test_update_team_blocks_org_admin_null_metadata_clear_require_trace_id( mock_db_client: MagicMock, diff --git a/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py b/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py index bbe7f4383b6..2f469bb8684 100644 --- a/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py +++ b/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py @@ -21,6 +21,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( get_team_provider_deployment, is_litellm_executed_batch, map_raw_file_ids_to_unified, + prepare_data_with_credentials, ) from litellm.proxy.route_llm_request import ProxyModelNotFoundError from litellm.proxy.utils import InternalUsageCache, handle_exception_on_proxy @@ -952,3 +953,19 @@ def test_a_provider_wildcard_grant_finds_only_deployments_named_under_it(deploye ) assert (None if credentials is None else credentials["api_key"]) == api_key + + +@pytest.mark.parametrize("field", ["s3_bucket_name", "s3_output_bucket_name"]) +def test_create_paths_keep_a_request_bucket_and_reads_keep_the_deployment_bucket(field: str) -> None: + deployment: Final = {"custom_llm_provider": "bedrock", "api_key": "sk-deployment", field: "model-bucket"} + create_with_bucket: Final[dict[str, object]] = {field: "team-a-bucket"} + create_without_bucket: Final[dict[str, object]] = {} + read_with_bucket: Final[dict[str, object]] = {field: "team-a-bucket"} + + prepare_data_with_credentials(data=create_with_bucket, credentials=dict(deployment), keep_request_buckets=True) + prepare_data_with_credentials(data=create_without_bucket, credentials=dict(deployment), keep_request_buckets=True) + prepare_data_with_credentials(data=read_with_bucket, credentials=dict(deployment)) + + assert create_with_bucket == {field: "team-a-bucket", "api_key": "sk-deployment"} + assert create_without_bucket == {field: "model-bucket", "api_key": "sk-deployment"} + assert read_with_bucket == {field: "model-bucket", "api_key": "sk-deployment"} diff --git a/tests/unit/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/unit/proxy/openai_files_endpoint/test_files_endpoint.py index 27a65a91c41..1a57fc81a9f 100644 --- a/tests/unit/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/unit/proxy/openai_files_endpoint/test_files_endpoint.py @@ -951,6 +951,45 @@ def test_batch_upload_for_a_provider_model_still_goes_to_the_provider(batch_uplo assert provider_upload.call_args.kwargs["custom_llm_provider"] == "gemini" +def _upload_as_team_a(bucket: str | None): + import litellm.proxy.proxy_server as ps + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="sk-team-a", team_id="team-a", team_metadata={"allowed_s3_buckets": ["team-a-bucket"]} + ) + return _upload_batch_file( + {"x-litellm-model": "gemini-2.0-flash"}, {} if bucket is None else {"s3_bucket_name": bucket} + ) + + +def test_batch_upload_from_a_team_with_an_allowlist_and_no_bucket_leaves_the_bucket_to_the_model(batch_upload_seams): + _, provider_upload, _ = batch_upload_seams + + response = _upload_as_team_a(None) + + assert response.status_code == 200, response.text + assert "s3_bucket_name" not in provider_upload.call_args.kwargs + + +def test_batch_upload_sends_a_bucket_on_the_team_allowlist_to_the_provider_upload(batch_upload_seams): + _, provider_upload, _ = batch_upload_seams + + response = _upload_as_team_a("team-a-bucket") + + assert response.status_code == 200, response.text + assert provider_upload.call_args.kwargs["s3_bucket_name"] == "team-a-bucket" + + +def test_batch_upload_rejects_a_bucket_off_the_team_allowlist_before_the_provider_upload(batch_upload_seams): + _, provider_upload, _ = batch_upload_seams + + response = _upload_as_team_a("team-b-bucket") + + assert response.status_code == 401, response.text + assert "s3_bucket_name is not allowed in request body" in response.text + provider_upload.assert_not_awaited() + + @pytest.mark.parametrize( "model, expected_drop_params", [("vertex-batch-drop", True), ("vertex-batch-keep", False)], diff --git a/tests/unit/test_router/core/test_endpoints_characterization_batch.py b/tests/unit/test_router/core/test_endpoints_characterization_batch.py index 6ba298accfe..626f25d00e6 100644 --- a/tests/unit/test_router/core/test_endpoints_characterization_batch.py +++ b/tests/unit/test_router/core/test_endpoints_characterization_batch.py @@ -125,6 +125,32 @@ async def test_acreate_file_sends_target_model_names_to_a_litellm_proxy_deployme assert dict(host.calls[0].kwargs["extra_body"]) == {"keep": 1, "target_model_names": "remote-model"} # pyright: ignore[reportArgumentType] # a mapping +@pytest.mark.parametrize( + "request_bucket,expected_bucket", [({}, "model-bucket"), ({"s3_bucket_name": "team-a-bucket"}, "team-a-bucket")] +) +async def test_acreate_file_prefers_the_request_s3_bucket_over_the_deployment_bucket( + make_core: MakeCore, host: EndpointHost, request_bucket: dict[str, str], expected_bucket: str +): + core: Final = make_core(model_list=[deployment("g", "d1", s3_bucket_name="model-bucket")]) + await core.acreate_file(model="g", file=_jsonl(), purpose="batch", **request_bucket) + assert host.calls[0].kwargs["s3_bucket_name"] == expected_bucket + + +@pytest.mark.parametrize( + "request_bucket,expected_bucket", + [({}, "model-bucket"), ({"s3_output_bucket_name": "team-a-bucket"}, "team-a-bucket")], +) +async def test_acreate_batch_prefers_the_request_output_bucket_over_the_deployment_bucket( + make_core: MakeCore, host: EndpointHost, request_bucket: dict[str, str], expected_bucket: str +): + host.answer("openai/d1", _batch()) + core: Final = make_core(model_list=[deployment("g", "d1", s3_output_bucket_name="model-bucket")]) + await core.acreate_batch( + model="g", input_file_id="f", endpoint="/v1/chat/completions", completion_window="24h", **request_bucket + ) + assert host.calls[0].kwargs["s3_output_bucket_name"] == expected_bucket + + async def test_acreate_file_sends_the_deployment_gcs_bucket_in_litellm_metadata( make_core: MakeCore, host: EndpointHost ): From f0d234c4c6605607d5411679adc7d4dad43fd3aa Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Tue, 10 Nov 2026 11:18:30 -0800 Subject: [PATCH 04/86] fix(router): let an explicit routing tag outrank the encrypted content pin (#50975) * fix(router): let an explicit routing tag outrank the encrypted content pin A Responses follow-up that replays encrypted reasoning minted on one deployment, but asks for another with a routing tag, failed with "Not allowed to access model due to tags configuration". The affinity check now runs the router's tag filter on the deployment it is about to pin, and on encryption-boundary peers. When the filter would reject them, it releases the pin and strips the reasoning the tagged deployment cannot decrypt, so the request routes there. Requests the tag filter accepts behave as before. Fixes #50926 * fix(router): strip reasoning against only the boundary peers the tags keep When tags rejected some encryption-boundary peers, reasoning was still judged against every peer, so a blob only a rejected peer could decrypt reached the tagged one. Pin and strip against the tag-kept peers. * fix(router): mark the deployment TypeVar Final * fix(router): keep every boundary peer for the router's tag check Returning only the tag-kept peers hid tags the later tag check needs to fail open, so a request that passed the affinity check was refused. Return all boundary peers and judge the reasoning against the kept ones. --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- .../encrypted_content_affinity_check.py | 43 +++- .../test_encrypted_content_affinity_check.py | 212 ++++++++++++++++++ 2 files changed, 248 insertions(+), 7 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py index 2dfe7921c60..8db5a5f0005 100644 --- a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -41,7 +41,7 @@ from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass, field, replace from functools import cache, lru_cache, partial from time import monotonic -from typing import TYPE_CHECKING, Final, Optional, cast +from typing import TYPE_CHECKING, Final, Optional, TypeVar, cast from pydantic import BaseModel, TypeAdapter, ValidationError @@ -49,6 +49,7 @@ import litellm from litellm._logging import verbose_router_logger from litellm.exceptions import AuthenticationError, BadRequestError from litellm.integrations.custom_logger import CustomLogger, Span +from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs from litellm.litellm_core_utils.get_llm_provider_logic import declared_provider, get_llm_provider from litellm.litellm_core_utils.litellm_logging import has_sensitive_keyword from litellm.litellm_core_utils.prompt_templates.reasoning_utils import ( @@ -60,6 +61,7 @@ from litellm.litellm_core_utils.routing_common_utils import as_excluded_deployme from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.router_deprecated import RenamedRouterAttribute, renamed_router_param +from litellm.router_strategy.tag_based_routing import get_deployments_for_tag from litellm.secret_managers.main import secret_manager_would_be_consulted from litellm.types.llms.openai import AllMessageValues from litellm.types.router import Deployment, GenericLiteLLMParams, ModelInfo @@ -70,6 +72,7 @@ if TYPE_CHECKING: from litellm.router_api import RoutingContext _REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_DeploymentT: Final = TypeVar("_DeploymentT", bound=Mapping[str, object]) _HEADERS_ADAPTER: Final = TypeAdapter(Mapping[object, object]) _ACCOUNT_SCOPE_HEADERS: Final = frozenset({"openai-organization", "openai-project", "cookie"}) _SINGLE_VENDOR_PROVIDERS: Final = frozenset({LlmProviders.OPENAI.value}) @@ -245,7 +248,9 @@ class EncryptedContentAffinityCheck(CustomLogger): return None if deployment_id is None else str(deployment_id) @staticmethod - def _find_deployment_by_model_id(healthy_deployments: list[dict], model_id: str) -> dict | None: + def _find_deployment_by_model_id( + healthy_deployments: list[dict[str, object]], model_id: str + ) -> dict[str, object] | None: for deployment in healthy_deployments: model_info = deployment.get("model_info") if not isinstance(model_info, dict): @@ -546,6 +551,27 @@ class EncryptedContentAffinityCheck(CustomLogger): # Request routing (pre-call filter) # ------------------------------------------------------------------ + async def _tag_routable( + self, model: str, pinned: Sequence[_DeploymentT], request_kwargs: Mapping[str, object] + ) -> tuple[_DeploymentT, ...]: + """The deployments in ``pinned`` the router's tag filter keeps for this request, none when it rejects + them all, so an explicit routing tag outranks the pin.""" + if self.ctx is None: + return tuple(pinned) + try: + kept: Final = await get_deployments_for_tag( + ctx=self.ctx, + model=model, + healthy_deployments=pinned, # pyright: ignore[reportArgumentType] # deployment dicts + request_kwargs=request_kwargs, # pyright: ignore[reportArgumentType] # the request kwargs + metadata_variable_name=get_metadata_variable_name_from_kwargs(request_kwargs), + request_deployments=self.ctx.registry().deployments(model) or pinned, # pyright: ignore[reportArgumentType] # deployment dicts + ) + except ValueError: + return () + kept_identities: Final = frozenset(map(id, kept)) + return tuple(deployment for deployment in pinned if id(deployment) in kept_identities) + async def async_filter_deployments( self, model: str, @@ -629,7 +655,7 @@ class EncryptedContentAffinityCheck(CustomLogger): healthy_deployments=untried_candidates, model_id=model_id, ) - if deployment is not None: + if deployment is not None and await self._tag_routable(model, (deployment,), typed_request_kwargs): verbose_router_logger.debug( "EncryptedContentAffinityCheck: pinning -> deployment=%s", model_id, @@ -650,19 +676,22 @@ class EncryptedContentAffinityCheck(CustomLogger): healthy_deployments=untried_candidates, model_id=model_id, ) - if boundary_matches: + routable_peers: Final = ( + await self._tag_routable(model, boundary_matches, typed_request_kwargs) if boundary_matches else () + ) + if routable_peers: verbose_router_logger.debug( - "EncryptedContentAffinityCheck: model_id=%s not in healthy_deployments; " + "EncryptedContentAffinityCheck: model_id=%s cannot serve this turn; " "pinning to %d deployment(s) on same encryption boundary", model_id, - len(boundary_matches), + len(routable_peers), ) request_kwargs["_encrypted_content_affinity_pinned"] = True self.strip_reasoning_the_targets_cannot_decrypt( self.ctx, request_input, anthropic_messages, - boundary_matches, + routable_peers, unmarked_origin=model_id, clock=self._clock, ) diff --git a/tests/unit/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/unit/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index 23417e11948..e908481ca2b 100644 --- a/tests/unit/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/unit/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -3508,3 +3508,215 @@ async def _forwarded_reasoning_after_order_fallback(boundary: str | None) -> tup ) async def test_order_fallback_keeps_reasoning_only_on_a_declared_boundary_peer(boundary, expected_reasoning): assert await _forwarded_reasoning_after_order_fallback(boundary) == ("region-3", expected_reasoning) + + +def _tagged_deployment( + vendor: str, boundary: str | None = None, tags: tuple[str, ...] = ("default",) +) -> dict[str, object]: + return { + "model_name": "tagged-group", + "litellm_params": { + "model": "openai/gpt-5.6", + "api_base": f"https://{vendor}.example.invalid/v1", + "api_key": f"sk-{vendor}", + "tags": [*tags, f"vendor:{vendor}"], + }, + "model_info": {"id": vendor} | ({} if boundary is None else {"encrypted_content_boundary": boundary}), + } + + +async def _tagged_followup( + tags: list[str] | None, peer_boundary: str | None = None, origin_tags: tuple[str, ...] = ("default",) +) -> tuple[str, object]: + router: Final = litellm.Router( + model_list=[_tagged_deployment("a", "org-a", origin_tags), _tagged_deployment("b", peer_boundary)], + enable_tag_filtering=True, + optional_pre_call_checks=["encrypted_content_affinity"], + num_retries=0, + ) + handler: Final = AsyncMock(return_value=_build_mock_response(output_items=[], response_id="resp_tagged")) + with patch( + "litellm.llms.custom_httpx.llm_http_handler.BaseLLMHTTPHandler.async_response_api_handler", + new=handler, + ): + response: Final = await router.aresponses( + model="tagged-group", + input=[ + {"role": "user", "content": "first question"}, + { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "thought about it"}], + "encrypted_content": ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id( + "blob-from-a", "a" + ), + }, + {"role": "user", "content": "follow-up"}, + ], + litellm_metadata={} if tags is None else {"tags": tags}, + ) + return response._hidden_params["model_id"], handler.await_args.kwargs["input"][1] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tags", [None, ["vendor:a"]], ids=["untagged", "tag-matches-pin"]) +async def test_tagged_followup_stays_on_the_deployment_that_minted_its_reasoning(tags: list[str] | None) -> None: + served_by, reasoning = await _tagged_followup(tags) + + assert served_by == "a" + assert reasoning["encrypted_content"] == "blob-from-a" + + +@pytest.mark.asyncio +async def test_untagged_followup_stays_pinned_when_only_another_deployment_is_tagged_default() -> None: + served_by, reasoning = await _tagged_followup(None, origin_tags=()) + + assert served_by == "a" + assert reasoning["encrypted_content"] == "blob-from-a" + + +@pytest.mark.asyncio +async def test_routing_tag_that_excludes_the_pin_routes_to_the_tagged_deployment_without_its_reasoning() -> None: + served_by, reasoning = await _tagged_followup(["vendor:b"]) + + assert served_by == "b" + assert "encrypted_content" not in reasoning + assert reasoning["summary"] == [{"type": "summary_text", "text": "thought about it"}] + + +@pytest.mark.asyncio +async def test_routing_tag_that_picks_an_encryption_boundary_peer_keeps_the_reasoning() -> None: + served_by, reasoning = await _tagged_followup(["vendor:b"], peer_boundary="org-a") + + assert served_by == "b" + assert reasoning["encrypted_content"] == "blob-from-a" + + +@pytest.mark.asyncio +async def test_routing_tag_left_to_fail_open_keeps_the_pin_when_only_a_cooled_deployment_opts_in() -> None: + router: Final = litellm.Router( + model_list=[ + _tagged_deployment("a"), + _tagged_deployment("opted-in") | {"model_info": {"id": "opted-in", "allow_fail_open": True}}, + ], + enable_tag_filtering=True, + optional_pre_call_checks=["encrypted_content_affinity"], + num_retries=0, + ) + router.cooldown_cache.add_deployment_to_cooldown( + model_id="opted-in", original_exception=RuntimeError("unavailable"), exception_status=500, cooldown_time=3600 + ) + handler: Final = AsyncMock(return_value=_build_mock_response(output_items=[], response_id="resp_fail_open")) + with patch( + "litellm.llms.custom_httpx.llm_http_handler.BaseLLMHTTPHandler.async_response_api_handler", + new=handler, + ): + response: Final = await router.aresponses( + model="tagged-group", + input=[ + { + "type": "reasoning", + "summary": [], + "encrypted_content": ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id( + "blob-from-a", "a" + ), + }, + {"role": "user", "content": "follow-up"}, + ], + litellm_metadata={"tags": ["!default"], "inherited_tags": []}, + ) + + assert response._hidden_params["model_id"] == "a" + assert handler.await_args.kwargs["input"][0]["encrypted_content"] == "blob-from-a" + + +@pytest.mark.asyncio +async def test_routing_tag_strips_reasoning_only_a_rejected_boundary_peer_could_decrypt() -> None: + shares_only_a_credentials: Final = _tagged_deployment("a", tags=()) | { + "model_name": "other-group", + "model_info": {"id": "c"}, + } + router: Final = litellm.Router( + model_list=[_tagged_deployment("a", "org-a"), _tagged_deployment("b", "org-a"), shares_only_a_credentials], + enable_tag_filtering=True, + optional_pre_call_checks=["encrypted_content_affinity"], + num_retries=0, + ) + handler: Final = AsyncMock(return_value=_build_mock_response(output_items=[], response_id="resp_peer")) + with patch( + "litellm.llms.custom_httpx.llm_http_handler.BaseLLMHTTPHandler.async_response_api_handler", + new=handler, + ): + response: Final = await router.aresponses( + model="tagged-group", + input=[ + { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "from a"}], + "encrypted_content": ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id( + "blob-from-a", "a" + ), + }, + { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "from c"}], + "encrypted_content": ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id( + "blob-from-c", "c" + ), + }, + {"role": "user", "content": "follow-up"}, + ], + litellm_metadata={"tags": ["vendor:b"]}, + ) + forwarded: Final = handler.await_args.kwargs["input"] + + assert response._hidden_params["model_id"] == "b" + assert [item.get("encrypted_content") for item in forwarded[:2]] == ["blob-from-a", None] + + +def _boundary_peer(name: str, tags: list[str], model_info: Mapping[str, object]) -> dict[str, object]: + return { + "model_name": "tagged-group", + "litellm_params": { + "model": "openai/gpt-5.6", + "api_base": f"https://{name}.example.invalid/v1", + "api_key": f"sk-{name}", + "tags": tags, + }, + "model_info": {"id": name, "encrypted_content_boundary": "org-a", **model_info}, + } + + +@pytest.mark.asyncio +async def test_routing_tags_that_fail_open_onto_a_boundary_peer_are_served_by_it() -> None: + router: Final = litellm.Router( + model_list=[ + _boundary_peer("a", ["z"], {}) | {"model_name": "other-group"}, + _boundary_peer("default-peer", ["default", "x"], {"allow_fail_open": True}), + _boundary_peer("y-peer", ["y"], {}), + ], + enable_tag_filtering=True, + optional_pre_call_checks=["encrypted_content_affinity"], + num_retries=0, + ) + handler: Final = AsyncMock(return_value=_build_mock_response(output_items=[], response_id="resp_fail_open")) + with patch( + "litellm.llms.custom_httpx.llm_http_handler.BaseLLMHTTPHandler.async_response_api_handler", + new=handler, + ): + response: Final = await router.aresponses( + model="tagged-group", + input=[ + { + "type": "reasoning", + "summary": [], + "encrypted_content": ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id( + "blob-from-a", "a" + ), + }, + {"role": "user", "content": "follow-up"}, + ], + litellm_metadata={"tags": ["&x", "&y"], "inherited_tags": []}, + ) + + assert response._hidden_params["model_id"] == "default-peer" + assert handler.await_args.kwargs["input"][0]["encrypted_content"] == "blob-from-a" From 263281ac89c9b81b17451f0c5c84126ccb2f6213 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:49:15 +0000 Subject: [PATCH 05/86] feat(cost): bill batch cached image input tokens for OpenAI image models (#50997) * feat(cost): bill batch cached image input tokens for OpenAI image models * fix(cost): bill batch cached audio and image tokens without a text cache price * fix(cost): honor modality cache prices in the half-price batch fallback --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: kerry --- .../crates/model-catalog/src/model_info.rs | 1 + litellm/cost_calculator.py | 132 ++++++++++----- .../litellm_core_utils/llm_cost_calc/utils.py | 3 + ...odel_prices_and_context_window_backup.json | 6 + litellm/types/utils.py | 2 + litellm/utils.py | 3 + model_prices_and_context_window.json | 6 + .../llm_cost_calc/test_llm_cost_calc_utils.py | 17 ++ tests/unit/test_cost_calculator.py | 155 +++++++++++++++++- tests/unit/test_utils.py | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 + 11 files changed, 290 insertions(+), 40 deletions(-) diff --git a/litellm-rust/crates/model-catalog/src/model_info.rs b/litellm-rust/crates/model-catalog/src/model_info.rs index afbbfec59e6..fcd0c027323 100644 --- a/litellm-rust/crates/model-catalog/src/model_info.rs +++ b/litellm-rust/crates/model-catalog/src/model_info.rs @@ -28,6 +28,7 @@ pub struct ModelInfoFields { pub cache_read_input_audio_token_cost_flex: Option, pub cache_read_input_audio_token_cost_priority: Option, pub cache_read_input_audio_token_cost_batches: Option, + pub cache_read_input_image_token_cost_batches: Option, pub explicit_cache_read_input_token_cost: Option, pub prompt_cache_min_tokens: Option, pub cache_read_input_token_cost_above_200k_tokens: Option, diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 58947f9d0c2..53137b73f8b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -49,7 +49,6 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import ( BilledTokenRates, CostCalculatorUtils, calculate_cache_writing_cost, - calculate_cost_component, generic_cost_per_character, generic_cost_per_token, get_batch_cost_rates, @@ -3177,6 +3176,21 @@ def default_video_cost_calculator( return 0.0 +def _optional_batch_rate( + model_info: ModelInfo, + key: Literal[ + "input_cost_per_audio_token_batches", + "cache_read_input_audio_token_cost_batches", + "input_cost_per_image_token_batches", + "input_cost_per_video_token_batches", + ], + prompt_tokens: int, +) -> float | None: + context_key: Final = get_modality_token_cost_key(model_info, key, prompt_tokens) + context_rate: Final = get_cost_per_unit(model_info, context_key, None) + return model_info.get(key) if context_rate is None else context_rate + + def _batch_rate( model_info: ModelInfo, key: Literal[ @@ -3189,9 +3203,7 @@ def _batch_rate( *, prompt_tokens: int = 0, ) -> float: - context_key: Final = get_modality_token_cost_key(model_info, key, prompt_tokens) - context_rate: Final = get_cost_per_unit(model_info, context_key, None) - rate: Final = model_info.get(key) if context_rate is None else context_rate + rate: Final = _optional_batch_rate(model_info, key, prompt_tokens) return fallback if rate is None else rate @@ -3275,30 +3287,14 @@ def batch_cost_calculator( model_info, batch_rates.input, batch_rates.cache_read, + batch_rates.cache_read_image, batch_rates.cache_creation, batch_rates.cache_creation_rates_by_ttl, ) elif input_cost_per_token is not None: - details: Final = parse_prompt_tokens_details(usage) - cache_read_tokens: Final = details["cache_hit_tokens"] - cache_creation_tokens: Final = details["cache_creation_tokens"] - - # Subtract cached tokens from prompt_tokens before calculating cost - # Fixes issue where cached tokens are being charged again - base_input_tokens: Final = get_billable_input_tokens(usage) - cache_creation_tokens - total_prompt_cost = ( - base_input_tokens * (input_cost_per_token) / 2 - ) # batch cost is usually half of the regular token cost - - # Add cache read cost if applicable - cache_read_cost_key: Final = get_service_tier_cost_key("cache_read_input_token_cost", None) - total_prompt_cost += calculate_cost_component(model_info, cache_read_cost_key, cache_read_tokens) / 2 - - configured_cache_creation_cost: Final = model_info.get("cache_creation_input_token_cost") - cache_creation_cost: Final = ( - input_cost_per_token if configured_cache_creation_cost is None else configured_cache_creation_cost + total_prompt_cost = _half_price_prompt_cost( + usage, model_info, input_cost_per_token, batch_rates.cache_read_image ) - total_prompt_cost += cache_creation_tokens * cache_creation_cost / 2 text_rate: Final = _batch_or_half(batch_rates.output, output_cost_per_token) image_rate: Final = _batch_or_half( model_info.get("output_cost_per_image_token_batches"), @@ -3324,15 +3320,54 @@ def _standard_rate(model_info: ModelInfo, key: str, prompt_tokens: int) -> float return get_cost_per_unit(model_info, get_modality_token_cost_key(model_info, key, prompt_tokens), None) -def _cached_audio_batch_fallback( - model_info: ModelInfo, batch_cache_read_rate: float | None, cache_read_rate: float | None, prompt_tokens: int -) -> float: +def _cached_modality_rate( + model_info: ModelInfo, + standard_key: Literal["cache_read_input_audio_token_cost", "cache_read_input_image_token_cost"], + batch_modality_rate: float | None, + batch_cache_read_rate: float | None, + cache_read_rate: float | None, + prompt_tokens: int, +) -> float | None: + if batch_modality_rate is not None: + return batch_modality_rate if batch_cache_read_rate is not None: return batch_cache_read_rate - standard_audio_rate: Final = _standard_rate(model_info, "cache_read_input_audio_token_cost", prompt_tokens) - if standard_audio_rate is not None: - return standard_audio_rate - return 0.0 if cache_read_rate is None else cache_read_rate + standard_modality_rate: Final = _standard_rate(model_info, standard_key, prompt_tokens) + return cache_read_rate if standard_modality_rate is None else standard_modality_rate + + +def _half_price_prompt_cost( + usage: Usage, model_info: ModelInfo, input_cost_per_token: float, batch_cache_read_image_rate: float | None +) -> float: + details: Final = parse_prompt_tokens_details(usage) + cache_creation_tokens: Final = details["cache_creation_tokens"] + base_input_tokens: Final = get_billable_input_tokens(usage) - cache_creation_tokens + cache_read_cost_key: Final = get_service_tier_cost_key("cache_read_input_token_cost", None) + half_cache_read_rate: Final = (get_cost_per_unit(model_info, cache_read_cost_key, 0.0) or 0.0) / 2 + cached_audio_rate: Final = _batch_or_half( + _optional_batch_rate(model_info, "cache_read_input_audio_token_cost_batches", usage.prompt_tokens), + _standard_rate(model_info, "cache_read_input_audio_token_cost", usage.prompt_tokens), + default=half_cache_read_rate, + ) + cached_image_rate: Final = _batch_or_half( + batch_cache_read_image_rate, + _standard_rate(model_info, "cache_read_input_image_token_cost", usage.prompt_tokens), + default=half_cache_read_rate, + ) + cached_audio_tokens: Final = details["cache_hit_audio_tokens"] + cached_image_tokens: Final = details["cached_image_tokens"] + cached_text_tokens: Final = max(details["cache_hit_tokens"] - cached_audio_tokens - cached_image_tokens, 0) + configured_cache_creation_cost: Final = model_info.get("cache_creation_input_token_cost") + cache_creation_cost: Final = ( + input_cost_per_token if configured_cache_creation_cost is None else configured_cache_creation_cost + ) + return ( + base_input_tokens * input_cost_per_token / 2 + + cached_text_tokens * half_cache_read_rate + + cached_audio_tokens * cached_audio_rate + + cached_image_tokens * cached_image_rate + + cache_creation_tokens * cache_creation_cost / 2 + ) def _batch_prompt_cost( @@ -3340,6 +3375,7 @@ def _batch_prompt_cost( model_info: ModelInfo, input_rate: float, batch_cache_read_rate: float | None, + batch_cache_read_image_rate: float | None, cache_creation_rate: float | None, cache_creation_rates_by_ttl: Mapping[str, float], ) -> float: @@ -3349,8 +3385,30 @@ def _batch_prompt_cost( if batch_cache_read_rate is None else batch_cache_read_rate ) - cached_tokens: Final = details["cache_hit_tokens"] if cache_read_rate is not None else 0 - cached_audio_tokens: Final = details["cache_hit_audio_tokens"] if cache_read_rate is not None else 0 + cached_audio_rate: Final = _cached_modality_rate( + model_info, + "cache_read_input_audio_token_cost", + _optional_batch_rate(model_info, "cache_read_input_audio_token_cost_batches", usage.prompt_tokens), + batch_cache_read_rate, + cache_read_rate, + usage.prompt_tokens, + ) + cached_image_rate: Final = _cached_modality_rate( + model_info, + "cache_read_input_image_token_cost", + batch_cache_read_image_rate, + batch_cache_read_rate, + cache_read_rate, + usage.prompt_tokens, + ) + cached_audio_tokens: Final = details["cache_hit_audio_tokens"] if cached_audio_rate is not None else 0 + cached_image_tokens: Final = details["cached_image_tokens"] if cached_image_rate is not None else 0 + cached_text_tokens: Final = ( + max(details["cache_hit_tokens"] - details["cache_hit_audio_tokens"] - details["cached_image_tokens"], 0) + if cache_read_rate is not None + else 0 + ) + cached_tokens: Final = cached_text_tokens + cached_audio_tokens + cached_image_tokens bills_cache_writes: Final = cache_creation_rate is not None or bool(cache_creation_rates_by_ttl) written_tokens: Final = details["cache_creation_tokens"] if bills_cache_writes else 0 audio_tokens, image_tokens, video_tokens = ( @@ -3367,13 +3425,9 @@ def _batch_prompt_cost( * _batch_rate(model_info, "input_cost_per_audio_token_batches", input_rate, prompt_tokens=usage.prompt_tokens) + image_tokens * _batch_rate(model_info, "input_cost_per_image_token_batches", input_rate) + video_tokens * _batch_rate(model_info, "input_cost_per_video_token_batches", input_rate) - + (cached_tokens - cached_audio_tokens) * (cache_read_rate or 0.0) - + cached_audio_tokens - * _batch_rate( - model_info, - "cache_read_input_audio_token_cost_batches", - _cached_audio_batch_fallback(model_info, batch_cache_read_rate, cache_read_rate, usage.prompt_tokens), - ) + + cached_text_tokens * (cache_read_rate or 0.0) + + cached_audio_tokens * (cached_audio_rate or 0.0) + + cached_image_tokens * (cached_image_rate or 0.0) + ( calculate_cache_writing_cost( written_tokens, diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index ae7c4b47ece..2eb204d151b 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -112,6 +112,7 @@ _BATCH_RATE_PREFIXES: Final = ( "input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost", + "cache_read_input_image_token_cost", "cache_creation_input_token_cost", ) _BATCH_TIER_PREFIXES: Final = (*_BATCH_RATE_PREFIXES, *_TTL_PRICED_CACHE_WRITE_RATE_KEYS.values()) @@ -326,6 +327,7 @@ class BatchCostRates: cache_read: float | None cache_creation: float | None cache_creation_rates_by_ttl: Mapping[str, float] = field(default_factory=lambda: MappingProxyType({})) + cache_read_image: float | None = None def _batch_rate(model_info: ModelInfo, key: str) -> float | None: @@ -388,6 +390,7 @@ def get_batch_cost_rates(model_info: ModelInfo, usage: Usage, custom_llm_provide if (rate := _batch_rate_for_prefix(model_info, key, usage, inclusive)) is not None } ), + cache_read_image=_batch_rate_for_prefix(model_info, "cache_read_input_image_token_cost", usage, inclusive), ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index a4c16fbeaa3..b37a5197483 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -36449,6 +36449,7 @@ }, "gpt-image-1.5": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.3e-07, "deprecation_date": "2026-12-01", @@ -36497,6 +36498,7 @@ }, "gpt-image-2": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.25e-07, "deprecation_date": "2027-02-10", @@ -36539,6 +36541,7 @@ }, "gpt-image-2.5-flare": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.25e-07, "input_cost_per_token": 5e-06, @@ -36575,6 +36578,7 @@ }, "gpt-image-2.5-sunburst": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.25e-07, "input_cost_per_token": 5e-06, @@ -39936,6 +39940,7 @@ }, "gpt-image-1-mini": { "cache_read_input_image_token_cost": 2.5e-07, + "cache_read_input_image_token_cost_batches": 1.3e-07, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_batches": 1e-07, "deprecation_date": "2026-12-01", @@ -60402,6 +60407,7 @@ }, "chatgpt-image-latest": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.3e-07, "deprecation_date": "2026-12-01", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index ffc9e915ea4..6325de96839 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -372,6 +372,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): cache_read_input_token_cost_above_512k_tokens: float | None cache_read_input_token_cost_batches: ReadOnly[float | None] cache_read_input_audio_token_cost_batches: ReadOnly[float | None] + cache_read_input_image_token_cost_batches: ReadOnly[float | None] cache_read_input_token_cost_above_200k_tokens_batches: ReadOnly[float | None] cache_read_input_token_cost_above_100k_tokens_batches: ReadOnly[float | None] cache_read_input_token_cost_above_272k_tokens_batches: ReadOnly[float | None] @@ -4190,6 +4191,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): cache_read_input_token_cost_above_200k_tokens_batches: float | None = None cache_read_input_token_cost_above_272k_tokens_batches: float | None = None cache_read_input_audio_token_cost_batches: float | None = None + cache_read_input_image_token_cost_batches: float | None = None cache_creation_input_token_cost_batches: float | None = None cache_creation_input_token_cost_above_100k_tokens_batches: float | None = None cache_creation_input_token_cost_above_200k_tokens_batches: float | None = None diff --git a/litellm/utils.py b/litellm/utils.py index ca916e049d1..717b1b9009a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7266,6 +7266,9 @@ def get_model_info_helper( cache_read_input_audio_token_cost_batches=_model_info.get( "cache_read_input_audio_token_cost_batches", None ), + cache_read_input_image_token_cost_batches=_model_info.get( + "cache_read_input_image_token_cost_batches", None + ), explicit_cache_read_input_token_cost=_model_info.get("explicit_cache_read_input_token_cost", None), cache_read_input_image_token_cost=_model_info.get("cache_read_input_image_token_cost", None), prompt_cache_min_tokens=_model_info.get("prompt_cache_min_tokens", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index a4c16fbeaa3..b37a5197483 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -36449,6 +36449,7 @@ }, "gpt-image-1.5": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.3e-07, "deprecation_date": "2026-12-01", @@ -36497,6 +36498,7 @@ }, "gpt-image-2": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.25e-07, "deprecation_date": "2027-02-10", @@ -36539,6 +36541,7 @@ }, "gpt-image-2.5-flare": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.25e-07, "input_cost_per_token": 5e-06, @@ -36575,6 +36578,7 @@ }, "gpt-image-2.5-sunburst": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.25e-07, "input_cost_per_token": 5e-06, @@ -39936,6 +39940,7 @@ }, "gpt-image-1-mini": { "cache_read_input_image_token_cost": 2.5e-07, + "cache_read_input_image_token_cost_batches": 1.3e-07, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_batches": 1e-07, "deprecation_date": "2026-12-01", @@ -60402,6 +60407,7 @@ }, "chatgpt-image-latest": { "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_image_token_cost_batches": 1e-06, "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.3e-07, "deprecation_date": "2026-12-01", diff --git a/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index fd7ec0d6bca..0aa76deb9d4 100644 --- a/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -4163,6 +4163,23 @@ def test_get_batch_cost_rates_parses_string_rates(): assert (rates.input, rates.output, rates.cache_read) == (2e-6, 6e-6, 2e-7) +def test_get_batch_cost_rates_reads_the_batch_cached_image_rate_for_the_crossed_tier(): + from litellm.litellm_core_utils.llm_cost_calc.utils import get_batch_cost_rates + + rates = get_batch_cost_rates( + _batch_rates_model_info( + input_cost_per_token_batches=2.5e-6, + cache_read_input_image_token_cost=2e-6, + cache_read_input_image_token_cost_batches=1e-6, + cache_read_input_image_token_cost_above_272k_tokens_batches=1.5e-6, + ), + Usage(prompt_tokens=300_000, completion_tokens=1, total_tokens=300_001), + "openai", + ) + + assert rates.cache_read_image == 1.5e-6 + + def test_get_batch_cost_rates_falls_back_to_the_flat_rates_when_tier_rates_are_unparsable(): from litellm.litellm_core_utils.llm_cost_calc.utils import get_batch_cost_rates diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py index 9f3bfc2de86..8af9ae29c67 100644 --- a/tests/unit/test_cost_calculator.py +++ b/tests/unit/test_cost_calculator.py @@ -3,6 +3,7 @@ import asyncio import datetime import time from collections.abc import Iterator, Mapping +from pathlib import Path from types import MappingProxyType, SimpleNamespace from typing import Final, Literal, cast @@ -10,7 +11,7 @@ import httpx import pytest import respx from openai.types.completion_usage import CompletionUsage -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter import litellm from litellm import TranscriptionResponse, model_cost @@ -4580,6 +4581,158 @@ def test_batch_cost_calculator_bills_cached_tokens_at_the_standard_cache_price_w assert prompt_cost == pytest.approx(1000 * expected_rate) +def _cached_image_batch_usage() -> Usage: + return Usage( + prompt_tokens=1000, + completion_tokens=0, + total_tokens=1000, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=1000, image_tokens=1000, cached_tokens_details=CachedTokensDetails(image_tokens=1000) + ), + ) + + +@pytest.mark.parametrize( + ("image_batch_cache_price", "text_batch_cache_price", "expected_rate"), + [ + pytest.param(1e-6, 6.25e-7, 1e-6, id="the-batch-cached-image-price-wins"), + pytest.param(None, 6.25e-7, 6.25e-7, id="without-it-cached-images-bill-the-batch-cache-price"), + pytest.param(None, None, 2e-6, id="without-any-batch-cache-price-cached-images-bill-the-standard-image-cache-price"), + ], +) +def test_batch_cost_calculator_bills_cached_image_tokens_at_the_batch_cached_image_price( + image_batch_cache_price: float | None, text_batch_cache_price: float | None, expected_rate: float +) -> None: + model_info: Final = cast( + ModelInfo, + { + "input_cost_per_token": 5e-6, + "input_cost_per_image_token": 8e-6, + "cache_read_input_token_cost": 1.25e-6, + "cache_read_input_image_token_cost": 2e-6, + "input_cost_per_token_batches": 2.5e-6, + "input_cost_per_image_token_batches": 4e-6, + "cache_read_input_token_cost_batches": text_batch_cache_price, + "cache_read_input_image_token_cost_batches": image_batch_cache_price, + }, + ) + + prompt_cost, _ = batch_cost_calculator( + usage=_cached_image_batch_usage(), model="gpt-image-2", custom_llm_provider="openai", model_info=model_info + ) + + assert prompt_cost == pytest.approx(1000 * expected_rate) + + +_COST_MAP_ROWS: Final = TypeAdapter(dict[str, dict[str, object]]) + + +def _rows_with_a_batch_cached_image_price() -> tuple[tuple[str, str, float, ModelInfo], ...]: + rows: Final = _COST_MAP_ROWS.validate_json( + Path(litellm.__file__).with_name("model_prices_and_context_window_backup.json").read_bytes() + ) + return tuple( + (name, str(entry["litellm_provider"]), rate, cast(ModelInfo, entry)) + for name, entry in rows.items() + if isinstance(rate := entry.get("cache_read_input_image_token_cost_batches"), float) + ) + + +def _batch_cached_image_cost(name: str, provider: str, entry: ModelInfo) -> float: + return batch_cost_calculator(_cached_image_batch_usage(), name, provider, model_info=entry)[0] + + +@pytest.mark.parametrize( + ("usage", "modality_batch_cache_key"), + [ + pytest.param(_cached_audio_batch_usage(), "cache_read_input_audio_token_cost_batches", id="cached-audio"), + pytest.param(_cached_image_batch_usage(), "cache_read_input_image_token_cost_batches", id="cached-image"), + ], +) +def test_batch_cost_calculator_bills_cached_modality_tokens_at_their_batch_cache_price_without_a_text_cache_price( + usage: Usage, modality_batch_cache_key: str +) -> None: + model_info: Final = cast( + ModelInfo, + { + "input_cost_per_token": 5e-6, + "input_cost_per_audio_token": 8e-6, + "input_cost_per_image_token": 8e-6, + "input_cost_per_token_batches": 2.5e-6, + "input_cost_per_audio_token_batches": 4e-6, + "input_cost_per_image_token_batches": 4e-6, + modality_batch_cache_key: 1e-6, + }, + ) + + prompt_cost, _ = batch_cost_calculator( + usage=usage, model="gpt-image-2", custom_llm_provider="openai", model_info=model_info + ) + + assert prompt_cost == pytest.approx(1000 * 1e-6) + + +@pytest.mark.parametrize( + ("usage", "modality_cache_key", "expected_prompt_cost"), + [ + pytest.param( + _cached_audio_batch_usage(), + "cache_read_input_audio_token_cost_batches", + 1000 * 1e-6, + id="cached-audio-batch-price", + ), + pytest.param( + _cached_image_batch_usage(), + "cache_read_input_image_token_cost_batches", + 1000 * 1e-6, + id="cached-image-batch-price", + ), + pytest.param( + _cached_audio_batch_usage(), + "cache_read_input_audio_token_cost", + 1000 * 1e-6 / 2, + id="cached-audio-standard-price-halved", + ), + pytest.param( + _cached_image_batch_usage(), + "cache_read_input_image_token_cost", + 1000 * 1e-6 / 2, + id="cached-image-standard-price-halved", + ), + ], +) +def test_batch_cost_calculator_half_price_fallback_bills_cached_modality_tokens_at_their_own_cache_price( + usage: Usage, modality_cache_key: str, expected_prompt_cost: float +) -> None: + model_info: Final = cast( + ModelInfo, + { + "input_cost_per_token": 5e-6, + "cache_read_input_token_cost": 2.5e-6, + modality_cache_key: 1e-6, + }, + ) + + prompt_cost, _ = batch_cost_calculator( + usage=usage, model="gpt-image-2", custom_llm_provider="openai", model_info=model_info + ) + + assert prompt_cost == pytest.approx(expected_prompt_cost) + + +def test_batch_cached_image_tokens_bill_the_rows_batch_cached_image_price_on_every_row_that_has_one() -> None: + rows: Final = _rows_with_a_batch_cached_image_price() + assert rows, "no cost map row prices Batch cached image input" + + mispriced: Final = tuple( + (name, _batch_cached_image_cost(name, provider, entry), 1000 * rate) + for name, provider, rate, entry in rows + if _batch_cached_image_cost(name, provider, entry) != pytest.approx(1000 * rate) + ) + + assert mispriced == () + + def test_batch_cost_calculator_bills_long_context_cached_tokens_at_the_standard_long_context_cache_price() -> None: model_info: Final[ModelInfo] = { "input_cost_per_token": 1.25e-6, diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index eaa0b3970bd..045d78fc1e7 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1023,6 +1023,7 @@ _COST_MAP_ROW_SCHEMA: Final = { "cache_creation_input_token_cost_above_1hr_above_200k_tokens": {"type": "number"}, "cache_read_input_audio_token_cost": {"type": "number"}, "cache_read_input_audio_token_cost_batches": {"type": "number"}, + "cache_read_input_image_token_cost_batches": {"type": "number"}, "cache_read_input_audio_token_cost_flex": {"type": "number"}, "cache_read_input_audio_token_cost_priority": {"type": "number"}, "cache_read_input_image_token_cost": {"type": "number"}, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e1b0104e089..ddf6eb21c63 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -36614,6 +36614,8 @@ export interface components { cache_read_input_audio_token_cost_priority?: number | null; /** Cache Read Input Image Token Cost */ cache_read_input_image_token_cost?: number | null; + /** Cache Read Input Image Token Cost Batches */ + cache_read_input_image_token_cost_batches?: number | null; /** Cache Read Input Token Cost */ cache_read_input_token_cost?: number | null; /** Cache Read Input Token Cost Above 100K Tokens */ @@ -52804,6 +52806,8 @@ export interface components { cache_read_input_audio_token_cost_priority?: number | null; /** Cache Read Input Image Token Cost */ cache_read_input_image_token_cost?: number | null; + /** Cache Read Input Image Token Cost Batches */ + cache_read_input_image_token_cost_batches?: number | null; /** Cache Read Input Token Cost */ cache_read_input_token_cost?: number | null; /** Cache Read Input Token Cost Above 100K Tokens */ From c319616c1f8bae5df86193dc01df5a4611dd6b20 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:56:15 -0800 Subject: [PATCH 06/86] Registry audit 2026-11-10, Together, Mistral, Fireworks, Azure AI and Copilot fixes (#51003) Lowers Together Tev1 4B input price to the pricing page value, adds Mistral Leanstral vision and reasoning flags, and absorbs #50991, #50988 and #50958. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Kerry Lu <326648561+kerry-berri@users.noreply.github.com> Co-authored-by: zoveki <349028329+zoveki@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 195 +++++++++++++++++- model_prices_and_context_window.json | 195 +++++++++++++++++- .../test_fireworks_ai_chat_transformation.py | 16 ++ .../fireworks_ai/latest_router_aliases.py | 13 ++ ...t_fireworks_ai_responses_transformation.py | 22 ++ .../test_model_prices_and_context_window.py | 23 +++ 6 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 tests/unit/llms/fireworks_ai/latest_router_aliases.py diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b37a5197483..edcea48e4df 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -3921,6 +3921,11 @@ "max_input_tokens": 200000, "max_output_tokens": 64000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "output_cost_per_token": 2.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, @@ -3932,6 +3937,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, + "supports_web_search": true, "prompt_cache_min_tokens": 4096, "source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/model-retirement-schedule" }, @@ -4074,7 +4080,7 @@ "deprecation_source": "https://learn.microsoft.com/en-us/azure/foundry/concepts/model-lifecycle-retirement" }, "azure_ai/claude-fable-latest": { - "alias_of": "azure_ai/claude-fable-5-1" + "alias_of": "azure_ai/claude-fable-5-2" }, "azure_ai/claude-mythos-5-1": { "input_cost_per_token": 1e-05, @@ -4088,6 +4094,11 @@ "max_input_tokens": 1000000, "max_output_tokens": 128000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "supports_adaptive_thinking": true, "thinking_always_on": true, "supports_mid_conversation_system": true, @@ -4105,6 +4116,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": false, + "supports_web_search": true, "prompt_cache_min_tokens": 512, "source": "https://learn.microsoft.com/en-us/azure/foundry/concepts/model-lifecycle-retirement" }, @@ -4229,6 +4241,11 @@ "max_input_tokens": 200000, "max_output_tokens": 64000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, @@ -4239,6 +4256,7 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "supports_web_search": true, "prompt_cache_min_tokens": 1024, "source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/model-retirement-schedule" }, @@ -4290,6 +4308,11 @@ "max_input_tokens": 1000000, "max_output_tokens": 128000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -4302,6 +4325,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, + "supports_web_search": true, "prompt_cache_min_tokens": 1024, "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/claude-models" }, @@ -28947,6 +28971,24 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/minimax-latest": { + "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 9e-08, + "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 4.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 512000, + "max_output_tokens": 512000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.8e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/minimax-latest": { "alias_of": "fireworks_ai/accounts/fireworks/models/minimax-m3" }, @@ -33848,7 +33890,7 @@ "thinking_always_on": true }, "github_copilot/claude-fable-latest": { - "alias_of": "github_copilot/claude-fable-5.1" + "alias_of": "github_copilot/claude-fable-5.2" }, "github_copilot/claude-haiku-4.5": { "cache_creation_input_token_cost": 1.25e-06, @@ -65253,7 +65295,9 @@ "source": "https://docs.mistral.ai/models/model-cards/leanstral-1-5", "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-moderation-2603": { "input_cost_per_token": 0.0, @@ -65775,6 +65819,30 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/kimi-latest": { + "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_priority": 3.75e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_priority": 3.75e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_priority": 1.875e-05, + "reasoning_effort_levels": [ + "low", + "high", + "max" + ], + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/kimi-latest": { "alias_of": "fireworks_ai/accounts/fireworks/models/kimi-k3" }, @@ -66074,6 +66142,7 @@ "supports_function_calling": true, "supports_mid_conversation_system": true, "supports_reasoning": true, + "supports_tool_choice": true, "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b": { @@ -66137,6 +66206,24 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/qwen-max-latest": { + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_priority": 3.75e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_priority": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_priority": 9e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/routers/glm-5p2-fast": { "cache_read_input_token_cost": 2.1e-07, "deprecation_date": "2026-09-25", @@ -66194,6 +66281,27 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/kimi-fast-latest": { + "cache_read_input_token_cost": 4.5e-07, + "input_cost_per_token": 4.5e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.25e-05, + "reasoning_effort_levels": [ + "low", + "high", + "max" + ], + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/routers/kimi-k3-us": { "cache_read_input_token_cost": 4.5e-07, "cache_read_input_token_cost_priority": 5.625e-07, @@ -69008,7 +69116,9 @@ "source": "https://docs.mistral.ai/models/model-cards/leanstral-1-5", "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/glm-5p3": { "cache_read_input_token_cost": 2.6e-07, @@ -69029,6 +69139,25 @@ "supports_tool_choice": true, "supports_vision": false }, + "fireworks_ai/accounts/fireworks/routers/glm-latest": { + "cache_read_input_token_cost": 2.6e-07, + "cache_read_input_token_cost_priority": 3.25e-07, + "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, "fireworks_ai/accounts/fireworks/routers/glm-5p3-us": { "cache_read_input_token_cost": 3.9e-07, "cache_read_input_token_cost_priority": 4.875e-07, @@ -69102,6 +69231,22 @@ "supports_tool_choice": true, "supports_vision": false }, + "fireworks_ai/accounts/fireworks/routers/glm-fast-latest": { + "cache_read_input_token_cost": 3.9e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, "fireworks_ai/accounts/fireworks/routers/auto": { "litellm_provider": "fireworks_ai", "mode": "chat", @@ -69163,6 +69308,24 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/glm-flash-latest": { + "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_priority": 3.75e-08, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.875e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_priority": 6.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/routers/glm-5p3-flash-us": { "cache_read_input_token_cost": 4.5e-08, "cache_read_input_token_cost_priority": 5.625e-08, @@ -71992,12 +72155,12 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/together/Tev1-4B-experimental": { - "input_cost_per_token": 4.2e-08, + "input_cost_per_token": 4e-08, "litellm_provider": "together_ai", "max_input_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, - "source": "https://api.together.ai/v1/models" + "source": "https://www.together.ai/pricing" }, "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { "deprecation_date": "2026-02-06", @@ -79437,6 +79600,26 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/deepseek-flash-latest": { + "cache_read_input_token_cost": 6e-09, + "cache_read_input_token_cost_priority": 7.5e-09, + "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 3.75e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "github_copilot/claude-sonnet-4.6": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b37a5197483..edcea48e4df 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -3921,6 +3921,11 @@ "max_input_tokens": 200000, "max_output_tokens": 64000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "output_cost_per_token": 2.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, @@ -3932,6 +3937,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, + "supports_web_search": true, "prompt_cache_min_tokens": 4096, "source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/model-retirement-schedule" }, @@ -4074,7 +4080,7 @@ "deprecation_source": "https://learn.microsoft.com/en-us/azure/foundry/concepts/model-lifecycle-retirement" }, "azure_ai/claude-fable-latest": { - "alias_of": "azure_ai/claude-fable-5-1" + "alias_of": "azure_ai/claude-fable-5-2" }, "azure_ai/claude-mythos-5-1": { "input_cost_per_token": 1e-05, @@ -4088,6 +4094,11 @@ "max_input_tokens": 1000000, "max_output_tokens": 128000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "supports_adaptive_thinking": true, "thinking_always_on": true, "supports_mid_conversation_system": true, @@ -4105,6 +4116,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": false, + "supports_web_search": true, "prompt_cache_min_tokens": 512, "source": "https://learn.microsoft.com/en-us/azure/foundry/concepts/model-lifecycle-retirement" }, @@ -4229,6 +4241,11 @@ "max_input_tokens": 200000, "max_output_tokens": 64000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, @@ -4239,6 +4256,7 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "supports_web_search": true, "prompt_cache_min_tokens": 1024, "source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/model-retirement-schedule" }, @@ -4290,6 +4308,11 @@ "max_input_tokens": 1000000, "max_output_tokens": 128000, "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -4302,6 +4325,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, + "supports_web_search": true, "prompt_cache_min_tokens": 1024, "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/claude-models" }, @@ -28947,6 +28971,24 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/minimax-latest": { + "cache_read_input_token_cost": 6e-08, + "cache_read_input_token_cost_priority": 9e-08, + "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 4.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 512000, + "max_output_tokens": 512000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.8e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/minimax-latest": { "alias_of": "fireworks_ai/accounts/fireworks/models/minimax-m3" }, @@ -33848,7 +33890,7 @@ "thinking_always_on": true }, "github_copilot/claude-fable-latest": { - "alias_of": "github_copilot/claude-fable-5.1" + "alias_of": "github_copilot/claude-fable-5.2" }, "github_copilot/claude-haiku-4.5": { "cache_creation_input_token_cost": 1.25e-06, @@ -65253,7 +65295,9 @@ "source": "https://docs.mistral.ai/models/model-cards/leanstral-1-5", "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-moderation-2603": { "input_cost_per_token": 0.0, @@ -65775,6 +65819,30 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/kimi-latest": { + "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_priority": 3.75e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_priority": 3.75e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_priority": 1.875e-05, + "reasoning_effort_levels": [ + "low", + "high", + "max" + ], + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/kimi-latest": { "alias_of": "fireworks_ai/accounts/fireworks/models/kimi-k3" }, @@ -66074,6 +66142,7 @@ "supports_function_calling": true, "supports_mid_conversation_system": true, "supports_reasoning": true, + "supports_tool_choice": true, "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b": { @@ -66137,6 +66206,24 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/qwen-max-latest": { + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_priority": 3.75e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_priority": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_priority": 9e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/routers/glm-5p2-fast": { "cache_read_input_token_cost": 2.1e-07, "deprecation_date": "2026-09-25", @@ -66194,6 +66281,27 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/kimi-fast-latest": { + "cache_read_input_token_cost": 4.5e-07, + "input_cost_per_token": 4.5e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.25e-05, + "reasoning_effort_levels": [ + "low", + "high", + "max" + ], + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/routers/kimi-k3-us": { "cache_read_input_token_cost": 4.5e-07, "cache_read_input_token_cost_priority": 5.625e-07, @@ -69008,7 +69116,9 @@ "source": "https://docs.mistral.ai/models/model-cards/leanstral-1-5", "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/glm-5p3": { "cache_read_input_token_cost": 2.6e-07, @@ -69029,6 +69139,25 @@ "supports_tool_choice": true, "supports_vision": false }, + "fireworks_ai/accounts/fireworks/routers/glm-latest": { + "cache_read_input_token_cost": 2.6e-07, + "cache_read_input_token_cost_priority": 3.25e-07, + "input_cost_per_token": 1.4e-06, + "input_cost_per_token_priority": 1.75e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "output_cost_per_token_priority": 5.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, "fireworks_ai/accounts/fireworks/routers/glm-5p3-us": { "cache_read_input_token_cost": 3.9e-07, "cache_read_input_token_cost_priority": 4.875e-07, @@ -69102,6 +69231,22 @@ "supports_tool_choice": true, "supports_vision": false }, + "fireworks_ai/accounts/fireworks/routers/glm-fast-latest": { + "cache_read_input_token_cost": 3.9e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, "fireworks_ai/accounts/fireworks/routers/auto": { "litellm_provider": "fireworks_ai", "mode": "chat", @@ -69163,6 +69308,24 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/glm-flash-latest": { + "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_priority": 3.75e-08, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_priority": 1.875e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_priority": 6.25e-07, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "fireworks_ai/accounts/fireworks/routers/glm-5p3-flash-us": { "cache_read_input_token_cost": 4.5e-08, "cache_read_input_token_cost_priority": 5.625e-08, @@ -71992,12 +72155,12 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/together/Tev1-4B-experimental": { - "input_cost_per_token": 4.2e-08, + "input_cost_per_token": 4e-08, "litellm_provider": "together_ai", "max_input_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, - "source": "https://api.together.ai/v1/models" + "source": "https://www.together.ai/pricing" }, "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { "deprecation_date": "2026-02-06", @@ -79437,6 +79600,26 @@ "supports_tool_choice": true, "supports_vision": true }, + "fireworks_ai/accounts/fireworks/routers/deepseek-flash-latest": { + "cache_read_input_token_cost": 6e-09, + "cache_read_input_token_cost_priority": 7.5e-09, + "input_cost_per_token": 3e-07, + "input_cost_per_token_priority": 3.75e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_priority": 1.5e-06, + "source": "https://api.fireworks.ai/v1/serverless/models?format=nested", + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "github_copilot/claude-sonnet-4.6": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, diff --git a/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index dd352aac66d..0968dedd427 100644 --- a/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -23,6 +23,7 @@ from litellm.types.utils import ( Message, ModelResponse, ) +from tests.unit.llms.fireworks_ai.latest_router_aliases import LATEST_ROUTER_ALIAS_TARGETS def test_validate_environment_sets_session_affinity_from_litellm_session_id(): @@ -368,6 +369,21 @@ def test_get_supported_openai_params_parallel_tool_calls_without_tool_choice( assert "tool_choice" not in supported_params +@pytest.mark.parametrize(("alias", "target"), LATEST_ROUTER_ALIAS_TARGETS) +def test_latest_router_alias_accepts_the_same_params_as_the_model_it_points_to(alias: str, target: str) -> None: + config: Final = FireworksAIConfig() + alias_params: Final = set(config.get_supported_openai_params(alias)) + target_params: Final = set(config.get_supported_openai_params(target)) + excluded_params: Final = ( + {"reasoning_effort", "reasoning_history", "thinking"} + if alias == "accounts/fireworks/routers/glm-flash-latest" + else set() + ) + + assert alias_params - excluded_params == target_params - excluded_params + assert "tool_choice" in alias_params + + def test_get_provider_info_omits_false_supports_reasoning(monkeypatch): """Test that Fireworks only overrides supports_reasoning for supported models.""" config = FireworksAIConfig() diff --git a/tests/unit/llms/fireworks_ai/latest_router_aliases.py b/tests/unit/llms/fireworks_ai/latest_router_aliases.py new file mode 100644 index 00000000000..6c305034b93 --- /dev/null +++ b/tests/unit/llms/fireworks_ai/latest_router_aliases.py @@ -0,0 +1,13 @@ +from typing import Final + +# https://api.fireworks.ai/v1/serverless/models?format=nested, read 2026-10-29 +LATEST_ROUTER_ALIAS_TARGETS: Final = ( + ("accounts/fireworks/routers/minimax-latest", "accounts/fireworks/models/minimax-m3"), + ("accounts/fireworks/routers/kimi-latest", "accounts/fireworks/models/kimi-k3"), + ("accounts/fireworks/routers/kimi-fast-latest", "accounts/fireworks/routers/kimi-k3-fast"), + ("accounts/fireworks/routers/qwen-max-latest", "accounts/fireworks/models/qwen3p8-max"), + ("accounts/fireworks/routers/glm-latest", "accounts/fireworks/models/glm-5p3"), + ("accounts/fireworks/routers/glm-fast-latest", "accounts/fireworks/routers/glm-5p3-fast"), + ("accounts/fireworks/routers/glm-flash-latest", "accounts/fireworks/models/glm-5p3-flash"), + ("accounts/fireworks/routers/deepseek-flash-latest", "accounts/fireworks/models/deepseek-v4p1-flash-1017"), +) diff --git a/tests/unit/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/unit/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py index fe33a26dfd7..9a9c6ed3c82 100644 --- a/tests/unit/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py +++ b/tests/unit/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -26,6 +26,7 @@ from litellm.types.llms.openai import InputTokensDetails, ResponseAPIUsage, Resp from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager +from tests.unit.llms.fireworks_ai.latest_router_aliases import LATEST_ROUTER_ALIAS_TARGETS FIREWORKS_RESPONSES_URL: Final = "https://api.fireworks.ai/inference/v1/responses" HTTPX_CLIENT_FACTORY: Final = "litellm.llms.custom_httpx.llm_http_handler.get_httpx_client" @@ -249,6 +250,27 @@ def test_responses_call_keeps_developer_items_in_place_for_a_model_the_cost_map_ ) +@respx.mock +@pytest.mark.parametrize(("alias", "target"), LATEST_ROUTER_ALIAS_TARGETS) +def test_latest_router_alias_places_developer_items_like_the_model_it_points_to(alias: str, target: str) -> None: + route: Final = _fireworks_responses_route(alias) + for model in (alias, target): + litellm.responses( + model=f"fireworks_ai/{model}", + input=[ + {"role": "user", "content": "Hi there"}, + {"role": "developer", "content": "Answer with exactly one word."}, + {"role": "user", "content": "What is the capital of France?"}, + {"type": "message", "role": "assistant", "content": "Paris"}, + {"role": "developer", "content": "Reply with PONG."}, + ], + api_key="fw-test-key", + ) + alias_body, target_body = (json.loads(call.request.content) for call in route.calls) + assert alias_body["model"] == alias + assert {**alias_body, "model": target} == target_body + + @respx.mock def test_responses_call_folds_developer_items_when_the_loaded_cost_map_turns_the_flag_off( declare_mid_conversation_system: Callable[[str, bool], None], diff --git a/tests/unit/test_model_prices_and_context_window.py b/tests/unit/test_model_prices_and_context_window.py index b270d385693..165f92e0e59 100644 --- a/tests/unit/test_model_prices_and_context_window.py +++ b/tests/unit/test_model_prices_and_context_window.py @@ -1050,6 +1050,29 @@ def test_bedrock_gpt_oss_vision_is_explicitly_disabled_for_the_router_gate() -> assert litellm.utils.is_vision_explicitly_disabled("bedrock_mantle/openai.gpt-oss-20b") is True +@pytest.mark.parametrize("path", (PRICES_PATH, BACKUP_PRICES_PATH), ids=("main", "backup")) +def test_azure_ai_claude_rows_bill_web_search_like_their_anthropic_rows(path: Path) -> None: + prices: Final[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue]).validate_json(path.read_bytes()) + pairs: Final = { + key: (row, prices[key.removeprefix("azure_ai/")]) + for key, row in prices.items() + if key.startswith("azure_ai/claude") and isinstance(row, dict) and key.removeprefix("azure_ai/") in prices + } + priced_pairs: Final = { + key: (row, bare) + for key, (row, bare) in pairs.items() + if isinstance(bare, dict) and bare.get("search_context_cost_per_query") + } + assert priced_pairs + drifted: Final = tuple( + key + for key, (row, bare) in priced_pairs.items() + if row.get("search_context_cost_per_query") != bare["search_context_cost_per_query"] + or row.get("supports_web_search") is not True + ) + assert drifted == (), drifted + + @pytest.mark.parametrize("path", (PRICES_PATH, BACKUP_PRICES_PATH), ids=("main", "backup")) def test_azure_ai_claude_rows_priced_for_web_search_say_they_support_it(path: Path) -> None: prices: Final[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue]).validate_json(path.read_bytes()) From 63afd25823fd994ef834d7eee5292b8331bd4473 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:36:41 +0000 Subject: [PATCH 07/86] fix(cost-map): sync together_ai together/Tev1-4B-experimental input price with /v1/models (#51013) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/model_prices_and_context_window_backup.json | 4 ++-- model_prices_and_context_window.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index edcea48e4df..f3f56143964 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -72155,12 +72155,12 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/together/Tev1-4B-experimental": { - "input_cost_per_token": 4e-08, + "input_cost_per_token": 4.2e-08, "litellm_provider": "together_ai", "max_input_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, - "source": "https://www.together.ai/pricing" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { "deprecation_date": "2026-02-06", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index edcea48e4df..f3f56143964 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -72155,12 +72155,12 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/together/Tev1-4B-experimental": { - "input_cost_per_token": 4e-08, + "input_cost_per_token": 4.2e-08, "litellm_provider": "together_ai", "max_input_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, - "source": "https://www.together.ai/pricing" + "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { "deprecation_date": "2026-02-06", From 88dd18f4ab8275aaaef9ca5a0f0274a0aee26b66 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:38:09 +0000 Subject: [PATCH 08/86] chore(cost-map): add github_copilot/gemini-4-carbon from the Copilot changelog launch post (#51012) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../model_prices_and_context_window_backup.json | 17 +++++++++++++++++ model_prices_and_context_window.json | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f3f56143964..662909b5cb7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -88711,5 +88711,22 @@ "supports_vision": true, "supports_web_search": true, "web_search_billing_unit": "per_query" + }, + "github_copilot/gemini-4-carbon": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "github_copilot", + "max_input_tokens": 200000, + "max_output_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://github.blog/changelog/2026-11-09-gemini-4-carbon-in-github-copilot", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_vision": true } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f3f56143964..662909b5cb7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -88711,5 +88711,22 @@ "supports_vision": true, "supports_web_search": true, "web_search_billing_unit": "per_query" + }, + "github_copilot/gemini-4-carbon": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "github_copilot", + "max_input_tokens": 200000, + "max_output_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://github.blog/changelog/2026-11-09-gemini-4-carbon-in-github-copilot", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_vision": true } } From 4867a56e9aef1310c741b90ee9c1a9e1bbd49e0d Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:50:17 +0000 Subject: [PATCH 09/86] fix(proxy)!: reject client-supplied Vertex location and project unless the admin opts in (#47028) * fix(proxy): reject client-supplied Vertex location and project unless the admin opts in A request body could carry vertex_location, vertex_project, or their vertex_ai_* aliases and override the region and project pinned on a Vertex AI deployment, sending the admin's credentials to a caller-chosen project or region. Add the four fields to the banned request params so they need allow_client_side_credentials or configurable_clientside_auth_params. /vertex_ai/live and /rag/ingest name their Vertex project and location by design and have no deployment pin to override, so they keep those two fields while every other banned param is still rejected there * fix(proxy): close the remaining Vertex target overrides and keep inert fields working /vertex_ai/live now closes with 1008 when the query names a project or location that differs from a use_in_pass_through deployment's pin, unless that deployment or the proxy opts in A model group's opt-in now counts only when every deployment in it carries it, and Google-style routes look the opt-in up on the model named in the URL Vertex target names inside metadata, the live route's vertex_ai_* aliases, and /health/test_connection inheritance go back to the base behavior, since nothing reads them as a target * test(batches): give the batch FakeRouter the model-list lookup the opt-in check reads * fix(proxy): pin default_vertex_config on /vertex_ai/live and the proxy's Vertex project on /rag/ingest * fix(proxy): require the Vertex opt-in on alias targets and fallbacks, and pass the live rewriter flag in main's tests * test(proxy): give the batch tests' fake router the alias and routed-group lookups * fix(proxy): close the Vertex opt-in, RAG corpus, and live setup-frame gaps found in review A Vertex opt-in now counts only when every group the request can reach opts in, router, context window, and content policy fallbacks and one alias hop included, while a group with no Google deployment never needs the fields. A model named in the route path is checked next to the body model. RAG ingest from a non-admin key refuses a bare or dot-segment corpus id, a vertex_ai_project or vertex_ai_location naming another target, and a store a non-admin saved before the upgrade, and reads a corpus named by project number on the proxy's own project. /vertex_ai/live closes 1008 on any caller-named project when the proxy pins none, moves a setup frame sent as a binary message onto the session's target, and resolves the region from default_vertex_config before the model path. * fix(proxy): let a live caller name the proxy's configured Vertex project when nothing pins one With default_vertex_config set but no project pinned, /vertex_ai/live closed every query that named a project, including the one the admin set in VERTEXAI_PROJECT or DEFAULT_VERTEXAI_PROJECT. A query naming one of those projects now connects, and any other project still closes with 1008 before a token is minted. * fix(proxy): keep an admin-created vector store's corpus as saved on RAG ingest A non-admin ingest into a store an admin created named by project number had its corpus moved onto the proxy's own project before the admin-store exemption ran, so the upload went to a different corpus than the admin saved. The exemption now runs first, and a non-admin store still gets the project-number rewrite and the target check. * fix(proxy): authorize a RAG corpus moved onto the proxy's project and check client-side opt-ins once per request * fix(proxy): merge opt-in fallback groups only when their deployments share provider and opt-in list * fix(proxy): check team public models before allowing target overrides * fix(proxy): include team wildcard targets in opt-in checks * fix(proxy): validate overrides against authenticated team routes * fix(proxy): retain URL model during deferred credential checks --- litellm/proxy/auth/auth_utils.py | 401 +++++++++--- litellm/proxy/auth/user_api_key_auth.py | 19 +- litellm/proxy/batches_endpoints/endpoints.py | 1 + .../litellm_executed_batches.py | 5 +- .../health_endpoints/_health_endpoints.py | 8 +- .../openai_files_endpoints/files_endpoints.py | 1 + .../llm_passthrough_endpoints.py | 153 ++++- .../pass_through_endpoints.py | 46 +- .../passthrough_endpoint_router.py | 10 +- litellm/proxy/prompts/prompt_endpoints.py | 1 + litellm/proxy/proxy_server.py | 5 +- litellm/proxy/rag_endpoints/endpoints.py | 119 +++- litellm/rag/ingestion/vertex_ai_ingestion.py | 4 +- .../test_vertex_image_regional_uplift.py | 10 +- tests/unit/proxy/auth/test_auth_utils.py | 591 +++++++++++++++++- .../test_user_api_key_auth_request_flow.py | 92 ++- .../proxy/batches_endpoints/test_endpoints.py | 8 +- .../test_litellm_executed_batches.py | 22 + .../health_endpoints/test_health_endpoints.py | 6 + .../test_llm_pass_through_endpoints.py | 404 +++++++++++- .../test_pass_through_endpoints.py | 97 ++- .../public_v1/test_model_hub.py | 4 + .../proxy/rag_endpoints/test_rag_endpoints.py | 290 ++++++++- 23 files changed, 2163 insertions(+), 134 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 715b98a441a..7413490e44f 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -2,8 +2,9 @@ import importlib.util import os import re import sys -from collections.abc import Collection, Iterator, Mapping, Sequence -from functools import lru_cache, partial +from collections.abc import Callable, Collection, Iterator, Mapping, Sequence +from dataclasses import dataclass +from functools import cache, lru_cache from itertools import accumulate, chain, repeat from logging import Logger from types import MappingProxyType @@ -24,6 +25,7 @@ from litellm.constants import ( ) from litellm.litellm_core_utils.cloud_storage_security import REQUEST_S3_BUCKET_PARAMS, STORED_S3_BUCKET_PARAM from litellm.litellm_core_utils.credential_accessor import CredentialAccessor, reject_api_key_secret_reference +from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.url_utils import ( SSRFError, @@ -42,11 +44,13 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import ( from litellm.types.router import ( CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS, Deployment, + DeploymentTypedDict, + LiteLLMParamsTypedDict, reject_server_owned_wif_fields, server_owned_wif_fields_present, ) from litellm.types.router import reject_server_owned_wif_params as _reject_server_owned_wif_params -from litellm.types.utils import CustomPricingLiteLLMParams, oauth_token_exchange_litellm_params +from litellm.types.utils import CustomPricingLiteLLMParams, LlmProviders, oauth_token_exchange_litellm_params from litellm.types.videos.main import DecodedVideoId @@ -212,35 +216,74 @@ def _is_param_allowed( return False +def deployment_clientside_auth_params(litellm_params: LiteLLMParamsTypedDict) -> CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS: + if "configurable_clientside_auth_params" not in litellm_params: + return None + return litellm_params["configurable_clientside_auth_params"] + + +_DEPLOYMENT_LIST: Final = TypeAdapter(tuple[DeploymentTypedDict, ...], config=ConfigDict(arbitrary_types_allowed=True)) + + +def _model_group_deployments( + model: str, llm_router: Router, team_id: str | None = None +) -> Sequence[DeploymentTypedDict]: + registry: Final = llm_router.registry() + provider_group: Final = model.split("/", 1)[0] + if team_id is not None and registry.routing_group(model) is None: + team_deployments: Final = registry.deployments( + model, source="all", team_id=team_id + ) or _DEPLOYMENT_LIST.validate_python(registry.deployments(model, source="wildcard", team_id=team_id)) + if team_deployments: + return team_deployments + return ( + registry.deployments(model, source="routed_group", team_id=team_id) + or (registry.deployments(provider_group, team_id=team_id) if provider_group in provider_list else None) + or () + ) + + +_VERTEX_TARGET_READING_PROVIDERS: Final[frozenset[str]] = frozenset( + (LlmProviders.VERTEX_AI.value, LlmProviders.VERTEX_AI_BETA.value, LlmProviders.GEMINI.value, LlmProviders.GDC.value) +) + + +def _deployment_provider(litellm_params: LiteLLMParamsTypedDict) -> str | None: + match litellm_params: + case {"custom_llm_provider": str(declared)} if declared: + return declared + case {"model": str(model)}: + return inferred_provider(model) + case _: + return None + + +def _model_group_reads_vertex_target(model: str, llm_router: Router | None, team_id: str | None = None) -> bool: + if llm_router is None: + return True + deployments: Final = _model_group_deployments(model, llm_router, team_id) + providers: Final = frozenset(_deployment_provider(deployment["litellm_params"]) for deployment in deployments) + return not deployments or None in providers or bool(providers & _VERTEX_TARGET_READING_PROVIDERS) + + def _allow_model_level_clientside_configurable_parameters( - model: str, param: str, request_body_value: object, llm_router: Router | None + model: str, param: str, request_body_value: object, llm_router: Router | None, team_id: str | None = None ) -> bool: """ - Check if model is allowed to use configurable client-side params - - get matching model - - check if 'clientside_configurable_parameters' is set for model - - + The router, not the caller, picks which deployment serves the request, so an + opt-in counts only when every deployment of the model group, already alias + resolved, carries it """ if llm_router is None: return False - # check if model is set - registry: Final = llm_router.registry() - model_info = registry.group(model) - if model_info is None: - # check if wildcard model is set - if model.split("/", 1)[0] in provider_list: - model_info = registry.group(model.split("/", 1)[0]) - - if model_info is None: - return False - - if model_info is None or model_info.configurable_clientside_auth_params is None: - return False - - return _is_param_allowed( - param=param, - request_body_value=request_body_value, - configurable_clientside_auth_params=model_info.configurable_clientside_auth_params, + deployments: Final = _model_group_deployments(model, llm_router, team_id) + return bool(deployments) and all( + _is_param_allowed( + param=param, + request_body_value=request_body_value, + configurable_clientside_auth_params=deployment_clientside_auth_params(deployment["litellm_params"]), + ) + for deployment in deployments ) @@ -319,6 +362,20 @@ def route_manages_mcp_servers(route: str | None) -> bool: return route is not None and _MCP_SERVER_MANAGEMENT_ROUTE.fullmatch(route) is not None +VERTEX_TARGET_PARAMS: Final[frozenset[str]] = frozenset( + ("vertex_project", "vertex_location", "vertex_ai_project", "vertex_ai_location") +) +_ROUTES_THAT_NAME_THEIR_VERTEX_TARGET: Final[frozenset[str]] = frozenset( + ("/vertex_ai/live", "/rag/ingest", "/v1/rag/ingest") +) + + +def _without_route_named_vertex_target(request_body: Mapping[str, object], route: str | None) -> Mapping[str, object]: + if route not in _ROUTES_THAT_NAME_THEIR_VERTEX_TARGET: + return request_body + return MappingProxyType({key: value for key, value in request_body.items() if key not in VERTEX_TARGET_PARAMS}) + + _NESTED_CONFIG_KEYS: Final[tuple[str, ...]] = ("litellm_embedding_config", "extra_body") # Metadata containers that carry per-request configuration consumed by the @@ -411,6 +468,10 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "aws_external_id", "aws_session_tags", "vertex_credentials", + "vertex_location", + "vertex_project", + "vertex_ai_location", + "vertex_ai_project", # Azure managed-identity / federated-auth token. The Azure provider # transformer reads ``azure_ad_token`` (top-level or via # ``extra_body``) and resolves it through ``get_secret`` before @@ -483,6 +544,105 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( *sorted(CustomPricingLiteLLMParams.model_fields.keys()), ) +_BANNED_METADATA_PARAMS: Final[tuple[str, ...]] = tuple( + param for param in _BANNED_REQUEST_BODY_PARAMS if param not in VERTEX_TARGET_PARAMS +) + + +def _routed_opt_in_hop( + frontier_and_reached: tuple[frozenset[str], frozenset[str]], router_and_team: tuple[Router, str | None] +) -> tuple[frozenset[str], frozenset[str]]: + frontier, reached = frontier_and_reached + llm_router, team_id = router_and_team + deployments: Final = chain.from_iterable(_model_group_deployments(model, llm_router, team_id) for model in frontier) + routed_groups: Final = frozenset(deployment["model_name"] for deployment in deployments) - reached + fallbacks: Final = frozenset[str]().union( + *(router_fallback_model_names(name, llm_router, _EVERY_FALLBACK_TYPE) for name in routed_groups) + ) + registry: Final = llm_router.registry() + next_hop: Final = ( + routed_groups | frozenset(registry.resolve(name, kind="alias") or name for name in fallbacks) + ) - reached + return next_hop, reached | next_hop + + +def _opt_in_model_groups( + model: str, also_requested_models: tuple[str, ...], llm_router: Router | None, team_id: str | None = None +) -> frozenset[str]: + requested: Final = frozenset((model, *also_requested_models)) + if llm_router is None: + return requested + registry: Final = llm_router.registry() + routed: Final = frozenset(registry.resolve(name, kind="alias") or name for name in requested) + fallbacks: Final = frozenset[str]().union( + *(router_fallback_model_names(name, llm_router, _EVERY_FALLBACK_TYPE) for name in requested | routed) + ) + groups: Final = routed | frozenset(registry.resolve(name, kind="alias") or name for name in fallbacks) + hops: Final = accumulate(repeat((llm_router, team_id)), _routed_opt_in_hop, initial=(groups, groups)) + _, reached = next(hop for hop in hops if not hop[0]) + return reached + + +_MAX_OPT_IN_FALLBACK_MODELS: Final = 100 + + +@dataclass(frozen=True, slots=True) +class _OptInGroups: + every: tuple[str, ...] + vertex_target: tuple[str, ...] + team_id: str | None = None + + +_OptInKey = tuple[str | tuple[str, str | None], ...] + + +def _opt_in_key(litellm_params: LiteLLMParamsTypedDict) -> _OptInKey: + return tuple( + item if isinstance(item, str) else ("api_base", item.get("api_base")) + for item in deployment_clientside_auth_params(litellm_params) or () + ) + + +def _same_answer_key( + model_group: str, llm_router: Router, team_id: str | None +) -> frozenset[tuple[str | None, _OptInKey]] | str: + deployments: Final = _model_group_deployments(model_group, llm_router, team_id) + key: Final = frozenset( + (_deployment_provider(deployment["litellm_params"]), _opt_in_key(deployment["litellm_params"])) + for deployment in deployments + ) + return key or model_group + + +def _distinct_opt_in_groups( + model_groups: frozenset[str], llm_router: Router | None, team_id: str | None +) -> tuple[str, ...]: + if llm_router is None: + return tuple(sorted(model_groups)) + return tuple( + { + _same_answer_key(model_group, llm_router, team_id): model_group for model_group in sorted(model_groups) + }.values() + ) + + +def _resolve_opt_in_groups( + model: str, also_requested_models: tuple[str, ...], llm_router: Router | None, team_id: str | None = None +) -> _OptInGroups: + if len(frozenset(also_requested_models)) > _MAX_OPT_IN_FALLBACK_MODELS: + raise ValueError( + f"Rejected Request: the request names more than {_MAX_OPT_IN_FALLBACK_MODELS} fallback models, " + "too many to check against configurable_clientside_auth_params." + ) + every: Final = _distinct_opt_in_groups( + _opt_in_model_groups(model, also_requested_models, llm_router, team_id), llm_router, team_id + ) + return _OptInGroups( + every=every, + vertex_target=tuple(group for group in every if _model_group_reads_vertex_target(group, llm_router, team_id)), + team_id=team_id, + ) + _ClientSideAllowlistSetting: TypeAlias = Literal[ "allowed_client_side_s3_buckets", "allowed_client_side_s3_endpoint_urls" @@ -538,12 +698,14 @@ def _banned_param_error(param: str) -> ValueError: def _check_banned_params( body: Mapping[str, object], - general_settings: dict, + general_settings: Mapping[str, object], llm_router: Router | None, - model: str, *, manages_deployments: bool = False, mcp_server_fields_allowed: bool = False, + banned_params: tuple[str, ...] = _BANNED_REQUEST_BODY_PARAMS, + opt_in_groups: Callable[[], _OptInGroups], + defer_model_checks: bool = False, defer_s3_buckets: bool = False, ) -> None: """Raise ``ValueError`` if ``body`` carries a banned param without admin opt-in. @@ -560,7 +722,7 @@ def _check_banned_params( reject_server_owned_wif_params(wif_body) if not manages_deployments: reject_federated_credential_reference(body) - for param in _BANNED_REQUEST_BODY_PARAMS: + for param in banned_params: if param not in body: continue if general_settings.get("allow_client_side_credentials") is True: @@ -573,14 +735,20 @@ def _check_banned_params( continue if defer_s3_buckets and param in REQUEST_S3_BUCKET_PARAMS: continue - if ( + if defer_model_checks: + continue + if all( _allow_model_level_clientside_configurable_parameters( - model=model, + model=model_group, param=param, request_body_value=body[param], llm_router=llm_router, + team_id=opt_in_groups().team_id, ) is True + for model_group in ( + opt_in_groups().vertex_target if param in VERTEX_TARGET_PARAMS else opt_in_groups().every + ) ): # Per-param opt-in: only THIS param is permitted by the # deployment's ``configurable_clientside_auth_params``. Skip @@ -664,27 +832,39 @@ def fallback_target_model_name(target: object) -> str | None: return None -def _direct_fallback_model_names(model: str, llm_router: Router) -> frozenset[str]: +_EVERY_FALLBACK_TYPE: Final[tuple[str, ...]] = ("general", "context_window", "content_policy") + + +def _direct_fallback_model_names(model: str, llm_router: Router, fallback_types: tuple[str, ...]) -> frozenset[str]: from litellm.proxy.auth.model_checks import get_all_fallbacks - return frozenset( - name for target in get_all_fallbacks(model, llm_router) if (name := fallback_target_model_name(target)) + targets: Final = chain.from_iterable( + get_all_fallbacks(model, llm_router, fallback_type) for fallback_type in fallback_types ) + return frozenset(name for target in targets if (name := fallback_target_model_name(target))) def _fallback_hop( - frontier_and_reached: tuple[frozenset[str], frozenset[str]], llm_router: Router + frontier_and_reached: tuple[frozenset[str], frozenset[str]], router_and_types: tuple[Router, tuple[str, ...]] ) -> tuple[frozenset[str], frozenset[str]]: frontier, reached = frontier_and_reached + llm_router, fallback_types = router_and_types next_hop: Final = ( - frozenset(chain.from_iterable(_direct_fallback_model_names(model, llm_router) for model in frontier)) - reached + frozenset( + chain.from_iterable(_direct_fallback_model_names(model, llm_router, fallback_types) for model in frontier) + ) + - reached ) return next_hop, reached | next_hop -def router_fallback_model_names(model: str, llm_router: Router) -> frozenset[str]: +def router_fallback_model_names( + model: str, llm_router: Router, fallback_types: tuple[str, ...] = ("general",) +) -> frozenset[str]: """The groups a failure on `model` can reach through the router's fallbacks, chained and generic (`*`) ones.""" - hops: Final = accumulate(repeat(llm_router), _fallback_hop, initial=(frozenset({model}), frozenset({model}))) + hops: Final = accumulate( + repeat((llm_router, fallback_types)), _fallback_hop, initial=(frozenset({model}), frozenset({model})) + ) _, reached = next(hop for hop in hops if not hop[0]) return reached - {model} @@ -714,12 +894,14 @@ def _reject_url_valued_fallback_target(value: str) -> None: def is_request_body_safe( - request_body: dict, - general_settings: dict, + request_body: Mapping[str, object], + general_settings: Mapping[str, object], llm_router: Router | None, model: str, *, route: str | None = None, + team_id: str | None = None, + defer_model_checks: bool = False, defer_s3_buckets: bool = False, ) -> bool: """ @@ -750,28 +932,72 @@ def is_request_body_safe( if "model_list" in request_body: raise ValueError("Rejected Request: model_list is not allowed in the request body.") manages_deployments: Final = route_manages_deployments(route) - check_banned_params: Final = partial( - _check_banned_params, - general_settings=general_settings, - llm_router=llm_router, - model=model, + root_body: Final = _without_route_named_vertex_target(request_body, route) + route_model: Final = _model_from_route_path(route) if route is not None else None + also_requested_models: Final = ( + *request_fallback_model_names(root_body), + *((route_model,) if route_model else ()), + ) + opt_in_groups: Final = cache( + lambda: _resolve_opt_in_groups(model or route_model or "", also_requested_models, llm_router, team_id) + ) + _check_banned_params( + root_body, + general_settings, + llm_router, manages_deployments=manages_deployments, + mcp_server_fields_allowed=route_manages_mcp_servers(route), + opt_in_groups=opt_in_groups, + defer_model_checks=defer_model_checks, defer_s3_buckets=defer_s3_buckets, ) - check_banned_params(request_body, mcp_server_fields_allowed=route_manages_mcp_servers(route)) for nested_key in _NESTED_CONFIG_KEYS: nested = _coerce_metadata_to_dict(request_body.get(nested_key)) if nested is not None: - check_banned_params(nested) + _check_banned_params( + nested, + general_settings, + llm_router, + manages_deployments=manages_deployments, + opt_in_groups=opt_in_groups, + defer_model_checks=defer_model_checks, + defer_s3_buckets=defer_s3_buckets, + ) for metadata_key in _NESTED_METADATA_KEYS: metadata = _coerce_metadata_to_dict(request_body.get(metadata_key)) if metadata is not None: - check_banned_params(metadata) - if any(isinstance(key, str) and key.startswith(f"{metadata_key}[") for key in request_body): - check_banned_params(extract_nested_form_metadata(form_data=request_body, prefix=f"{metadata_key}[")) + _check_banned_params( + metadata, + general_settings, + llm_router, + manages_deployments=manages_deployments, + banned_params=_BANNED_METADATA_PARAMS, + opt_in_groups=opt_in_groups, + defer_model_checks=defer_model_checks, + defer_s3_buckets=defer_s3_buckets, + ) + if any(key.startswith(f"{metadata_key}[") for key in request_body): + _check_banned_params( + extract_nested_form_metadata(form_data=request_body, prefix=f"{metadata_key}["), + general_settings, + llm_router, + manages_deployments=manages_deployments, + banned_params=_BANNED_METADATA_PARAMS, + opt_in_groups=opt_in_groups, + defer_model_checks=defer_model_checks, + defer_s3_buckets=defer_s3_buckets, + ) for target in iter_request_fallback_targets(request_body): if isinstance(target, dict): - check_banned_params(target) + _check_banned_params( + target, + general_settings, + llm_router, + manages_deployments=manages_deployments, + opt_in_groups=opt_in_groups, + defer_model_checks=defer_model_checks, + defer_s3_buckets=defer_s3_buckets, + ) target_model = target.get("model") if isinstance(target_model, str): _reject_url_valued_fallback_target(target_model) @@ -784,8 +1010,17 @@ def is_request_body_safe( _reject_request_credential_params(litellm_params, manages_deployments=manages_deployments) litellm_params_metadata: Final = _coerce_metadata_to_dict(litellm_params.get("metadata")) if litellm_params_metadata is not None: - check_banned_params(litellm_params_metadata) - for user_config_params in _user_config_litellm_params(request_body): # pyright: ignore[reportUnknownArgumentType] # request_body is an untyped dict + _check_banned_params( + litellm_params_metadata, + general_settings, + llm_router, + manages_deployments=manages_deployments, + banned_params=_BANNED_METADATA_PARAMS, + opt_in_groups=opt_in_groups, + defer_model_checks=defer_model_checks, + defer_s3_buckets=defer_s3_buckets, + ) + for user_config_params in _user_config_litellm_params(request_body): reject_api_key_secret_reference(user_config_params) return True @@ -880,6 +1115,8 @@ def enforce_team_s3_bucket_allowlist( general_settings: Mapping[str, object], llm_router: Router | None, team_metadata: Mapping[str, object] | None, + *, + team_id: str | None = None, ) -> None: """Re-run the request body check that auth relaxed for bucket params, now admitting the caller team's buckets.""" proxy_buckets: Final = _ClientSideStorageAllowlist.model_validate(general_settings).allowed_client_side_s3_buckets @@ -893,6 +1130,7 @@ def enforce_team_s3_bucket_allowlist( }, llm_router=llm_router, model=model if isinstance(model, str) else "", + team_id=team_id, ) except ValueError as rejection: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Authentication Error, {rejection}") @@ -902,6 +1140,8 @@ async def pre_db_read_auth_checks( request: Request, request_data: dict, route: str, + *, + defer_team_checks: bool = False, ): """ 1. Checks if request size is under max_request_size_mb (if set) @@ -921,12 +1161,17 @@ async def pre_db_read_auth_checks( await check_if_request_size_is_safe(request=request) # Check 2. Request body is safe + defer_model_checks: Final = ( + defer_team_checks and llm_router is not None and bool(llm_router.registry().index("team_model")) + ) + request.state.deferred_model_body_check = defer_model_checks # rebind-ok: hand state to auth is_request_body_safe( request_body=request_data, general_settings=general_settings, llm_router=llm_router, - model=request_data.get("model", ""), # [TODO] use model passed in url as well (azure openai routes) + model=request_data.get("model") or _model_from_route_path(route) or "", route=route, + defer_model_checks=defer_model_checks, defer_s3_buckets=checks_team_s3_buckets_in_handler(request), ) @@ -2454,37 +2699,8 @@ def get_model_from_request( team_id=team_id, ) model = _format_model_candidates(candidates) - - # If no explicit model was found, try to extract from route if model is None: - # Parse model from route that follows the pattern /openai/deployments/{model}/* - match: Final = re.match(r"/openai/deployments/([^/]+)", route) - if match: - model = match.group(1) - - # If still not found, extract model from Google generateContent-style routes. - # These routes put the model in the path and allow "/" inside the model id. - # Examples: - # - /v1beta/models/gemini-2.0-flash:generateContent - # - /v1beta/models/bedrock/claude-sonnet-3.7:generateContent - # - /models/custom/ns/model:streamGenerateContent - if model is None and not route.lower().startswith("/vertex"): - google_match = re.search(r"/(?:v1beta|beta)/models/([^:]+):", route) - if google_match: - model = google_match.group(1) - - if model is None and not route.lower().startswith("/vertex"): - google_match = re.search(r"^/models/([^:]+):", route) - if google_match: - model = google_match.group(1) - - # If still not found, extract from Vertex AI passthrough route - # Pattern: /vertex_ai/.../models/{model_id}:* - # Example: /vertex_ai/v1/.../models/gemini-1.5-pro:generateContent - if model is None and route.lower().startswith("/vertex"): - vertex_match: Final = re.search(r"/models/([^:]+)", route) - if vertex_match: - model = vertex_match.group(1) + model = _model_from_route_path(route) if route.lower().startswith("/bedrock"): bedrock_model: Final = _model_from_bedrock_route(route) @@ -2503,6 +2719,23 @@ def get_model_from_request( return model +def _model_from_route_path(route: str) -> str | None: + """ + Google generateContent-style routes allow "/" inside the model id, e.g. + /v1beta/models/bedrock/claude-sonnet-3.7:generateContent + """ + azure_deployment: Final = re.match(r"/openai/deployments/([^/]+)", route) + if azure_deployment: + return azure_deployment.group(1) + if route.lower().startswith("/vertex"): + vertex_model: Final = re.search(r"/models/([^:]+)", route) + return vertex_model.group(1) if vertex_model else None + google_model: Final = re.search(r"/(?:v1beta|beta)/models/([^:]+):", route) or re.search( + r"^/models/([^:]+):", route + ) + return google_model.group(1) if google_model else None + + def _router_model_from_azure_route(route: str, llm_router: Router | None) -> str | None: if llm_router is None: return None diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 1dca6075a3d..5bf4db6a725 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -109,12 +109,14 @@ from litellm.proxy.auth.auth_object_prefetch import ( ) from litellm.proxy.auth.auth_utils import ( abbreviate_api_key, + checks_team_s3_buckets_in_handler, fallback_target_model_name, get_end_user_id_from_request_body, get_model_from_request, get_request_route, get_request_route_template, is_invalid_virtual_key_error, + is_request_body_safe, iter_request_fallback_targets, log_model_access_denial, normalize_request_route, @@ -2276,6 +2278,7 @@ async def user_api_key_auth_builder( azure_apim_header: str | None, request_data: dict[str, object], custom_litellm_key_header: str | None = None, + defer_team_checks: bool = False, ) -> UserAPIKeyAuth: from litellm.proxy.proxy_server import ( general_settings, @@ -2310,6 +2313,7 @@ async def user_api_key_auth_builder( request_data=request_data, request=request, route=route, + defer_team_checks=defer_team_checks, ) pass_through_endpoints: Final[list[dict] | None] = general_settings.get("pass_through_endpoints", None) ## CHECK IF X-LITELM-API-KEY IS PASSED IN - supercedes Authorization header @@ -3684,6 +3688,18 @@ async def run_centralized_common_checks( user_custom_auth, ) + if not sub_call and getattr(request.state, "deferred_model_body_check", False) is True: + body_model: Final = request_data.get("model") + is_request_body_safe( + request_body=request_data, + general_settings=_GENERAL_SETTINGS_ADAPTER.validate_python(general_settings), + llm_router=llm_router, + model=body_model if isinstance(body_model, str) else "", + route=route, + team_id=user_api_key_auth_obj.team_id, + defer_s3_buckets=checks_team_s3_buckets_in_handler(request), + ) + # Public routes (e.g. /health/liveness) are exempt from # auth in the builder — the wrapper must not retroactively apply # authz on top, or k8s readiness probes and other unauthenticated @@ -4520,6 +4536,7 @@ async def user_api_key_auth( azure_apim_header=azure_apim_header, request_data=request_data, custom_litellm_key_header=custom_litellm_key_header, + defer_team_checks=True, ) except Exception: # The body was read first, so a caller who sent both a malformed body and @@ -4999,7 +5016,7 @@ async def authorize_internal_virtual_key( ) ) route: Final = get_request_route(request=request) - await pre_db_read_auth_checks(request_data=request_data, request=request, route=route) + await pre_db_read_auth_checks(request_data=request_data, request=request, route=route, defer_team_checks=True) auth: Final = await validate_resolved_virtual_key( request=request, request_data=request_data, diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 00fa7f3c33e..0b443572fe6 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -303,6 +303,7 @@ async def create_batch( general_settings=general_settings_view(), llm_router=llm_router, team_metadata=user_api_key_dict.team_metadata, + team_id=user_api_key_dict.team_id, ) _raise_when_routing_headers_disagree(request, data.get("model")) verbose_proxy_logger.debug( diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py index 4dd6496812e..8c78e650339 100644 --- a/litellm/proxy/batches_endpoints/litellm_executed_batches.py +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -452,7 +452,7 @@ class LiteLLMExecutedBatchRunner: ) -> LiteLLMBatch: endpoint: Final = _validate_endpoint(create_request.get("endpoint")) content: Final = await self._download_input(unified_input_file_id, user_api_key_dict) - parsed: Final = parse_batch_input(content, endpoint, self._body_rejection(model)) + parsed: Final = parse_batch_input(content, endpoint, self._body_rejection(model, user_api_key_dict.team_id)) if isinstance(parsed, InvalidBatchInput): raise batch_error(400, f"Invalid batch input file: {parsed.describe()}") llm_batch_id: Final = f"{LITELLM_EXECUTED_BATCH_ID_PREFIX}{uuid_module.uuid4().hex}" @@ -533,7 +533,7 @@ class LiteLLMExecutedBatchRunner: return failed return await self.batches.load_batch(batch.id) or batch - def _body_rejection(self, model: str) -> BodyRejection: + def _body_rejection(self, model: str, team_id: str | None) -> BodyRejection: def reject(body: Mapping[str, object]) -> str | None: try: is_request_body_safe( @@ -541,6 +541,7 @@ class LiteLLMExecutedBatchRunner: general_settings=dict(self.general_settings), llm_router=self.llm_router, model=model, + team_id=team_id, ) except ValueError as e: return str(e) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 333687b07cd..1211c56975b 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -62,6 +62,7 @@ from litellm.proxy.auth.auth_exception_handler import ( ) from litellm.proxy.auth.auth_utils import ( _BANNED_REQUEST_BODY_PARAMS, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the request-body check + VERTEX_TARGET_PARAMS, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils import http_parsing_utils @@ -156,6 +157,11 @@ _CONFIG_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset( ) +_BANNED_CONNECTION_PARAMS: Final[tuple[str, ...]] = tuple( + param for param in _BANNED_REQUEST_BODY_PARAMS if param not in VERTEX_TARGET_PARAMS +) + + def _request_inherits_config_credentials( config_params: Mapping[str, object], request_params: Mapping[str, object], @@ -181,7 +187,7 @@ def _request_inherits_config_credentials( return False if allow_client_side_credentials: return True - return not any(param in request_params for param in _BANNED_REQUEST_BODY_PARAMS) + return not any(param in request_params for param in _BANNED_CONNECTION_PARAMS) def _admin_api_key_from_request( diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index ca1470bc94e..8b8d9ec81be 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -639,6 +639,7 @@ async def create_file( general_settings=general_settings_view(), llm_router=llm_router, team_metadata=user_api_key_dict.team_metadata, + team_id=user_api_key_dict.team_id, ) file_params: Final = await extract_file_creation_params( request=request, diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 5bdb2219194..6ab31e7d4b0 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -93,6 +93,7 @@ from litellm.proxy.auth.auth_checks import ( key_model_aliases_for_auth_check, team_model_aliases_for_auth_check, ) +from litellm.proxy.auth.auth_utils import deployment_clientside_auth_params from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.user_api_key_auth import ( # noqa: F401 # legacy module exports @@ -4241,6 +4242,10 @@ VERTEX_LIVE_UNCONFIGURED_CLOSE_REASON: Final = ( "Vertex AI auth failed: set a use_in_pass_through vertex model, default_vertex_config, or DEFAULT_VERTEXAI_* env" ) +VERTEX_LIVE_UNPINNED_PROJECT_CLOSE_REASON: Final = ( + "Rejected Request: vertex_project must come from the proxy's configuration, see allow_client_side_credentials" +) + VERTEX_PUBLISHER_MODEL_PREFIX: Final = "publishers/google/models/" VERTEX_PUBLISHERS_SEGMENT: Final = "publishers/" @@ -4265,11 +4270,25 @@ def _get_llm_router() -> Router | None: return llm_router -def _resolve_vertex_live_credentials( +@dataclass(frozen=True, slots=True) +class _ConfiguredVertexLiveTarget: + credentials: VertexPassThroughCredentials | None + + +@dataclass(frozen=True, slots=True) +class _DeploymentVertexLiveTarget: + credentials: VertexPassThroughCredentials + deployment_params: LiteLLMParamsTypedDict + + +_VertexLiveTarget = _ConfiguredVertexLiveTarget | _DeploymentVertexLiveTarget + + +def _resolve_vertex_live_target( vertex_project: str | None, vertex_location: str | None, model: str | None, -) -> VertexPassThroughCredentials | None: +) -> _VertexLiveTarget: """ Resolution order: an explicit project/location registration, then ``default_vertex_config`` (which the proxy fills from the ``DEFAULT_VERTEXAI_*`` env vars whenever the yaml leaves it out), then any DB model entry @@ -4283,36 +4302,133 @@ def _resolve_vertex_live_credentials( location=vertex_location, ) if keyed is not None and keyed.vertex_project is not None: - return keyed - from_deployments: Final = passthrough_endpoint_router.get_vertex_credentials_from_router_deployments(model=model) + return _ConfiguredVertexLiveTarget(credentials=keyed) + from_deployments: Final = passthrough_endpoint_router.get_vertex_deployment_from_router(model=model) if from_deployments is not None: - return from_deployments + deployment, credentials = from_deployments + return _DeploymentVertexLiveTarget(credentials=credentials, deployment_params=deployment["litellm_params"]) if keyed is not None: - return keyed + return _ConfiguredVertexLiveTarget(credentials=keyed) passthrough_endpoint_router.set_default_vertex_config() - return passthrough_endpoint_router.get_vertex_credentials( - project_id=vertex_project, - location=vertex_location, + return _ConfiguredVertexLiveTarget( + credentials=passthrough_endpoint_router.get_vertex_credentials( + project_id=vertex_project, + location=vertex_location, + ) ) +def _requested_vertex_live_target( + credentials: VertexPassThroughCredentials, vertex_project: str | None, vertex_location: str | None +) -> tuple[tuple[str, str | None, str | None], ...]: + return ( + ("vertex_project", vertex_project, credentials.vertex_project), + ("vertex_location", vertex_location, credentials.vertex_location), + ) + + +def _proxy_configured_vertex_projects() -> frozenset[str]: + return frozenset( + project + for project in ( + litellm.vertex_project, + get_secret_str("VERTEXAI_PROJECT"), + get_secret_str("DEFAULT_VERTEXAI_PROJECT"), + ) + if project + ) + + +def _names_an_unconfigured_project( + credentials: VertexPassThroughCredentials | None, vertex_project: str | None +) -> bool: + return ( + bool(vertex_project) + and (credentials is None or credentials.vertex_project is None) + and vertex_project not in _proxy_configured_vertex_projects() + ) + + +def _live_target_override_reason( + target: _VertexLiveTarget, + vertex_project: str | None, + vertex_location: str | None, + allow_client_side_credentials: bool, +) -> str | None: + if allow_client_side_credentials: + return None + match target: + case _ConfiguredVertexLiveTarget(credentials=credentials) if _names_an_unconfigured_project( + credentials, vertex_project + ): + return VERTEX_LIVE_UNPINNED_PROJECT_CLOSE_REASON + case _ConfiguredVertexLiveTarget(credentials=VertexPassThroughCredentials() as credentials): + configured_field: Final = next( + ( + name + for name, asked, pinned in _requested_vertex_live_target( + credentials, vertex_project, vertex_location + ) + if asked and pinned and asked != pinned + ), + None, + ) + if configured_field is None: + return None + return ( + f"Rejected Request: {configured_field} is pinned by default_vertex_config, " + "see allow_client_side_credentials" + ) + case _ConfiguredVertexLiveTarget(): + return None + case _DeploymentVertexLiveTarget(credentials=credentials, deployment_params=deployment_params): + opted_in: Final = deployment_clientside_auth_params(deployment_params) or () + deployment_field: Final = next( + ( + name + for name, asked, pinned in _requested_vertex_live_target( + credentials, vertex_project, vertex_location + ) + if asked and asked != pinned and name not in opted_in + ), + None, + ) + if deployment_field is None: + return None + return ( + f"Rejected Request: {deployment_field} is pinned by the deployment, " + "see configurable_clientside_auth_params" + ) + + +_VERTEX_RESOURCE_PROJECT_AND_LOCATION: Final = re.compile(r"projects/[^/]+/locations/(?P[^/]+)/") + + def _build_vertex_live_setup_model_rewriter( vertex_project: str | None, vertex_location: str | None, + configured_location: str | None, llm_router: Router | None, + keeps_caller_resource_path: bool, ) -> Callable[[str], str] | None: """ Rewrite the ``setup`` frame's model into the full Vertex resource path the Live API requires. Clients address the gateway the way they address LiteLLM (bare id or model alias); Vertex reads anything - that is not a ``projects/...`` path as a project name and closes the socket + that is not a ``projects/...`` path as a project name and closes the socket. A ``projects/...`` path + picks the project the proxy's token is spent on, so it is moved onto the connection's project, and onto its + region when one is configured, unless ``allow_client_side_credentials`` lets callers choose them """ if vertex_project is None or vertex_location is None: return None def rewrite(setup_model: str) -> str: if setup_model.startswith("projects/"): - return setup_model + resource_prefix: Final = _VERTEX_RESOURCE_PROJECT_AND_LOCATION.match(setup_model) + if keeps_caller_resource_path or resource_prefix is None: + return setup_model + location: Final = configured_location or resource_prefix.group("location") + return f"projects/{vertex_project}/locations/{location}/{setup_model[resource_prefix.end() :]}" aliased: Final = _resolve_alias_to_upstream_model(setup_model, llm_router) return f"projects/{vertex_project}/locations/{vertex_location}/{_vertex_publisher_model_suffix(aliased)}" @@ -4366,11 +4482,22 @@ async def vertex_ai_live_websocket_passthrough( await websocket.accept() incoming_headers: Final = dict(websocket.headers) - vertex_credentials_config: Final = _resolve_vertex_live_credentials( + live_target: Final = _resolve_vertex_live_target( vertex_project=vertex_project, vertex_location=vertex_location, model=model, ) + allow_client_side_credentials: Final = _proxy_general_settings().get("allow_client_side_credentials") is True + override_reason: Final = _live_target_override_reason( + live_target, + vertex_project=vertex_project, + vertex_location=vertex_location, + allow_client_side_credentials=allow_client_side_credentials, + ) + if override_reason is not None: + await websocket.close(code=1008, reason=override_reason) + return + vertex_credentials_config: Final = live_target.credentials configured_project: Final = vertex_project or ( vertex_credentials_config.vertex_project if vertex_credentials_config is not None else None @@ -4452,7 +4579,9 @@ async def vertex_ai_live_websocket_passthrough( setup_model_rewriter=_build_vertex_live_setup_model_rewriter( vertex_project=resolved_project, vertex_location=resolved_location, + configured_location=configured_location, llm_router=_get_llm_router(), + keeps_caller_resource_path=allow_client_side_credentials, ), ) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 66634688b9a..42a7c458ef2 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2613,6 +2613,21 @@ def _rewrite_vertex_live_setup_model(text_data: str, setup_model_rewriter: Calla return json.dumps({**message, "setup": {**setup, "model": rewritten_model}}) +VERTEX_LIVE_UNDECODABLE_BINARY_CLOSE_REASON: Final = "Rejected Request: binary messages must be UTF-8 encoded JSON" + + +def _rewrite_vertex_live_binary_setup_model( + bytes_data: bytes, setup_model_rewriter: Callable[[str], str] | None +) -> bytes | None: + if setup_model_rewriter is None: + return bytes_data + try: + text_data: Final = bytes_data.decode("utf-8") + except UnicodeDecodeError: + return None + return _rewrite_vertex_live_setup_model(text_data, setup_model_rewriter).encode("utf-8") + + def _resolved_vertex_live_setup( setup_data: Mapping[str, object], setup_model_rewriter: Callable[[str], str] | None ) -> Mapping[str, object]: @@ -3042,6 +3057,28 @@ async def _relay_client_frame( return verdict +_UNDECODABLE_BINARY_CLIENT_FRAME: Final = _RefuseClientFrame( + refusal=WebsocketFrameModelRefused( + message=VERTEX_LIVE_UNDECODABLE_BINARY_CLOSE_REASON, error_type="invalid_request_error" + ) +) + + +async def _relay_client_binary_frame( + websocket: WebSocket, + upstream_ws: ClientConnection, + bytes_data: bytes, + setup_model_rewriter: Callable[[str], str] | None, + gate: WebsocketFrameModelGate | None, + ledger: _WebsocketTurnLedger, +) -> _ForwardClientFrame | _RefuseClientFrame: + rewritten_bytes: Final = _rewrite_vertex_live_binary_setup_model(bytes_data, setup_model_rewriter) + if rewritten_bytes is None: + await websocket.close(code=CloseCode.POLICY_VIOLATION, reason=VERTEX_LIVE_UNDECODABLE_BINARY_CLOSE_REASON) + return _UNDECODABLE_BINARY_CLIENT_FRAME + return await _relay_client_frame(websocket, upstream_ws, bytes_data, rewritten_bytes, gate, ledger) + + _EXPLICIT_SUCCESS_KWARGS: Final = frozenset({"logging_obj", "passthrough_logging_payload", "custom_llm_provider"}) WALKOUT_TURN_DRAIN_SECONDS: Final = 600.0 @@ -3409,8 +3446,13 @@ async def websocket_passthrough_request( metered_total += _metered_client_text(meter, text_data) elif bytes_data is not None: if isinstance( - await _relay_client_frame( - websocket, upstream_ws, bytes_data, bytes_data, client_frame_model_gate, ledger + await _relay_client_binary_frame( + websocket, + upstream_ws, + bytes_data, + setup_model_rewriter, + client_frame_model_gate, + ledger, ), _RefuseClientFrame, ): diff --git a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py index e793dac4d06..877c4ece6c7 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py @@ -223,6 +223,12 @@ class PassthroughEndpointRouter: return provider def get_vertex_credentials_from_router_deployments(self, model: str | None) -> VertexPassThroughCredentials | None: + found: Final = self.get_vertex_deployment_from_router(model) + return found[1] if found is not None else None + + def get_vertex_deployment_from_router( + self, model: str | None + ) -> tuple[DeploymentTypedDict, VertexPassThroughCredentials] | None: """ Resolve vertex pass-through credentials from the live router deployments flagged ``use_in_pass_through``. @@ -242,7 +248,7 @@ class PassthroughEndpointRouter: ) matched: Final = next( ( - credentials + (deployment, credentials) for deployment, credentials in resolved if model is not None and self._deployment_matches_model(deployment, model) ), @@ -260,7 +266,7 @@ class PassthroughEndpointRouter: ) if len(targets) != 1: return None - return resolved[0][1] + return resolved[0] def _resolve_vertex_deployment_credentials( self, litellm_params: LiteLLMParamsTypedDict diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py index b7775b99419..04d6c78f08a 100644 --- a/litellm/proxy/prompts/prompt_endpoints.py +++ b/litellm/proxy/prompts/prompt_endpoints.py @@ -1111,6 +1111,7 @@ async def test_prompt( general_settings=general_settings, llm_router=llm_router, model=data.get("model", ""), + team_id=user_api_key_dict.team_id, ) # Use ProxyBaseLLMRequestProcessing to go through all proxy logic diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3fddca6f4d8..f69d9bd4724 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -16496,7 +16496,9 @@ async def model_info_lookup(model: str, custom_llm_provider: str | None = None): dependencies=[Depends(user_api_key_auth)], response_model=RawRequestTypedDict, ) -async def transform_request(request: TransformRequestBody): +async def transform_request( + request: TransformRequestBody, user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)] +): from litellm.utils import return_raw_request try: @@ -16505,6 +16507,7 @@ async def transform_request(request: TransformRequestBody): general_settings=general_settings, llm_router=llm_router, model=request.request_body.get("model", ""), + team_id=user_api_key_dict.team_id, ) except ValueError as e: raise HTTPException(status_code=400, detail={"error": str(e)}) diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index f4710fa30e3..47050f01644 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -8,6 +8,7 @@ Provides: import base64 import json +import re from collections.abc import Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final @@ -21,12 +22,13 @@ from starlette.datastructures import UploadFile import litellm from litellm._logging import verbose_proxy_logger -from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH, LITELLM_PROXY_ADMIN_NAME from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( LiteLLM_ManagedVectorStore, ) +from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.proxy._types import * -from litellm.proxy.auth.auth_utils import is_request_body_safe +from litellm.proxy.auth.auth_utils import get_request_route, is_request_body_safe from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, @@ -70,9 +72,12 @@ from litellm.proxy.vector_store_endpoints.utils import ( assert_user_can_access_vector_store_id, with_managed_search_file_ids, ) +from litellm.rag.ingestion.vertex_ai_ingestion import DEFAULT_VERTEX_RAG_LOCATION from litellm.rag.main import get_ingestion_class from litellm.rag.rag_query import RAGQuery from litellm.repositories.table_repositories import ManagedVectorStoresRepository +from litellm.repositories.user_repository import UserRepository +from litellm.secret_managers.main import get_secret_str from litellm.types.rag import RAGIngestResponse from litellm.types.utils import Choices, ModelResponse @@ -192,6 +197,103 @@ def _ingest_provider_error(vector_store_config: Mapping[str, object]) -> str | N return None +_VERTEX_RAG_CORPUS_NAME: Final = re.compile( + r"projects/(?P[^/%\\]+)/locations/(?P[^/%\\]+)/ragCorpora/(?P[^/%\\]+)" +) +_DOT_SEGMENTS: Final = frozenset({".", ".."}) + + +def _clean_vertex_corpus_name(corpus_name: object) -> re.Match[str] | None: + if not isinstance(corpus_name, str) or not _DOT_SEGMENTS.isdisjoint(corpus_name.split("/")): + return None + return _VERTEX_RAG_CORPUS_NAME.fullmatch(corpus_name) + + +def _vertex_target_message(field: str) -> str: + return ( + f"Rejected Request: {field} picks a Vertex project or region other than the proxy's. " + "Use a vector store a proxy admin created, or set general_settings.allow_client_side_credentials" + ) + + +def _vertex_target_is_checked( + vector_store_config: Mapping[str, object], + user_api_key_dict: UserAPIKeyAuth, + general_settings: Mapping[str, object], +) -> bool: + return ( + vector_store_config.get("custom_llm_provider") == "vertex_ai" + and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN + and general_settings.get("allow_client_side_credentials") is not True + ) + + +def _configured_vertex_project() -> str | None: + return litellm.vertex_project or get_secret_str("VERTEXAI_PROJECT") + + +def _corpus_on_configured_project(vector_store_config: Mapping[str, object]) -> Mapping[str, object]: + corpus: Final = _clean_vertex_corpus_name(vector_store_config.get("vector_store_id")) + configured_project: Final = _configured_vertex_project() + if corpus is None or configured_project is None or not corpus["project"].isdigit(): + return vector_store_config + return MappingProxyType( + { + **vector_store_config, + "vector_store_id": ( + f"projects/{configured_project}/locations/{corpus['location']}/ragCorpora/{corpus['corpus']}" + ), + } + ) + + +async def _authorize_rewritten_corpus( + requested: Mapping[str, object], rewritten: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth +) -> None: + rewritten_id: Final = rewritten.get("vector_store_id") + if isinstance(rewritten_id, str) and rewritten_id != requested.get("vector_store_id"): + await assert_user_can_access_vector_store_id(vector_store_id=rewritten_id, user_api_key_dict=user_api_key_dict) + + +def _vertex_target_error(vector_store_config: Mapping[str, object]) -> str | None: + corpus_name: Final = vector_store_config.get("vector_store_id") + corpus: Final = _clean_vertex_corpus_name(corpus_name) + if corpus_name and corpus is None: + return _vertex_target_message("vector_store_id") + configured_project: Final = _configured_vertex_project() + configured_location: Final = VertexBase.safe_get_vertex_ai_location({}) or DEFAULT_VERTEX_RAG_LOCATION + requested: Final = ( + *( + (name, vector_store_config.get(name), configured_project) + for name in ("vertex_project", "vertex_ai_project") + ), + *( + (name, vector_store_config.get(name), configured_location) + for name in ("vertex_location", "vertex_ai_location") + ), + ("vector_store_id", corpus["project"] if corpus is not None else None, configured_project), + ("vector_store_id", corpus["location"] if corpus is not None else None, configured_location), + ) + field: Final = next((name for name, asked, configured in requested if asked and asked != configured), None) + return None if field is None else _vertex_target_message(field) + + +async def _admin_set_store_target( + managed_store: LiteLLM_ManagedVectorStore | None, prisma_client: "PrismaClient | None" +) -> bool: + if managed_store is None: + return False + if managed_store.get("is_config", False): + return True + creator: Final = managed_store.get("user_id") + if creator == LITELLM_PROXY_ADMIN_NAME: + return True + if creator is None or prisma_client is None: + return False + creator_row: Final = await UserRepository(prisma_client).find_by_id(creator) + return creator_row is not None and creator_row.user_role == LitellmUserRoles.PROXY_ADMIN + + _MANAGED_STORE_CALLER_OPTIONS: Final = frozenset( { "vector_store_id", @@ -757,6 +859,7 @@ async def rag_ingest( from litellm.proxy.proxy_server import ( add_litellm_data_to_request, general_settings, + general_settings_view, llm_router, prisma_client, proxy_config, @@ -782,15 +885,25 @@ async def rag_ingest( general_settings=general_settings, llm_router=llm_router, model="", + route=get_request_route(request=request), + team_id=user_api_key_dict.team_id, ) except ValueError as e: raise HTTPException(status_code=400, detail={"error": str(e)}) managed_store: Final = resolved_stores.get(request_vector_store_config.get("vector_store_id")) - base: Final = { + requested_base: Final = { **_caller_vector_store_options(request_vector_store_config, managed_store), **_managed_store_overrides(managed_store), } + vertex_target_checked: Final = _vertex_target_is_checked( + requested_base, user_api_key_dict, general_settings_view() + ) and not await _admin_set_store_target(managed_store, prisma_client) + base: Final = _corpus_on_configured_project(requested_base) if vertex_target_checked else requested_base + await _authorize_rewritten_corpus(requested_base, base, user_api_key_dict) + vertex_target_error: Final = _vertex_target_error(base) if vertex_target_checked else None + if vertex_target_error is not None: + raise HTTPException(status_code=400, detail={"error": vertex_target_error}) request_data: Final[dict[str, Any]] = await add_litellm_data_to_request( data={}, request=request, diff --git a/litellm/rag/ingestion/vertex_ai_ingestion.py b/litellm/rag/ingestion/vertex_ai_ingestion.py index cceddf31117..8157c0c6405 100644 --- a/litellm/rag/ingestion/vertex_ai_ingestion.py +++ b/litellm/rag/ingestion/vertex_ai_ingestion.py @@ -28,6 +28,8 @@ if TYPE_CHECKING: from litellm import Router from litellm.types.rag import RAGIngestOptions +DEFAULT_VERTEX_RAG_LOCATION: Final = "us-central1" + def _present_fields(fields: tuple[tuple[str, object], ...]) -> Mapping[str, object]: return {name: value for name, value in fields if value} @@ -90,7 +92,7 @@ class VertexAIRAGIngestion(BaseRAGIngestion, VertexBase): # Get project, location, and credentials using VertexBase methods self.project_id = self.safe_get_vertex_ai_project(litellm_params) - self.location = self.get_vertex_ai_location(litellm_params) or "us-central1" + self.location = self.get_vertex_ai_location(litellm_params) or DEFAULT_VERTEX_RAG_LOCATION self.vertex_credentials = self.safe_get_vertex_ai_credentials(litellm_params) async def embed( diff --git a/tests/integration/spend/test_vertex_image_regional_uplift.py b/tests/integration/spend/test_vertex_image_regional_uplift.py index 6900a6d489a..dfd52d28f1f 100644 --- a/tests/integration/spend/test_vertex_image_regional_uplift.py +++ b/tests/integration/spend/test_vertex_image_regional_uplift.py @@ -100,6 +100,7 @@ def _deployment( location: Mapping[str, JsonValue], multiplier: JsonValue = 1.1, backend: str = _IMAGE_BACKEND, + clientside_params: tuple[str, ...] = (), ) -> str: return scenario.model( model=f"vertex_ai/{backend}", @@ -111,6 +112,7 @@ def _deployment( output_cost_per_token=4e-5, output_cost_per_image=0.05, **({} if multiplier is None else {"regional_endpoint_uplift_multiplier": multiplier}), + **({"configurable_clientside_auth_params": list(clientside_params)} if clientside_params else {}), **location, ) @@ -380,7 +382,13 @@ def test_request_body_location_overrides_the_deployment( gateway: Gateway, deployment_location: str, extra: Mapping[str, JsonValue], expected: float ) -> None: with wire_server(_scripted()) as wire, gateway.scenario() as scenario: - model: Final = _deployment(gateway, scenario, wire, location={"vertex_location": deployment_location}) + model: Final = _deployment( + gateway, + scenario, + wire, + location={"vertex_location": deployment_location}, + clientside_params=("vertex_location", "vertex_ai_location"), + ) call_id, response = _generate(gateway, model, extra=extra) _assert_images(response, call_id, expected) _assert_one_upstream_image_call(wire) diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 82b80ad8da9..73a2c534628 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -4,6 +4,7 @@ Unit tests for auth_utils functions related to rate limiting and customer ID ext import base64 import logging +import time from collections.abc import Callable, Mapping from types import SimpleNamespace from typing import Final, Optional @@ -371,7 +372,11 @@ _SERVICE_ACCOUNT_TOKEN_REFERENCE: Final = "oidc/file//var/run/secrets/kubernetes def _user_config_with(litellm_params: dict[str, str]) -> dict[str, object]: - return {"model_list": [{"model_name": "devstral", "litellm_params": {"model": "mistral/devstral-latest", **litellm_params}}]} + return { + "model_list": [ + {"model_name": "devstral", "litellm_params": {"model": "mistral/devstral-latest", **litellm_params}} + ] + } def _user_config_defaulting_to(litellm_params: dict[str, str]) -> dict[str, object]: @@ -391,7 +396,11 @@ def _user_config_search_tool_with(litellm_params: dict[str, str]) -> dict[str, o "search_tools": [ { "search_tool_name": "web", - "litellm_params": {"search_provider": "tavily", "api_base": "https://attacker.example", **litellm_params}, + "litellm_params": { + "search_provider": "tavily", + "api_base": "https://attacker.example", + **litellm_params, + }, } ] } @@ -512,7 +521,9 @@ def test_a_call_cannot_carry_an_oidc_api_key_in_litellm_params(route: str | None ) -@pytest.mark.parametrize("route", ["/model/new", "/model/update", "/model/deployment-1/update", "/health/test_connection"]) +@pytest.mark.parametrize( + "route", ["/model/new", "/model/update", "/model/deployment-1/update", "/health/test_connection"] +) def test_a_deployment_write_may_carry_an_oidc_api_key_in_litellm_params(route: str): """A proxy admin configures an oidc/ api_key on a deployment through these routes, and the model management role gate refuses anyone else with a 403.""" @@ -3675,6 +3686,7 @@ def test_request_cannot_select_chatgpt_auth_file(allow_client_side_credentials: model="chatgpt-account-a", ) + class TestIsRequestBodySafeBlocksBedrockProjectOverride: """``aws_bedrock_project_id`` pins a deployment to a Bedrock project so that project's data-retention policy applies to its requests. A @@ -3934,7 +3946,7 @@ class TestIsRequestBodySafeBlocksNVCFFunctionOverride: monkeypatch.setattr( auth_utils, "_allow_model_level_clientside_configurable_parameters", - lambda model, param, request_body_value, llm_router: param == "nvcf_function_id", + lambda model, param, request_body_value, llm_router, team_id=None: param == "nvcf_function_id", ) assert ( @@ -4019,7 +4031,7 @@ class TestIsRequestBodySafeBlocksRivaUseSsl: monkeypatch.setattr( auth_utils, "_allow_model_level_clientside_configurable_parameters", - lambda model, param, request_body_value, llm_router: param == "use_ssl", + lambda model, param, request_body_value, llm_router, team_id=None: param == "use_ssl", ) assert ( @@ -4457,7 +4469,7 @@ def test_model_level_allow_does_not_skip_subsequent_banned_params(monkeypatch): monkeypatch.setattr( auth_utils, "_allow_model_level_clientside_configurable_parameters", - lambda model, param, request_body_value, llm_router: param == "api_base", + lambda model, param, request_body_value, llm_router, team_id=None: param == "api_base", ) with pytest.raises(ValueError, match="Rejected Request: langfuse_host is not allowed in request") as exc: @@ -4981,6 +4993,538 @@ class TestIsRequestBodySafeBlocksAwsIdentitySelectors: ) +_VERTEX_TARGET_PARAMS: Final = ("vertex_location", "vertex_project", "vertex_ai_location", "vertex_ai_project") + + +def _vertex_pinned_router(configurable_clientside_auth_params: list[str]) -> Router: + return Router( + model_list=[ + { + "model_name": "gemini-2.5-flash", + "litellm_params": { + "model": "vertex_ai/gemini-2.5-flash", + "vertex_project": "admin-project", + "vertex_location": "europe-west4", + "configurable_clientside_auth_params": configurable_clientside_auth_params, + }, + } + ] + ) + + +class TestIsRequestBodySafeBlocksVertexTargetOverrides: + @pytest.mark.parametrize("param", _VERTEX_TARGET_PARAMS) + @pytest.mark.parametrize( + "placement", + [ + lambda param: {param: "us-central1"}, + lambda param: {"extra_body": {param: "us-central1"}}, + ], + ids=["root", "extra_body"], + ) + def test_vertex_target_is_rejected_without_admin_opt_in( + self, param: str, placement: Callable[[str], dict[str, object]] + ) -> None: + with pytest.raises(ValueError, match=param): + is_request_body_safe( + request_body={"model": "gemini-2.5-flash", **placement(param)}, + general_settings={}, + llm_router=_vertex_pinned_router([]), + model="gemini-2.5-flash", + route="/v1/chat/completions", + ) + + @pytest.mark.parametrize("param", _VERTEX_TARGET_PARAMS) + @pytest.mark.parametrize( + "placement", + [ + lambda param: {"metadata": {param: "us-central1"}}, + lambda param: {"litellm_metadata": {param: "us-central1"}}, + lambda param: {f"metadata[{param}]": "us-central1"}, + lambda param: {"litellm_params": {"metadata": {param: "us-central1"}}}, + ], + ids=["metadata", "litellm_metadata", "form_metadata", "litellm_params_metadata"], + ) + def test_vertex_target_in_metadata_never_reaches_the_provider_and_is_allowed( + self, param: str, placement: Callable[[str], dict[str, object]] + ) -> None: + assert ( + is_request_body_safe( + request_body={"model": "gemini-2.5-flash", **placement(param)}, + general_settings={}, + llm_router=_vertex_pinned_router([]), + model="gemini-2.5-flash", + route="/v1/chat/completions", + ) + is True + ) + + def test_vertex_target_is_allowed_under_proxy_wide_opt_in(self) -> None: + assert ( + is_request_body_safe( + request_body={"model": "gemini-2.5-flash", **{param: "us-central1" for param in _VERTEX_TARGET_PARAMS}}, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="gemini-2.5-flash", + route="/v1/chat/completions", + ) + is True + ) + + def test_deployment_opt_in_allows_only_the_listed_vertex_target(self) -> None: + router: Final = _vertex_pinned_router(["vertex_location"]) + assert ( + is_request_body_safe( + request_body={"model": "gemini-2.5-flash", "vertex_location": "us-central1"}, + general_settings={}, + llm_router=router, + model="gemini-2.5-flash", + route="/v1/chat/completions", + ) + is True + ) + with pytest.raises(ValueError, match="vertex_project"): + is_request_body_safe( + request_body={ + "model": "gemini-2.5-flash", + "vertex_location": "us-central1", + "vertex_project": "caller-project", + }, + general_settings={}, + llm_router=router, + model="gemini-2.5-flash", + route="/v1/chat/completions", + ) + + @pytest.mark.parametrize("opted_in_first", [True, False]) + def test_group_opt_in_does_not_count_while_any_deployment_lacks_it(self, opted_in_first: bool) -> None: + opted_in: Final = { + "model_name": "gemini-group", + "litellm_params": { + "model": "vertex_ai/gemini-2.5-flash", + "vertex_location": "us-central1", + "configurable_clientside_auth_params": ["vertex_location"], + }, + } + pinned: Final = { + "model_name": "gemini-group", + "litellm_params": {"model": "vertex_ai/gemini-2.5-flash", "vertex_location": "europe-west4"}, + } + router: Final = Router(model_list=[opted_in, pinned] if opted_in_first else [pinned, opted_in]) + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe( + request_body={"model": "gemini-group", "vertex_location": "us-central1"}, + general_settings={}, + llm_router=router, + model="gemini-group", + route="/v1/chat/completions", + ) + + @pytest.mark.parametrize("model", ["chat", "chat-alias", "entry"]) + @pytest.mark.parametrize("opted_in", [False, True]) + def test_team_public_model_requires_the_team_deployment_opt_in(self, model: str, opted_in: bool) -> None: + router: Final = Router( + model_list=[ + {"model_name": "chat", "litellm_params": {"model": "openai/test-model", "api_key": "test-key"}}, + {"model_name": "entry", "litellm_params": {"model": "openai/test-model", "api_key": "test-key"}}, + { + "model_name": "team-chat", + "model_info": {"team_id": "team-one", "team_public_model_name": "chat"}, + "litellm_params": { + "model": "vertex_ai/test-model", + "vertex_location": "europe-west4", + "configurable_clientside_auth_params": ["vertex_location"] if opted_in else [], + }, + }, + ], + model_group_alias={"chat-alias": "chat"}, + fallbacks=[{"entry": ["chat"]}], + ) + request_body: Final = {"model": model, "vertex_location": "us-central1"} + if opted_in: + assert ( + is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions", team_id="team-one") + is True + ) + return + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions", team_id="team-one") + + @pytest.mark.parametrize( + "model, opted_in, global_exact, is_allowed", + [ + ("chat-test-model", False, False, False), + ("chat-test-model", True, False, True), + ("other-test-model", False, False, True), + ("chat-test-model", False, True, True), + ], + ) + def test_team_public_wildcard_keeps_its_own_opt_in( + self, model: str, opted_in: bool, global_exact: bool, is_allowed: bool + ) -> None: + router: Final = Router( + model_list=[ + {"model_name": "*", "litellm_params": {"model": "openai/*", "api_key": "test-key"}}, + { + "model_name": "team-chat", + "model_info": {"team_id": "team-one", "team_public_model_name": "chat-*"}, + "litellm_params": { + "model": "vertex_ai/*", + "configurable_clientside_auth_params": ["vertex_location"] if opted_in else [], + }, + }, + ] + + ( + [ + { + "model_name": "chat-test-model", + "litellm_params": {"model": "openai/test-model", "api_key": "test-key"}, + } + ] + if global_exact + else [] + ) + ) + request_body: Final = {"model": model, "vertex_location": "us-central1"} + if is_allowed: + assert ( + is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions", team_id="team-one") + is True + ) + return + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions", team_id="team-one") + + @pytest.mark.parametrize("fallback_setting", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"]) + def test_team_internal_fallback_to_another_public_name_keeps_its_pin(self, fallback_setting: str) -> None: + router: Final = Router( + model_list=[ + {"model_name": name, "litellm_params": {"model": "openai/test-model", "api_key": "test-key"}} + for name in ("chat", "backup") + ] + + [ + { + "model_name": internal, + "model_info": {"team_id": "team-one", "team_public_model_name": public}, + "litellm_params": { + "model": "vertex_ai/test-model", + "configurable_clientside_auth_params": opt_in, + }, + } + for internal, public, opt_in in ( + ("team-chat", "chat", ["vertex_location"]), + ("team-backup", "backup", []), + ) + ], + **{fallback_setting: [{"team-chat": ["backup"]}]}, + ) + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe( + {"model": "chat", "vertex_location": "us-central1"}, + {}, + router, + "chat", + route="/v1/chat/completions", + team_id="team-one", + ) + + def test_model_with_no_deployment_has_no_opt_in(self) -> None: + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe( + request_body={"model": "not-configured", "vertex_location": "us-central1"}, + general_settings={}, + llm_router=_vertex_pinned_router(["vertex_location"]), + model="not-configured", + route="/v1/chat/completions", + ) + + def test_group_opt_in_counts_when_every_deployment_carries_it(self) -> None: + router: Final = Router( + model_list=[ + { + "model_name": "gemini-group", + "litellm_params": { + "model": "vertex_ai/gemini-2.5-flash", + "vertex_location": location, + "configurable_clientside_auth_params": ["vertex_location"], + }, + } + for location in ("us-central1", "europe-west4") + ] + ) + assert ( + is_request_body_safe( + request_body={"model": "gemini-group", "vertex_location": "us-central1"}, + general_settings={}, + llm_router=router, + model="gemini-group", + route="/v1/chat/completions", + ) + is True + ) + + @pytest.mark.parametrize( + "model, is_allowed", [("public-alias", False), ("public-gemini-2.5-flash", True)], ids=["alias", "direct"] + ) + def test_alias_opt_in_comes_from_the_group_the_alias_routes_to(self, model: str, is_allowed: bool) -> None: + router: Final = Router( + model_list=[ + { + "model_name": "gemini-eu/*", + "litellm_params": {"model": "vertex_ai/*", "vertex_location": "europe-west4"}, + }, + { + "model_name": "public-*", + "litellm_params": { + "model": "vertex_ai/*", + "vertex_location": "europe-west4", + "configurable_clientside_auth_params": ["vertex_location"], + }, + }, + ], + model_group_alias={"public-alias": "gemini-eu/gemini-2.5-flash"}, + ) + request_body: Final = {"model": model, "vertex_location": "us-central1"} + if is_allowed: + assert is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions") is True + return + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions") + + @staticmethod + def _opted_in_and_pinned_router( + router_fallbacks: list[dict[str, list[str]]], + fallback_setting: str = "fallbacks", + model_group_alias: dict[str, str] | None = None, + ) -> Router: + return Router( + model_list=[ + { + "model_name": name, + "litellm_params": { + "model": "vertex_ai/gemini-2.5-flash", + "vertex_location": "europe-west4", + "configurable_clientside_auth_params": opt_in, + }, + } + for name, opt_in in ( + ("gemini-optin", ["vertex_location"]), + ("gemini-optin-other", ["vertex_location"]), + ("gemini-pinned", []), + ) + ], + model_group_alias=model_group_alias or {}, + **{fallback_setting: router_fallbacks}, + ) + + @pytest.mark.parametrize( + "fields, is_allowed", + [ + ({"vertex_location": "us-central1", "fallbacks": ["gemini-pinned"]}, False), + ({"fallbacks": [{"model": "gemini-pinned", "vertex_location": "us-central1"}]}, False), + ({"vertex_location": "us-central1", "fallbacks": ["gemini-optin-other"]}, True), + ({"fallbacks": [{"model": "gemini-optin-other", "vertex_location": "us-central1"}]}, True), + ], + ids=["pinned", "pinned_entry_with_field", "opted_in", "opted_in_entry_with_field"], + ) + def test_request_fallback_needs_the_same_opt_in(self, fields: dict[str, object], is_allowed: bool) -> None: + router: Final = self._opted_in_and_pinned_router([]) + request_body: Final = {"model": "gemini-optin", **fields} + if is_allowed: + assert is_request_body_safe(request_body, {}, router, "gemini-optin", route="/v1/chat/completions") is True + return + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, "gemini-optin", route="/v1/chat/completions") + + @pytest.mark.parametrize("fallback_setting", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"]) + @pytest.mark.parametrize( + "fallback_group, is_allowed", + [("gemini-pinned", False), ("gemini-optin-other", True)], + ids=["pinned", "opted_in"], + ) + def test_router_fallback_needs_the_same_opt_in( + self, fallback_setting: str, fallback_group: str, is_allowed: bool + ) -> None: + router: Final = self._opted_in_and_pinned_router([{"gemini-optin": [fallback_group]}], fallback_setting) + request_body: Final = {"model": "gemini-optin", "vertex_location": "us-central1"} + if is_allowed: + assert is_request_body_safe(request_body, {}, router, "gemini-optin", route="/v1/chat/completions") is True + return + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, "gemini-optin", route="/v1/chat/completions") + + @pytest.mark.parametrize( + "model, fields, is_allowed", + [ + ("gemini-optin", {"vertex_location": "us-central1"}, True), + ("openai-group", {"vertex_location": "us-central1"}, True), + ("gemini-optin", {"api_base": "https://eu.example.com"}, False), + ("studio-group", {"vertex_location": "us-central1"}, False), + ], + ids=["vertex_field_to_openai_fallback", "vertex_field_on_openai", "api_base_to_openai_fallback", "gemini"], + ) + def test_only_google_groups_need_the_vertex_opt_in( + self, model: str, fields: dict[str, object], is_allowed: bool + ) -> None: + router: Final = Router( + model_list=[ + { + "model_name": "gemini-optin", + "litellm_params": { + "model": "vertex_ai/gemini-2.5-flash", + "configurable_clientside_auth_params": ["vertex_location", {"api_base": r"https://eu\..*"}], + }, + }, + {"model_name": "openai-group", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}}, + {"model_name": "studio-group", "litellm_params": {"model": "gemini/gemini-2.5-flash", "api_key": "k"}}, + ], + fallbacks=[{"gemini-optin": ["openai-group"]}, {"studio-group": ["openai-group"]}], + ) + request_body: Final = {"model": model, **fields} + if is_allowed: + assert is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions") is True + return + with pytest.raises(ValueError, match=r"vertex_location|api_base"): + is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions") + + def test_many_fallback_entries_on_one_wildcard_are_checked_in_linear_time(self) -> None: + router: Final = Router( + model_list=[{"model_name": "*", "litellm_params": {"model": "openai/*", "api_key": "k"}}] + ) + request_body: Final = { + "model": "gpt-4o-mini", + "fallbacks": [{"model": f"model-{index % 100}", "vertex_location": f"r{index}"} for index in range(5000)], + } + + started: Final = time.perf_counter() + assert is_request_body_safe(request_body, {}, router, "gpt-4o-mini", route="/v1/chat/completions") is True + assert time.perf_counter() - started < 10 + + def test_too_many_fallback_models_for_an_opt_in_check_are_rejected(self) -> None: + router: Final = Router( + model_list=[{"model_name": "*", "litellm_params": {"model": "openai/*", "api_key": "k"}}] + ) + request_body: Final = { + "model": "gpt-4o-mini", + "vertex_location": "us-central1", + "fallbacks": [f"model-{index}" for index in range(101)], + } + + with pytest.raises(ValueError, match="more than 100 fallback models"): + is_request_body_safe(request_body, {}, router, "gpt-4o-mini", route="/v1/chat/completions") + + def test_one_wildcard_serving_two_providers_checks_the_google_one(self) -> None: + router: Final = Router(model_list=[{"model_name": "*", "litellm_params": {"model": "*"}}]) + request_body: Final = { + "model": "vertex_ai/gemini-2.5-flash", + "vertex_location": "us-central1", + "fallbacks": ["xai/grok-3"], + } + + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, "vertex_ai/gemini-2.5-flash", route="/v1/chat/completions") + + def test_a_pinned_group_sharing_a_deployment_id_with_an_opted_in_group_is_still_checked(self) -> None: + router: Final = Router( + model_list=[ + { + "model_name": name, + "litellm_params": { + "model": "vertex_ai/gemini-2.5-flash", + "vertex_location": "europe-west4", + "configurable_clientside_auth_params": opt_in, + }, + "model_info": {"id": "shared-id"}, + } + for name, opt_in in (("a-pinned", []), ("z-optin", ["vertex_location"])) + ] + ) + request_body: Final = {"model": "a-pinned", "vertex_location": "us-central1", "fallbacks": ["z-optin"]} + + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, "a-pinned", route="/v1/chat/completions") + + @pytest.mark.parametrize( + "model, is_allowed", [("public-alias", True), ("gemini-optin", False)], ids=["alias", "aliased_target"] + ) + def test_alias_chain_is_checked_one_hop_like_the_router_serves_it(self, model: str, is_allowed: bool) -> None: + router: Final = self._opted_in_and_pinned_router( + [], model_group_alias={"public-alias": "gemini-optin", "gemini-optin": "gemini-pinned"} + ) + request_body: Final = {"model": model, "vertex_location": "us-central1"} + if is_allowed: + assert is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions") is True + return + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, model, route="/v1/chat/completions") + + @pytest.mark.parametrize( + "route", + [ + "/openai/deployments/gemini-pinned/chat/completions", + "/v1beta/models/gemini-pinned:generateContent", + ], + ) + def test_model_named_in_the_route_needs_the_opt_in_too(self, route: str) -> None: + router: Final = self._opted_in_and_pinned_router([]) + request_body: Final = {"model": "gemini-optin", "vertex_location": "us-central1"} + with pytest.raises(ValueError, match="vertex_location"): + is_request_body_safe(request_body, {}, router, "gemini-optin", route=route) + + @pytest.mark.asyncio + @pytest.mark.parametrize("opted_in", [True, False]) + async def test_opt_in_of_the_model_named_in_a_google_route_decides(self, monkeypatch, opted_in: bool) -> None: + route: Final = "/v1beta/models/gemini-2.5-flash:generateContent" + monkeypatch.setattr( + "litellm.proxy.proxy_server.llm_router", _vertex_pinned_router(["vertex_location"] if opted_in else []) + ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + request: Final = Request({"type": "http", "method": "POST", "path": route, "headers": [], "query_string": b""}) + if opted_in: + await pre_db_read_auth_checks(request=request, request_data={"vertex_location": "us-central1"}, route=route) + return + with pytest.raises(ValueError, match="vertex_location"): + await pre_db_read_auth_checks(request=request, request_data={"vertex_location": "us-central1"}, route=route) + + @pytest.mark.parametrize( + "route, kept_params", + [ + ("/vertex_ai/live", _VERTEX_TARGET_PARAMS), + ("/rag/ingest", _VERTEX_TARGET_PARAMS), + ("/v1/rag/ingest", _VERTEX_TARGET_PARAMS), + ], + ) + def test_routes_that_name_their_vertex_target_keep_it(self, route: str, kept_params: tuple[str, ...]) -> None: + assert ( + is_request_body_safe( + request_body={param: "caller-chosen" for param in kept_params}, + general_settings={}, + llm_router=None, + model="", + route=route, + ) + is True + ) + + @pytest.mark.parametrize( + "route, param", + [ + ("/vertex_ai/live", "api_base"), + ("/rag/ingest", "api_base"), + ("/v1/rag/ingest", "api_base"), + ], + ) + def test_routes_that_name_their_vertex_target_reject_every_other_banned_param(self, route: str, param: str) -> None: + with pytest.raises(ValueError, match=param): + is_request_body_safe( + request_body={"vertex_location": "us-central1", param: "caller-chosen"}, + general_settings={}, + llm_router=None, + model="", + route=route, + ) + + @pytest.mark.parametrize( "allowed_param, input_value, should_return_true", [ @@ -5446,6 +5990,29 @@ def _enforce_for_team( ) +@pytest.mark.parametrize("opted_in", [False, True]) +def test_team_bucket_validation_preserves_the_authenticated_model_opt_in(opted_in: bool) -> None: + router: Final = Router( + model_list=[ + {"model_name": "chat", "litellm_params": {"model": "openai/test-model"}}, + { + "model_name": "team-chat", + "model_info": {"team_id": "team-one", "team_public_model_name": "chat"}, + "litellm_params": { + "model": "vertex_ai/test-model", + "configurable_clientside_auth_params": ["vertex_location"] if opted_in else [], + }, + }, + ] + ) + body: Final = {"model": "chat", "s3_bucket_name": "team-a-bucket", "vertex_location": "us-central1"} + if opted_in: + enforce_team_s3_bucket_allowlist(body, {}, router, _TEAM_A_METADATA, team_id="team-one") + return + with pytest.raises(HTTPException, match="vertex_location"): + enforce_team_s3_bucket_allowlist(body, {}, router, _TEAM_A_METADATA, team_id="team-one") + + def _rejection_for_team(body: Mapping[str, object], team_metadata: Mapping[str, object] | None) -> HTTPException: with pytest.raises(HTTPException) as rejection: _enforce_for_team(body, team_metadata) @@ -5499,7 +6066,13 @@ def test_team_allowlist_admits_no_other_blocked_field() -> None: def _routed_request(method: str, route_template: str | None, path: str) -> Request: - scope: Final[dict[str, object]] = {"type": "http", "method": method, "path": path, "headers": [], "query_string": b""} + scope: Final[dict[str, object]] = { + "type": "http", + "method": method, + "path": path, + "headers": [], + "query_string": b"", + } return Request(scope if route_template is None else {**scope, "route": SimpleNamespace(path=route_template)}) @@ -5661,7 +6234,9 @@ async def test_a_non_numeric_max_request_size_mb_is_ignored_without_premium(): def test_chatgpt_model_configuration_reaches_role_authorization( allow_client_side_credentials: bool, route: str ) -> None: - body: Final = {"litellm_params": {"model": "chatgpt/gpt-6-luna", "chatgpt_auth_file": "/tokens/account-a/auth.json"}} + body: Final = { + "litellm_params": {"model": "chatgpt/gpt-6-luna", "chatgpt_auth_file": "/tokens/account-a/auth.json"} + } assert is_request_body_safe( request_body=body, general_settings={"allow_client_side_credentials": allow_client_side_credentials}, diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 1489afc02af..8a5d4f7969b 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -3279,7 +3279,9 @@ class TestJWTOAuth2Coexistence: async def test_auto_register_passes_validated_org_context_to_generated_key(self): jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature" general_settings = {"enable_jwt_auth": True} - user_api_key_cache: Final = _validated_jwt_admission_cache(org_id="validated-org", end_user_id="validated-end-user") + user_api_key_cache: Final = _validated_jwt_admission_cache( + org_id="validated-org", end_user_id="validated-end-user" + ) prisma_client = MagicMock() jwt_handler = MagicMock() jwt_handler.is_jwt.return_value = True @@ -4851,6 +4853,92 @@ def _proxy_attrs_for_centralized_checks(user_custom_auth=None, flag=False, maste } +@pytest.mark.asyncio +@pytest.mark.parametrize( + "route, include_model, path_params", + [ + ("/v1/files", True, {}), + ("/v1/batches", True, {}), + ("/v1/chat/completions", True, {}), + ("/v1beta/models/chat:generateContent", False, {"model_name": "chat"}), + ("/openai/deployments/chat/chat/completions", False, {"model": "chat"}), + ], +) +@pytest.mark.parametrize("team_id, is_allowed", [(None, True), ("other-team", True), ("pinned-team", False)]) +async def test_deferred_body_check_uses_only_the_authenticated_teams_deployments( + monkeypatch: pytest.MonkeyPatch, + team_id: str | None, + is_allowed: bool, + route: str, + include_model: bool, + path_params: Mapping[str, str], +) -> None: + from litellm.proxy.utils import ProxyLogging + + async def authenticate(request: Request, api_key: str) -> UserAPIKeyAuth: + return UserAPIKeyAuth( + team_id=team_id, api_key=api_key, user_role=LitellmUserRoles.INTERNAL_USER, models=["chat"] + ) + + router: Final = litellm.Router( + model_list=[ + { + "model_name": "chat", + "litellm_params": { + "model": "vertex_ai/test-model", + "configurable_clientside_auth_params": ["vertex_location"], + }, + }, + { + "model_name": "private-chat", + "model_info": {"team_id": "pinned-team", "team_public_model_name": "chat"}, + "litellm_params": {"model": "vertex_ai/test-model", "vertex_location": "europe-west4"}, + }, + ] + ) + key_cache: Final = UserApiKeyCache() + attributes: Final = { + **_proxy_attrs_for_centralized_checks(user_custom_auth=authenticate), + "llm_router": router, + "llm_model_list": None, + "user_api_key_cache": key_cache, + "proxy_logging_obj": ProxyLogging(user_api_key_cache=key_cache), + } + for name, value in attributes.items(): + monkeypatch.setattr(litellm.proxy.proxy_server, name, value) + request: Final = Request( + { + "type": "http", + "method": "POST", + "path": route, + "headers": [], + "path_params": path_params, + "route": SimpleNamespace(path=route), + }, + receive=AsyncMock( + return_value={ + "type": "http.request", + "more_body": False, + "body": json.dumps( + { + "vertex_location": "us-central1", + **({"model": "chat"} if include_model else {}), + **({"s3_bucket_name": "team-bucket"} if route in {"/v1/files", "/v1/batches"} else {}), + } + ).encode(), + } + ), + ) + if is_allowed: + identity: Final = await user_api_key_auth(request=request, api_key="Bearer external-token") + assert identity.team_id == team_id + return + with pytest.raises(ProxyException) as rejection: + await user_api_key_auth(request=request, api_key="Bearer external-token") + assert int(rejection.value.code) == 401 + assert "vertex_location" in str(rejection.value) + + @pytest.mark.asyncio async def test_centralized_common_checks_runs_for_standard_auth(): """Regardless of which _user_api_key_auth_builder path returned, the @@ -5556,8 +5644,6 @@ async def _run_centralized_checks_for_end_user( setattr(_proxy_server_mod, attribute, value) - - @pytest.mark.parametrize( ("make_token", "make_end_user", "gpt_4o_spend", "model", "route", "must_raise"), [ diff --git a/tests/unit/proxy/batches_endpoints/test_endpoints.py b/tests/unit/proxy/batches_endpoints/test_endpoints.py index cee8a129172..127cf85710b 100644 --- a/tests/unit/proxy/batches_endpoints/test_endpoints.py +++ b/tests/unit/proxy/batches_endpoints/test_endpoints.py @@ -221,6 +221,7 @@ def harness(monkeypatch: pytest.MonkeyPatch): registry.access_groups = MagicMock(return_value={}) registry.resolve = MagicMock(side_effect=lambda name, **_: name) registry.all = MagicMock(return_value=[]) + registry.deployments = MagicMock(return_value=[]) creds_resolver = MagicMock(side_effect=_registry_creds_lookup) registry.credentials = _route_credentials({"with_provider": creds_resolver}) router.model_list = [] @@ -474,9 +475,7 @@ async def test_create__team_with_an_allowlist_and_no_request_bucket_leaves_the_b @pytest.mark.asyncio -@pytest.mark.parametrize( - "request_bucket,expected_bucket", [("team-a-bucket", "team-a-bucket"), (None, "model-bucket")] -) +@pytest.mark.parametrize("request_bucket,expected_bucket", [("team-a-bucket", "team-a-bucket"), (None, "model-bucket")]) @pytest.mark.parametrize( "routing", [ @@ -1564,6 +1563,7 @@ def retrieve_harness(): registry.access_groups = MagicMock(return_value={}) registry.resolve = MagicMock(side_effect=lambda name, **_: name) registry.all = MagicMock(return_value=[]) + registry.deployments = MagicMock(return_value=[]) creds_resolver = MagicMock(side_effect=_registry_creds_lookup) router.model_list = [] router.aretrieve_batch = AsyncMock(return_value=make_batch()) @@ -2151,6 +2151,7 @@ def list_harness(): registry.access_groups = MagicMock(return_value={}) registry.resolve = MagicMock(side_effect=lambda name, **_: name) registry.all = MagicMock(return_value=[]) + registry.deployments = MagicMock(return_value=[]) creds_resolver = MagicMock(side_effect=_registry_creds_lookup) registry.credentials = _route_credentials({"with_provider": creds_resolver}) router.model_list = [] @@ -2576,6 +2577,7 @@ def cancel_harness(): registry.access_groups = MagicMock(return_value={}) registry.resolve = MagicMock(side_effect=lambda name, **_: name) registry.all = MagicMock(return_value=[]) + registry.deployments = MagicMock(return_value=[]) creds_resolver = MagicMock(side_effect=_registry_creds_lookup) registry.credentials = _route_credentials({"with_provider": creds_resolver}) router.model_list = [] diff --git a/tests/unit/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/unit/proxy/batches_endpoints/test_litellm_executed_batches.py index f9918d5a1ee..ce2e740c39c 100644 --- a/tests/unit/proxy/batches_endpoints/test_litellm_executed_batches.py +++ b/tests/unit/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -252,11 +252,20 @@ class FakeRouter: def registry(self) -> "FakeRegistry": return FakeRegistry() + def settings(self) -> "FakeSettings": + return FakeSettings() + def get_model_group_info(self, model_group: str) -> None: return None class FakeRegistry: + def routing_group(self, name: str) -> None: + return None + + def index(self, name: Literal["team_model"]) -> Mapping[tuple[str, str], Sequence[int]]: + return MappingProxyType({}) + def names(self, model_name: str | None = None, *, kind: str = "groups") -> list[str]: assert kind == "ids" return [DEPLOYMENT_ID] if model_name == BATCH_MODEL else [] @@ -264,6 +273,19 @@ class FakeRegistry: def group(self, name: str, *, field: str = "info") -> None: return None + def resolve(self, name: str, *, kind: str = "group") -> None: + return None + + def deployments( + self, model_name: str | None = None, *, source: str = "group", team_id: str | None = None + ) -> list[object]: + return [] + + +class FakeSettings: + def is_set(self, name: str) -> bool: + return False + class FakeStorageBackend: def __init__(self, contents: Mapping[str, bytes]) -> None: diff --git a/tests/unit/proxy/health_endpoints/test_health_endpoints.py b/tests/unit/proxy/health_endpoints/test_health_endpoints.py index 2258eb95fb8..4aff6adadd3 100644 --- a/tests/unit/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/unit/proxy/health_endpoints/test_health_endpoints.py @@ -5271,6 +5271,12 @@ class TestConfigBaseForHealthCheck: assert "vertex_credentials" not in base assert base["rpm"] == 100 + @pytest.mark.parametrize("param", ["vertex_project", "vertex_location", "vertex_ai_project", "vertex_ai_location"]) + def test_request_naming_a_vertex_target_still_inherits_config_credentials(self, param: str) -> None: + base = self._base(self.CONFIG, {"model": "openai/gpt-4o", param: "us-central1"}, selected_by_id=False) + assert base["vertex_credentials"] == "configured-creds" + assert base["api_key"] == "sk-configured" + def test_add_model_flow_keeps_its_own_credentials(self): """Adding a second deployment for an already-configured name sends a complete connection; it is tested as sent, not as configured.""" diff --git a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 70f5e855e16..6e551f8ff0a 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -6399,7 +6399,11 @@ class TestVertexAILiveWebsocketPassthrough: ), ( "projects/other/locations/us-central1/publishers/google/models/gemini-2.0-flash", - "projects/other/locations/us-central1/publishers/google/models/gemini-2.0-flash", + "projects/proj-db/locations/global/publishers/google/models/gemini-2.0-flash", + ), + ( + "projects/other/locations/us-central1/endpoints/1234", + "projects/proj-db/locations/global/endpoints/1234", ), ], ) @@ -6425,7 +6429,9 @@ class TestVertexAILiveWebsocketPassthrough: rewriter = passthrough_module._build_vertex_live_setup_model_rewriter( vertex_project="proj-db", vertex_location="global", + configured_location="global", llm_router=llm_router, + keeps_caller_resource_path=False, ) assert rewriter is not None @@ -6517,6 +6523,402 @@ class TestVertexAILiveWebsocketPassthrough: assert "default_vertex_config" in close_kwargs["reason"] assert len(close_kwargs["reason"].encode("utf-8")) <= 123 + def _pinned_live_router(self, extra_params: Mapping[str, object]) -> litellm.Router: + return litellm.Router( + model_list=[ + { + "model_name": "gemini-live", + "litellm_params": { + "model": "vertex_ai/gemini-live-2.5-flash", + "use_in_pass_through": True, + "vertex_project": "proj-db", + "vertex_location": "global", + "vertex_credentials": '{"type": "service_account"}', + **extra_params, + }, + } + ] + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "query, field", + [ + ({"vertex_location": "us-central1"}, "vertex_location"), + ({"vertex_project": "caller-project"}, "vertex_project"), + ({"vertex_project": "proj-db", "vertex_location": "us-central1"}, "vertex_location"), + ], + ) + async def test_query_overriding_a_pinned_deployment_closes_before_minting_a_token( + self, monkeypatch, query: Mapping[str, str], field: str + ): + from litellm.proxy.pass_through_endpoints import ( + llm_passthrough_endpoints as passthrough_module, + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", self._pinned_live_router({})) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + monkeypatch.setattr(passthrough_module.passthrough_endpoint_router, "default_vertex_config", None) + self._clear_vertex_env(monkeypatch) + websocket = self._websocket() + ensure_token = AsyncMock(return_value=("token-abc", "proj-db")) + ws_passthrough = AsyncMock() + + with ( + patch.object(passthrough_module.vertex_llm_base, "ensure_access_token_async", ensure_token), + patch.object(passthrough_module, "websocket_passthrough_request", ws_passthrough), + ): + await passthrough_module.vertex_ai_live_websocket_passthrough( + websocket=websocket, + user_api_key_dict=UserAPIKeyAuth(), + **query, + ) + + close_kwargs = websocket.close.await_args.kwargs + assert close_kwargs["code"] == 1008 + assert field in close_kwargs["reason"] + assert len(close_kwargs["reason"].encode("utf-8")) <= 123 + ensure_token.assert_not_awaited() + ws_passthrough.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "extra_params, general_settings, query, expected_location", + [ + ({}, {}, {"vertex_project": "proj-db", "vertex_location": "global"}, "global"), + ( + {"configurable_clientside_auth_params": ["vertex_location"]}, + {}, + {"vertex_location": "us-central1"}, + "us-central1", + ), + ({}, {"allow_client_side_credentials": True}, {"vertex_location": "us-central1"}, "us-central1"), + ], + ids=["matches_the_pin", "deployment_opt_in", "proxy_wide_opt_in"], + ) + async def test_query_the_admin_allows_reaches_vertex( + self, + monkeypatch, + extra_params: Mapping[str, object], + general_settings: Mapping[str, object], + query: Mapping[str, str], + expected_location: str, + ): + from litellm.proxy.pass_through_endpoints import ( + llm_passthrough_endpoints as passthrough_module, + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", self._pinned_live_router(extra_params)) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", dict(general_settings)) + monkeypatch.setattr(passthrough_module.passthrough_endpoint_router, "default_vertex_config", None) + self._clear_vertex_env(monkeypatch) + websocket = self._websocket() + ensure_token = AsyncMock(return_value=("token-abc", "proj-db")) + ws_passthrough = AsyncMock() + + with ( + patch.object(passthrough_module.vertex_llm_base, "ensure_access_token_async", ensure_token), + patch.object(passthrough_module, "websocket_passthrough_request", ws_passthrough), + ): + await passthrough_module.vertex_ai_live_websocket_passthrough( + websocket=websocket, + user_api_key_dict=UserAPIKeyAuth(), + **query, + ) + + websocket.close.assert_not_awaited() + rewriter = ws_passthrough.await_args.kwargs["setup_model_rewriter"] + assert rewriter("gemini-live") == ( + f"projects/proj-db/locations/{expected_location}/publishers/google/models/gemini-live-2.5-flash" + ) + + def _use_default_vertex_config(self, monkeypatch, general_settings: Mapping[str, object], vertex_location): + from litellm.proxy.pass_through_endpoints import ( + llm_passthrough_endpoints as passthrough_module, + ) + from litellm.types.passthrough_endpoints.vertex_ai import ( + VertexPassThroughCredentials, + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", dict(general_settings)) + monkeypatch.setattr( + passthrough_module.passthrough_endpoint_router, + "default_vertex_config", + VertexPassThroughCredentials( + vertex_project="proj-env", + vertex_location=vertex_location, + vertex_credentials='{"type": "env_account"}', + ), + ) + monkeypatch.setattr(passthrough_module.passthrough_endpoint_router, "deployment_key_to_vertex_credentials", {}) + self._clear_vertex_env(monkeypatch) + + async def _open_live(self, query: Mapping[str, str]): + from litellm.proxy.pass_through_endpoints import ( + llm_passthrough_endpoints as passthrough_module, + ) + + websocket = self._websocket() + ensure_token = AsyncMock( + side_effect=lambda credentials, project_id, custom_llm_provider: ("token-abc", project_id or "proj-env") + ) + ws_passthrough = AsyncMock() + with ( + patch.object(passthrough_module.vertex_llm_base, "ensure_access_token_async", ensure_token), + patch.object(passthrough_module, "websocket_passthrough_request", ws_passthrough), + ): + await passthrough_module.vertex_ai_live_websocket_passthrough( + websocket=websocket, + user_api_key_dict=UserAPIKeyAuth(), + **query, + ) + return websocket, ensure_token, ws_passthrough + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "query, field", + [ + ({"vertex_project": "caller-project"}, "vertex_project"), + ({"vertex_location": "us-central1"}, "vertex_location"), + ({"vertex_project": "proj-env", "vertex_location": "us-central1"}, "vertex_location"), + ], + ) + async def test_query_moving_default_vertex_config_closes_before_minting_a_token( + self, monkeypatch, query: Mapping[str, str], field: str + ): + self._use_default_vertex_config(monkeypatch, {}, vertex_location="europe-west4") + + websocket, ensure_token, ws_passthrough = await self._open_live(query) + + close_kwargs = websocket.close.await_args.kwargs + assert close_kwargs["code"] == 1008 + assert f"{field} is pinned by default_vertex_config" in close_kwargs["reason"] + assert len(close_kwargs["reason"].encode("utf-8")) <= 123 + ensure_token.assert_not_awaited() + ws_passthrough.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "general_settings, default_location, query, expected_project, expected_location", + [ + ( + {}, + "europe-west4", + {"vertex_project": "proj-env", "vertex_location": "europe-west4"}, + "proj-env", + "europe-west4", + ), + ({}, None, {"vertex_location": "us-central1"}, "proj-env", "us-central1"), + ( + {"allow_client_side_credentials": True}, + "europe-west4", + {"vertex_project": "caller-project", "vertex_location": "us-central1"}, + "caller-project", + "us-central1", + ), + ], + ids=["matches_the_pin", "location_left_unset", "proxy_wide_opt_in"], + ) + async def test_default_vertex_config_serves_the_queries_it_allows( + self, + monkeypatch, + general_settings: Mapping[str, object], + default_location, + query: Mapping[str, str], + expected_project: str, + expected_location: str, + ): + self._use_default_vertex_config(monkeypatch, general_settings, vertex_location=default_location) + + websocket, ensure_token, ws_passthrough = await self._open_live(query) + + websocket.close.assert_not_awaited() + assert ensure_token.await_args.kwargs["project_id"] == expected_project + rewriter = ws_passthrough.await_args.kwargs["setup_model_rewriter"] + assert rewriter("gemini-live-2.5-flash") == ( + f"projects/{expected_project}/locations/{expected_location}/publishers/google/models/gemini-live-2.5-flash" + ) + + @pytest.mark.asyncio + async def test_a_registered_project_and_location_pair_still_serves_its_query(self, monkeypatch): + from litellm.proxy.pass_through_endpoints import ( + llm_passthrough_endpoints as passthrough_module, + ) + from litellm.types.passthrough_endpoints.vertex_ai import ( + VertexPassThroughCredentials, + ) + + self._use_default_vertex_config(monkeypatch, {}, vertex_location="europe-west4") + monkeypatch.setattr( + passthrough_module.passthrough_endpoint_router, + "deployment_key_to_vertex_credentials", + { + "caller-project-us-central1": VertexPassThroughCredentials( + vertex_project="caller-project", + vertex_location="us-central1", + vertex_credentials='{"type": "registered_account"}', + ) + }, + ) + + websocket, ensure_token, _ = await self._open_live( + {"vertex_project": "caller-project", "vertex_location": "us-central1"} + ) + + websocket.close.assert_not_awaited() + ensure_token.assert_awaited_once_with( + credentials='{"type": "registered_account"}', + project_id="caller-project", + custom_llm_provider="vertex_ai_beta", + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "general_settings, expected", + [ + ({}, "projects/proj-env/locations/europe-west4/publishers/google/models/gemini-2.0-flash"), + ( + {"allow_client_side_credentials": True}, + "projects/caller-project/locations/us-central1/publishers/google/models/gemini-2.0-flash", + ), + ], + ids=["pinned", "proxy_wide_opt_in"], + ) + async def test_setup_frame_resource_path_cannot_move_the_session_to_another_project( + self, monkeypatch, general_settings: Mapping[str, object], expected: str + ): + self._use_default_vertex_config(monkeypatch, general_settings, vertex_location="europe-west4") + + _, _, ws_passthrough = await self._open_live({}) + + rewriter = ws_passthrough.await_args.kwargs["setup_model_rewriter"] + assert ( + rewriter("projects/caller-project/locations/us-central1/publishers/google/models/gemini-2.0-flash") + == expected + ) + + def _pin_no_vertex_project( + self, + monkeypatch, + general_settings: Mapping[str, object], + default_vertex_config: VertexPassThroughCredentials | None, + ): + from litellm.proxy.pass_through_endpoints import ( + llm_passthrough_endpoints as passthrough_module, + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", dict(general_settings)) + monkeypatch.setattr( + passthrough_module.passthrough_endpoint_router, "default_vertex_config", default_vertex_config + ) + monkeypatch.setattr(passthrough_module.passthrough_endpoint_router, "deployment_key_to_vertex_credentials", {}) + self._clear_vertex_env(monkeypatch) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "default_vertex_config", + [None, VertexPassThroughCredentials(vertex_location="europe-west4")], + ids=["no_default_vertex_config", "default_vertex_config_without_a_project"], + ) + async def test_caller_named_project_closes_before_any_upstream_connect_when_nothing_pins_one( + self, monkeypatch, default_vertex_config: VertexPassThroughCredentials | None + ): + self._pin_no_vertex_project(monkeypatch, {}, default_vertex_config) + + websocket, ensure_token, ws_passthrough = await self._open_live({"vertex_project": "other-project"}) + + ws_passthrough.assert_not_awaited() + ensure_token.assert_not_awaited() + close_kwargs = websocket.close.await_args.kwargs + assert close_kwargs["code"] == 1008 + assert "vertex_project" in close_kwargs["reason"] + assert len(close_kwargs["reason"].encode("utf-8")) <= 123 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "configured_by, default_vertex_config", + [ + ("VERTEXAI_PROJECT", None), + ("DEFAULT_VERTEXAI_PROJECT", VertexPassThroughCredentials(vertex_location="europe-west4")), + ], + ids=["vertexai_project_env", "default_vertexai_project_env"], + ) + async def test_with_nothing_pinned_the_caller_may_name_the_project_the_admin_configured( + self, monkeypatch, configured_by: str, default_vertex_config: VertexPassThroughCredentials | None + ): + self._pin_no_vertex_project(monkeypatch, {}, default_vertex_config) + monkeypatch.setattr(litellm, "vertex_project", None) + monkeypatch.delenv("VERTEXAI_PROJECT", raising=False) + monkeypatch.setenv(configured_by, "proj-configured") + + websocket, _, ws_passthrough = await self._open_live( + {"vertex_project": "proj-configured", "vertex_location": "europe-west4"} + ) + + websocket.close.assert_not_awaited() + assert ws_passthrough.await_args.kwargs["custom_headers"]["x-goog-user-project"] == "proj-configured" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "general_settings, query, expected_project, expected_location", + [ + ({}, {"vertex_location": "europe-west4"}, "proj-env", "europe-west4"), + ( + {"allow_client_side_credentials": True}, + {"vertex_project": "other-project", "vertex_location": "europe-west4"}, + "other-project", + "europe-west4", + ), + ], + ids=["caller_region", "proxy_wide_opt_in"], + ) + async def test_with_nothing_pinned_the_caller_keeps_the_region_and_the_opt_in_keeps_the_project( + self, + monkeypatch, + general_settings: Mapping[str, object], + query: Mapping[str, str], + expected_project: str, + expected_location: str, + ): + self._pin_no_vertex_project(monkeypatch, general_settings, None) + + websocket, _, ws_passthrough = await self._open_live(query) + + websocket.close.assert_not_awaited() + passthrough_kwargs = ws_passthrough.await_args.kwargs + assert passthrough_kwargs["custom_headers"]["x-goog-user-project"] == expected_project + assert passthrough_kwargs["setup_model_rewriter"]("gemini-2.0-flash") == ( + f"projects/{expected_project}/locations/{expected_location}/publishers/google/models/gemini-2.0-flash" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "setup_model, expected", + [ + ( + "projects/proj-env/locations/europe-west4/publishers/google/models/gemini-2.0-flash", + "projects/proj-env/locations/europe-west4/publishers/google/models/gemini-2.0-flash", + ), + ( + "projects/caller-project/locations/europe-west4/publishers/google/models/gemini-2.0-flash", + "projects/proj-env/locations/europe-west4/publishers/google/models/gemini-2.0-flash", + ), + ], + ids=["own_project", "other_project"], + ) + async def test_setup_frame_keeps_its_region_when_no_region_is_pinned( + self, monkeypatch, setup_model: str, expected: str + ): + self._use_default_vertex_config(monkeypatch, {}, vertex_location=None) + + websocket, _, ws_passthrough = await self._open_live({}) + + websocket.close.assert_not_awaited() + rewriter = ws_passthrough.await_args.kwargs["setup_model_rewriter"] + assert rewriter(setup_model) == expected + @pytest.mark.asyncio @pytest.mark.parametrize("model, expected_model", [("gemini-3.8-live", "gemini-3.8-live"), (None, "")]) async def test_credential_failure_logs_the_named_model_under_vertex_ai( diff --git a/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 4f73e8935e8..8222ca09b24 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -7712,7 +7712,7 @@ async def test_websocket_passthrough_keeps_normal_upstream_close_normal(): websocket.close.assert_awaited_once_with() -async def _run_setup_rewrite_passthrough(setup_model: str, llm_router) -> str: +async def _run_setup_rewrite_passthrough(setup_model: str, llm_router, keeps_caller_resource_path: bool = False) -> str: from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( _build_vertex_live_setup_model_rewriter, ) @@ -7740,7 +7740,9 @@ async def _run_setup_rewrite_passthrough(setup_model: str, llm_router) -> str: setup_model_rewriter=_build_vertex_live_setup_model_rewriter( vertex_project="proj-db", vertex_location="global", + configured_location="global", llm_router=llm_router, + keeps_caller_resource_path=keeps_caller_resource_path, ), ) @@ -7778,6 +7780,67 @@ async def test_websocket_passthrough_rewrites_gateway_alias_setup_model(): assert sent_setup["model"] == "projects/proj-db/locations/global/publishers/google/models/gemini-live-2.5-flash" +async def _run_binary_client_frame_passthrough(frame: bytes): + from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + _build_vertex_live_setup_model_rewriter, + ) + + upstream_ws = RecordingUpstreamWebSocket() + websocket = _client_websocket( + AsyncMock( + side_effect=[ + {"type": "websocket.receive", "bytes": frame}, + {"type": "websocket.disconnect"}, + ] + ) + ) + + with _patched_websocket_passthrough_environment(upstream_ws): + await websocket_passthrough_request( + websocket=websocket, + target="wss://aiplatform.googleapis.com/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent", + custom_headers={"Authorization": "Bearer token"}, + user_api_key_dict=UserAPIKeyAuth(), + forward_headers=False, + endpoint="/vertex_ai/live", + accept_websocket=False, + setup_model_rewriter=_build_vertex_live_setup_model_rewriter( + vertex_project="proj-a", + vertex_location="europe-west4", + configured_location="europe-west4", + llm_router=None, + keeps_caller_resource_path=False, + ), + ) + + return websocket, upstream_ws + + +@pytest.mark.asyncio +async def test_websocket_passthrough_moves_a_binary_setup_frame_onto_the_pinned_project(): + setup_frame = json.dumps( + {"setup": {"model": "projects/proj-b/locations/europe-west4/publishers/google/models/gemini-2.0-flash"}} + ).encode("utf-8") + + _, upstream_ws = await _run_binary_client_frame_passthrough(setup_frame) + + upstream_ws.send.assert_awaited_once() + sent_frame = upstream_ws.send.await_args.args[0] + assert isinstance(sent_frame, bytes), "a binary client message must stay binary upstream" + assert json.loads(sent_frame)["setup"]["model"] == ( + "projects/proj-a/locations/europe-west4/publishers/google/models/gemini-2.0-flash" + ) + + +@pytest.mark.asyncio +async def test_websocket_passthrough_closes_on_a_binary_frame_that_is_not_utf8(): + websocket, upstream_ws = await _run_binary_client_frame_passthrough(b"\xff\xfe\x00projects/proj-b") + + upstream_ws.send.assert_not_awaited() + websocket.close.assert_awaited_once() + assert websocket.close.await_args.kwargs["code"] == 1008 + + @pytest.mark.parametrize( "setup_model", ["gemini-live-2.5-flash", "models/gemini-live-2.5-flash", "publishers/google/models/gemini-live-2.5-flash"], @@ -7799,7 +7862,11 @@ def test_vertex_live_setup_model_resolves_before_extraction(setup_model): ) rewriter = _build_vertex_live_setup_model_rewriter( - vertex_project="proj-db", vertex_location="global", llm_router=None + vertex_project="proj-db", + vertex_location="global", + configured_location="global", + llm_router=None, + keeps_caller_resource_path=False, ) setup_data = {"model": setup_model} @@ -7849,7 +7916,11 @@ async def test_websocket_passthrough_logs_a_bare_alias_setup_model(): endpoint="/vertex_ai/live", accept_websocket=False, setup_model_rewriter=_build_vertex_live_setup_model_rewriter( - vertex_project="proj-db", vertex_location="global", llm_router=None + vertex_project="proj-db", + vertex_location="global", + configured_location="global", + llm_router=None, + keeps_caller_resource_path=False, ), ) @@ -7914,14 +7985,24 @@ async def test_websocket_passthrough_rewrites_alias_of_unrecognised_upstream_mod @pytest.mark.asyncio -async def test_websocket_passthrough_leaves_full_resource_setup_model_untouched(): - full_resource = "projects/other/locations/us-central1/publishers/google/models/gemini-2.0-flash-live-preview-04-09" +@pytest.mark.parametrize( + "keeps_caller_resource_path, expected_prefix", + [(True, "projects/other/locations/us-central1/"), (False, "projects/proj-db/locations/global/")], + ids=["caller_path_kept", "moved_onto_session_target"], +) +async def test_websocket_passthrough_full_resource_setup_model( + keeps_caller_resource_path: bool, expected_prefix: str +) -> None: + model_suffix = "publishers/google/models/gemini-2.0-flash-live-preview-04-09" - sent_frame = await _run_setup_rewrite_passthrough(full_resource, llm_router=None) + sent_frame = await _run_setup_rewrite_passthrough( + f"projects/other/locations/us-central1/{model_suffix}", + llm_router=None, + keeps_caller_resource_path=keeps_caller_resource_path, + ) - assert json.loads(sent_frame)["setup"]["model"] == full_resource assert sent_frame == json.dumps( - {"setup": {"model": full_resource, "generationConfig": {"responseModalities": ["TEXT"]}}} + {"setup": {"model": f"{expected_prefix}{model_suffix}", "generationConfig": {"responseModalities": ["TEXT"]}}} ) diff --git a/tests/unit/proxy/public_endpoints/public_v1/test_model_hub.py b/tests/unit/proxy/public_endpoints/public_v1/test_model_hub.py index 143f5bfb83b..7facd6a62d6 100644 --- a/tests/unit/proxy/public_endpoints/public_v1/test_model_hub.py +++ b/tests/unit/proxy/public_endpoints/public_v1/test_model_hub.py @@ -2,6 +2,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone from types import MappingProxyType +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest @@ -26,6 +27,7 @@ class _FakeRouter: infos: Mapping[str, ModelGroupInfo] model_group_alias: Mapping[str, str] = MappingProxyType({}) + team_model_to_deployment_indices: Mapping[tuple[str, str], Sequence[int]] = MappingProxyType({}) def get_model_group_info(self, model_group: str, team_id: str | None = None) -> ModelGroupInfo | None: return self.infos.get(model_group) @@ -351,6 +353,8 @@ def test_no_router_answers_with_a_problem_rather_than_the_openai_error_shape(mon def test_an_unexpected_router_failure_answers_as_a_problem_not_the_openai_error_shape(monkeypatch): class _Exploding: + team_model_to_deployment_indices: Final[Mapping[tuple[str, str], Sequence[int]]] = MappingProxyType({}) + def get_model_group_info(self, model_group: str, team_id: str | None = None) -> ModelGroupInfo: raise RuntimeError("router blew up") diff --git a/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py b/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py index 6eff950dddf..1d5a1f8fa30 100644 --- a/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py +++ b/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py @@ -18,7 +18,8 @@ from fastapi.testclient import TestClient import litellm from litellm import Router -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.constants import LITELLM_PROXY_ADMIN_NAME +from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.openai_files_endpoints.common_utils import ( TeamProviderDeployment, @@ -292,6 +293,7 @@ VERTEX_REGISTRY_STORE = { "vector_store_id": "projects/registry-project/locations/us-central1/ragCorpora/42", "custom_llm_provider": "vertex_ai", "litellm_params": {"vertex_project": "registry-project", "vertex_location": "us-central1"}, + "is_config": True, } UNSUPPORTED_INGEST_PROVIDER_ERROR = ( "Provider '{provider}' is not supported for RAG ingestion. " @@ -1501,6 +1503,292 @@ def test_rag_ingest_unmanaged_store_keeps_the_callers_full_config(client_interna assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] == caller_config +_PROXY_VERTEX_PROJECT: Final = "proxy-project" +_PROXY_VERTEX_LOCATION: Final = "europe-west4" + + +@pytest.fixture +def proxy_vertex_target(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "vertex_project", None) + monkeypatch.setattr(litellm, "vertex_location", None) + monkeypatch.setenv("VERTEXAI_PROJECT", _PROXY_VERTEX_PROJECT) + monkeypatch.setenv("VERTEXAI_LOCATION", _PROXY_VERTEX_LOCATION) + + +def _vertex_ingest(client: TestClient, vector_store: Mapping[str, str]) -> tuple[httpx.Response, AsyncMock]: + aingest_patch, registry_patch = _patched_ingest_boundary( + None, {"vector_store_id": "projects/p/locations/l/ragCorpora/7", "file_id": "file_123"} + ) + with aingest_patch as mock_aingest, registry_patch, _patched_prisma_client(None): + response = client.post("/v1/rag/ingest", **_ingest_form({"custom_llm_provider": "vertex_ai", **vector_store})) + return response, mock_aingest + + +@pytest.mark.parametrize( + "vector_store, field", + [ + ({"vertex_project": "other-project"}, "vertex_project"), + ({"vertex_location": "us-east5"}, "vertex_location"), + ( + {"vector_store_id": f"projects/other-project/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/7"}, + "vector_store_id", + ), + ( + { + "vertex_project": _PROXY_VERTEX_PROJECT, + "vector_store_id": f"projects/other-project/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/7", + }, + "vector_store_id", + ), + ({"vector_store_id": f"projects/{_PROXY_VERTEX_PROJECT}/locations/us-east5/ragCorpora/7"}, "vector_store_id"), + ({"vertex_ai_project": "other-project"}, "vertex_ai_project"), + ({"vertex_ai_location": "us-east5"}, "vertex_ai_location"), + ({"vector_store_id": "projects/123456789/locations/us-east5/ragCorpora/7"}, "vector_store_id"), + ], + ids=[ + "project_field", + "location_field", + "corpus_project", + "corpus_project_behind_matching_field", + "corpus_region", + "project_alias_field", + "location_alias_field", + "corpus_region_by_project_number", + ], +) +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_refuses_a_key_pointing_the_proxys_vertex_credentials_elsewhere( + client_internal_user, vector_store: Mapping[str, str], field: str +): + response, mock_aingest = _vertex_ingest(client_internal_user, vector_store) + + assert response.status_code == 400, response.json() + assert response.json()["detail"]["error"].startswith(f"Rejected Request: {field} picks a Vertex project") + mock_aingest.assert_not_awaited() + + +def test_rag_ingest_refuses_a_key_naming_a_vertex_project_when_the_proxy_names_none( + client_internal_user, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "vertex_project", None) + monkeypatch.delenv("VERTEXAI_PROJECT", raising=False) + + response, mock_aingest = _vertex_ingest(client_internal_user, {"vertex_project": "other-project"}) + + assert response.status_code == 400, response.json() + mock_aingest.assert_not_awaited() + + +@pytest.mark.parametrize( + "vector_store", + [ + {}, + {"vertex_project": _PROXY_VERTEX_PROJECT, "vertex_location": _PROXY_VERTEX_LOCATION}, + {"vector_store_id": f"projects/{_PROXY_VERTEX_PROJECT}/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/7"}, + {"vertex_ai_project": _PROXY_VERTEX_PROJECT, "vertex_ai_location": _PROXY_VERTEX_LOCATION}, + ], + ids=["proxy_defaults", "same_fields", "corpus_in_the_proxy_project", "same_alias_fields"], +) +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_lets_a_key_use_the_proxys_own_vertex_target(client_internal_user, vector_store: Mapping[str, str]): + response, mock_aingest = _vertex_ingest(client_internal_user, vector_store) + + assert response.status_code == 200, response.json() + assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] == { + "custom_llm_provider": "vertex_ai", + **vector_store, + } + + +@pytest.mark.parametrize( + "project_field, location_field", + [("vertex_project", "vertex_location"), ("vertex_ai_project", "vertex_ai_location")], + ids=["fields", "alias_fields"], +) +@pytest.mark.parametrize( + "user_role, general_settings", + [ + (LitellmUserRoles.PROXY_ADMIN, {}), + (LitellmUserRoles.INTERNAL_USER, {"allow_client_side_credentials": True}), + ], + ids=["proxy_admin", "proxy_wide_opt_in"], +) +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_lets_the_admin_choose_another_vertex_target( + user_role: LitellmUserRoles, general_settings: Mapping[str, object], project_field: str, location_field: str +): + caller: Final = UserAPIKeyAuth(user_id="vertex-caller", user_role=user_role) + original_overrides: Final = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: caller + vector_store: Final = { + project_field: "other-project", + location_field: "us-east5", + "vector_store_id": "projects/other-project/locations/us-east5/ragCorpora/7", + } + try: + with patch.dict("litellm.proxy.proxy_server.general_settings", general_settings): + response, mock_aingest = _vertex_ingest(TestClient(app), vector_store) + finally: + app.dependency_overrides = original_overrides + + assert response.status_code == 200, response.json() + assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] == { + "custom_llm_provider": "vertex_ai", + **vector_store, + } + + +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_reads_a_corpus_named_by_project_number_on_the_proxys_own_project(client_internal_user): + response, mock_aingest = _vertex_ingest( + client_internal_user, {"vector_store_id": f"projects/123456789/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/7"} + ) + + assert response.status_code == 200, response.json() + assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]["vector_store_id"] == ( + f"projects/{_PROXY_VERTEX_PROJECT}/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/7" + ) + + +_PROXY_CORPUS_PREFIX: Final = f"projects/{_PROXY_VERTEX_PROJECT}/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora" +_FOREIGN_CORPUS: Final = f"projects/other-project/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/1" + + +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_refuses_another_teams_store_named_by_a_project_number(client_internal_user): + other_teams_store: Final = LiteLLM_ManagedVectorStore( + vector_store_id=f"{_PROXY_CORPUS_PREFIX}/1", + custom_llm_provider="vertex_ai", + litellm_params={}, + user_id="other-user", + team_id="other-team", + ) + registry: Final = MagicMock() + registry.get_litellm_managed_vector_store_from_registry.side_effect = lambda vector_store_id: ( + other_teams_store if vector_store_id == other_teams_store["vector_store_id"] else None + ) + with ( + patch( # test-quality-ok: aingest is the endpoint's downstream boundary; the test asserts it is never called + "litellm.proxy.rag_endpoints.endpoints.litellm.aingest", + new=AsyncMock(return_value={"vector_store_id": f"{_PROXY_CORPUS_PREFIX}/1", "file_id": "file_123"}), + ) as mock_aingest, + patch.object(litellm, "vector_store_registry", registry), # test-quality-ok: seeds the saved stores by id + _patched_prisma_client(None), + ): + response = client_internal_user.post( + "/v1/rag/ingest", + **_ingest_form( + { + "custom_llm_provider": "vertex_ai", + "vector_store_id": f"projects/0/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/1", + } + ), + ) + + assert response.status_code == 403, response.json() + mock_aingest.assert_not_awaited() + + +@pytest.mark.parametrize( + "vector_store_id", + [ + f"./{_FOREIGN_CORPUS}", + f"x/../{_FOREIGN_CORPUS}", + f"{_PROXY_CORPUS_PREFIX}/1{'/..' * 6}/{_FOREIGN_CORPUS}", + f"{_PROXY_CORPUS_PREFIX}/..", + f"{_PROXY_CORPUS_PREFIX}/1%2F..%2F..", + f"{_PROXY_CORPUS_PREFIX}/1\\..\\..", + ], + ids=["dot_prefix", "dot_dot_prefix", "own_corpus_then_dot_dots", "dot_dot_corpus", "percent", "backslash"], +) +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_refuses_a_vertex_corpus_name_with_path_tricks(client_internal_user, vector_store_id: str): + response, mock_aingest = _vertex_ingest(client_internal_user, {"vector_store_id": vector_store_id}) + + assert response.status_code == 400, response.json() + assert response.json()["detail"]["error"].startswith("Rejected Request: vector_store_id picks a Vertex project") + mock_aingest.assert_not_awaited() + + +_FOREIGN_CORPUS_BY_NUMBER: Final = f"projects/987654321/locations/{_PROXY_VERTEX_LOCATION}/ragCorpora/1" + + +def _foreign_vertex_store(created_by: str | None, corpus: str = _FOREIGN_CORPUS) -> LiteLLM_ManagedVectorStore: + return LiteLLM_ManagedVectorStore( + vector_store_id=corpus, custom_llm_provider="vertex_ai", litellm_params={}, user_id=created_by + ) + + +def _prisma_with_user_row(user_row: LiteLLM_UserTable | None) -> MagicMock: + prisma_client: Final = MagicMock() + prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row) + prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock( + return_value=MagicMock(vector_store_metadata={}) + ) + prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock() + return prisma_client + + +def _ingest_into_saved_store( + store: LiteLLM_ManagedVectorStore, creator_row: LiteLLM_UserTable | None +) -> tuple[httpx.Response, AsyncMock]: + caller: Final = UserAPIKeyAuth(user_id="vertex-caller", user_role=LitellmUserRoles.INTERNAL_USER) + original_overrides: Final = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: caller + aingest_patch, registry_patch = _patched_ingest_boundary( + store, {"vector_store_id": store["vector_store_id"], "file_id": "file_123"} + ) + try: + with aingest_patch as mock_aingest, registry_patch, _patched_prisma_client(_prisma_with_user_row(creator_row)): + response = TestClient(app).post( + "/v1/rag/ingest", **_ingest_form({"vector_store_id": store["vector_store_id"]}) + ) + finally: + app.dependency_overrides = original_overrides + return response, mock_aingest + + +@pytest.mark.parametrize( + "creator_row", + [LiteLLM_UserTable(user_id="vertex-caller", user_role=LitellmUserRoles.INTERNAL_USER.value), None], + ids=["saved_by_an_internal_user", "saved_by_a_key_with_no_user"], +) +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_refuses_a_saved_vertex_store_a_non_admin_pointed_elsewhere(creator_row: LiteLLM_UserTable | None): + store: Final = _foreign_vertex_store(creator_row.user_id if creator_row is not None else None) + + response, mock_aingest = _ingest_into_saved_store(store, creator_row) + + assert response.status_code == 400, response.json() + assert response.json()["detail"]["error"].startswith("Rejected Request: vector_store_id picks a Vertex project") + mock_aingest.assert_not_awaited() + + +@pytest.mark.parametrize("corpus", [_FOREIGN_CORPUS, _FOREIGN_CORPUS_BY_NUMBER], ids=["by_id", "by_number"]) +@pytest.mark.parametrize( + "created_by, is_config, creator_row", + [ + (LITELLM_PROXY_ADMIN_NAME, False, None), + ("admin-user", False, LiteLLM_UserTable(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN.value)), + (None, True, None), + ], + ids=["master_key_created", "admin_user_created", "config_store"], +) +@pytest.mark.usefixtures("proxy_vertex_target") +def test_rag_ingest_lets_a_key_use_a_vertex_store_an_admin_pointed_elsewhere( + corpus: str, created_by: str | None, is_config: bool, creator_row: LiteLLM_UserTable | None +): + store: Final = LiteLLM_ManagedVectorStore(**_foreign_vertex_store(created_by, corpus), is_config=is_config) + + response, mock_aingest = _ingest_into_saved_store(store, creator_row) + + assert response.status_code == 200, response.json() + assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] == { + "vector_store_id": corpus, + "custom_llm_provider": "vertex_ai", + } + + def test_rag_ingest_db_managed_store_drops_the_callers_credential_name(client_internal_user): aingest_patch, registry_patch = _patched_ingest_boundary( DB_MANAGED_STORE, {"vector_store_id": "db-store", "file_id": "file_123"} From 8fb2b97d5b2b09d0265122c8120951548f017d1d Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Tue, 10 Nov 2026 12:55:49 -0800 Subject: [PATCH 10/86] fix(router): sync responses() applies the Router's num_retries and fallbacks (#51011) * fix(router): sync responses() applies the Router's num_retries and fallbacks Sync Router.responses() made one attempt and raised, while aresponses() retried and fell back on the same Router. The sync plan now walks retries and fallbacks like the async one, stamps the deployment's num_retries on the error the same way, and the SDK no longer nests its own retries under the Router's for sync generic calls Fixes #49938 * fix(router): sync generate_content() retries and falls back through the Router Reading litellm_metadata in the SDK retry check left sync generate_content with no retries at all, since its Router plan made one attempt and the inner completion() call stopped retrying. Sync generate_content and generate_content_stream now walk the Router's retries and fallbacks like their async versions, so retries are neither lost nor nested --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- litellm/router_rust/invoke/endpoints.py | 22 ++++-- litellm/utils.py | 16 ++--- ...test_endpoints_characterization_generic.py | 2 +- .../core/test_parity_regress5_errors.py | 43 +++++++++++- tests/unit/test_utils.py | 68 +++++++++++++------ 5 files changed, 111 insertions(+), 40 deletions(-) diff --git a/litellm/router_rust/invoke/endpoints.py b/litellm/router_rust/invoke/endpoints.py index d728583cb31..a5307ff8af9 100644 --- a/litellm/router_rust/invoke/endpoints.py +++ b/litellm/router_rust/invoke/endpoints.py @@ -293,7 +293,7 @@ class Plan: return get_router_metadata_variable_name(function_name=self.function_name) -def _generic(endpoint: str, host_op: str | None = None, *, sync: bool = False) -> Plan: +def _generic(endpoint: str, host_op: str | None = None, *, sync: bool = False, single_attempt: bool = True) -> Plan: if sync: return Plan( endpoint=endpoint, @@ -303,9 +303,9 @@ def _generic(endpoint: str, host_op: str | None = None, *, sync: bool = False) - prepare_field="litellm_metadata", function_name="generic_api_call", splits_stream_timeout=_handler_name(host_op or endpoint) in STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS, - stamp="none", + stamp="none" if single_attempt else "deployment", fail_key="group", - single_attempt=True, + single_attempt=single_attempt, ) return Plan( endpoint=endpoint, @@ -483,7 +483,7 @@ PLANS: Final[Mapping[str, Plan]] = MappingProxyType( "aanthropic_messages": _generic("aanthropic_messages", "anthropic_messages"), "anthropic_messages": _generic("anthropic_messages", "anthropic_messages"), "aresponses": _generic("aresponses"), - "responses": _generic("responses", sync=True), + "responses": _generic("responses", sync=True, single_attempt=False), } ) @@ -583,6 +583,8 @@ _SYNC_GENERIC: Final[Mapping[str, str]] = MappingProxyType( } ) +_ROUTER_RETRIED_SYNC_GENERIC: Final = frozenset({"generate_content", "generate_content_stream"}) + _CONTAINER_FILE_ROUTED: Final = frozenset( { "aupload_container_file", @@ -708,7 +710,12 @@ def generic_plan(endpoint: str) -> Plan | None: if endpoint in _VIDEO_DIRECT: return _generic(endpoint, f"videos.{endpoint}") if endpoint in _SYNC_GENERIC: - return _generic(endpoint, _SYNC_GENERIC[endpoint], sync=True) + return _generic( + endpoint, + _SYNC_GENERIC[endpoint], + sync=True, + single_attempt=endpoint not in _ROUTER_RETRIED_SYNC_GENERIC, + ) return None @@ -1658,6 +1665,7 @@ class RustEndpoints: def _sync_factory(self, name: str, plan: Plan) -> Callable[..., object]: is_vector_store: Final = name.startswith("vector_store_") + attempts: Final = _SINGLE_ATTEMPT if plan.single_attempt else None def sync_wrapper( custom_llm_provider: str | None = None, @@ -1669,7 +1677,7 @@ class RustEndpoints: raise TypeError( "_generic_api_call_with_fallbacks() missing 1 required positional argument: 'model'" ) - return self._core.call_sync(plan, kwargs, _SINGLE_ATTEMPT) + return self._core.call_sync(plan, kwargs, attempts) provider_kwargs: dict[ # mutable-ok: the request kwargs the Python router mutates in place # rebind-ok: rebound as the Python router rebinds it str, object ] = ( @@ -1683,7 +1691,7 @@ class RustEndpoints: "_direct_vector_store_embedding_executor": self._embedding_executor(kwargs), } if provider_kwargs.get("model"): - return self._core.call_sync(plan, provider_kwargs, _SINGLE_ATTEMPT) + return self._core.call_sync(plan, provider_kwargs, attempts) if name == "vector_store_search": return self._invoke(plan.host_op)(**{**provider_kwargs, "router": self._core.public}) return self._invoke(plan.host_op)(**provider_kwargs) diff --git a/litellm/utils.py b/litellm/utils.py index 717b1b9009a..a69d3c063de 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2060,11 +2060,9 @@ def _get_call_type_from_public_name(function_name: str) -> str: return function_name -def _is_litellm_router_call(kwargs: Mapping[str, object], *, is_async: bool) -> bool: - """Router completion uses metadata. Async generic calls retry with litellm_metadata; sync generic calls need SDK retries.""" - metadata_buckets: Final = ( - (kwargs.get("metadata"), kwargs.get("litellm_metadata")) if is_async else (kwargs.get("metadata"),) - ) +def _is_litellm_router_call(kwargs: Mapping[str, object]) -> bool: + """Router completion stamps the group on metadata, generic endpoints on litellm_metadata.""" + metadata_buckets: Final = (kwargs.get("metadata"), kwargs.get("litellm_metadata")) return any(isinstance(bucket, Mapping) and "model_group" in bucket for bucket in metadata_buckets) @@ -2322,7 +2320,7 @@ def client(original_function): litellm.num_retries = None # set retries to None to prevent infinite loops context_window_fallback_dict: Final = kwargs.get("context_window_fallback_dict", {}) - is_completion_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=False) + is_completion_litellm_router_call: Final = _is_litellm_router_call(kwargs) if ( num_retries and not is_completion_litellm_router_call @@ -2360,7 +2358,7 @@ def client(original_function): if not is_resent_failure(e): litellm.num_retries = None # set retries to None to prevent infinite loops - is_responses_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=False) + is_responses_litellm_router_call: Final = _is_litellm_router_call(kwargs) if ( num_retries and not is_responses_litellm_router_call @@ -2664,7 +2662,7 @@ def client(original_function): if retry_call_type == CallTypes.acompletion.value: context_window_fallback_dict: Final = kwargs.get("context_window_fallback_dict", {}) - is_acompletion_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=True) + is_acompletion_litellm_router_call: Final = _is_litellm_router_call(kwargs) if ( num_retries @@ -2700,7 +2698,7 @@ def client(original_function): result = await original_function(*args, **kwargs) return result elif retry_call_type == CallTypes.aresponses.value: - is_aresponses_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=True) + is_aresponses_litellm_router_call: Final = _is_litellm_router_call(kwargs) if ( num_retries diff --git a/tests/unit/test_router/core/test_endpoints_characterization_generic.py b/tests/unit/test_router/core/test_endpoints_characterization_generic.py index b1a356cf8eb..f7669bff80c 100644 --- a/tests/unit/test_router/core/test_endpoints_characterization_generic.py +++ b/tests/unit/test_router/core/test_endpoints_characterization_generic.py @@ -195,7 +195,7 @@ async def test_sync_generic_endpoints_set_no_routing_hidden_params( assert response._hidden_params == {} -@pytest.mark.parametrize("endpoint", ["ocr", "generate_content"]) +@pytest.mark.parametrize("endpoint", ["ocr", "video_generation"]) async def test_sync_generic_endpoints_are_not_retried(make_core: MakeCore, host: EndpointHost, endpoint: str): host.answer("openai/d1", litellm.InternalServerError(message="boom", llm_provider="openai", model="d1")) _, request = ROUTED_SYNC[endpoint] diff --git a/tests/unit/test_router/core/test_parity_regress5_errors.py b/tests/unit/test_router/core/test_parity_regress5_errors.py index 3e8537af878..bfeca17b3a2 100644 --- a/tests/unit/test_router/core/test_parity_regress5_errors.py +++ b/tests/unit/test_router/core/test_parity_regress5_errors.py @@ -114,12 +114,51 @@ async def test_a_providers_bad_request_falls_back_and_notes_the_failed_fallback( def test_a_single_attempt_endpoint_raises_with_no_note(make_core: MakeCore, host: EndpointHost) -> None: - core: Final = build(make_core, fallbacks=[{"a": ["b"]}]) + core: Final = build(make_core, fallbacks=[{"a": ["b"]}], num_retries=1) show_router_debug(host) host.answer("openai/a", server_error()) with pytest.raises(litellm.InternalServerError) as raised: - core.responses(model="a", input="hi") # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] # an endpoint + core.ocr(model="a", document={}) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] # an endpoint assert NOTE not in str(raised.value) + assert host.deployments_called() == ["a"] + + +ROUTER_RETRIED: Final[dict[str, tuple[str, str, Mapping[str, object]]]] = { + "responses": ("responses", "aresponses", {"input": "hi"}), + "generate_content": ("generate_content", "agenerate_content", {"contents": []}), +} + + +async def call_routed(core: RouterCore, endpoint: str, mode: str) -> object: + sync_name, async_name, request = ROUTER_RETRIED[endpoint] + if mode == "sync": + return getattr(core, sync_name)(model="a", **request) + return await getattr(core, async_name)(model="a", **request) + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("endpoint", sorted(ROUTER_RETRIED)) +async def test_a_router_retried_endpoint_retries_a_failed_attempt_with_the_router_num_retries( + make_core: MakeCore, host: EndpointHost, endpoint: str, mode: str +) -> None: + core: Final = build(make_core, num_retries=1) + recovered: Final = completion_response(content="recovered") + host.answer("openai/a", server_error(), recovered) + assert await call_routed(core, endpoint, mode) is recovered + assert host.deployments_called() == ["a", "a"] + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("endpoint", sorted(ROUTER_RETRIED)) +async def test_a_router_retried_endpoint_falls_back_to_the_next_group_when_its_group_fails( + make_core: MakeCore, host: EndpointHost, endpoint: str, mode: str +) -> None: + core: Final = build(make_core, fallbacks=[{"a": ["b"]}]) + fallback: Final = completion_response(content="from b") + host.answer("openai/a", server_error()) + host.answer("openai/b", fallback) + assert await call_routed(core, endpoint, mode) is fallback + assert host.deployments_called() == ["a", "b"] async def test_an_empty_request_list_says_no_fallbacks_are_configured(make_core: MakeCore, host: EndpointHost) -> None: diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 045d78fc1e7..da8c3c6a451 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -5706,20 +5706,17 @@ async def test_wrapper_async_logs_converted_responses_stream_with_standard_loggi @pytest.mark.parametrize( - "kwargs, is_async, expected", + "kwargs, expected", [ - ({"metadata": {"model_group": "g"}}, False, True), - ({"metadata": {"model_group": "g"}}, True, True), - ({"litellm_metadata": {"model_group": "g"}}, False, False), - ({"litellm_metadata": {"model_group": "g"}}, True, True), - ({}, False, False), - ({}, True, False), - ({"metadata": None}, False, False), - ({"metadata": None}, True, False), + ({"metadata": {"model_group": "g"}}, True), + ({"litellm_metadata": {"model_group": "g"}}, True), + ({"metadata": {"user": "u"}, "litellm_metadata": {"user": "u"}}, False), + ({}, False), + ({"metadata": None}, False), ], ) -def test_is_litellm_router_call_is_async_aware(kwargs: Mapping[str, object], is_async: bool, expected: bool) -> None: - assert _is_litellm_router_call(kwargs, is_async=is_async) is expected +def test_is_litellm_router_call_reads_both_metadata_buckets(kwargs: Mapping[str, object], expected: bool) -> None: + assert _is_litellm_router_call(kwargs) is expected @pytest.mark.asyncio @@ -5758,11 +5755,24 @@ async def test_router_aresponses_does_not_run_sdk_retries(monkeypatch: pytest.Mo litellm.in_memory_llm_clients_cache.flush_cache() -def test_router_responses_keeps_sdk_retries_for_sync_router_call( - monkeypatch: pytest.MonkeyPatch, -) -> None: +SYNC_ROUTER_RETRIED_CALLS: Final[dict[str, tuple[str, Callable[[litellm.Router], object]]]] = { + "responses": ("/v1/responses", lambda router: router.responses(model="responses-retry", input="hi")), + "generate_content": ( + "/v1/chat/completions", + lambda router: router.generate_content( + model="responses-retry", contents=[{"role": "user", "parts": [{"text": "hi"}]}] + ), + ), +} + + +def _count_sync_router_attempts( + monkeypatch: pytest.MonkeyPatch, endpoint: str, router_num_retries: int, deployment_num_retries: int | None +) -> int: + path, call = SYNC_ROUTER_RETRIED_CALLS[endpoint] monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) litellm.in_memory_llm_clients_cache.flush_cache() + retries: Final = {} if deployment_num_retries is None else {"num_retries": deployment_num_retries} model_list: Final = [ { "model_name": "responses-retry", @@ -5770,15 +5780,16 @@ def test_router_responses_keeps_sdk_retries_for_sync_router_call( "model": "openai/gpt-4o-mini", "api_key": "sk-test", "api_base": "https://responses-retry.local/v1", - "num_retries": 2, + **retries, }, } ] - router: Final = litellm.Router(model_list=model_list, num_retries=0, retry_after=0, disable_cooldowns=True) - + router: Final = litellm.Router( + model_list=model_list, num_retries=router_num_retries, retry_after=0, disable_cooldowns=True + ) try: with respx.mock(assert_all_called=True) as respx_mock: - upstream: Final = respx_mock.post("https://responses-retry.local/v1/responses").mock( + upstream: Final = respx_mock.post(f"https://responses-retry.local{path}").mock( return_value=httpx.Response( 503, headers={"retry-after": "0"}, @@ -5786,14 +5797,29 @@ def test_router_responses_keeps_sdk_retries_for_sync_router_call( ) ) with pytest.raises(litellm.ServiceUnavailableError): - router.responses(model="responses-retry", input="hi") - - assert upstream.call_count == 3 + call(router) + return upstream.call_count finally: router.discard() litellm.in_memory_llm_clients_cache.flush_cache() +@pytest.mark.parametrize("router_num_retries", [0, 1]) +@pytest.mark.parametrize("endpoint", sorted(SYNC_ROUTER_RETRIED_CALLS)) +def test_sync_router_call_retries_as_often_as_the_deployment_num_retries( + monkeypatch: pytest.MonkeyPatch, router_num_retries: int, endpoint: str +) -> None: + assert _count_sync_router_attempts(monkeypatch, endpoint, router_num_retries, deployment_num_retries=2) == 3 + + +@pytest.mark.parametrize("endpoint", sorted(SYNC_ROUTER_RETRIED_CALLS)) +def test_sync_router_call_does_not_nest_the_global_num_retries_under_router_retries( + monkeypatch: pytest.MonkeyPatch, endpoint: str +) -> None: + monkeypatch.setattr(litellm, "num_retries", 2) + assert _count_sync_router_attempts(monkeypatch, endpoint, router_num_retries=1, deployment_num_retries=None) == 2 + + @pytest.mark.asyncio async def test_aresponses_uses_sdk_retries_without_router(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) From 894552205b1749f843db91880593d9b921000fdd Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Tue, 10 Nov 2026 13:02:26 -0800 Subject: [PATCH 11/86] feat(spend): report guardrail spend separately in daily activity (#51010) * feat(spend): report guardrail spend separately in daily activity Model and key spend in /user/daily/activity/aggregated include the cost of guardrails like Bedrock guardrails, and nothing in the response says how much. A customer that bills tenants per key and per model could not get model cost alone, so tenants that pay AWS for their own guardrails were charged for them twice Daily spend rows now carry a guardrail_spend column next to spend, and every breakdown in the daily activity responses exposes it, so model cost is spend - guardrail_spend. spend keeps its meaning, so budgets still count guardrail cost as before The value comes from the request's cost_breakdown.guardrail_cost, capped at the row's spend because an allowance discount or a repeat job poll can charge less than the guardrail cost the breakdown records. The delivery ledger freezes it into the accounting payload next to accounting_savings, since the stored payload does not keep cost_breakdown, and the legacy daily queue writes the same value The migration only adds the column with a 0 default, so rows written before it read 0 * fix(spend): count guardrail spend on rows billed without a cost breakdown A request blocked before the LLM call is billed for its guardrails straight from guardrail_information, so it carries no cost_breakdown and its guardrail_spend read 0. Both writers now price guardrail_spend from guardrail_information with guardrail_information_cost, the same function success and failure rows use to add guardrail cost to spend Also fills guardrail_spend in the client usage view built from request logs, qualifies the field description for rows recorded before it existed, and adds the lock timeout the migration checks require * fix(ui): avoid a nested ternary when reading guardrail entries from request logs --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- .../migration.sql | 22 +++++ .../litellm_proxy_extras/schema.prisma | 7 ++ litellm/accounting/projection.py | 1 + litellm/accounting/snapshot.py | 2 + .../llm_cost_calc/guardrail_cost.py | 6 ++ litellm/proxy/_lazy_openapi_snapshot.json | 6 ++ litellm/proxy/_types.py | 1 + litellm/proxy/db/daily_spend_bulk_upsert.py | 1 + litellm/proxy/db/db_spend_update_writer.py | 5 ++ .../daily_spend_update_queue.py | 4 + .../common_daily_activity.py | 6 ++ litellm/proxy/schema.prisma | 7 ++ .../repositories/daily_activity_repository.py | 1 + litellm/repositories/daily_activity_sql.py | 1 + .../common_daily_activity.py | 6 ++ litellm/types/repositories/daily_activity.py | 2 + schema.prisma | 7 ++ .../database/test_spendlogs_ledger_parity.py | 2 + .../spend/fixtures/daily_activity_team.json | 2 +- .../spend/fixtures/daily_activity_user.json | 2 +- .../daily_activity_team_aggregated.json | 42 ++++++++++ .../golden/daily_activity_team_paginated.json | 42 ++++++++++ .../daily_activity_user_aggregated.json | 42 ++++++++++ .../golden/daily_activity_user_paginated.json | 42 ++++++++++ .../spend/test_daily_activity_routes.py | 1 + .../spend/test_daily_rollup_retry.py | 1 + tests/unit/accounting/test_projection.py | 19 ++++- .../llm_cost_calc/test_guardrail_cost.py | 13 +++ .../test_daily_spend_update_queue.py | 2 + .../proxy/db/test_daily_spend_bulk_upsert.py | 7 +- .../test_common_daily_activity.py | 80 ++++++++++++++++++- .../test_daily_activity_routes.py | 5 ++ .../test_team_endpoints.py | 1 + .../test_daily_activity_repository.py | 1 + .../repositories/test_daily_activity_sql.py | 1 + .../_components/CacheLeakageCard.test.tsx | 1 + .../_components/hooks/clientUsageData.test.ts | 20 +++++ .../_components/hooks/clientUsageData.ts | 22 +++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 6 ++ 39 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20261110120000_add_daily_spend_guardrail_spend/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261110120000_add_daily_spend_guardrail_spend/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261110120000_add_daily_spend_guardrail_spend/migration.sql new file mode 100644 index 00000000000..c2c264325a6 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261110120000_add_daily_spend_guardrail_spend/migration.sql @@ -0,0 +1,22 @@ +SET LOCAL lock_timeout = '10s'; + +-- AlterTable +ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN IF NOT EXISTS "guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + +-- AlterTable +ALTER TABLE "LiteLLM_DailyTeamSpend" ADD COLUMN IF NOT EXISTS "guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + +-- AlterTable +ALTER TABLE "LiteLLM_DailyOrganizationSpend" ADD COLUMN IF NOT EXISTS "guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + +-- AlterTable +ALTER TABLE "LiteLLM_DailyEndUserSpend" ADD COLUMN IF NOT EXISTS "guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + +-- AlterTable +ALTER TABLE "LiteLLM_DailyAgentSpend" ADD COLUMN IF NOT EXISTS "guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + +-- AlterTable +ALTER TABLE "LiteLLM_DailyTagSpend" ADD COLUMN IF NOT EXISTS "guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + +-- AlterTable +ALTER TABLE "LiteLLM_DailyGlobalSpend" ADD COLUMN IF NOT EXISTS "guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 37783275d9b..5a5d56cc6e2 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -883,6 +883,7 @@ model LiteLLM_DailyUserSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -919,6 +920,7 @@ model LiteLLM_DailyGlobalSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -952,6 +954,7 @@ model LiteLLM_DailyOrganizationSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -990,6 +993,7 @@ model LiteLLM_DailyEndUserSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1027,6 +1031,7 @@ model LiteLLM_DailyAgentSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1064,6 +1069,7 @@ model LiteLLM_DailyTeamSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1104,6 +1110,7 @@ model LiteLLM_DailyTagSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) diff --git a/litellm/accounting/projection.py b/litellm/accounting/projection.py index dd52b49f101..9d04d39884e 100644 --- a/litellm/accounting/projection.py +++ b/litellm/accounting/projection.py @@ -133,6 +133,7 @@ def _daily_counters(row: Mapping[str, object], metadata: Mapping[str, object]) - ("prompt_caching_savings_spend", _number(savings.get("prompt_caching"))), ("gateway_injected_caching_savings_spend", _number(savings.get("gateway_injected_caching"))), ("autorouter_savings_spend", 0.0 if internal else _number(savings.get("autorouter"))), + ("guardrail_spend", _number(metadata.get("accounting_guardrail_spend"))), ("total_response_time_ms", _number(duration) if timed else 0), ("timed_requests", int(timed)), ) diff --git a/litellm/accounting/snapshot.py b/litellm/accounting/snapshot.py index ab85df08320..874b90110c1 100644 --- a/litellm/accounting/snapshot.py +++ b/litellm/accounting/snapshot.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_share_of_spend from litellm.litellm_core_utils.safe_json_dumps import safe_json_structure from litellm.proxy._types import SpendLogsPayload from litellm.proxy.spend_tracking.compression_savings import extract_compression_saved_tokens @@ -73,6 +74,7 @@ def build_accounting_payload( **{key: _accounting_metadata_field(key, value) for key, value in metadata.items() if key in _METADATA_FIELDS}, "accounting_entities": entities, "accounting_savings": savings._asdict(), + "accounting_guardrail_spend": guardrail_share_of_spend(metadata, payload["spend"]), "accounting_model_access_groups": model_access_groups, "accounting_windows": windows, } diff --git a/litellm/litellm_core_utils/llm_cost_calc/guardrail_cost.py b/litellm/litellm_core_utils/llm_cost_calc/guardrail_cost.py index e4ad36f50bc..03f6794889c 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/guardrail_cost.py +++ b/litellm/litellm_core_utils/llm_cost_calc/guardrail_cost.py @@ -311,6 +311,12 @@ def guardrail_information_cost(guardrail_information: object) -> float: return _validated_entry_cost(guardrail_information) +def guardrail_share_of_spend(spend_log_metadata: Mapping[str, object], spend: float) -> float: + """The part of a spend row's ``spend`` billed for its guardrails, priced the way success and failure rows add + it to spend and capped at the row's spend, since an allowance discount or a repeat job poll can charge less.""" + return min(guardrail_information_cost(spend_log_metadata.get("guardrail_information")), max(spend, 0.0)) + + def cost_breakdown_with_guardrail(cost_breakdown: CostBreakdown | None, guardrail_cost: float) -> CostBreakdown | None: if guardrail_cost <= 0.0: return cost_breakdown diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 2cadfd6c4a3..36e2cb46e90 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -4882,6 +4882,12 @@ "title": "Gateway Injected Caching Savings Spend", "type": "number" }, + "guardrail_spend": { + "default": 0.0, + "description": "Guardrail spend in USD, already included in spend, so model spend is spend - guardrail_spend. Rows recorded before this field existed report 0 even when their spend includes guardrail cost", + "title": "Guardrail Spend", + "type": "number" + }, "prompt_caching_savings_spend": { "default": 0.0, "title": "Prompt Caching Savings Spend", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 228e80da4bd..736c09277f1 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -5335,6 +5335,7 @@ class BaseDailySpendTransaction(TypedDict): # missing value to zero, so requiring it here would describe a shape the aggregation # is explicitly tested against. autorouter_savings_spend: NotRequired[float] + guardrail_spend: NotRequired[float] # writable-ok: the rollup queue accumulates into this key in place # request level metrics spend: float diff --git a/litellm/proxy/db/daily_spend_bulk_upsert.py b/litellm/proxy/db/daily_spend_bulk_upsert.py index 2d1770f4c1d..38b132e7e10 100644 --- a/litellm/proxy/db/daily_spend_bulk_upsert.py +++ b/litellm/proxy/db/daily_spend_bulk_upsert.py @@ -98,6 +98,7 @@ _SPEND_COLUMNS: Final = ( "prompt_caching_savings_spend", "gateway_injected_caching_savings_spend", "autorouter_savings_spend", + "guardrail_spend", ) _CASTS: Final[Mapping[str, str]] = MappingProxyType( diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 4a1871ff1f5..fc280dd4f73 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -39,6 +39,7 @@ from litellm.constants import ( from litellm.litellm_core_utils.batch_utils import BATCH_COST_REQUEST_ID_SUFFIX from litellm.litellm_core_utils.core_helpers import finite_cost, single_log_line from litellm.litellm_core_utils.litellm_logging import coerce_model_access_groups +from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_share_of_spend from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.proxy._types import ( DB_RETRY_SAFE_ERROR_TYPES, @@ -3164,6 +3165,10 @@ class DBSpendUpdateWriter: prompt_caching_savings_spend=savings_spend.prompt_caching, gateway_injected_caching_savings_spend=savings_spend.gateway_injected_caching, autorouter_savings_spend=0.0 if is_internal_call else savings_spend.autorouter, + guardrail_spend=guardrail_share_of_spend( + _metadata, + payload["spend"], # pyright: ignore[reportUnknownArgumentType] # legacy dict | SpendLogsPayload union + ), total_response_time_ms=timed_duration_ms or 0, timed_requests=0 if timed_duration_ms is None else 1, ) diff --git a/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py b/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py index 288c85c3513..70a9aaa5512 100644 --- a/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py +++ b/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py @@ -154,6 +154,10 @@ class DailySpendUpdateQueue(BaseUpdateQueue): payload.get("autorouter_savings_spend", 0) or 0 ) + daily_transaction.get("autorouter_savings_spend", 0) + daily_transaction["guardrail_spend"] = ( + payload.get("guardrail_spend", 0) or 0 + ) + daily_transaction.get("guardrail_spend", 0) + daily_transaction["total_response_time_ms"] = ( payload.get("total_response_time_ms", 0) or 0 ) + daily_transaction.get("total_response_time_ms", 0) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index a0db1b00ec4..80d873587c3 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -163,6 +163,9 @@ class DailySpendRecord(Protocol): @property def autorouter_savings_spend(self) -> float: ... + @property + def guardrail_spend(self) -> float: ... + @property def api_requests(self) -> int: ... @@ -250,6 +253,7 @@ def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) -> existing_metrics.prompt_caching_savings_spend += record.prompt_caching_savings_spend or 0 existing_metrics.gateway_injected_caching_savings_spend += record.gateway_injected_caching_savings_spend or 0 existing_metrics.autorouter_savings_spend += record.autorouter_savings_spend or 0 + existing_metrics.guardrail_spend += record.guardrail_spend or 0 # rebind-ok: callers rely on in-place accumulation existing_metrics.api_requests += record.api_requests or 0 existing_metrics.successful_requests += record.successful_requests or 0 existing_metrics.failed_requests += record.failed_requests or 0 @@ -736,6 +740,7 @@ def sum_spend_metrics(metrics: Sequence[SpendMetrics]) -> SpendMetrics: prompt_caching_savings_spend=sum(metric.prompt_caching_savings_spend for metric in metrics), gateway_injected_caching_savings_spend=sum(metric.gateway_injected_caching_savings_spend for metric in metrics), autorouter_savings_spend=sum(metric.autorouter_savings_spend for metric in metrics), + guardrail_spend=sum(metric.guardrail_spend for metric in metrics), total_tokens=sum(metric.total_tokens for metric in metrics), successful_requests=sum(metric.successful_requests for metric in metrics), failed_requests=sum(metric.failed_requests for metric in metrics), @@ -766,6 +771,7 @@ def _record_to_spend_metrics(record: RollupMetricsRow) -> SpendMetrics: prompt_caching_savings_spend=record.prompt_caching_savings_spend or 0, gateway_injected_caching_savings_spend=record.gateway_injected_caching_savings_spend or 0, autorouter_savings_spend=record.autorouter_savings_spend or 0, + guardrail_spend=record.guardrail_spend or 0, api_requests=record.api_requests or 0, successful_requests=record.successful_requests or 0, failed_requests=record.failed_requests or 0, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 37783275d9b..5a5d56cc6e2 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -883,6 +883,7 @@ model LiteLLM_DailyUserSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -919,6 +920,7 @@ model LiteLLM_DailyGlobalSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -952,6 +954,7 @@ model LiteLLM_DailyOrganizationSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -990,6 +993,7 @@ model LiteLLM_DailyEndUserSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1027,6 +1031,7 @@ model LiteLLM_DailyAgentSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1064,6 +1069,7 @@ model LiteLLM_DailyTeamSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1104,6 +1110,7 @@ model LiteLLM_DailyTagSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) diff --git a/litellm/repositories/daily_activity_repository.py b/litellm/repositories/daily_activity_repository.py index 809f78e378c..9aa0950785b 100644 --- a/litellm/repositories/daily_activity_repository.py +++ b/litellm/repositories/daily_activity_repository.py @@ -132,6 +132,7 @@ class _CustomerDailyRow(BaseModel): prompt_caching_savings_spend: float gateway_injected_caching_savings_spend: float autorouter_savings_spend: float + guardrail_spend: float spend: float api_requests: int successful_requests: int diff --git a/litellm/repositories/daily_activity_sql.py b/litellm/repositories/daily_activity_sql.py index 5d1f85658ea..dc3ca19bb30 100644 --- a/litellm/repositories/daily_activity_sql.py +++ b/litellm/repositories/daily_activity_sql.py @@ -223,6 +223,7 @@ def _rollup_metric_select(table: DailyActivityTable) -> str: SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend, SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, + SUM(guardrail_spend)::float AS guardrail_spend, SUM(api_requests)::bigint AS api_requests, SUM(successful_requests)::bigint AS successful_requests, SUM(failed_requests)::bigint AS failed_requests, diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 19791b7f620..c4816e7d1ea 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -31,6 +31,11 @@ class SpendMetrics(LiteLLMBaseModel): prompt_caching_savings_spend: float = Field(default=0.0) gateway_injected_caching_savings_spend: float = Field(default=0.0) autorouter_savings_spend: float = Field(default=0.0) + guardrail_spend: float = Field( + default=0.0, + description="Guardrail spend in USD, already included in spend, so model spend is spend - guardrail_spend. " + "Rows recorded before this field existed report 0 even when their spend includes guardrail cost", + ) total_tokens: int = Field(default=0) successful_requests: int = Field(default=0) failed_requests: int = Field(default=0) @@ -259,6 +264,7 @@ class LiteLLM_DailyUserSpend(LiteLLMBaseModel): prompt_caching_savings_spend: float = 0.0 gateway_injected_caching_savings_spend: float = 0.0 autorouter_savings_spend: float = 0.0 + guardrail_spend: float = 0.0 spend: float = 0.0 api_requests: int = 0 successful_requests: int = 0 diff --git a/litellm/types/repositories/daily_activity.py b/litellm/types/repositories/daily_activity.py index 27e169cec5a..8d68d92a336 100644 --- a/litellm/types/repositories/daily_activity.py +++ b/litellm/types/repositories/daily_activity.py @@ -144,6 +144,7 @@ class RollupMetricsRow: prompt_caching_savings_spend: float | None gateway_injected_caching_savings_spend: float | None autorouter_savings_spend: float | None + guardrail_spend: float | None = field(default=None, kw_only=True) api_requests: int | None successful_requests: int | None failed_requests: int | None @@ -219,6 +220,7 @@ class DailyActivityRow(Protocol): prompt_caching_savings_spend: float gateway_injected_caching_savings_spend: float autorouter_savings_spend: float + guardrail_spend: float spend: float api_requests: int successful_requests: int diff --git a/schema.prisma b/schema.prisma index 37783275d9b..5a5d56cc6e2 100644 --- a/schema.prisma +++ b/schema.prisma @@ -883,6 +883,7 @@ model LiteLLM_DailyUserSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -919,6 +920,7 @@ model LiteLLM_DailyGlobalSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -952,6 +954,7 @@ model LiteLLM_DailyOrganizationSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -990,6 +993,7 @@ model LiteLLM_DailyEndUserSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1027,6 +1031,7 @@ model LiteLLM_DailyAgentSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1064,6 +1069,7 @@ model LiteLLM_DailyTeamSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) @@ -1104,6 +1110,7 @@ model LiteLLM_DailyTagSpend { prompt_caching_savings_spend Float @default(0.0) gateway_injected_caching_savings_spend Float @default(0.0) autorouter_savings_spend Float @default(0.0) + guardrail_spend Float @default(0.0) spend Float @default(0.0) api_requests BigInt @default(0) successful_requests BigInt @default(0) diff --git a/tests/integration/database/test_spendlogs_ledger_parity.py b/tests/integration/database/test_spendlogs_ledger_parity.py index 7a8c57d234e..3019d75a2a7 100644 --- a/tests/integration/database/test_spendlogs_ledger_parity.py +++ b/tests/integration/database/test_spendlogs_ledger_parity.py @@ -424,6 +424,7 @@ async def _assert_legacy_database_parity( "guardrail_status": "success", "guardrail_usage": {"text": 2}, "guardrail_cost_by_unit": {"text": 0.02}, + "guardrail_cost": 0.02, "guardrail_cost_in_spend": True, } ], @@ -579,6 +580,7 @@ async def _assert_legacy_database_parity( assert daily["autorouter_savings_spend"] == pytest.approx( (0 if origin == "auto_router" else 0.125) + (0.2 if correction_order else 0) ) + assert daily["guardrail_spend"] == pytest.approx(min(0.02, cost)) group_spend = {row["access_group_name"]: row["spend"] for row in results[-1]["LiteLLM_ModelAccessGroupBudgetTable"]} assert group_spend == {"group": cost + prior_spend, **({"unserved-group": prior_spend} if fallback_narrowing else {})} assert results[-1]["LiteLLM_DailyGuardrailUsageUnits"] diff --git a/tests/integration/spend/fixtures/daily_activity_team.json b/tests/integration/spend/fixtures/daily_activity_team.json index 8f4956dd7fe..cf42f7ae2dd 100644 --- a/tests/integration/spend/fixtures/daily_activity_team.json +++ b/tests/integration/spend/fixtures/daily_activity_team.json @@ -1 +1 @@ -{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"team-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{"team_alias":"Usage Team"},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}},"guardrails":{}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_ptu_hours":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"team-1":5}}} +{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"team-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{"team_alias":"Usage Team"},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}},"guardrails":{}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_ptu_hours":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"team-1":5}}} diff --git a/tests/integration/spend/fixtures/daily_activity_user.json b/tests/integration/spend/fixtures/daily_activity_user.json index 058e12a5044..53f3bfd5487 100644 --- a/tests/integration/spend/fixtures/daily_activity_user.json +++ b/tests/integration/spend/fixtures/daily_activity_user.json @@ -1 +1 @@ -{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"user-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{"user_email":"user@example.com","user_alias":null},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}},"guardrails":{}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_ptu_hours":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"user-1":5}}} +{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"user-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{"user_email":"user@example.com","user_alias":null},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"ptu_hours":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"guardrail_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}},"guardrails":{}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_ptu_hours":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"user-1":5}}} diff --git a/tests/integration/spend/golden/daily_activity_team_aggregated.json b/tests/integration/spend/golden/daily_activity_team_aggregated.json index 91a5d33391b..ce8ccbcaafb 100644 --- a/tests/integration/spend/golden/daily_activity_team_aggregated.json +++ b/tests/integration/spend/golden/daily_activity_team_aggregated.json @@ -39,6 +39,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -68,6 +69,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -97,6 +99,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -126,6 +129,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -155,6 +159,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -188,6 +193,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -217,6 +223,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -246,6 +253,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -275,6 +283,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -304,6 +313,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -327,6 +337,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -360,6 +371,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -389,6 +401,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -418,6 +431,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -447,6 +461,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -476,6 +491,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -501,6 +517,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -535,6 +552,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -558,6 +576,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -589,6 +608,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -618,6 +638,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -647,6 +668,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -670,6 +692,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -694,6 +717,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -725,6 +749,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -748,6 +773,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -781,6 +807,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -804,6 +831,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -835,6 +863,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -864,6 +893,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -893,6 +923,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -916,6 +947,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -940,6 +972,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -971,6 +1004,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -994,6 +1028,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1027,6 +1062,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1056,6 +1092,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1085,6 +1122,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1114,6 +1152,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1143,6 +1182,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1166,6 +1206,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -1190,6 +1231,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, diff --git a/tests/integration/spend/golden/daily_activity_team_paginated.json b/tests/integration/spend/golden/daily_activity_team_paginated.json index 18ee30a74e6..43aee6bdfb6 100644 --- a/tests/integration/spend/golden/daily_activity_team_paginated.json +++ b/tests/integration/spend/golden/daily_activity_team_paginated.json @@ -39,6 +39,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -68,6 +69,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -97,6 +99,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -126,6 +129,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -155,6 +159,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -188,6 +193,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -217,6 +223,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -246,6 +253,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -275,6 +283,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -304,6 +313,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -327,6 +337,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -360,6 +371,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -389,6 +401,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -418,6 +431,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -447,6 +461,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -476,6 +491,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -501,6 +517,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -535,6 +552,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -558,6 +576,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -589,6 +608,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -618,6 +638,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -647,6 +668,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -670,6 +692,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -694,6 +717,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -725,6 +749,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -748,6 +773,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -781,6 +807,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -804,6 +831,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -835,6 +863,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -864,6 +893,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -893,6 +923,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -916,6 +947,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -940,6 +972,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -971,6 +1004,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -994,6 +1028,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1027,6 +1062,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1056,6 +1092,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1085,6 +1122,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1114,6 +1152,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1143,6 +1182,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1166,6 +1206,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -1190,6 +1231,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, diff --git a/tests/integration/spend/golden/daily_activity_user_aggregated.json b/tests/integration/spend/golden/daily_activity_user_aggregated.json index 3189c000035..35d7e57eba8 100644 --- a/tests/integration/spend/golden/daily_activity_user_aggregated.json +++ b/tests/integration/spend/golden/daily_activity_user_aggregated.json @@ -39,6 +39,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -68,6 +69,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -97,6 +99,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -126,6 +129,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -155,6 +159,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -188,6 +193,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -217,6 +223,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -246,6 +253,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -275,6 +283,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -304,6 +313,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -327,6 +337,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -360,6 +371,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -389,6 +401,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -418,6 +431,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -447,6 +461,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -476,6 +491,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -502,6 +518,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -536,6 +553,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -559,6 +577,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -590,6 +609,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -619,6 +639,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -648,6 +669,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -671,6 +693,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -695,6 +718,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -726,6 +750,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -749,6 +774,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -782,6 +808,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -805,6 +832,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -836,6 +864,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -865,6 +894,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -894,6 +924,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -917,6 +948,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -941,6 +973,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -972,6 +1005,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -995,6 +1029,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1028,6 +1063,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1057,6 +1093,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1086,6 +1123,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1115,6 +1153,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1144,6 +1183,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1167,6 +1207,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -1191,6 +1232,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, diff --git a/tests/integration/spend/golden/daily_activity_user_paginated.json b/tests/integration/spend/golden/daily_activity_user_paginated.json index 4f7b62b3147..9ce806e48b5 100644 --- a/tests/integration/spend/golden/daily_activity_user_paginated.json +++ b/tests/integration/spend/golden/daily_activity_user_paginated.json @@ -39,6 +39,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -68,6 +69,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -97,6 +99,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -126,6 +129,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -155,6 +159,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -188,6 +193,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -217,6 +223,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -246,6 +253,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -275,6 +283,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -304,6 +313,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -327,6 +337,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -360,6 +371,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -389,6 +401,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -418,6 +431,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -447,6 +461,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -476,6 +491,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -502,6 +518,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -536,6 +553,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -559,6 +577,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -590,6 +609,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -619,6 +639,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -648,6 +669,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -671,6 +693,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -695,6 +718,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -726,6 +750,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -749,6 +774,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -782,6 +808,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -805,6 +832,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -836,6 +864,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -865,6 +894,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -894,6 +924,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -917,6 +948,7 @@ "metrics": { "api_requests": 3, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 3, "completion_tokens": 6, @@ -941,6 +973,7 @@ "metrics": { "api_requests": 0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "completion_tokens": 0, @@ -972,6 +1005,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -995,6 +1029,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1028,6 +1063,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1057,6 +1093,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1086,6 +1123,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1115,6 +1153,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1, "completion_tokens": 2, @@ -1144,6 +1183,7 @@ "metrics": { "api_requests": 1, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 2, "completion_tokens": 2, @@ -1167,6 +1207,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, @@ -1191,6 +1232,7 @@ "metrics": { "api_requests": 5, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6, "completion_tokens": 10, diff --git a/tests/integration/spend/test_daily_activity_routes.py b/tests/integration/spend/test_daily_activity_routes.py index 9bfe4bb50ce..972d2d87098 100644 --- a/tests/integration/spend/test_daily_activity_routes.py +++ b/tests/integration/spend/test_daily_activity_routes.py @@ -402,6 +402,7 @@ def _daily_tag_metrics( "prompt_caching_savings_spend": 0.0, "gateway_injected_caching_savings_spend": 0.0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "total_tokens": prompt_tokens + completion_tokens, "successful_requests": api_requests, "failed_requests": 0, diff --git a/tests/integration/spend/test_daily_rollup_retry.py b/tests/integration/spend/test_daily_rollup_retry.py index 9ed712cdd06..47a8fb8d7bf 100644 --- a/tests/integration/spend/test_daily_rollup_retry.py +++ b/tests/integration/spend/test_daily_rollup_retry.py @@ -152,6 +152,7 @@ def test_failed_daily_user_rollup_commit_is_retried_so_spend_report_and_daily_ac "prompt_caching_savings_spend": pytest.approx(0.0), "gateway_injected_caching_savings_spend": pytest.approx(0.0), "autorouter_savings_spend": pytest.approx(0.0), + "guardrail_spend": pytest.approx(0.0), "total_tokens": 40, "successful_requests": 1, "failed_requests": 0, diff --git a/tests/unit/accounting/test_projection.py b/tests/unit/accounting/test_projection.py index c78d6bec757..e618c706fac 100644 --- a/tests/unit/accounting/test_projection.py +++ b/tests/unit/accounting/test_projection.py @@ -23,8 +23,23 @@ _FIELDS: Final = TypeAdapter(dict[str, object]) @pytest.mark.parametrize("status", ["success", "failure"]) @pytest.mark.parametrize("origin", [None, "auto_router", "billed_retry"]) @pytest.mark.parametrize("spend", [0.0, 0.25]) +@pytest.mark.parametrize( + ("guardrail_information", "billed_guardrail_cost"), + [ + (None, 0.0), + ([{"guardrail_name": "guard", "guardrail_cost": 0.1}], 0.1), + ([{"guardrail_name": "guard", "guardrail_cost": 0.5}], 0.5), + ([{"guardrail_name": "guard", "guardrail_cost": 0.1, "guardrail_cost_in_spend": False}], 0.0), + ], +) @pytest.mark.asyncio -async def test_daily_projection_matches_legacy_writer(status: str, origin: str | None, spend: float) -> None: +async def test_daily_projection_matches_legacy_writer( + status: str, + origin: str | None, + spend: float, + guardrail_information: list[dict[str, object]] | None, + billed_guardrail_cost: float, +) -> None: payload: Final = get_logging_payload( kwargs={ "model": "projection-test", @@ -43,6 +58,7 @@ async def test_daily_projection_matches_legacy_writer(status: str, origin: str | **_FIELDS.validate_json(payload["metadata"]), **({BILLED_RETRY_ATTEMPT_METADATA_KEY: True} if origin == "billed_retry" else {}), **({INTERNAL_CALL_ORIGIN_METADATA_KEY: origin} if origin not in (None, "billed_retry") else {}), + **({"guardrail_information": guardrail_information} if guardrail_information is not None else {}), } row: Final = {**payload, "spend": spend, "metadata": json.dumps(metadata), "request_duration_ms": 25} legacy: Final = await DBSpendUpdateWriter()._common_add_spend_log_transaction_to_daily_transaction( @@ -60,6 +76,7 @@ async def test_daily_projection_matches_legacy_writer(status: str, origin: str | restored: Final = _FIELDS.validate_json(json.dumps(accounting, default=str)) stored_daily: Final = next(item for item in project(restored) if item.table == "LiteLLM_DailyUserSpend") assert dict(stored_daily.increments) == {name: legacy[name] for name, _ in stored_daily.increments} + assert dict(stored_daily.increments)["guardrail_spend"] == min(billed_guardrail_cost, spend) assert dict(stored_daily.key)["date"] == legacy["date"] assert dict(stored_daily.key)["api_key"] == legacy["api_key"] assert not {"messages", "response", "proxy_server_request", "requester_ip_address"}.intersection(accounting) diff --git a/tests/unit/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py b/tests/unit/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py index 949f55b8a82..35813996a4f 100644 --- a/tests/unit/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py +++ b/tests/unit/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py @@ -13,6 +13,7 @@ from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import ( cost_breakdown_with_guardrail, guardrail_cost_total, guardrail_information_cost, + guardrail_share_of_spend, ) @@ -349,3 +350,15 @@ def test_native_trace_entry_redacts_the_pii_matches_before_they_reach_spend_logs assert entry["guardrail_status"] == "guardrail_intervened" assert "123-45-6789" not in json.dumps(entry["guardrail_response"]) assert entry["guardrail_usage"]["sensitiveInformationPolicyUnits"] == 2 + + + +@pytest.mark.parametrize(("spend", "expected"), [(1.0, 0.3), (0.2, 0.2), (-1.0, 0.0)]) +def test_guardrail_share_of_spend_is_the_billed_guardrail_cost_capped_at_the_row_spend( + spend: float, expected: float +) -> None: + information: Final = [ + {"guardrail_name": "billed", "guardrail_cost": 0.3}, + {"guardrail_name": "report-only", "guardrail_cost": 0.4, "guardrail_cost_in_spend": False}, + ] + assert guardrail_share_of_spend({"guardrail_information": information}, spend) == pytest.approx(expected) diff --git a/tests/unit/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py b/tests/unit/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py index 79447d0fc59..497ebd3093a 100644 --- a/tests/unit/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py +++ b/tests/unit/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py @@ -209,6 +209,7 @@ async def test_get_aggregated_daily_spend_update_transactions_same_key(): "prompt_caching_savings_spend": 0, "gateway_injected_caching_savings_spend": 0, "autorouter_savings_spend": 0, + "guardrail_spend": 0, "total_response_time_ms": 0, "timed_requests": 0, } @@ -263,6 +264,7 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions( "prompt_caching_savings_spend": 0, "gateway_injected_caching_savings_spend": 0, "autorouter_savings_spend": 0, + "guardrail_spend": 0, "total_response_time_ms": 0, "timed_requests": 0, } diff --git a/tests/unit/proxy/db/test_daily_spend_bulk_upsert.py b/tests/unit/proxy/db/test_daily_spend_bulk_upsert.py index 5bce9137666..1b5dd90205c 100644 --- a/tests/unit/proxy/db/test_daily_spend_bulk_upsert.py +++ b/tests/unit/proxy/db/test_daily_spend_bulk_upsert.py @@ -88,10 +88,10 @@ def test_one_statement_carries_every_row_in_the_batch(): assert sql.count("INSERT INTO") == 1 assert len(re.findall(r"ON CONFLICT", sql)) == 1 - # 25 bound columns per row plus the inlined updated_at, so the row count is what + # 26 bound columns per row plus the inlined updated_at, so the row count is what # separates one multi-row statement from a hundred single-row ones. - assert len(params) == 100 * 25 - assert "$2500::text" in sql + assert len(params) == 100 * 26 + assert "$2600::text" in sql assert sql.count("(NOW() AT TIME ZONE 'UTC')") == 100 + 1 @@ -111,6 +111,7 @@ def test_conflict_target_is_the_full_unique_constraint(): "prompt_tokens", "completion_tokens", "spend", + "guardrail_spend", "api_requests", "successful_requests", "failed_requests", diff --git a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py index bba294b571e..d38f71ee8bc 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -710,6 +710,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "prompt_caching_savings_spend": 0.0, "gateway_injected_caching_savings_spend": 0.0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "total_response_time_ms": 0, "timed_requests": 0, "failed_requests": 0, @@ -1294,6 +1295,7 @@ def test_update_breakdown_metrics_includes_user_email(): prompt_caching_savings_spend=0, gateway_injected_caching_savings_spend=0, autorouter_savings_spend=0, + guardrail_spend=0, total_response_time_ms=0, timed_requests=0, total_tokens=2, @@ -1375,6 +1377,7 @@ async def test_tag_daily_activity_metadata_totals_not_zero(): mock_record_1.prompt_caching_savings_spend = 0.0 mock_record_1.gateway_injected_caching_savings_spend = 0.0 mock_record_1.autorouter_savings_spend = 0.0 + mock_record_1.guardrail_spend = 0.0 mock_record_1.total_response_time_ms = 18_000 mock_record_1.timed_requests = 9 mock_record_1.api_requests = 10 @@ -1401,6 +1404,7 @@ async def test_tag_daily_activity_metadata_totals_not_zero(): mock_record_2.prompt_caching_savings_spend = 0.0 mock_record_2.gateway_injected_caching_savings_spend = 0.0 mock_record_2.autorouter_savings_spend = 0.0 + mock_record_2.guardrail_spend = 0.0 mock_record_2.total_response_time_ms = 2_500 mock_record_2.timed_requests = 5 mock_record_2.api_requests = 5 @@ -1473,6 +1477,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): "prompt_caching_savings_spend": 0.0, "gateway_injected_caching_savings_spend": 0.0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "total_response_time_ms": 0, "timed_requests": 0, "failed_requests": 0, @@ -1569,6 +1574,7 @@ async def test_aggregated_activity_flags_only_keys_that_key_info_can_still_resol "prompt_caching_savings_spend": 0.0, "gateway_injected_caching_savings_spend": 0.0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "total_response_time_ms": 0, "timed_requests": 0, "api_requests": 1, @@ -1618,7 +1624,7 @@ async def test_aggregated_activity_flags_only_keys_that_key_info_can_still_resol assert key_breakdown["deleted-key"].metadata.key_alias == "deleted" -def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_group="gpt-4"): +def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_group="gpt-4", guardrail_spend=0.0): """A LiteLLM_DailyUserSpend row as the per-user breakdown reads it.""" return SimpleNamespace( date="2024-01-01", @@ -1639,6 +1645,7 @@ def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_gr prompt_caching_savings_spend=0.0, gateway_injected_caching_savings_spend=0.0, autorouter_savings_spend=0.0, + guardrail_spend=guardrail_spend, total_response_time_ms=0, timed_requests=0, api_requests=1, @@ -1647,6 +1654,38 @@ def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_gr ) +@pytest.mark.asyncio +async def test_paginated_activity_separates_guardrail_spend_per_model_and_key(): + records = [ + _daily_user_spend_record(user_id="u", api_key="key", spend=0.0076916, model="guarded", guardrail_spend=0.00015), + _daily_user_spend_record(user_id="u", api_key="key", spend=0.002, model="unguarded"), + ] + mock_prisma = MagicMock() + mock_prisma.db.litellm_dailyuserspend.count = AsyncMock(return_value=len(records)) + mock_prisma.db.litellm_dailyuserspend.find_many = AsyncMock(return_value=records) + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + result = await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, + entity_metadata_field=None, + start_date="2024-01-01", + end_date="2024-01-01", + model=None, + api_key=None, + page=1, + page_size=1000, + ) + + models = result.results[0].breakdown.models + guarded = models["guarded"].api_key_breakdown["key"].metrics + assert guarded.spend - guarded.guardrail_spend == pytest.approx(0.0075416) + assert models["unguarded"].api_key_breakdown["key"].metrics.guardrail_spend == 0.0 + assert result.results[0].metrics.guardrail_spend == pytest.approx(0.00015) + + @pytest.mark.asyncio async def test_get_daily_activity_applies_resolve_entity_metadata_to_breakdown(): """Regression for LIT-3889: the Spend Per User chart showed raw UUIDs. @@ -1788,6 +1827,7 @@ async def test_get_daily_activity_aggregated_empty_result_set(): "prompt_caching_savings_spend": None, "gateway_injected_caching_savings_spend": None, "autorouter_savings_spend": None, + "guardrail_spend": None, "total_response_time_ms": None, "timed_requests": None, "api_requests": None, @@ -1836,6 +1876,7 @@ def _no_spend_record(): prompt_caching_savings_spend=None, gateway_injected_caching_savings_spend=None, autorouter_savings_spend=None, + guardrail_spend=None, total_response_time_ms=None, timed_requests=None, api_requests=None, @@ -1975,6 +2016,7 @@ def _spend_record(api_key, *, model="gpt-4o-mini-ptu", spend=0.0, ptu_flat_cost= prompt_caching_savings_spend=0, gateway_injected_caching_savings_spend=0, autorouter_savings_spend=0, + guardrail_spend=0, total_response_time_ms=0, timed_requests=0, total_tokens=0, @@ -2021,6 +2063,7 @@ def _grouping_row( endpoint=None, spend=0.0, ptu_flat_cost=0.0, + guardrail_spend=None, ): return GroupingSetsRow( date="2024-01-01", @@ -2043,6 +2086,7 @@ def _grouping_row( prompt_caching_savings_spend=0.0, gateway_injected_caching_savings_spend=0.0, autorouter_savings_spend=0.0, + guardrail_spend=guardrail_spend, total_response_time_ms=0, timed_requests=0, api_requests=0, @@ -2088,6 +2132,38 @@ def test_grouping_sets_dispatcher_excludes_ptu_sentinel_from_key_breakdowns(ptu_ assert "real-key" in model_bucket.api_key_breakdown +def test_model_key_breakdown_separates_guardrail_spend_from_model_spend(): + from litellm.proxy.management_endpoints.common_daily_activity import ( + _GROUP_DATE_API_KEY, + _GROUP_DATE_MODEL, + _GROUP_DATE_MODEL_API_KEY, + _GROUP_GRAND_TOTAL, + _aggregate_grouping_sets_records_sync, + ) + + records = [ + _grouping_row(_GROUP_DATE_MODEL, model="guarded", spend=0.0076916, guardrail_spend=0.00015), + _grouping_row(_GROUP_DATE_MODEL, model="unguarded", spend=0.002), + _grouping_row( + _GROUP_DATE_MODEL_API_KEY, model="guarded", api_key="key", spend=0.0076916, guardrail_spend=0.00015 + ), + _grouping_row(_GROUP_DATE_MODEL_API_KEY, model="unguarded", api_key="key", spend=0.002), + _grouping_row(_GROUP_DATE_API_KEY, api_key="key", spend=0.0096916, guardrail_spend=0.00015), + _grouping_row(_GROUP_GRAND_TOTAL, spend=0.0096916, guardrail_spend=0.00015), + ] + + aggregated = _aggregate_grouping_sets_records_sync(records=records, api_key_metadata={}) + day = aggregated["results"][0] + + guarded = day.breakdown.models["guarded"].api_key_breakdown["key"].metrics + unguarded = day.breakdown.models["unguarded"].api_key_breakdown["key"].metrics + assert guarded.spend - guarded.guardrail_spend == pytest.approx(0.0075416) + assert unguarded.guardrail_spend == 0.0 + assert day.breakdown.models["guarded"].metrics.guardrail_spend == pytest.approx(0.00015) + assert day.breakdown.api_keys["key"].metrics.guardrail_spend == pytest.approx(0.00015) + assert aggregated["totals"].guardrail_spend == pytest.approx(0.00015) + + def test_grouping_sets_dispatcher_populates_every_breakdown_level(ptu_cost_attribution_enabled): """Every GROUPING SETS level lands in its bucket, and the flat-cost sentinel is kept out of the model_group and provider api_key sub-breakdowns too.""" @@ -2205,6 +2281,7 @@ def test_update_breakdown_metrics_covers_mcp_endpoint_and_entity(ptu_cost_attrib prompt_caching_savings_spend=0, gateway_injected_caching_savings_spend=0, autorouter_savings_spend=0, + guardrail_spend=0, total_response_time_ms=0, timed_requests=0, total_tokens=0, @@ -2560,6 +2637,7 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown(): "prompt_caching_savings_spend": 0.0, "gateway_injected_caching_savings_spend": 0.0, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "total_response_time_ms": 0, "timed_requests": 0, "failed_requests": 0, diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py index 9cd2bab07f0..23ad9efdc70 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -67,6 +67,7 @@ class _Activity: prompt_caching_savings_spend: float gateway_injected_caching_savings_spend: float autorouter_savings_spend: float + guardrail_spend: float api_requests: int successful_requests: int failed_requests: int @@ -109,6 +110,7 @@ def _activity_for_entity( prompt_caching_savings_spend=0.2, gateway_injected_caching_savings_spend=0.3, autorouter_savings_spend=0.4, + guardrail_spend=0.5, api_requests=1, successful_requests=1, failed_requests=0, @@ -165,6 +167,7 @@ def _seeded_activity() -> tuple[_Activity, ...]: prompt_caching_savings_spend=0.0, gateway_injected_caching_savings_spend=0.0, autorouter_savings_spend=0.0, + guardrail_spend=0.0, api_requests=1, successful_requests=1, failed_requests=0, @@ -188,6 +191,7 @@ def _metrics(rows: Sequence[_Activity]) -> Mapping[str, int | float]: "prompt_caching_savings_spend": sum(row.prompt_caching_savings_spend for row in rows), "gateway_injected_caching_savings_spend": sum(row.gateway_injected_caching_savings_spend for row in rows), "autorouter_savings_spend": sum(row.autorouter_savings_spend for row in rows), + "guardrail_spend": sum(row.guardrail_spend for row in rows), "api_requests": sum(row.api_requests for row in rows), "successful_requests": sum(row.successful_requests for row in rows), "failed_requests": sum(row.failed_requests for row in rows), @@ -791,6 +795,7 @@ def test_search_folds_each_entity_key_across_days( "prompt_caching_savings_spend": 0.4, "gateway_injected_caching_savings_spend": 0.6, "autorouter_savings_spend": 0.8, + "guardrail_spend": 1.0, "total_tokens": 30, "successful_requests": 2, "failed_requests": 0, diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index 83a121da21a..a79ebd307fe 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -19308,6 +19308,7 @@ def _db_with_one_ptu_hour_of_team_spend(team_id: str) -> MagicMock: prompt_caching_savings_spend=0.0, gateway_injected_caching_savings_spend=0.0, autorouter_savings_spend=0.0, + guardrail_spend=0.0, total_response_time_ms=0, timed_requests=0, api_requests=3, diff --git a/tests/unit/repositories/test_daily_activity_repository.py b/tests/unit/repositories/test_daily_activity_repository.py index b68bb948b7b..bca18c15d61 100644 --- a/tests/unit/repositories/test_daily_activity_repository.py +++ b/tests/unit/repositories/test_daily_activity_repository.py @@ -675,6 +675,7 @@ async def test_customer_team_page_returns_loaded_metrics_and_retains_count_on_em "prompt_caching_savings_spend": 0.25, "gateway_injected_caching_savings_spend": 0.125, "autorouter_savings_spend": 0.0, + "guardrail_spend": 0.0, "total_response_time_ms": 10, "timed_requests": 1, "created_at": "2026-01-01T00:00:00Z", diff --git a/tests/unit/repositories/test_daily_activity_sql.py b/tests/unit/repositories/test_daily_activity_sql.py index f8375b93dad..051db54a161 100644 --- a/tests/unit/repositories/test_daily_activity_sql.py +++ b/tests/unit/repositories/test_daily_activity_sql.py @@ -284,6 +284,7 @@ def test_aggregated_query_uses_the_caller_date_bounds(offset_minutes: int | None def test_aggregate_query_sums_all_savings_drivers_and_response_time() -> None: query = build_aggregated_sql(_scope(), api_key_limit=constants.USAGE_TOP_API_KEYS_DEFAULT) fields: Final = tuple(field for field in SpendMetrics.model_fields if field.endswith("_savings_spend")) + ( + "guardrail_spend", "total_response_time_ms", "timed_requests", ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx index 95abc4e22cb..e87fbbf79d1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx @@ -36,6 +36,7 @@ const baseMetrics = (overrides: Partial): components["schemas"]["S prompt_caching_savings_spend: 0, gateway_injected_caching_savings_spend: 0, autorouter_savings_spend: 0, + guardrail_spend: 0, total_response_time_ms: 0, timed_requests: 0, ...overrides, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts index 1a92f1d1bc8..3f17685f295 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts @@ -88,6 +88,26 @@ describe("client usage from request logs", () => { expect(day?.date).toBe(localDay); }); + it("should report guardrail spend inside model spend, capped at what each request was charged", () => { + const guarded = (request_id: string, spend: number, guardrail_information: unknown) => + log({ request_id, spend, metadata: { user_agent: desktopAgent, guardrail_information } }); + const result = clientUsageFromLogs( + page([ + guarded("billed", 0.0076916, [ + { guardrail_cost: 0.00015 }, + { guardrail_cost: 0.0004, guardrail_cost_in_spend: false }, + ]), + guarded("capped", 0.0001, { guardrail_cost: 0.0003 }), + guarded("malformed", 0.002, [{ guardrail_cost: "free" }]), + log(), + ]), + scope, + ); + expect(result.error).toBeNull(); + const metrics = result.data.results?.[0]?.breakdown?.models?.["provider-model"].metrics; + expect(metrics?.guardrail_spend).toBeCloseTo(0.00025, 10); + }); + it("should deduplicate request ids without double counting bare and full user-agent tags", () => { const result = clientUsageFromLogs(page([log(), log({ spend: 0.02 })]), scope); expect(result.data.metadata?.total_api_requests).toBe(1); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts index 998dfd2b271..996efd06061 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts @@ -31,11 +31,19 @@ const usageFields = { input_tokens_details: cacheDetailsSchema.nullish(), }; const usageSchema = z.object(usageFields); +const guardrailEntrySchema = z.object({ + guardrail_cost: z.number().finite().nullish().catch(null), + guardrail_cost_in_spend: z.boolean().nullish().catch(null), +}); const metadataFields = { user_agent: z.string().nullish(), status: z.string().nullish(), additional_usage_values: usageSchema.nullish(), usage_object: usageSchema.nullish(), + guardrail_information: z + .union([z.array(guardrailEntrySchema), guardrailEntrySchema]) + .nullish() + .catch(null), }; const logFields = { request_id: z.string().min(1), @@ -127,6 +135,19 @@ const cacheTokens = (row: ClientLog) => { cacheCreationTokens: additional.cacheCreationTokens ?? usage.cacheCreationTokens, }; }; +const billedGuardrailCost = (entry: z.infer) => + entry.guardrail_cost_in_spend === false ? 0 : Math.max(entry.guardrail_cost ?? 0, 0); +type GuardrailInformation = NonNullable["guardrail_information"]>; +const guardrailEntries = (information: GuardrailInformation) => + Array.isArray(information) ? information : [information]; +const guardrailSpend = (row: ClientLog) => { + const information = row.metadata?.guardrail_information; + const entries = information == null ? [] : guardrailEntries(information); + return Math.min( + entries.reduce((total, entry) => total + billedGuardrailCost(entry), 0), + Math.max(row.spend, 0), + ); +}; const sum = (rows: readonly ClientLog[], value: (row: ClientLog) => number) => rows.reduce((total, row) => total + value(row), 0); const metricsFor = (rows: readonly ClientLog[]): SpendMetrics => ({ @@ -137,6 +158,7 @@ const metricsFor = (rows: readonly ClientLog[]): SpendMetrics => ({ prompt_caching_savings_spend: 0, gateway_injected_caching_savings_spend: 0, autorouter_savings_spend: 0, + guardrail_spend: sum(rows, guardrailSpend), spend: sum(rows, (row) => row.spend), prompt_tokens: sum(rows, (row) => row.prompt_tokens), completion_tokens: sum(rows, (row) => row.completion_tokens), diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ddf6eb21c63..bd5e87bcbdd 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -47697,6 +47697,12 @@ export interface components { * @default 0 */ gateway_injected_caching_savings_spend: number; + /** + * Guardrail Spend + * @description Guardrail spend in USD, already included in spend, so model spend is spend - guardrail_spend. Rows recorded before this field existed report 0 even when their spend includes guardrail cost + * @default 0 + */ + guardrail_spend: number; /** * Prompt Caching Savings Spend * @default 0 From 9fb29c7e385c99dbe52150dc61b41c17a6eec839 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:04:06 +0000 Subject: [PATCH 12/86] fix(tool_choice): accept the Responses API's flat allowed_tools layout on chat completions (#50467) * fix(tool_choice): accept the Responses API's flat allowed_tools layout on chat completions An allowed_tools tool_choice in the flat Responses layout ({"type": "allowed_tools", "mode": ..., "tools": [...]}) answered 400 on litellm.completion, acompletion, Router and the proxy's chat and /v1/responses-to-chat routes. One parser now reads both layouts into the nested Chat layout, and the existing Responses bridge flattens it back for /v1/responses, so either layout reaches OpenAI in the shape each endpoint expects * fix(tool_choice): drop an allowed_tools choice under drop_params and refuse conflicting flat entries An allowed_tools tool_choice a provider can't enforce, or a malformed one, now drops under drop_params and answers 400 without it, the same rule LiteLLM uses for other unsupported values. That covers providers outside OpenAI and Azure, malformed choices on every provider, and Azure api versions older than 2023-12-01 A flat allowed_tools entry that carries both name and a nested function or custom key answers 400, since the two names can disagree, matching what flattening already refuses * fix(tool-choice): keep the 400 when LiteLLM runs the tools, report auto after a drop Under drop_params an allowed_tools choice a provider can't enforce was dropped, so on MCP gateway tools and in run_tool_loop and arun_tool_loop LiteLLM could run a tool the allow list excluded. Those paths now keep the 400 they answered before the flat layout was accepted, and a malformed choice stays a 400 on MCP gateway tools. /v1/responses served through chat echoed a choice it had dropped, on sync, async and streamed responses. It now reports auto, and a choice the provider received is still echoed as sent. * fix(tool-choice): refuse an excluded tool right before LiteLLM runs it Checking the provider up front missed a model alias, an old Azure api_version, a litellm_proxy hop, and SDK fallbacks. The tool loop and the MCP gateway now check the allowed_tools list right before they run a tool call, so an excluded call answers 400 and never runs. Under drop_params the choice now drops only when the caller runs every tool, so a provider-run MCP server or built-in keeps the 400. The Azure checks read the request's api_version, which also fixes the /v1/responses echo there. * fix(tool-choice): drop the choice under drop_params and hand an excluded MCP call back unrun The drop now applies to every provider that cannot take an allowed_tools tool_choice, whatever the request's tools hold. LiteLLM never runs a call the choice excludes: on MCP paths the whole round goes back to the caller in a 200, the same shape as require_approval, and the tool loop keeps its 400 because it only returns final content. A malformed choice stays a 400 before any request on the tool loop and on chat with MCP gateway tools. * fix(tool-choice): refuse an allowed_tools entry whose name is not a string An mcp or other non-function entry with a list or dict name passed the parser and crashed when the allow list was hashed. The parser now treats it as malformed on both layouts, so it answers the same 400 as an empty allow list, or drops under drop_params. * fix(tool-choice): honor server-level and null-name mcp entries and stop hooks running excluded calls * fix(tool-choice): match named mcp entries by server label and let built-in entries allow their interception call * fix(tool-choice): leave a malformed allowed_tools choice unrewritten in the interception hooks * fix(tool-choice): resolve mcp entries like the gateway and keep the choice on every hook path Under drop_params a malformed allowed_tools choice now acts as if it was never sent, and only a choice that parses is stored and enforced. Without drop_params it answers 400 before any provider request, on /v1/responses with gateway tools before MCP listing An mcp entry resolves its label the way the gateway picks servers to list (server name in any case, alias, access group, toolset), and a named entry matches the tool's own name or the gateway's prefixed name. The gateway rewrites mcp entries into function entries in the caller's layout and sends "none" when nothing is left The web search follow-up keeps an allowed_tools choice, compression's litellm_content_retrieve is never excluded and joins the provider-facing choice, and the native /v1/responses hook loop applies the exclusion check and stores the choice * fix(allowed_tools): stop web search and code interpreter follow-ups forcing another call A kept allowed_tools choice now goes to the follow-up in mode auto, so a required choice no longer loops to the agentic loop limit. The MCP label scope dataclass uses a default_factory so the module imports on Python 3.11 * fix(allowed_tools): stop the Responses web search follow-up forcing another call The Responses-shaped follow-up after a web search left tool_choice out of its patch, so the caller's required choice went out again through the chat to Responses bridge and looped to the loop limit. It now carries a kept allowed_tools choice in mode auto, like the chat follow-up * fix(mcp): scope each MCP entry to its own allowed_tools and keep the allowlist on follow-ups A label now covers only the tools its own allowed_tools lists, so a second label on the same server no longer widens it. Responses MCP follow-ups, streamed or not, keep an allowed_tools choice in auto mode instead of dropping it. The new Responses tests fake only the upstream MCP listing, so LiteLLM's own discovery runs * fix(mcp): match an entry's allowed_tools against the tool name the gateway lists A server with a display name override lists the display name, and the listing filter matches allowed_tools against it, but the label scope only checked the original name, so no spelling worked. The scope now also accepts the listed name and its unprefixed part, like the listing filter. Tests now cover the whole gateway and toolset entries and a display-named tool. * fix(mcp): match an entry's allowed_tools only against a server's own prefix, not the first hyphen * fix(mcp): match a tool no server resolves only by its exact listed name in an entry's allowed_tools * fix(mcp): refuse a tool whose display name only looks like an allowed name, and let the chat follow-up answer An MCP entry's allowed_tools now matches a tool only by its exact listed name or a spelling routing accepts for its real name, so a display name that carries the server's own prefix no longer passes for another tool. The chat MCP follow-up keeps an allowed_tools choice in auto mode, so the model can answer with text after the tool ran. * fix(mcp): match a toolset label's tools by exact native name A toolset label served any tool whose name a routing spelling of a granted name matched, so a toolset granting the literal name "server-tool" also served the real tool "tool" that only another toolset grants. It now serves a tool only when the toolset grants its exact native name, the same check tool_is_granted uses when the gateway lists tools --- .../transformation.py | 4 +- .../code_interpreter_interception/handler.py | 24 +- .../compression_interception/handler.py | 5 + .../websearch_interception/handler.py | 84 ++- .../agentic_loop_refusal.py | 10 + .../allowed_tools_tool_choice.py | 328 +++++++++++- .../chat_completion_agentic_loop.py | 137 +++-- .../litellm_core_utils/get_litellm_params.py | 3 +- litellm/llms/azure/chat/gpt_transformation.py | 21 +- litellm/llms/custom_httpx/llm_http_handler.py | 16 +- litellm/main.py | 33 +- .../handler.py | 30 +- litellm/responses/main.py | 25 +- .../responses/mcp/chat_completions_handler.py | 46 +- .../mcp/litellm_proxy_mcp_handler.py | 189 ++++++- .../responses/mcp/mcp_streaming_iterator.py | 27 +- litellm/tool_loop.py | 24 +- litellm/types/integrations/custom_logger.py | 5 + litellm/types/litellm_params.py | 1 + litellm/utils.py | 35 +- ...responses_transformation_transformation.py | 4 + .../test_handler.py | 73 +++ .../test_compression_interception_handler.py | 47 ++ .../test_websearch_chat_completion.py | 92 ++++ .../test_websearch_responses.py | 65 +++ .../test_allowed_tools_tool_choice.py | 500 +++++++++++++++++- .../test_chat_completion_agentic_loop.py | 255 ++++++++- .../test_azure_chat_gpt_transformation.py | 19 +- .../custom_httpx/test_llm_http_handler.py | 89 ++++ .../test_anthropic_responses_bridge.py | 167 ++++++ .../test_handler.py | 34 ++ .../mcp/test_aresponses_api_with_mcp.py | 331 +++++++++++- .../mcp/test_chat_completions_handler.py | 375 ++++++++++++- .../mcp/test_litellm_proxy_mcp_handler.py | 279 +++++++++- .../test_responses_api_bridge_flag.py | 38 ++ tests/unit/test_main.py | 103 +++- tests/unit/test_tool_loop.py | 215 +++++++- tests/unit/test_utils.py | 27 +- tests/unit/types/test_litellm_params.py | 1 + 39 files changed, 3537 insertions(+), 224 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index a2de41a0c45..dd1ef626a69 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -36,7 +36,7 @@ from litellm.completion_extras.litellm_responses_transformation.history import ( ) from litellm.integrations.anthropic_cache_control_hook import routed_model_info, supports_openai_prompt_cache_breakpoint from litellm.litellm_core_utils.allowed_tools_tool_choice import ( - parse_chat_allowed_tools_tool_choice, + parse_allowed_tools_tool_choice, responses_allowed_tools_tool_choice, ) from litellm.litellm_core_utils.core_helpers import ( @@ -900,7 +900,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if isinstance(choice_type, str) and choice_type in ("auto", "none", "required"): return choice_type if choice_type == "allowed_tools": - parsed: Final = parse_chat_allowed_tools_tool_choice( + parsed: Final = parse_allowed_tools_tool_choice( cast( # cast-ok: widening an untyped dict to object is always sound, the helper validates it with pydantic object, tool_choice ) diff --git a/litellm/integrations/code_interpreter_interception/handler.py b/litellm/integrations/code_interpreter_interception/handler.py index b035732afa6..ce5cbc15902 100644 --- a/litellm/integrations/code_interpreter_interception/handler.py +++ b/litellm/integrations/code_interpreter_interception/handler.py @@ -18,6 +18,10 @@ from pydantic import ValidationError import litellm from litellm._logging import verbose_logger from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + allowed_tools_tool_choice_in_auto_mode, + allowed_tools_tool_choice_with_builtin_as_function, +) from litellm.llms.base_llm.sandbox.transformation import ( CodeExecutionResult, ContainerHandle, @@ -175,6 +179,10 @@ def _sandbox_unavailable(step: str, error: Exception, output: str = _SANDBOX_UNA return _SandboxUnavailable(output=output) +def _is_code_interpreter_tool(tool: Mapping[str, object]) -> bool: + return tool.get("type") == "code_interpreter" + + def _output_item_type(item: object) -> object: if isinstance(item, dict): item_mapping: Final[dict[str, object]] = item @@ -343,8 +351,8 @@ class CodeInterpreterInterceptionLogger(CustomLogger): (function_tool if isinstance(tool, dict) and tool.get("type") == "code_interpreter" else tool) for tool in tools ] - if self._tool_choice_targets_code_interpreter(kwargs.get("tool_choice")): - kwargs["tool_choice"] = self._get_function_tool_choice(call_type=call_type) + if "tool_choice" in kwargs: + kwargs["tool_choice"] = self._intercepted_tool_choice(kwargs["tool_choice"], call_type) return kwargs @staticmethod @@ -428,6 +436,14 @@ class CodeInterpreterInterceptionLogger(CustomLogger): or (isinstance(function, dict) and function.get("name") == LITELLM_CODE_EXECUTION_TOOL_NAME) ) + @classmethod + def _intercepted_tool_choice(cls, tool_choice: object, call_type: CallTypes | None) -> object: + if cls._tool_choice_targets_code_interpreter(tool_choice): + return cls._get_function_tool_choice(call_type=call_type) + return allowed_tools_tool_choice_with_builtin_as_function( + tool_choice, _is_code_interpreter_tool, LITELLM_CODE_EXECUTION_TOOL_NAME + ) + def _resolve_provider(self, kwargs: dict[str, object]) -> str | None: provider: Final = kwargs.get("custom_llm_provider") if isinstance(provider, str) and provider: @@ -694,7 +710,9 @@ class CodeInterpreterInterceptionLogger(CustomLogger): def _get_followup_optional_params(self, optional_params: dict[str, object]) -> dict[str, object]: drop_tool_choice: Final = self._tool_choice_targets_code_interpreter(optional_params.get("tool_choice")) return { - k: v for k, v in optional_params.items() if k != "tools" and not (k == "tool_choice" and drop_tool_choice) + k: allowed_tools_tool_choice_in_auto_mode(v) if k == "tool_choice" else v + for k, v in optional_params.items() + if k != "tools" and not (k == "tool_choice" and drop_tool_choice) } async def async_post_agentic_loop_response_hook( diff --git a/litellm/integrations/compression_interception/handler.py b/litellm/integrations/compression_interception/handler.py index 5352ce6b6a0..6c04d16f817 100644 --- a/litellm/integrations/compression_interception/handler.py +++ b/litellm/integrations/compression_interception/handler.py @@ -15,6 +15,7 @@ from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_logger from litellm.compression import compress from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.allowed_tools_tool_choice import allowed_tools_tool_choice_with_function from litellm.litellm_core_utils.asyncify import asyncify from litellm.types.integrations.compression_interception import ( CompressionInterceptionConfig, @@ -180,6 +181,10 @@ class CompressionInterceptionLogger(CustomLogger): existing_tools=cast(list[dict[str, object]] | None, kwargs.get("tools")), compressed_tools=compressed_tools, ) + if "tool_choice" in kwargs: + kwargs["tool_choice"] = allowed_tools_tool_choice_with_function( + kwargs["tool_choice"], LITELLM_CONTENT_RETRIEVE_TOOL_NAME + ) call_id = cast(str | None, kwargs.get("litellm_call_id")) if not call_id: call_id = str(uuid.uuid4()) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index b61bb6cdc90..b386c020a1b 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -9,11 +9,12 @@ server-side using litellm router's search tools. import asyncio import math import uuid -from collections.abc import AsyncIterator, Mapping, Sequence +from collections.abc import AsyncIterator, Callable, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, cast +from pydantic import TypeAdapter from typing_extensions import Never, ReadOnly import litellm @@ -38,6 +39,10 @@ from litellm.integrations.websearch_interception.transformation import ( from litellm.litellm_core_utils.agentic_loop_settings import ( validated_max_agentic_loops, ) +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + allowed_tools_tool_choice_kept_on_follow_up, + allowed_tools_tool_choice_with_builtin_as_function, +) from litellm.litellm_core_utils.core_helpers import qualify_provider_stripped_model from litellm.llms.base_llm.search.transformation import SearchResponse from litellm.types.integrations.custom_logger import ( @@ -100,6 +105,8 @@ WEBSEARCH_NATIVE_BLOCKS_METADATA_KEY: Final = "websearch_native_blocks" _RESPONSE_CONTENT_FIELD: Final = "content" +_OPTIONAL_PARAMS: Final = TypeAdapter(Mapping[str, object]) + _CLAUDE_CODE_WEB_SEARCH_PROMPT_PREFIX: Final = "Perform a web search for the query:" _ResponseT: Final = TypeVar("_ResponseT") @@ -263,6 +270,22 @@ class _ParentRequestCorrelation: _NO_ACOMPLETION_NAMED: Final[_AcompletionNamedParams] = {} +def _tool_choice_naming_litellm_web_search( + kwargs: Mapping[str, object], is_search_tool: Callable[[Mapping[str, object]], bool] +) -> Mapping[str, object]: + if "tool_choice" not in kwargs: + return MappingProxyType({}) + return { + "tool_choice": allowed_tools_tool_choice_with_builtin_as_function( + kwargs["tool_choice"], is_search_tool, LITELLM_WEB_SEARCH_TOOL_NAME + ) + } + + +def _kept_allowed_tools_tool_choice(optional_params: Mapping[str, object]) -> Mapping[str, object]: + return allowed_tools_tool_choice_kept_on_follow_up(optional_params.get("tool_choice")) + + class WebSearchInterceptionLogger(CustomLogger): """ CustomLogger that intercepts WebSearch tool calls for models that don't @@ -514,13 +537,17 @@ class WebSearchInterceptionLogger(CustomLogger): # Keep other tools as-is converted_tools.append(tool) - kwargs["tools"] = converted_tools + converted_kwargs: Final[dict[str, object]] = { + **kwargs, + "tools": converted_tools, + **_tool_choice_naming_litellm_web_search(kwargs, lambda tool: is_search_tool(dict(tool))), + } if kwargs.get("stream"): verbose_logger.debug("WebSearchInterception: deployment hook converting stream=True to stream=False") - return as_converted_stream(kwargs, WEBSEARCH_INTERCEPTION_PREFIX) + return as_converted_stream(converted_kwargs, WEBSEARCH_INTERCEPTION_PREFIX) - return kwargs + return converted_kwargs def _convert_responses_tools( self, kwargs: Mapping[str, object], tools: Sequence[dict[str, object]] @@ -535,7 +562,11 @@ class WebSearchInterceptionLogger(CustomLogger): get_litellm_web_search_tool_responses() if is_web_search_tool_responses(tool) else tool for tool in tools ] - converted_kwargs: Final = {**kwargs, "tools": converted_tools} + converted_kwargs: Final = { + **kwargs, + "tools": converted_tools, + **_tool_choice_naming_litellm_web_search(kwargs, is_web_search_tool_responses), + } if kwargs.get("stream"): verbose_logger.debug("WebSearchInterception: deployment hook converting stream=True to stream=False") @@ -1256,9 +1287,13 @@ class WebSearchInterceptionLogger(CustomLogger): tools_param: Final = optional_params.get("tools") optional_params_clean: Final = { - k: v - for k, v in optional_params.items() - if k not in {"tools", "tool_choice", "stream", "model_alias_map", "stream_response", "custom_prompt_dict"} + **{ + k: v + for k, v in optional_params.items() + if k + not in {"tools", "tool_choice", "stream", "model_alias_map", "stream_response", "custom_prompt_dict"} + }, + **_kept_allowed_tools_tool_choice(_OPTIONAL_PARAMS.validate_python(optional_params)), } kwargs_for_followup: Final = { @@ -1843,9 +1878,11 @@ class WebSearchInterceptionLogger(CustomLogger): ) if request_patch.messages is None: raise ValueError("WebSearchInterception: missing follow-up messages") - params: Final = dict(optional_params) - params.update(request_patch.optional_params) - params.pop("tool_choice", None) + merged_params: Final = {**optional_params, **request_patch.optional_params} + params: Final = { + **{key: value for key, value in merged_params.items() if key != "tool_choice"}, + **_kept_allowed_tools_tool_choice(merged_params), + } patch_kwargs: Final = dict[str, object](request_patch.kwargs) return await litellm.acompletion( model=request_patch.model or model, @@ -1958,17 +1995,20 @@ class WebSearchInterceptionLogger(CustomLogger): tools_param: Final = optional_params.get("tools") optional_params_clean: Final = { - k: v - for k, v in optional_params.items() - if k - not in { - "tools", - "tool_choice", - "extra_body", - "model_alias_map", - "stream_response", - "custom_prompt_dict", - } + **{ + k: v + for k, v in optional_params.items() + if k + not in { + "tools", + "tool_choice", + "extra_body", + "model_alias_map", + "stream_response", + "custom_prompt_dict", + } + }, + **_kept_allowed_tools_tool_choice(_OPTIONAL_PARAMS.validate_python(optional_params)), } if tools_param is not None: optional_params_clean["tools"] = tools_param diff --git a/litellm/litellm_core_utils/agentic_loop_refusal.py b/litellm/litellm_core_utils/agentic_loop_refusal.py index f3ee15dffdd..828d5fa03ad 100644 --- a/litellm/litellm_core_utils/agentic_loop_refusal.py +++ b/litellm/litellm_core_utils/agentic_loop_refusal.py @@ -40,6 +40,16 @@ async def every_claimed_tool_call( return MappingProxyType({"tool_calls": (*_claimed_calls(refused_tool_calls), *chain.from_iterable(claims))}) +_TOOL_CALL_ITEM_TYPES: Final = frozenset({"function_call", "custom_tool_call", "tool_use"}) + + +def called_tool_names(response: object) -> frozenset[str]: + items: Final = (*parts_of(message_field(response, "output")), *parts_of(message_field(response, "content"))) + return _strings( + message_field(item, "name") for item in items if message_field(item, "type") in _TOOL_CALL_ITEM_TYPES + ) + + def _call_ids(call: object) -> frozenset[str]: return _strings((message_field(call, "id"), message_field(call, "call_id"))) diff --git a/litellm/litellm_core_utils/allowed_tools_tool_choice.py b/litellm/litellm_core_utils/allowed_tools_tool_choice.py index f4c4ecbbb26..4e6824e51e9 100644 --- a/litellm/litellm_core_utils/allowed_tools_tool_choice.py +++ b/litellm/litellm_core_utils/allowed_tools_tool_choice.py @@ -1,12 +1,17 @@ -from collections.abc import Mapping +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from types import MappingProxyType from typing import Final, Literal +from openai.types.chat.chat_completion_allowed_tool_choice_param import ChatCompletionAllowedToolChoiceParam +from openai.types.chat.chat_completion_allowed_tools_param import ChatCompletionAllowedToolsParam from openai.types.responses.tool_choice_allowed_param import ToolChoiceAllowedParam from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError from litellm.exceptions import BadRequestError ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS: Final = frozenset({"openai", "azure", "litellm_proxy"}) +_FIRST_AZURE_API_VERSION_WITH_TOOL_CHOICE: Final = "2023-12-01" HOSTED_TOOL_CHOICE_TYPES: Final = frozenset( { @@ -29,6 +34,9 @@ HOSTED_TOOL_CHOICE_TYPES: Final = frozenset( CHAT_ALLOWED_TOOLS_LAYOUT: Final = ( '{"type": "allowed_tools", "allowed_tools": {"mode": "auto" | "required", "tools": []}}' ) +RESPONSES_ALLOWED_TOOLS_LAYOUT: Final = ( + '{"type": "allowed_tools", "mode": "auto" | "required", "tools": []}' +) class ChatAllowedTools(BaseModel): @@ -45,7 +53,17 @@ class ChatAllowedToolsToolChoice(BaseModel): allowed_tools: ChatAllowedTools -_CHAT_ALLOWED_TOOLS_TOOL_CHOICE: Final = TypeAdapter(ChatAllowedToolsToolChoice) +class ResponsesAllowedToolsToolChoice(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + type: Literal["allowed_tools"] + mode: Literal["auto", "required"] + tools: tuple[Mapping[str, object], ...] = Field(min_length=1) + + +_ALLOWED_TOOLS_TOOL_CHOICE: Final[TypeAdapter[ChatAllowedToolsToolChoice | ResponsesAllowedToolsToolChoice]] = ( + TypeAdapter(ChatAllowedToolsToolChoice | ResponsesAllowedToolsToolChoice) +) _SELECTOR: Final = TypeAdapter(dict[str, object]) @@ -67,21 +85,22 @@ def is_allowed_tools_tool_choice(tool_choice: object) -> bool: return False -def raise_if_allowed_tools_tool_choice_unsupported( +def drops_unsupported_allowed_tools_tool_choice( tool_choice: object, custom_llm_provider: str, model: str, -) -> None: - """Providers outside the set would silently send the request with no tool restriction at all, so the call stops - here with a 400. ``drop_params`` is deliberately not consulted: dropping a restriction widens what the model may call.""" + drop_params: bool, +) -> bool: if not is_allowed_tools_tool_choice(tool_choice) or custom_llm_provider in ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS: - return + return False + if drop_params: + return True raise BadRequestError( message=( "tool_choice type 'allowed_tools' is only supported for " f"{', '.join(sorted(ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS))}, got custom_llm_provider={custom_llm_provider}. " - "drop_params does not drop it, since that would let the model call tools the request excluded. " - "Send only the allowed tools in `tools` with tool_choice='auto' or 'required' instead." + "Send only the allowed tools in `tools` with tool_choice='auto' or 'required', or set drop_params=True " + "to drop the tool_choice, which lets the model call any tool in `tools`." ), model=model, llm_provider=custom_llm_provider, @@ -119,18 +138,295 @@ def raise_if_hosted_tool_choice_unsupported( ) -def parse_chat_allowed_tools_tool_choice(tool_choice: object) -> ChatAllowedToolsToolChoice | None: - try: - return _CHAT_ALLOWED_TOOLS_TOOL_CHOICE.validate_python(tool_choice) - except ValidationError: - return None - - def _has_name(selector: Mapping[str, object]) -> bool: name: Final = selector.get("name") return isinstance(name, str) and name != "" +def _names_a_tool_or_nothing(tool: Mapping[str, object]) -> bool: + tool_type: Final = tool.get("type") + if not isinstance(tool_type, str) or tool_type not in ("function", "custom"): + return tool.get("name") is None or _has_name(tool) + match tool.get(tool_type): + case {"name": str()}: + return True + case {"name": object()}: + return False + case _: + return True + + +def _names_are_strings(tools: Iterable[Mapping[str, object]]) -> bool: + return all(map(_names_a_tool_or_nothing, tools)) + + +def _chat_allowed_tool(tool: Mapping[str, object]) -> Mapping[str, object] | None: + tool_type: Final = tool.get("type") + if not isinstance(tool_type, str) or tool_type not in ("function", "custom"): + return tool + if tool_type in tool: + return None if "name" in tool else tool + if not _has_name(tool): + return None + return {**{key: value for key, value in tool.items() if key != "name"}, tool_type: {"name": tool["name"]}} + + +def _chat_layout(choice: ResponsesAllowedToolsToolChoice) -> ChatAllowedToolsToolChoice | None: + nested: Final = tuple(_chat_allowed_tool(tool) for tool in choice.tools) + if any(tool is None for tool in nested): + return None + return ChatAllowedToolsToolChoice( + type="allowed_tools", + allowed_tools=ChatAllowedTools(mode=choice.mode, tools=tuple(tool for tool in nested if tool is not None)), + ) + + +def parse_allowed_tools_tool_choice(tool_choice: object) -> ChatAllowedToolsToolChoice | None: + try: + parsed: Final = _ALLOWED_TOOLS_TOOL_CHOICE.validate_python(tool_choice) + except ValidationError: + return None + match parsed: + case ChatAllowedToolsToolChoice(): + return parsed if _names_are_strings(parsed.allowed_tools.tools) else None + case ResponsesAllowedToolsToolChoice(): + return _chat_layout(parsed) if _names_are_strings(parsed.tools) else None + + +def azure_api_version_takes_tool_choice(api_version: str) -> bool: + return api_version.count("-") < 2 or api_version >= _FIRST_AZURE_API_VERSION_WITH_TOOL_CHOICE + + +def _azure_request_takes_tool_choice(api_version: str | None) -> bool: + import litellm + from litellm.secret_managers.main import get_secret_str + + return azure_api_version_takes_tool_choice( + api_version or litellm.api_version or get_secret_str("AZURE_API_VERSION") or litellm.AZURE_DEFAULT_API_VERSION + ) + + +def _enforces_allowed_tools(custom_llm_provider: str | None, api_version: str | None) -> bool: + if custom_llm_provider == "azure": + return _azure_request_takes_tool_choice(api_version) + return custom_llm_provider in ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS + + +def unenforced_allowed_tools_tool_choice( + tool_choice: object, custom_llm_provider: str | None, api_version: str | None = None +) -> bool: + return is_allowed_tools_tool_choice(tool_choice) and ( + not _enforces_allowed_tools(custom_llm_provider, api_version) + or parse_allowed_tools_tool_choice(tool_choice) is None + ) + + +def _allowed_tool_names(tool_choice: object) -> frozenset[object]: + parsed: Final = parse_allowed_tools_tool_choice(tool_choice) + if parsed is None: + return frozenset() + flattened: Final = tuple(_responses_allowed_tool(tool) for tool in parsed.allowed_tools.tools) + return frozenset(tool.get("name") for tool in flattened if tool is not None and _server_entry(tool) is None) + + +def parse_allowed_tools_tool_choice_or_refuse( + tool_choice: object, model: str, drop_params: bool +) -> ChatAllowedToolsToolChoice | None: + parsed: Final = parse_allowed_tools_tool_choice(tool_choice) + if parsed is not None or drop_params: + return parsed + raise BadRequestError( + message=( + f"Invalid tool choice, tool_choice={tool_choice}. An allowed_tools tool_choice must be " + f"{CHAT_ALLOWED_TOOLS_LAYOUT} or {RESPONSES_ALLOWED_TOOLS_LAYOUT}" + ), + model=model, + llm_provider="", + ) + + +def allowed_tools_tool_choice_in_effect(tool_choice: object, model: str, drop_params: bool) -> object: + if not is_allowed_tools_tool_choice(tool_choice): + return tool_choice + return None if parse_allowed_tools_tool_choice_or_refuse(tool_choice, model, drop_params) is None else tool_choice + + +def _server_entry(tool: Mapping[str, object]) -> tuple[str, str | None] | None: + label: Final = tool.get("server_label") + name: Final = tool.get("name") + if tool.get("type") != "mcp" or not isinstance(label, str): + return None + return label, name if isinstance(name, str) else None + + +def _server_entries(tool_choice: object) -> frozenset[tuple[str, str | None]]: + parsed: Final = parse_allowed_tools_tool_choice(tool_choice) + entries: Final = () if parsed is None else tuple(map(_server_entry, parsed.allowed_tools.tools)) + return frozenset(entry for entry in entries if entry is not None) + + +@dataclass(frozen=True, slots=True) +class GatewayTool: + names: frozenset[str] + server_labels: frozenset[str] + + +@dataclass(frozen=True, slots=True) +class GatewayTools: + server_labels: frozenset[str] + tools: Mapping[str, GatewayTool] + + +NO_GATEWAY_TOOLS: Final = GatewayTools(server_labels=frozenset(), tools=MappingProxyType({})) + + +def _server_entry_allows(entry: tuple[str, str | None], tool: GatewayTool | None) -> bool: + label, name = entry + return tool is not None and label in tool.server_labels and (name is None or name in tool.names) + + +def _a_server_entry_allows(entries: Iterable[tuple[str, str | None]], tool: GatewayTool | None) -> bool: + return any(_server_entry_allows(entry, tool) for entry in entries) + + +def tool_calls_the_choice_excludes( + tool_choice: object, tool_names: Iterable[str], gateway: GatewayTools = NO_GATEWAY_TOOLS +) -> tuple[str, ...]: + if not is_allowed_tools_tool_choice(tool_choice): + return () + allowed: Final = _allowed_tool_names(tool_choice) + server_entries: Final = _server_entries(tool_choice) + return tuple( + name + for name in tool_names + if name not in allowed and not _a_server_entry_allows(server_entries, gateway.tools.get(name)) + ) + + +def refuse_tool_calls_the_choice_excludes(tool_choice: object, tool_names: Iterable[str], model: str) -> None: + excluded: Final = tool_calls_the_choice_excludes(tool_choice, tool_names) + if excluded: + raise BadRequestError( + message=( + f"The model called {', '.join(excluded)}, which the allowed_tools tool_choice excludes, because this " + "request did not reach a model that enforces the choice. LiteLLM did not run the call. Send only the " + "allowed tools in `tools` with tool_choice='auto' or 'required'." + ), + model=model, + llm_provider="", + ) + + +def _with_builtin_as( + tool: Mapping[str, object], is_builtin: Callable[[Mapping[str, object]], bool], function_entry: Mapping[str, object] +) -> Mapping[str, object]: + return function_entry if tool.get("type") not in ("function", "custom", "mcp") and is_builtin(tool) else tool + + +def _function_entry( + layout: ChatAllowedToolsToolChoice | ResponsesAllowedToolsToolChoice, name: str +) -> Mapping[str, object]: + match layout: + case ChatAllowedToolsToolChoice(): + return {"type": "function", "function": {"name": name}} + case ResponsesAllowedToolsToolChoice(): + return {"type": "function", "name": name} + + +def _entries_of( + layout: ChatAllowedToolsToolChoice | ResponsesAllowedToolsToolChoice, +) -> tuple[Mapping[str, object], ...]: + match layout: + case ChatAllowedToolsToolChoice(): + return layout.allowed_tools.tools + case ResponsesAllowedToolsToolChoice(): + return layout.tools + + +def _with_entries( + layout: ChatAllowedToolsToolChoice | ResponsesAllowedToolsToolChoice, tools: Iterable[Mapping[str, object]] +) -> Mapping[str, object]: + entries: Final = [dict(tool) for tool in tools] + match layout: + case ChatAllowedToolsToolChoice(): + return {"type": "allowed_tools", "allowed_tools": {"mode": layout.allowed_tools.mode, "tools": entries}} + case ResponsesAllowedToolsToolChoice(): + return {"type": "allowed_tools", "mode": layout.mode, "tools": entries} + + +def _callers_layout(tool_choice: object) -> ChatAllowedToolsToolChoice | ResponsesAllowedToolsToolChoice | None: + if parse_allowed_tools_tool_choice(tool_choice) is None: + return None + return _ALLOWED_TOOLS_TOOL_CHOICE.validate_python(tool_choice) + + +def allowed_tools_tool_choice_with_builtin_as_function( + tool_choice: object, is_builtin: Callable[[Mapping[str, object]], bool], function_name: str +) -> object: + layout: Final = _callers_layout(tool_choice) + if layout is None: + return tool_choice + entry: Final = _function_entry(layout, function_name) + return _with_entries(layout, (_with_builtin_as(tool, is_builtin, entry) for tool in _entries_of(layout))) + + +def allowed_tools_tool_choice_with_function(tool_choice: object, function_name: str) -> object: + layout: Final = _callers_layout(tool_choice) + if layout is None or function_name in _allowed_tool_names(tool_choice): + return tool_choice + return _with_entries(layout, (*_entries_of(layout), _function_entry(layout, function_name))) + + +def allowed_tools_tool_choice_in_auto_mode(tool_choice: object) -> object: + layout: Final = _callers_layout(tool_choice) + match layout: + case None: + return tool_choice + case ChatAllowedToolsToolChoice(): + return layout.model_copy( + update={"allowed_tools": layout.allowed_tools.model_copy(update={"mode": "auto"})} + ).model_dump(mode="json") + case ResponsesAllowedToolsToolChoice(): + return layout.model_copy(update={"mode": "auto"}).model_dump(mode="json") + + +def allowed_tools_tool_choice_kept_on_follow_up(tool_choice: object) -> Mapping[str, object]: + if not is_allowed_tools_tool_choice(tool_choice): + return MappingProxyType({}) + return MappingProxyType({"tool_choice": allowed_tools_tool_choice_in_auto_mode(tool_choice)}) + + +def _gateway_entry(tool: Mapping[str, object], gateway_labels: frozenset[str]) -> tuple[str, str | None] | None: + entry: Final = _server_entry(tool) + return entry if entry is not None and entry[0] in gateway_labels else None + + +def allowed_tools_tool_choice_with_gateway_tools_as_functions(tool_choice: object, gateway: GatewayTools) -> object: + layout: Final = _callers_layout(tool_choice) + entries: Final = () if layout is None else _entries_of(layout) + gateway_entries: Final = tuple( + entry for entry in (_gateway_entry(tool, gateway.server_labels) for tool in entries) if entry is not None + ) + if layout is None or not gateway_entries: + return tool_choice + kept: Final = tuple(tool for tool in entries if _gateway_entry(tool, gateway.server_labels) is None) + allowed: Final = tuple( + name for name, tool in gateway.tools.items() if _a_server_entry_allows(gateway_entries, tool) + ) + if not kept and not allowed: + return "none" + return _with_entries(layout, (*kept, *(_function_entry(layout, name) for name in allowed))) + + +def chat_allowed_tools_tool_choice(choice: ChatAllowedToolsToolChoice) -> ChatCompletionAllowedToolChoiceParam: + return ChatCompletionAllowedToolChoiceParam( + type="allowed_tools", + allowed_tools=ChatCompletionAllowedToolsParam( + mode=choice.allowed_tools.mode, tools=[dict(tool) for tool in choice.allowed_tools.tools] + ), + ) + + def _responses_allowed_tool(tool: Mapping[str, object]) -> Mapping[str, object] | None: tool_type: Final = tool.get("type") if tool_type not in ("function", "custom"): diff --git a/litellm/litellm_core_utils/chat_completion_agentic_loop.py b/litellm/litellm_core_utils/chat_completion_agentic_loop.py index ce98d2cbb01..53451b04515 100644 --- a/litellm/litellm_core_utils/chat_completion_agentic_loop.py +++ b/litellm/litellm_core_utils/chat_completion_agentic_loop.py @@ -1,7 +1,7 @@ # this is a patch to allow for agentic loops covering llm_http_handler.py and openai sdk based calling flows for the .completion() api import json -from collections.abc import Awaitable, Iterator, Mapping, Sequence +from collections.abc import Awaitable, Iterable, Iterator, Mapping, Sequence from itertools import chain from types import MappingProxyType from typing import Final, TypeVar @@ -18,7 +18,12 @@ from litellm.litellm_core_utils.agentic_loop_settings import ( DEFAULT_MAX_AGENTIC_LOOPS, validated_max_agentic_loops, ) +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + parse_allowed_tools_tool_choice, + tool_calls_the_choice_excludes, +) from litellm.litellm_core_utils.core_helpers import qualify_agentic_followup_model +from litellm.litellm_core_utils.get_litellm_params import AGENTIC_LOOP_TOOL_CHOICE_KEY from litellm.litellm_core_utils.hidden_params import get_hidden_params, served_from_cache from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import is_str_keyed_dict, object_items @@ -34,7 +39,11 @@ from litellm.types.integrations.custom_logger import ( is_interception_internal_key, stashed_stream_options, ) -from litellm.types.utils import ModelResponse +from litellm.types.utils import ( + ChatCompletionMessageCustomToolCall, + ChatCompletionMessageToolCall, + ModelResponse, +) from litellm.utils import CustomStreamWrapper _DispatchResult: Final = TypeVar("_DispatchResult") @@ -181,6 +190,40 @@ def _end_refused_turn( return finalized if replay is None else replay +def _tool_call_name(call: ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall) -> str: + match call: + case ChatCompletionMessageCustomToolCall(): + return call.custom.name + case ChatCompletionMessageToolCall(): + return call.function.name or "" + + +def _called_tool_names(response: ModelResponse) -> tuple[str, ...]: + calls: Final = chain.from_iterable(choice.message.tool_calls or () for choice in response.choices) + return tuple(map(_tool_call_name, calls)) + + +def _server_fulfilled_tool_names(callbacks: Iterable[CustomLogger]) -> frozenset[str]: + return frozenset(chain.from_iterable(callback.server_fulfilled_tool_names for callback in callbacks)) + + +def holds_a_call_the_choice_excludes( + tool_names: Iterable[str], kwargs: Mapping[str, object], callbacks: Iterable[CustomLogger] +) -> bool: + server_fulfilled: Final = _server_fulfilled_tool_names(callbacks) + client_tool_names: Final = (name for name in tool_names if name not in server_fulfilled) + return bool(tool_calls_the_choice_excludes(kwargs.get(AGENTIC_LOOP_TOOL_CHOICE_KEY), client_tool_names)) + + +def with_caller_tool_choice( + kwargs: Mapping[str, object], optional_params: Mapping[str, object] +) -> Mapping[str, object]: + tool_choice: Final = optional_params.get("tool_choice") + if AGENTIC_LOOP_TOOL_CHOICE_KEY in kwargs or parse_allowed_tools_tool_choice(tool_choice) is None: + return kwargs + return {**kwargs, AGENTIC_LOOP_TOOL_CHOICE_KEY: tool_choice} + + def _request_tools(optional_params: Mapping[str, object]) -> list[dict[str, object]]: return [tool for tool in object_items(optional_params.get("tools")) if is_str_keyed_dict(tool)] @@ -238,6 +281,35 @@ async def _refused_chat_turn( return finalize_refused_chat_turn(response, claimed) +def _fingerprint_of_a_turn_to_run( + *, + response: ModelResponse, + request_kwargs: Mapping[str, object], + callbacks: Sequence[CustomLogger], + tool_calls: object, + fingerprints: Sequence[str], + depth: int, + max_loops: int, + model: str, + stream: bool, +) -> str | None: + if holds_a_call_the_choice_excludes(_called_tool_names(response), request_kwargs, callbacks): + return None + try: + return _check_agentic_loop_safety( + tool_calls=tool_calls, + fingerprints=fingerprints, + depth=depth, + max_loops=max_loops, + model=model, + ) + except AgenticLoopSafetyError as e: + if stream: + raise + verbose_logger.warning("LiteLLM.AgenticLoopRefused: ending turn [rail=%s]", type(e).__name__) + return None + + def _with_agentic_loop_metadata(kwargs_for_followup: Mapping[str, object]) -> Mapping[str, object]: metadata: Final = kwargs_for_followup.get("litellm_metadata") return MappingProxyType( @@ -371,7 +443,8 @@ async def maybe_run_chat_completion_agentic_loop( stream: bool, ) -> ModelResponse | CustomStreamWrapper | None: callbacks: Final = _custom_loggers(logging_obj) - depth, max_loops, fingerprints = _agentic_loop_settings(kwargs) + request_kwargs: Final = with_caller_tool_choice(kwargs, optional_params) + depth, max_loops, fingerprints = _agentic_loop_settings(request_kwargs) tools: Final = _request_tools(optional_params) for callback in callbacks: @@ -379,7 +452,7 @@ async def maybe_run_chat_completion_agentic_loop( continue hook_kwargs = { - **kwargs, + **request_kwargs, "_agentic_loop_api_surface": CHAT_COMPLETION_AGENTIC_SURFACE, "custom_llm_provider": custom_llm_provider, } @@ -403,18 +476,18 @@ async def maybe_run_chat_completion_agentic_loop( if not should_run: continue - try: - fingerprint = _check_agentic_loop_safety( - tool_calls=tool_calls, - fingerprints=fingerprints, - depth=depth, - max_loops=max_loops, - model=model, - ) - except AgenticLoopSafetyError as e: - if stream: - raise - verbose_logger.warning("LiteLLM.AgenticLoopRefused: ending turn [rail=%s]", type(e).__name__) + fingerprint = _fingerprint_of_a_turn_to_run( + response=response, + request_kwargs=request_kwargs, + callbacks=callbacks, + tool_calls=tool_calls, + fingerprints=fingerprints, + depth=depth, + max_loops=max_loops, + model=model, + stream=stream, + ) + if fingerprint is None: return _end_refused_turn( await _refused_chat_turn( callbacks, @@ -425,9 +498,9 @@ async def maybe_run_chat_completion_agentic_loop( messages=messages, tools=tools, custom_llm_provider=custom_llm_provider, - kwargs=kwargs, + kwargs=request_kwargs, ), - kwargs=kwargs, + kwargs=request_kwargs, depth=depth, model=model, custom_llm_provider=custom_llm_provider, @@ -471,7 +544,7 @@ async def maybe_run_chat_completion_agentic_loop( callback=callback, model=model, optional_params=optional_params, - kwargs=kwargs, + kwargs=dict(request_kwargs), logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, depth=depth, @@ -482,7 +555,7 @@ async def maybe_run_chat_completion_agentic_loop( return _converted_stream_replay( response, - kwargs=kwargs, + kwargs=request_kwargs, depth=depth, model=model, custom_llm_provider=custom_llm_provider, @@ -526,18 +599,18 @@ async def run_chat_completion_agentic_hooks( if not should_run: continue - try: - fingerprint = _check_agentic_loop_safety( - tool_calls=tool_calls, - fingerprints=fingerprints, - depth=depth, - max_loops=max_loops, - model=model, - ) - except AgenticLoopSafetyError as e: - if stream: - raise - verbose_logger.warning("LiteLLM.AgenticLoopRefused: ending turn [rail=%s]", type(e).__name__) + fingerprint = _fingerprint_of_a_turn_to_run( + response=response, + request_kwargs=kwargs, + callbacks=callbacks, + tool_calls=tool_calls, + fingerprints=fingerprints, + depth=depth, + max_loops=max_loops, + model=model, + stream=stream, + ) + if fingerprint is None: return await _refused_chat_turn( callbacks, callback, diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index bbbbe9635c9..198a3cd0234 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -32,8 +32,9 @@ AWS_CREDENTIAL_KWARGS_KEYS: Final = frozenset( ) PROVIDER_AFFINITY_HEADER_KWARG_KEY: Final = "provider_affinity_header" +AGENTIC_LOOP_TOOL_CHOICE_KEY: Final = "_agentic_loop_tool_choice" AGENTIC_LOOP_CONTROL_KWARGS_KEYS: Final = frozenset( - {"_agentic_loop_depth", "_agentic_loop_fingerprints", "max_agentic_loops"} + {"_agentic_loop_depth", "_agentic_loop_fingerprints", "max_agentic_loops", AGENTIC_LOOP_TOOL_CHOICE_KEY} ) OAUTH_TOKEN_EXCHANGE_KWARGS_KEYS: Final = frozenset( { diff --git a/litellm/llms/azure/chat/gpt_transformation.py b/litellm/llms/azure/chat/gpt_transformation.py index ca036fa4bdf..5eae465134f 100644 --- a/litellm/llms/azure/chat/gpt_transformation.py +++ b/litellm/llms/azure/chat/gpt_transformation.py @@ -1,17 +1,12 @@ from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import ( - TYPE_CHECKING, - Any, - Final, - cast, # noqa: TID251 # non_default_params reaches the mapper as an untyped dict -) +from typing import TYPE_CHECKING, Any, Final from httpx._models import Headers, Response import litellm from litellm.integrations.anthropic_cache_control_hook import deployment_prompt_cache_breakpoint_support -from litellm.litellm_core_utils.allowed_tools_tool_choice import is_allowed_tools_tool_choice +from litellm.litellm_core_utils.allowed_tools_tool_choice import azure_api_version_takes_tool_choice from litellm.litellm_core_utils.core_helpers import drops_unsupported_params from litellm.litellm_core_utils.prompt_templates.common_utils import ( drop_tool_reference_parts_from_tool_messages, @@ -250,17 +245,11 @@ class AzureOpenAIConfig(BaseConfig): if api_version_year is None or api_version_month is None or api_version_day is None: optional_params["tool_choice"] = value else: - if ( - api_version_year < "2023" - or (api_version_year == "2023" and api_version_month < "12") - or (api_version_year == "2023" and api_version_month == "12" and api_version_day < "01") - ): - if is_allowed_tools_tool_choice( - cast(object, value) # cast-ok: widening an untyped dict value to object is always sound - ) or not (litellm.drop_params is True or (drop_params is not None and drop_params is True)): + if not azure_api_version_takes_tool_choice(api_version): + if not (litellm.drop_params is True or drop_params is True): raise UnsupportedParamsError( status_code=400, - message=f"""Azure does not support 'tool_choice', for api_version={api_version}. Bump your API version to '2023-12-01-preview' or later. This parameter requires 'api_version="2023-12-01-preview"' or later. drop_params never drops an allowed_tools tool_choice, since that would let the model call tools the request excluded. Azure API Reference: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions""", + message=f"""Azure does not support 'tool_choice', for api_version={api_version}. Bump your API version to '2023-12-01-preview' or later. This parameter requires 'api_version="2023-12-01-preview"' or later. Azure API Reference: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions""", ) elif value == "required" and ( api_version_year == "2024" and api_version_month <= "05" diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 72c086b74ed..5b6a64d2377 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -48,6 +48,7 @@ from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAG from litellm.files.types import FileContentStreamingResult from litellm.litellm_core_utils.agentic_followup_kwargs import build_agentic_followup_kwargs from litellm.litellm_core_utils.agentic_loop_refusal import ( + called_tool_names, every_claimed_tool_call, finalize_refused_responses_turn, is_refused_item, @@ -64,7 +65,9 @@ from litellm.litellm_core_utils.audio_utils.subtitle_utils import ( ) from litellm.litellm_core_utils.chat_completion_agentic_loop import ( bill_first_round_on_failure, + holds_a_call_the_choice_excludes, run_chat_completion_agentic_hooks, + with_caller_tool_choice, ) from litellm.litellm_core_utils.core_helpers import as_str_mapping, set_provider_response_headers_in_hidden_params from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value @@ -160,6 +163,7 @@ from litellm.types.integrations.custom_logger import ( AgenticLoopPlan, AgenticLoopRequestPatch, AgenticLoopSafetyError, + AgenticLoopToolChoiceError, converted_stream_requested, is_interception_internal_key, ) @@ -6406,7 +6410,13 @@ class BaseLLMHTTPHandler: tools: Final = anthropic_messages_optional_request_params.get("tools", []) depth, max_loops, fingerprints = self._get_agentic_loop_settings(kwargs=kwargs) - hook_kwargs: Final = {**kwargs, "_agentic_loop_api_surface": api_surface} + hook_kwargs: Final = { + **with_caller_tool_choice( + _OPTIONAL_PARAMS.validate_python(kwargs), + _OPTIONAL_PARAMS.validate_python(anthropic_messages_optional_request_params), + ), + "_agentic_loop_api_surface": api_surface, + } for callback in callbacks: if not isinstance(callback, CustomLogger): @@ -6445,6 +6455,10 @@ class BaseLLMHTTPHandler: # Safety guards must run OUTSIDE the callback try/except — they are # bounded-loop / cycle-break rails, not callback bugs. try: + if holds_a_call_the_choice_excludes( + called_tool_names(response), hook_kwargs, (cb for cb in callbacks if isinstance(cb, CustomLogger)) + ): + raise AgenticLoopToolChoiceError(model) fingerprint = self._check_agentic_loop_safety( tool_calls=tool_calls, fingerprints=fingerprints, diff --git a/litellm/main.py b/litellm/main.py index febf9503a7e..ce71a41d516 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -75,7 +75,8 @@ from litellm.constants import ( from litellm.exceptions import LiteLLMUnknownProvider from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.allowed_tools_tool_choice import ( - raise_if_allowed_tools_tool_choice_unsupported, + drops_unsupported_allowed_tools_tool_choice, + parse_allowed_tools_tool_choice, raise_if_hosted_tool_choice_unsupported, ) from litellm.litellm_core_utils.asyncify import asyncify, run_async_function @@ -91,6 +92,7 @@ from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, Str from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.get_litellm_params import ( AGENTIC_LOOP_CONTROL_KWARGS_KEYS, + AGENTIC_LOOP_TOOL_CHOICE_KEY, AWS_CREDENTIAL_KWARGS_KEYS, OAUTH_TOKEN_EXCHANGE_KWARGS_KEYS, PROVIDER_AFFINITY_HEADER_KWARG_KEY, @@ -5612,7 +5614,16 @@ def completion( messages = validate_and_fix_openai_messages(messages=messages) tools = validate_and_fix_openai_tools(tools=tools) # validate tool_choice - tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice, model=model) + requested_tool_choice: Final = cast( # cast-ok: widening an untyped dict to object is always sound + object, tool_choice + ) + tool_choice = validate_chat_completion_tool_choice( + tool_choice=tool_choice, + model=model, + drop_params=drops_unsupported_params( + cast("Mapping[str, object]", kwargs).get("drop_params") # cast-ok: kwargs is the untyped request dict + ), + ) # validate optional params stop = validate_openai_optional_params(stop=stop) thinking = validate_and_fix_thinking_param(thinking=thinking) @@ -5909,12 +5920,13 @@ def completion( if not is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model): tools = _drop_anthropic_only_tool_keys(tools=tools) - raise_if_allowed_tools_tool_choice_unsupported( + drops_tool_choice: Final = drops_unsupported_allowed_tools_tool_choice( tool_choice=cast( # cast-ok: widening an untyped dict to object is always sound, the helper validates it with pydantic object, tool_choice ), custom_llm_provider=custom_llm_provider, model=model, + drop_params=drop_unsupported_reasoning, ) raise_if_hosted_tool_choice_unsupported( tool_choice=cast( # cast-ok: widening an untyped dict to object is always sound, the helper validates it with pydantic @@ -6039,7 +6051,7 @@ def completion( "response_format": response_format, "seed": seed, "tools": tools, - "tool_choice": tool_choice, + "tool_choice": None if drops_tool_choice else tool_choice, "max_retries": max_retries, "logprobs": logprobs, "top_logprobs": top_logprobs, @@ -6228,7 +6240,18 @@ def completion( if key in kwargs }, ) - litellm_params: Final = with_control_options(requested_litellm_params, control_options) + litellm_params: Final = with_control_options( + { + **requested_litellm_params, + **( + {AGENTIC_LOOP_TOOL_CHOICE_KEY: requested_tool_choice} + if AGENTIC_LOOP_TOOL_CHOICE_KEY not in kwargs + and parse_allowed_tools_tool_choice(requested_tool_choice) is not None + else {} + ), + }, + control_options, + ) affinity_api_base: Final = ( str(client.base_url) if isinstance(client, openai.OpenAI | openai.AsyncOpenAI) diff --git a/litellm/responses/litellm_completion_transformation/handler.py b/litellm/responses/litellm_completion_transformation/handler.py index 802ed6bf9c5..aa5c7028240 100644 --- a/litellm/responses/litellm_completion_transformation/handler.py +++ b/litellm/responses/litellm_completion_transformation/handler.py @@ -10,6 +10,7 @@ from typing import Final from pydantic import TypeAdapter import litellm +from litellm.litellm_core_utils.allowed_tools_tool_choice import unenforced_allowed_tools_tool_choice from litellm.litellm_core_utils.core_helpers import drops_unsupported_params from litellm.litellm_core_utils.model_param_helper import embeds_thought_signature_in_tool_call_id from litellm.responses.additional_tools import HoistedAdditionalTools, hoist_additional_tools @@ -72,6 +73,22 @@ def _bridged_request( ) +def _with_the_tool_choice_sent( + responses_api_request: ResponsesAPIOptionalRequestParams, + custom_llm_provider: str | None, + request_kwargs: Mapping[str, object], +) -> ResponsesAPIOptionalRequestParams: + api_version: Final = request_kwargs.get("api_version") + tool_choice: Final[object] = responses_api_request.get( # pyright: ignore[reportUnknownMemberType] # ToolChoice has an untyped member + "tool_choice" + ) + if not unenforced_allowed_tools_tool_choice( + tool_choice, custom_llm_provider, api_version if isinstance(api_version, str) else None + ): + return responses_api_request + return {**responses_api_request, "tool_choice": None} + + class LiteLLMCompletionTransformationHandler: def response_api_handler( self, @@ -127,13 +144,16 @@ class LiteLLMCompletionTransformationHandler: ), **_chat_verbosity(bridged_request), } + echoed_request: Final = _with_the_tool_choice_sent( + bridged_request, custom_llm_provider, _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) + ) if _is_async: return self.async_response_api_handler( previous_response_client_ids, litellm_completion_request=litellm_completion_request, request_input=bridged_input, - responses_api_request=bridged_request, + responses_api_request=echoed_request, **kwargs, ) @@ -146,7 +166,7 @@ class LiteLLMCompletionTransformationHandler: LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( chat_completion_response=litellm_completion_response, request_input=bridged_input, - responses_api_request=bridged_request, + responses_api_request=echoed_request, ) ) @@ -157,7 +177,7 @@ class LiteLLMCompletionTransformationHandler: model=model, litellm_custom_stream_wrapper=litellm_completion_response, request_input=bridged_input, - responses_api_request=bridged_request, + responses_api_request=echoed_request, custom_llm_provider=custom_llm_provider, litellm_metadata=kwargs.get("litellm_metadata", {}), tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id( @@ -209,7 +229,9 @@ class LiteLLMCompletionTransformationHandler: **_chat_verbosity(bridged.responses_api_request), }, request_input=bridged.input, - responses_api_request=bridged.responses_api_request, + responses_api_request=_with_the_tool_choice_sent( + bridged.responses_api_request, custom_llm_provider, request_kwargs + ), **request_kwargs, ) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 7eab3985f59..78a3966f3b0 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -29,6 +29,10 @@ from litellm.integrations.anthropic_cache_control_hook import ( configured_injection_points, routed_model_info, ) +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + allowed_tools_tool_choice_in_effect, + allowed_tools_tool_choice_with_gateway_tools_as_functions, +) from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, StreamKind from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, normalize_drop_params, require_attributes @@ -316,6 +320,11 @@ async def aresponses_api_with_mcp( LiteLLM_Proxy_MCP_Handler, ) + request_kwargs: Final = _STR_MAPPING.validate_python(kwargs) + tool_choice_in_effect: Final = allowed_tools_tool_choice_in_effect( + tool_choice, model, drops_unsupported_params(request_kwargs.get("drop_params")) + ) + # Parse MCP tools and separate from other tools ( mcp_tools_with_litellm_proxy, @@ -334,14 +343,13 @@ async def aresponses_api_with_mcp( else (None, None, None, None) ) - request_kwargs: Final = _STR_MAPPING.validate_python(kwargs) logging_obj: Final = request_kwargs.get("litellm_logging_obj") joins_request_trace: Final = isinstance(logging_obj, LiteLLMLoggingObj) bridged_first_turn: Final = ( joins_request_trace and not stream and previous_response_id is None - and not provider_stores_the_conversation(model, custom_llm_provider, kwargs) + and not provider_stores_the_conversation(model, custom_llm_provider, request_kwargs) ) listing_trace_id: Final = ( @@ -365,6 +373,9 @@ async def aresponses_api_with_mcp( raw_headers=discovery_raw_headers, ) openai_tools: Final = LiteLLM_Proxy_MCP_Handler.transform_mcp_tools_to_openai(original_mcp_tools) + gateway: Final = await LiteLLM_Proxy_MCP_Handler.gateway_tools( + tool_choice_in_effect, tool_server_map, mcp_tools_with_litellm_proxy, user_api_key_auth + ) # Combine with other tools all_tools: Final = openai_tools + other_tools if (openai_tools or other_tools) else None @@ -383,7 +394,7 @@ async def aresponses_api_with_mcp( "stream": stream, "temperature": temperature, "text": text, - "tool_choice": tool_choice, + "tool_choice": allowed_tools_tool_choice_with_gateway_tools_as_functions(tool_choice_in_effect, gateway), "top_p": top_p, "truncation": truncation, "user": user, @@ -422,6 +433,8 @@ async def aresponses_api_with_mcp( previous_response_id=previous_response_id, tool_server_map=tool_server_map, served_tools=original_mcp_tools, + tool_choice=tool_choice_in_effect, + gateway=gateway, **kwargs, ) await mcp_streaming_response.start_initial_response() @@ -460,7 +473,11 @@ async def aresponses_api_with_mcp( ######################################################### if should_auto_execute and isinstance(response, ResponsesAPIResponse): with first_round_billed_on_failure(logging_obj, response): - tool_calls: Final = LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_response(response=response) + tool_calls: Final = LiteLLM_Proxy_MCP_Handler.tool_calls_to_run( + LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_response(response=response), + tool_choice_in_effect, + gateway, + ) if tool_calls: user_api_key_auth = kwargs.get("litellm_metadata", {}).get("user_api_key_auth") diff --git a/litellm/responses/mcp/chat_completions_handler.py b/litellm/responses/mcp/chat_completions_handler.py index f8a52227d1c..90895df3537 100644 --- a/litellm/responses/mcp/chat_completions_handler.py +++ b/litellm/responses/mcp/chat_completions_handler.py @@ -2,6 +2,7 @@ import asyncio import logging +from collections.abc import Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Final, cast @@ -10,6 +11,10 @@ from pydantic import TypeAdapter from typing_extensions import TypedDict, Unpack from litellm._internal_context import internal_sub_call +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + allowed_tools_tool_choice_kept_on_follow_up, + allowed_tools_tool_choice_with_gateway_tools_as_functions, +) from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.responses.mcp.chat_stream_round_billing import ChatStreamRoundBilling, engaged_round_billing @@ -38,7 +43,7 @@ class _MCPCompletionKwargs(TypedDict, total=False, extra_items=object): def _add_mcp_metadata_to_response( response: ModelResponse | CustomStreamWrapper, openai_tools: list | None, - tool_calls: list | None = None, + tool_calls: Sequence[object] | None = None, tool_results: list | None = None, ) -> None: """ @@ -162,6 +167,9 @@ async def acompletion_with_mcp( deduplicated_mcp_tools, target_format="chat", ) + gateway: Final = await LiteLLM_Proxy_MCP_Handler.gateway_tools( + kwargs.get("tool_choice"), tool_server_map, mcp_tools_with_litellm_proxy, user_api_key_auth + ) # Combine with other tools all_tools: Final = openai_tools + other_tools if (openai_tools or other_tools) else None @@ -181,6 +189,7 @@ async def acompletion_with_mcp( "tools": all_tools, "_skip_mcp_handler": True, # Prevent recursion **clean_kwargs, + "tool_choice": allowed_tools_tool_choice_with_gateway_tools_as_functions(kwargs.get("tool_choice"), gateway), } # If not auto-executing, just make the call with transformed tools @@ -253,7 +262,7 @@ async def acompletion_with_mcp( self.base_call_args = base_call_args self.request_tags = request_tags self.collected_chunks: list[ModelResponseStream] = [] - self.tool_calls: list | None = None + self.tool_calls: Sequence[object] | None = None self.tool_results: list | None = None self.complete_response: ModelResponse | None = None self.stream_exhausted = False @@ -490,8 +499,10 @@ async def acompletion_with_mcp( if isinstance(complete_response, ModelResponse): self.complete_response = complete_response # Extract tool calls from complete response - self.tool_calls = LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_chat_response( - response=complete_response + self.tool_calls = LiteLLM_Proxy_MCP_Handler.tool_calls_to_run( + LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_chat_response(response=complete_response), + kwargs.get("tool_choice"), + gateway, ) if self.tool_calls: @@ -527,11 +538,13 @@ async def acompletion_with_mcp( ) # Make follow-up call with streaming - follow_up_call_args: Final = dict(self.base_call_args) - follow_up_call_args["messages"] = follow_up_messages - follow_up_call_args["stream"] = True - # Ensure follow-up call doesn't trigger MCP handler again - follow_up_call_args["_skip_mcp_handler"] = True + follow_up_call_args: Final[dict[str, object]] = { + **self.base_call_args, + **allowed_tools_tool_choice_kept_on_follow_up(self.base_call_args.get("tool_choice")), + "messages": follow_up_messages, + "stream": True, + "_skip_mcp_handler": True, + } # Import litellm here to ensure we get the patched version # This ensures the patch works correctly in tests @@ -670,7 +683,11 @@ async def acompletion_with_mcp( if not isinstance(initial_response, ModelResponse): return initial_response - tool_calls: Final = LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_chat_response(response=initial_response) + tool_calls: Final = LiteLLM_Proxy_MCP_Handler.tool_calls_to_run( + LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_chat_response(response=initial_response), + kwargs.get("tool_choice"), + gateway, + ) if not tool_calls: _add_mcp_metadata_to_response( @@ -709,9 +726,12 @@ async def acompletion_with_mcp( tool_results=tool_results, ) - follow_up_call_args: Final = dict(base_call_args) - follow_up_call_args["messages"] = follow_up_messages - follow_up_call_args["stream"] = stream + follow_up_call_args: Final = { + **base_call_args, + **allowed_tools_tool_choice_kept_on_follow_up(base_call_args.get("tool_choice")), + "messages": follow_up_messages, + "stream": stream, + } with internal_sub_call(logging_obj.litellm_call_id if isinstance(logging_obj, LiteLLMLoggingObj) else None): final_response: Final = await litellm_acompletion(**follow_up_call_args) diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index d045689cb56..c3166b26a74 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -1,20 +1,33 @@ import traceback from collections.abc import Awaitable, Callable, Collection, Iterable, Mapping, Sequence +from dataclasses import dataclass, field from datetime import datetime +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypedDict, overload from openai.types.chat import ChatCompletionToolParam from openai.types.responses.function_tool_param import FunctionToolParam +from pydantic import TypeAdapter, ValidationError from typing_extensions import TypedDict as ExtraItemsTypedDict from typing_extensions import Unpack from litellm._logging import verbose_logger from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + NO_GATEWAY_TOOLS, + GatewayTool, + GatewayTools, + allowed_tools_tool_choice_kept_on_follow_up, + parse_allowed_tools_tool_choice, + tool_calls_the_choice_excludes, +) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy.mcp_server.catalog import catalog_operation, global_manager from litellm.proxy.mcp_server.utils import ( iter_known_server_prefixes, + iter_known_tool_name_spellings, logging_safe_mcp_headers, + match_known_tool_name, split_server_prefix_from_name, strip_known_server_prefix, ) @@ -117,6 +130,64 @@ async def _served_mcp_path_names( return await served_names(names) if names else frozenset[str]() +def _served_tool(tool_name: str, server_name: str) -> tuple[MCPServer | None, str]: + from litellm.proxy.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy.mcp_server.server import ( + _resolve_display_name_to_original, + ) + + mcp_server: Final = global_mcp_server_manager.get_mcp_server_by_name( + server_name + ) or global_mcp_server_manager.get_mcp_server_from_tool_name(tool_name) + resolved_tool_name: Final = _resolve_display_name_to_original(tool_name, [mcp_server]) if mcp_server else tool_name + return mcp_server, strip_known_server_prefix(resolved_tool_name, mcp_server) + + +@dataclass(frozen=True, slots=True) +class _LabelScope: + label: str + whole_gateway: bool = False + server_ids: frozenset[str] = frozenset() + toolset_tools: Mapping[str, tuple[str, ...]] = field(default_factory=lambda: MappingProxyType({})) + allowed_tools: frozenset[str] | None = None + + def serves(self, server: MCPServer | None, tool_name: str, listed_name: str) -> bool: + if not self._allows(server, tool_name, listed_name): + return False + if self.whole_gateway: + return True + if server is None: + return False + toolset_names: Final = self.toolset_tools.get(server.server_id) + return server.server_id in self.server_ids or (toolset_names is not None and tool_name in toolset_names) + + def _allows(self, server: MCPServer | None, tool_name: str, listed_name: str) -> bool: + if self.allowed_tools is None or listed_name in self.allowed_tools: + return True + return server is not None and match_known_tool_name(tool_name, server, self.allowed_tools) is not None + + +_OBJECT_LIST: Final = TypeAdapter(list[object]) + + +def _listed_allowed_tools(tool: Mapping[str, object]) -> frozenset[str] | None: + try: + allowed_tools: Final = _OBJECT_LIST.validate_python(tool.get("allowed_tools")) + except ValidationError: + return None + return frozenset(name for name in allowed_tools if isinstance(name, str)) if allowed_tools else None + + +def _gateway_tool(name: str, server: MCPServer | None, tool_name: str, scopes: Iterable[_LabelScope]) -> GatewayTool: + spellings: Final = () if server is None else tuple(iter_known_tool_name_spellings(tool_name, server)) + return GatewayTool( + names=frozenset((name, tool_name, *spellings)), + server_labels=frozenset(scope.label for scope in scopes if scope.serves(server, tool_name, name)), + ) + + class LiteLLM_Proxy_MCP_Handler: """ Helper class with static methods for MCP integration with Responses API. @@ -608,6 +679,88 @@ class LiteLLM_Proxy_MCP_Handler: _should_auto_execute_tools = should_auto_execute_tools + @staticmethod + async def _label_scope( + tool: Mapping[str, object], + servers: Sequence[MCPServer], + user_api_key_auth: "UserAPIKeyAuth | None", + granted_toolsets: "GrantedToolsetIds | None", + ) -> _LabelScope | None: + from litellm.proxy.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy.mcp_server.server import ( + _get_allowed_mcp_servers_from_mcp_server_names, + ) + + label: Final = tool.get("server_label") + server_url: Final = tool.get("server_url") + if not isinstance(label, str) or not isinstance(server_url, str): + return None + allowed_tools: Final = _listed_allowed_tools(tool) + if names_whole_gateway(tool): + return _LabelScope(label=label, whole_gateway=True, allowed_tools=allowed_tools) + kind, value = await LiteLLM_Proxy_MCP_Handler._resolve_proxy_mcp_name( + server_url.split("/")[-1], user_api_key_auth, granted_toolsets + ) + match kind: + case "server": + resolved: Final = await _get_allowed_mcp_servers_from_mcp_server_names([value], list(servers)) + return _LabelScope( + label=label, + server_ids=frozenset(server.server_id for server in resolved), + allowed_tools=allowed_tools, + ) + case "toolset": + permissions: Final = await global_mcp_server_manager.resolve_toolset_tool_permissions( + toolset_ids=[value] + ) + return _LabelScope( + label=label, + toolset_tools=MappingProxyType({key: tuple(names) for key, names in permissions.items()}), + allowed_tools=allowed_tools, + ) + case "denied_toolset": + return _LabelScope(label=label) + + @staticmethod + async def gateway_tools( + tool_choice: object, + tool_server_map: Mapping[str, str], + mcp_tools_with_litellm_proxy: Iterable[Mapping[str, object]], + user_api_key_auth: "UserAPIKeyAuth | None", + granted_toolsets: "GrantedToolsetIds | None" = None, + ) -> GatewayTools: + if parse_allowed_tools_tool_choice(tool_choice) is None: + return NO_GATEWAY_TOOLS + served: Final = {name: _served_tool(name, server_name) for name, server_name in tool_server_map.items()} + servers: Final = {server.server_id: server for server, _ in served.values() if server is not None} + scopes: Final = tuple( + [ + await LiteLLM_Proxy_MCP_Handler._label_scope( + tool, tuple(servers.values()), user_api_key_auth, granted_toolsets + ) + for tool in mcp_tools_with_litellm_proxy + ] + ) + label_scopes: Final = tuple(scope for scope in scopes if scope is not None) + return GatewayTools( + server_labels=frozenset(scope.label for scope in label_scopes), + tools=MappingProxyType( + { + name: _gateway_tool(name, server, tool_name, label_scopes) + for name, (server, tool_name) in served.items() + } + ), + ) + + @staticmethod + def tool_calls_to_run(tool_calls: Sequence[object], tool_choice: object, gateway: GatewayTools) -> Sequence[object]: + names: Final = tuple( + LiteLLM_Proxy_MCP_Handler.extract_tool_call_details(tool_call)[0] or "" for tool_call in tool_calls + ) + return () if tool_calls_the_choice_excludes(tool_choice, names, gateway) else tool_calls + @staticmethod def extract_tool_calls_from_response(response: ResponsesAPIResponse) -> list[object]: """Extract tool calls from the response output.""" @@ -749,9 +902,6 @@ class LiteLLM_Proxy_MCP_Handler: from litellm.proxy.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) - from litellm.proxy.mcp_server.server import ( - _resolve_display_name_to_original, - ) from litellm.proxy.proxy_server import proxy_logging_obj tool_results: Final[list[MCPToolResult]] = [] @@ -780,13 +930,7 @@ class LiteLLM_Proxy_MCP_Handler: server_name = tool_server_map[tool_name] - mcp_server = global_mcp_server_manager.get_mcp_server_by_name( - server_name - ) or global_mcp_server_manager.get_mcp_server_from_tool_name(tool_name) - resolved_tool_name = ( - _resolve_display_name_to_original(tool_name, [mcp_server]) if mcp_server else tool_name - ) - sanitized_tool_name = strip_known_server_prefix(resolved_tool_name, mcp_server) + mcp_server, sanitized_tool_name = _served_tool(tool_name, server_name) start_time = datetime.now() logging_input: Sequence[Mapping[str, object]] = [ @@ -1226,6 +1370,8 @@ class LiteLLM_Proxy_MCP_Handler: previous_response_id: str | None, tool_server_map: dict[str, str], served_tools: Sequence[MCPTool] | None = None, + tool_choice: object = None, + gateway: GatewayTools = NO_GATEWAY_TOOLS, **kwargs, ) -> "MCPEnhancedStreamingIterator": """ @@ -1260,6 +1406,8 @@ class LiteLLM_Proxy_MCP_Handler: user_api_key_auth=kwargs.get("user_api_key_auth") or kwargs.get("litellm_metadata", {}).get("user_api_key_auth"), original_request_params=request_params, + tool_choice=tool_choice, + gateway=gateway, ) @staticmethod @@ -1370,21 +1518,12 @@ class LiteLLM_Proxy_MCP_Handler: def prepare_follow_up_call_params( call_params: Mapping[str, object], original_stream_setting: bool ) -> _AresponsesOptions: - """ - Prepare call parameters for the follow-up LLM call after tool execution. - - Restores the original streaming setting and removes tool_choice since - we're now providing tool results, not requesting tool calls. - """ - follow_up_params: Final = _AresponsesOptions(**call_params) - - # Restore original streaming setting for follow-up call - follow_up_params["stream"] = original_stream_setting - - # Remove tool_choice since we're providing results, not requesting tool calls - follow_up_params.pop("tool_choice", None) - - return follow_up_params + """Prepare call parameters for the follow-up LLM call after tool execution.""" + return _AresponsesOptions( + **{key: value for key, value in call_params.items() if key not in ("stream", "tool_choice")}, + **allowed_tools_tool_choice_kept_on_follow_up(call_params.get("tool_choice")), + stream=original_stream_setting, + ) _prepare_follow_up_call_params = prepare_follow_up_call_params diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py index ea6064b554d..c28f40c3d2b 100644 --- a/litellm/responses/mcp/mcp_streaming_iterator.py +++ b/litellm/responses/mcp/mcp_streaming_iterator.py @@ -7,6 +7,11 @@ from typing_extensions import TypedDict as ExtraItemsTypedDict from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.litellm_core_utils.agentic_loop_settings import validated_max_agentic_loops +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + NO_GATEWAY_TOOLS, + GatewayTools, + allowed_tools_tool_choice_kept_on_follow_up, +) from litellm.responses.litellm_completion_transformation.streaming_iterator import LiteLLMCompletionStreamingIterator from litellm.responses.mcp.request_context import MCPRequestContext from litellm.responses.mcp.stream_round_billing import StreamedRoundBilling @@ -294,6 +299,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): user_api_key_auth: "UserAPIKeyAuth | None" = None, original_request_params: dict[str, Any] | None = None, served_tools: Sequence[MCPTool] | None = None, + tool_choice: object = None, + gateway: GatewayTools = NO_GATEWAY_TOOLS, ): # MCP setup self.mcp_tools_with_litellm_proxy = mcp_tools_with_litellm_proxy or [] @@ -318,6 +325,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): self.mcp_discovery_generated = True # Events are already generated self.mcp_events = mcp_events # Store the initial MCP events for backward compatibility self.tool_server_map = tool_server_map + self.tool_choice: Final = tool_choice + self.gateway: Final = gateway self.served_tools = tuple(served_tools) if served_tools is not None else None # Iterator references @@ -851,10 +860,15 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): try: # Extract tool calls from the response - if self.collected_response is not None: - tool_calls = LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_response(self.collected_response) - else: - tool_calls = [] + tool_calls: Final = ( + LiteLLM_Proxy_MCP_Handler.tool_calls_to_run( + LiteLLM_Proxy_MCP_Handler.extract_tool_calls_from_response(self.collected_response), + self.tool_choice, + self.gateway, + ) + if self.collected_response is not None + else () + ) if not tool_calls: return self.tool_call_round += 1 @@ -1038,7 +1052,6 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): ) else: return - # Remove tool_choice to avoid forcing more tool calls follow_up_params.pop("tool_choice", None) if self.tool_call_round >= self.max_tool_call_rounds: @@ -1050,6 +1063,10 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): "MCP auto-execute hit max_agentic_loops=%s; forcing a text-only follow-up.", self.max_tool_call_rounds, ) + else: + follow_up_params.update( + allowed_tools_tool_choice_kept_on_follow_up(self.original_request_params.get("tool_choice")) + ) follow_up_response: Final = await aresponses(**_FollowUpRequest(**follow_up_params)) diff --git a/litellm/tool_loop.py b/litellm/tool_loop.py index 554fdff4fce..3fc9210b1ba 100644 --- a/litellm/tool_loop.py +++ b/litellm/tool_loop.py @@ -6,6 +6,11 @@ from typing import Final, TypeAlias, cast from typing_extensions import TypedDict, Unpack from litellm.constants import DEFAULT_TOOL_LOOP_MAX_ROUNDS +from litellm.litellm_core_utils.allowed_tools_tool_choice import ( + allowed_tools_tool_choice_in_effect, + refuse_tool_calls_the_choice_excludes, +) +from litellm.litellm_core_utils.core_helpers import drops_unsupported_params from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionAssistantMessage, @@ -32,11 +37,14 @@ class ToolLoopMaxRoundsExceeded(RuntimeError): super().__init__(f"model still requested tool calls on round {max_rounds} of {max_rounds}") -def _validate_tool_loop_args(max_rounds: int, completion_kwargs: _ToolLoopCompletionKwargs) -> None: +def _tool_choice_in_effect(max_rounds: int, model: str, completion_kwargs: _ToolLoopCompletionKwargs) -> object: if max_rounds < 1: raise ValueError(f"max_rounds must be >= 1, got {max_rounds}") if completion_kwargs.get("stream"): raise ValueError("run_tool_loop requires whole responses; stream=True is not supported") + return allowed_tools_tool_choice_in_effect( + completion_kwargs.get("tool_choice"), model, drops_unsupported_params(completion_kwargs.get("drop_params")) + ) def _expect_model_response(response: object) -> ModelResponse: @@ -114,6 +122,14 @@ def _client_tool_calls( ) +def _refuse_tool_calls_the_choice_excludes( + tool_calls: tuple[ChatCompletionMessageToolCall, ...], model: str, tool_choice: object +) -> None: + refuse_tool_calls_the_choice_excludes( + tool_choice, tuple(tool_call.function.name or "" for tool_call in tool_calls), model + ) + + def _replays_provider_specific_fields( message: Message, tool_calls: tuple[ChatCompletionMessageToolCall, ...], @@ -151,7 +167,7 @@ def run_tool_loop( """ import litellm - _validate_tool_loop_args(max_rounds, completion_kwargs) + tool_choice: Final = _tool_choice_in_effect(max_rounds, model, completion_kwargs) history: tuple[AllMessageValues, ...] = tuple(messages) # rebind-ok: rounds append new turns for round_number in range(1, max_rounds + 1): message = ( @@ -169,6 +185,7 @@ def run_tool_loop( return message.content if round_number == max_rounds: break + _refuse_tool_calls_the_choice_excludes(client_tool_calls, model, tool_choice) tool_results = tuple(execute_tool(tool_call) for tool_call in client_tool_calls) history = ( *history, @@ -202,7 +219,7 @@ async def arun_tool_loop( """ import litellm - _validate_tool_loop_args(max_rounds, completion_kwargs) + tool_choice: Final = _tool_choice_in_effect(max_rounds, model, completion_kwargs) history: tuple[AllMessageValues, ...] = tuple(messages) # rebind-ok: rounds append new turns for round_number in range(1, max_rounds + 1): message = ( @@ -220,6 +237,7 @@ async def arun_tool_loop( return message.content if round_number == max_rounds: break + _refuse_tool_calls_the_choice_excludes(client_tool_calls, model, tool_choice) tool_results = tuple([await execute_tool(tool_call) for tool_call in client_tool_calls]) history = ( *history, diff --git a/litellm/types/integrations/custom_logger.py b/litellm/types/integrations/custom_logger.py index 14ed719a880..127b3d61753 100644 --- a/litellm/types/integrations/custom_logger.py +++ b/litellm/types/integrations/custom_logger.py @@ -81,6 +81,11 @@ class AgenticLoopLimitError(AgenticLoopSafetyError): pass +class AgenticLoopToolChoiceError(AgenticLoopSafetyError): + def __init__(self, model: str) -> None: + super().__init__(f"The allowed_tools tool_choice excludes a tool call the model made, model={model}") + + class StandardCustomLoggerInitParams(LiteLLMBaseModel): """ Params for initializing a CustomLogger. diff --git a/litellm/types/litellm_params.py b/litellm/types/litellm_params.py index c04430322c4..87f8e210782 100644 --- a/litellm/types/litellm_params.py +++ b/litellm/types/litellm_params.py @@ -362,6 +362,7 @@ class AgenticLoopState: api_surface: Literal["chat_completions", "responses"] | None = field( default=None, metadata=wire("_agentic_loop_api_surface") ) + tool_choice: Mapping[str, object] | None = field(default=None, metadata=wire("_agentic_loop_tool_choice")) code_interpreter_active: bool | None = field(default=None, metadata=wire("_code_interpreter_interception_active")) code_interpreter_sandbox_key: str | None = field( default=None, metadata=wire("_code_interpreter_interception_sandbox_key") diff --git a/litellm/utils.py b/litellm/utils.py index a69d3c063de..a3a1fe5ede4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -85,9 +85,9 @@ from litellm.constants import ( ) from litellm.litellm_core_utils.agentic_followup_failure import is_agentic_followup_failure from litellm.litellm_core_utils.allowed_tools_tool_choice import ( - CHAT_ALLOWED_TOOLS_LAYOUT, HOSTED_TOOL_CHOICE_TYPES, - parse_chat_allowed_tools_tool_choice, + chat_allowed_tools_tool_choice, + parse_allowed_tools_tool_choice_or_refuse, ) from litellm.litellm_core_utils.attempt_settlement import mark_attempt_answered from litellm.litellm_core_utils.core_helpers import ( @@ -9580,6 +9580,7 @@ def validate_chat_completion_user_messages(messages: list[AllMessageValues]): def validate_chat_completion_tool_choice( tool_choice: dict | str | None, model: str = "", + drop_params: bool = False, ) -> dict | str | None: """ Confirm the tool choice is passed in the OpenAI format. @@ -9592,35 +9593,21 @@ def validate_chat_completion_tool_choice( tool_choice_type = tool_choice.get("type") if tool_choice_type in ("auto", "none", "required") and "function" not in tool_choice: return tool_choice_type - if ( - tool_choice_type == "allowed_tools" - and ( - parse_chat_allowed_tools_tool_choice( - cast( # cast-ok: widening an untyped dict to object is always sound, the helper validates it with pydantic - object, tool_choice - ) - ) - is None - ) - ): - raise BadRequestError( - message=( - f"Invalid tool choice, tool_choice={tool_choice}. On chat completions an allowed_tools " - f"tool_choice must be {CHAT_ALLOWED_TOOLS_LAYOUT}" + if tool_choice_type == "allowed_tools": + allowed_tools: Final = parse_allowed_tools_tool_choice_or_refuse( + cast( # cast-ok: widening an untyped dict to object is always sound, the helper validates it with pydantic + object, tool_choice ), - model=model, - llm_provider="", + model, + drop_params, ) + return None if allowed_tools is None else dict(chat_allowed_tools_tool_choice(allowed_tools)) # Standard OpenAI format: {"type": "function", "function": {...}} forces_hosted_tool: Final[bool] = ( isinstance(tool_choice_type, str) and tool_choice_type in HOSTED_TOOL_CHOICE_TYPES ) - if ( - tool_choice_type != "allowed_tools" - and not forces_hosted_tool - and (tool_choice_type is None or tool_choice.get("function") is None) - ): + if not forces_hosted_tool and (tool_choice_type is None or tool_choice.get("function") is None): raise BadRequestError( message=f"Invalid tool choice, tool_choice={tool_choice}. Please ensure tool_choice follows the OpenAI spec", model=model, diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 7ba833aad14..c06c2f771c1 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -4938,6 +4938,10 @@ def test_output_item_done_with_stream_map_keeps_empty_delta(): }, {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "get_weather"}]}, ), + ( + {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "get_weather"}]}, + {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "get_weather"}]}, + ), ], ) async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire( diff --git a/tests/unit/integrations/code_interpreter_interception/test_handler.py b/tests/unit/integrations/code_interpreter_interception/test_handler.py index f4f6a127fc7..2a0712f1a53 100644 --- a/tests/unit/integrations/code_interpreter_interception/test_handler.py +++ b/tests/unit/integrations/code_interpreter_interception/test_handler.py @@ -12,6 +12,7 @@ from typing import Final import httpx import pytest +from pydantic import TypeAdapter import respx import litellm @@ -27,6 +28,7 @@ from litellm.integrations.code_interpreter_interception.handler import ( LITELLM_CODE_EXECUTION_TOOL_NAME, CodeInterpreterInterceptionLogger, ) +from litellm.litellm_core_utils.allowed_tools_tool_choice import tool_calls_the_choice_excludes from litellm.llms.base_llm.sandbox.transformation import CodeExecutionResult from litellm.types.integrations.custom_logger import ( CHAT_COMPLETION_AGENTIC_SURFACE, @@ -726,6 +728,45 @@ async def test_chat_completion_build_plan_runs_code_and_appends_tool_message(): assert plan.metadata["code_interpreter_calls"][0]["code"] == "print(40 + 2)" +@pytest.mark.asyncio +async def test_chat_completion_build_plan_keeps_an_allowed_tools_choice_without_forcing_another_call(): + allowed: Final = [ + {"type": "function", "function": {"name": LITELLM_CODE_EXECUTION_TOOL_NAME}}, + {"type": "function", "function": {"name": "get_time"}}, + ] + logger: Final = CodeInterpreterInterceptionLogger(sandbox_config=FakeSandbox(stdout="42")) + + plan: Final = await logger.async_build_agentic_loop_plan( + tools={ + "tool_calls": [ + {"id": "call_1", "name": LITELLM_CODE_EXECUTION_TOOL_NAME, "arguments": '{"code":"print(1)"}'} + ] + }, + model="gpt-5", + messages=[{"role": "user", "content": "x"}], + response={"choices": [{"message": {"tool_calls": [_chat_function_call_item()]}}]}, + anthropic_messages_provider_config=None, + anthropic_messages_optional_request_params={ + "tools": [{"type": "code_interpreter", "container": {"type": "auto"}}], + "tool_choice": {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": allowed}}, + }, + logging_obj=FakeLogging(litellm_call_id="k1"), + stream=False, + kwargs={ + "acompletion": True, + "litellm_call_id": "k1", + _ACTIVE_KEY: True, + _SANDBOX_KEY: "sbxkey1", + "_agentic_loop_api_surface": CHAT_COMPLETION_AGENTIC_SURFACE, + }, + ) + + assert plan.request_patch is not None + assert plan.request_patch.optional_params == { + "tool_choice": {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": allowed}} + } + + @pytest.mark.asyncio async def test_pre_call_strips_client_forged_marker_on_initial_request(): """A client cannot pre-set the active marker on the original request: with no @@ -1203,6 +1244,38 @@ async def test_extract_tool_calls_reads_object_attributes(): # --------------------------------------------------------------------------- +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("call_type", "tool_choice"), + [ + (CallTypes.aresponses, {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "code_interpreter"}]}), + ( + CallTypes.acompletion, + {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "code_interpreter"}]}}, + ), + ], +) +async def test_pre_call_lets_an_allowed_tools_choice_listing_code_interpreter_allow_the_run( + call_type: CallTypes, tool_choice: dict[str, object] +): + logger: Final = CodeInterpreterInterceptionLogger() + + result: Final = TypeAdapter(dict[str, object]).validate_python( + await logger.async_pre_call_deployment_hook( + { + "tools": [{"type": "code_interpreter", "container": {"type": "auto"}}], + "tool_choice": tool_choice, + "custom_llm_provider": "openai", + }, + call_type, + ) + ) + + assert tool_calls_the_choice_excludes(result["tool_choice"], (LITELLM_CODE_EXECUTION_TOOL_NAME, "get_weather")) == ( + "get_weather", + ) + + @pytest.mark.asyncio async def test_pre_call_uses_session_id_from_metadata_as_sandbox_key(): """When session_id is in request metadata, it becomes the sandbox key so the diff --git a/tests/unit/integrations/compression_interception/test_compression_interception_handler.py b/tests/unit/integrations/compression_interception/test_compression_interception_handler.py index d7a1d6f14e1..549c0363942 100644 --- a/tests/unit/integrations/compression_interception/test_compression_interception_handler.py +++ b/tests/unit/integrations/compression_interception/test_compression_interception_handler.py @@ -548,3 +548,50 @@ async def test_pre_call_hook_counts_tokens_off_the_event_loop(): assert result["messages"] is messages assert "tools" not in result assert_loop_stayed_free(took, lags) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool_choice", "retrieval_entry"), + [ + ( + {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "existing_tool"}]}, + {"type": "function", "name": "litellm_content_retrieve"}, + ), + ( + { + "type": "allowed_tools", + "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": "existing_tool"}}]}, + }, + {"type": "function", "function": {"name": "litellm_content_retrieve"}}, + ), + ], +) +async def test_pre_call_hook_lets_an_allowed_tools_choice_allow_the_retrieval_tool_it_adds( + monkeypatch, tool_choice, retrieval_entry +): + monkeypatch.setattr( + "litellm.integrations.compression_interception.handler.compress", + lambda **_: { + "messages": [{"role": "user", "content": "stubbed"}], + "original_tokens": 12000, + "compressed_tokens": 5000, + "cache": {"auth.py": "full file content"}, + "tools": [{"type": "function", "function": {"name": "litellm_content_retrieve", "parameters": {}}}], + }, + ) + + result = await CompressionInterceptionLogger().async_pre_call_deployment_hook( + kwargs={ + "model": "anthropic/claude-sonnet-4-5", + "messages": [{"role": "user", "content": "very large context"}], + "tools": [{"type": "function", "function": {"name": "existing_tool", "parameters": {"type": "object"}}}], + "tool_choice": tool_choice, + }, + call_type=CallTypes.anthropic_messages, + ) + + assert result is not None + entries = result["tool_choice"].get("tools") or result["tool_choice"]["allowed_tools"]["tools"] + assert entries[-1] == retrieval_entry + assert entries[:-1] == (tool_choice.get("tools") or tool_choice["allowed_tools"]["tools"]) diff --git a/tests/unit/integrations/websearch_interception/test_websearch_chat_completion.py b/tests/unit/integrations/websearch_interception/test_websearch_chat_completion.py index fc415374096..d49da255c67 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_chat_completion.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_chat_completion.py @@ -6,6 +6,7 @@ litellm.acompletion() for transparent server-side web search execution. """ import datetime +import json from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -422,6 +423,97 @@ async def test_execute_chat_completion_agentic_loop_strips_tool_choice(): ) +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["auto", "required"]) +async def test_execute_chat_completion_agentic_loop_keeps_an_allowed_tools_choice_without_forcing_another_call( + mode: str, + perplexity_search: respx.Route, + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-openai-key") + litellm.in_memory_llm_clients_cache.flush_cache() + followup: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").respond( + json={ + "id": "followup", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "done"}, "finish_reason": "stop"}], + } + ) + tool_choice: Final = { + "type": "allowed_tools", + "allowed_tools": { + "mode": mode, + "tools": [{"type": "function", "function": {"name": "litellm_web_search"}}], + }, + } + + await WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])._execute_chat_completion_agentic_loop( + model="gpt-4o", + messages=[{"role": "user", "content": "What is Bitcoin price?"}], + tool_calls=[{"id": "call_1", "name": "litellm_web_search", "input": {"query": "bitcoin price"}}], + optional_params={ + "tools": [ + {"type": "function", "function": {"name": "litellm_web_search"}}, + {"type": "function", "function": {"name": "get_time"}}, + ], + "tool_choice": tool_choice, + }, + logging_obj=MagicMock(), + stream=False, + kwargs={}, + ) + + assert perplexity_search.call_count == 1 + assert json.loads(followup.calls.last.request.content)["tool_choice"] == { + "type": "allowed_tools", + "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": "litellm_web_search"}}]}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool_choice", "followup_tool_choice"), + [ + ( + { + "type": "allowed_tools", + "allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": "get_time"}}]}, + }, + { + "type": "allowed_tools", + "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": "get_time"}}]}, + }, + ), + ({"type": "function", "function": {"name": "litellm_web_search"}}, None), + ("required", None), + ], +) +async def test_chat_followup_plan_keeps_only_an_allowed_tools_choice( + tool_choice: object, followup_tool_choice: object, perplexity_search: respx.Route +) -> None: + plan: Final = await WebSearchInterceptionLogger().async_build_chat_completion_agentic_loop_plan( + tools={ + "tool_calls": [{"id": "call_abc", "name": "litellm_web_search", "input": {"query": "latest ai news"}}], + "tool_type": "websearch", + "provider": "openai", + "response_format": "openai", + }, + model="gpt-4o", + messages=[{"role": "user", "content": "What's the latest AI news?"}], + response=MagicMock(), + optional_params={"tool_choice": tool_choice, "max_tokens": 512}, + logging_obj=None, + stream=False, + kwargs={"custom_llm_provider": "openai"}, + ) + + assert plan.request_patch is not None + assert plan.request_patch.optional_params.get("tool_choice") == followup_tool_choice + + if __name__ == "__main__": # Run with: pytest test_websearch_chat_completion.py -v -s pytest.main([__file__, "-v", "-s"]) diff --git a/tests/unit/integrations/websearch_interception/test_websearch_responses.py b/tests/unit/integrations/websearch_interception/test_websearch_responses.py index 6ca812a5e40..01267496668 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_responses.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_responses.py @@ -21,6 +21,7 @@ from litellm.integrations.websearch_interception.handler import ( from litellm.types.integrations.custom_logger import ( RESPONSES_AGENTIC_SURFACE, ) +from litellm.litellm_core_utils.allowed_tools_tool_choice import tool_calls_the_choice_excludes from litellm.litellm_core_utils.litellm_logging import Logging from litellm.types.utils import CallTypes, LlmProviders @@ -363,6 +364,37 @@ async def test_responses_followup_keeps_the_caller_model_across_the_chat_complet assert followup_model == requested_model +@pytest.mark.asyncio +@pytest.mark.usefixtures("perplexity_search") +@pytest.mark.parametrize( + ("tool_choice", "followup_tool_choice"), + [ + ( + {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "litellm_web_search"}]}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "litellm_web_search"}]}, + ), + ({"type": "function", "name": "litellm_web_search"}, None), + ("required", None), + ], +) +async def test_responses_followup_keeps_only_an_allowed_tools_choice_without_forcing_another_call( + tool_choice: object, followup_tool_choice: object +) -> None: + plan: Final = await WebSearchInterceptionLogger().async_build_responses_agentic_loop_plan( + tools={**_WEB_SEARCH_FUNCTION_CALL, "provider": "azure"}, + model="gpt-5.4-mini", + messages=[{"role": "user", "content": "What's the latest AI news?"}], + response=_responses_output_with_web_search(), + optional_params={"tools": [{"type": "function", "name": "litellm_web_search"}], "tool_choice": tool_choice}, + logging_obj=None, + stream=False, + kwargs={"custom_llm_provider": "azure", "_agentic_loop_api_surface": RESPONSES_AGENTIC_SURFACE}, + ) + + assert plan.request_patch is not None + assert plan.request_patch.optional_params.get("tool_choice") == followup_tool_choice + + @pytest.mark.asyncio async def test_deployment_hook_converts_native_responses_web_search_tool(): """async_pre_call_deployment_hook converts a native Responses web_search tool @@ -390,6 +422,39 @@ async def test_deployment_hook_converts_native_responses_web_search_tool(): assert tool["parameters"]["required"] == ["query"] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("call_type", "tool_choice"), + [ + (CallTypes.aresponses, {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "web_search"}]}), + (CallTypes.acompletion, {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "web_search"}]}), + ( + CallTypes.acompletion, + {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": [{"type": "web_search"}]}}, + ), + ], +) +async def test_deployment_hook_lets_an_allowed_tools_choice_listing_web_search_allow_the_search( + call_type: CallTypes, tool_choice: dict[str, object] +): + logger: Final = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + result: Final = await logger.async_pre_call_deployment_hook( + kwargs={ + "model": "gpt-4o", + "custom_llm_provider": "openai", + "tools": [{"type": "web_search"}, {"type": "function", "name": "get_weather"}], + "tool_choice": tool_choice, + }, + call_type=call_type, + ) + + assert result is not None + assert tool_calls_the_choice_excludes(result["tool_choice"], ("litellm_web_search", "get_weather")) == ( + "get_weather", + ) + + @pytest.mark.asyncio async def test_deployment_hook_responses_returns_none_without_web_search(): """No web search tool in a responses request -> deployment hook makes no change.""" diff --git a/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py b/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py index d020c7fcd85..7171bd9e0e5 100644 --- a/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py +++ b/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py @@ -4,16 +4,45 @@ from typing import Final import pytest +import litellm from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.allowed_tools_tool_choice import ( ChatAllowedToolsToolChoice, + GatewayTool, + GatewayTools, + allowed_tools_tool_choice_in_auto_mode, + allowed_tools_tool_choice_in_effect, + allowed_tools_tool_choice_with_builtin_as_function, + allowed_tools_tool_choice_with_function, + allowed_tools_tool_choice_with_gateway_tools_as_functions, + drops_unsupported_allowed_tools_tool_choice, hosted_tool_choice_type, is_allowed_tools_tool_choice, - parse_chat_allowed_tools_tool_choice, + chat_allowed_tools_tool_choice, + parse_allowed_tools_tool_choice, raise_if_hosted_tool_choice_unsupported, + refuse_tool_calls_the_choice_excludes, + tool_calls_the_choice_excludes, + unenforced_allowed_tools_tool_choice, responses_allowed_tools_tool_choice, ) +_CHOICES_WITH_A_NAME_THAT_IS_NOT_A_STRING: Final = ( + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": {"first": "get_time"}}]}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": 7}]}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ""}]}, + {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}}, + {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "mcp", "name": 7}]}}, + { + "type": "allowed_tools", + "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": ["get_time"]}}]}, + }, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "function": {"name": 7}}]}, +) + +_WHOLE_DOCS_SERVER: Final = {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "server_label": "docs"}]} + _CHAT_LAYOUT: Final[dict[str, object]] = { "type": "allowed_tools", "allowed_tools": { @@ -29,7 +58,7 @@ _CHAT_LAYOUT: Final[dict[str, object]] = { def _parsed(tool_choice: Mapping[str, object]) -> ChatAllowedToolsToolChoice: - parsed: Final = parse_chat_allowed_tools_tool_choice(tool_choice) + parsed: Final = parse_allowed_tools_tool_choice(tool_choice) assert parsed is not None return parsed @@ -75,18 +104,64 @@ def test_responses_layout_refuses_to_rewrite_a_malformed_or_conflicting_entry(to assert responses_allowed_tools_tool_choice(_parsed(tool_choice)) is None +_RESPONSES_LAYOUT: Final[dict[str, object]] = { + "type": "allowed_tools", + "mode": "required", + "tools": [ + {"type": "function", "name": "get_time"}, + {"type": "custom", "name": "apply_patch", "x-vendor": 1}, + {"type": "mcp", "server_label": "docs"}, + {"type": "function", "function": {"name": "already_nested"}}, + ], +} + + +def test_responses_layout_is_read_into_the_chat_layout(): + assert chat_allowed_tools_tool_choice(_parsed(_RESPONSES_LAYOUT)) == { + "type": "allowed_tools", + "allowed_tools": { + "mode": "required", + "tools": [ + {"type": "function", "function": {"name": "get_time"}}, + {"type": "custom", "x-vendor": 1, "custom": {"name": "apply_patch"}}, + {"type": "mcp", "server_label": "docs"}, + {"type": "function", "function": {"name": "already_nested"}}, + ], + }, + } + + +def test_responses_layout_round_trips_back_to_what_the_caller_sent(): + flat_only: Final = {**_RESPONSES_LAYOUT, "tools": list(_RESPONSES_LAYOUT["tools"])[:3]} + assert responses_allowed_tools_tool_choice(_parsed(flat_only)) == flat_only + + @pytest.mark.parametrize( "tool_choice", [ - {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "get_time"}]}, {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": []}}, {"type": "allowed_tools", "allowed_tools": {"mode": "any", "tools": [{"type": "function"}]}}, {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "function"}]}, "extra": 1}, + {"type": "allowed_tools", "mode": "auto", "tools": []}, + {"type": "allowed_tools", "mode": "none", "tools": [{"type": "function", "name": "get_time"}]}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function"}]}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "custom", "name": ""}]}, + { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "get_time", "function": {"name": "get_weather"}}], + }, + { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "get_time"}], + "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "name": "get_time"}]}, + }, {"type": "function", "function": {"name": "get_time"}}, ], ) -def test_parse_rejects_anything_but_the_chat_layout(tool_choice: dict[str, object]): - assert parse_chat_allowed_tools_tool_choice(tool_choice) is None +def test_parse_rejects_a_malformed_choice_in_either_layout(tool_choice: dict[str, object]): + assert parse_allowed_tools_tool_choice(tool_choice) is None @pytest.mark.parametrize( @@ -103,6 +178,421 @@ def test_is_allowed_tools_tool_choice(tool_choice: Mapping[str, object] | str | assert is_allowed_tools_tool_choice(tool_choice) is expected +_FLAT_CHOICE: Final = {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "get_time"}]} + + +@pytest.mark.parametrize( + ("tool_choice", "custom_llm_provider"), + [ + (_FLAT_CHOICE, "anthropic"), + (_CHAT_LAYOUT, "hosted_vllm"), + ({"type": "allowed_tools", "mode": "required", "tools": []}, "openai"), + ({"type": "allowed_tools", "tools": [{"type": "function", "name": "get_time"}]}, "azure"), + ], +) +def test_a_choice_nothing_would_enforce_counts_as_unenforced(tool_choice: object, custom_llm_provider: str): + assert unenforced_allowed_tools_tool_choice(tool_choice, custom_llm_provider) + + +@pytest.mark.parametrize( + ("tool_choice", "custom_llm_provider"), + [ + (_FLAT_CHOICE, "openai"), + (_CHAT_LAYOUT, "azure"), + (_FLAT_CHOICE, "litellm_proxy"), + ("required", "anthropic"), + ({"type": "function", "function": {"name": "get_time"}}, "anthropic"), + (None, "anthropic"), + ], +) +def test_a_choice_the_provider_enforces_or_any_other_choice_counts_as_enforced( + tool_choice: object, custom_llm_provider: str +): + assert not unenforced_allowed_tools_tool_choice(tool_choice, custom_llm_provider) + + +@pytest.mark.parametrize( + ("tool_choice", "tool_names"), + [(_FLAT_CHOICE, ("get_time",)), (_CHAT_LAYOUT, ("get_time", "apply_patch"))], +) +def test_a_tool_call_the_choice_lists_passes(tool_choice: object, tool_names: tuple[str, ...]): + assert refuse_tool_calls_the_choice_excludes(tool_choice, tool_names, "the-model") is None + + +@pytest.mark.parametrize( + "tool_choice", [_FLAT_CHOICE, _CHAT_LAYOUT, {"type": "allowed_tools", "mode": "required", "tools": []}] +) +def test_a_tool_call_the_choice_does_not_list_is_refused(tool_choice: object): + with pytest.raises(BadRequestError, match=r"The model called .*get_weather") as exc: + refuse_tool_calls_the_choice_excludes(tool_choice, ("get_time", "get_weather"), "the-model") + + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize("tool_choice", ["required", None, {"type": "function", "function": {"name": "get_time"}}]) +def test_any_other_choice_leaves_the_tool_calls_alone(tool_choice: object): + assert refuse_tool_calls_the_choice_excludes(tool_choice, ("get_weather",), "the-model") is None + + +@pytest.mark.parametrize( + ("api_version", "unenforced"), + [ + ("2023-07-01-preview", True), + ("2023-11-30", True), + ("2023-12-01-preview", False), + ("2025-04-01-preview", False), + ("v1", False), + ], +) +def test_azure_counts_as_enforcing_only_on_a_version_that_takes_tool_choice(api_version: str, unenforced: bool): + assert unenforced_allowed_tools_tool_choice(_FLAT_CHOICE, "azure", api_version) is unenforced + + +def test_azure_reads_the_same_api_version_fallback_as_the_request(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "api_version", "2023-07-01-preview") + + assert unenforced_allowed_tools_tool_choice(_FLAT_CHOICE, "azure") + + +def test_drop_params_drops_a_choice_the_provider_cannot_take(): + assert drops_unsupported_allowed_tools_tool_choice(_FLAT_CHOICE, "anthropic", "the-model", True) + + +def test_a_choice_the_provider_cannot_take_answers_400_without_drop_params(): + with pytest.raises(BadRequestError, match="only supported for") as exc: + drops_unsupported_allowed_tools_tool_choice(_FLAT_CHOICE, "anthropic", "the-model", False) + + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "required", "tools": []}, + {"type": "allowed_tools", "tools": [{"type": "function", "name": "get_time"}]}, + *_CHOICES_WITH_A_NAME_THAT_IS_NOT_A_STRING, + ], +) +def test_a_malformed_choice_is_refused_without_drop_params_and_dropped_with_it(tool_choice: object): + with pytest.raises(BadRequestError, match="Invalid tool choice") as exc: + allowed_tools_tool_choice_in_effect(tool_choice, "the-model", False) + + assert exc.value.status_code == 400 + assert allowed_tools_tool_choice_in_effect(tool_choice, "the-model", True) is None + + +@pytest.mark.parametrize("tool_choice", _CHOICES_WITH_A_NAME_THAT_IS_NOT_A_STRING) +def test_a_name_that_is_not_a_string_lists_no_tool(tool_choice: object): + assert tool_calls_the_choice_excludes(tool_choice, ("get_time",)) == ("get_time",) + + +@pytest.mark.parametrize("tool_choice", [_FLAT_CHOICE, _CHAT_LAYOUT, "required", None]) +@pytest.mark.parametrize("drop_params", [True, False]) +def test_a_valid_choice_or_any_other_choice_stays_in_effect(tool_choice: object, drop_params: bool): + assert allowed_tools_tool_choice_in_effect(tool_choice, "the-model", drop_params) is tool_choice + + +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "server_label": "docs", "name": None}]}, + { + "type": "allowed_tools", + "allowed_tools": {"mode": "auto", "tools": [{"type": "mcp", "server_label": "docs", "name": None}]}, + }, + ], +) +def test_an_mcp_entry_with_a_null_name_is_a_valid_choice(tool_choice: object): + assert parse_allowed_tools_tool_choice(tool_choice) is not None + assert allowed_tools_tool_choice_in_effect(tool_choice, "the-model", False) is tool_choice + + +_DOCS_SEARCH: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "mcp", "server_label": "docs", "name": "search"}], +} + + +def _gateway_serving_search(*server_labels: str) -> GatewayTools: + return GatewayTools( + server_labels=frozenset({"docs", "other"}), + tools={ + "docs_server-search": GatewayTool( + names=frozenset({"docs_server-search", "search"}), server_labels=frozenset(server_labels) + ) + }, + ) + + +@pytest.mark.parametrize("name", ["search", "docs_server-search"]) +def test_a_named_mcp_entry_allows_its_call_on_a_server_its_label_serves(name: str): + tool_choice: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "mcp", "server_label": "docs", "name": name}], + } + + assert ( + tool_calls_the_choice_excludes(tool_choice, ("docs_server-search",), _gateway_serving_search("docs", "other")) + == () + ) + + +def test_an_mcp_entry_without_a_name_allows_every_call_its_server_serves(): + assert ( + tool_calls_the_choice_excludes(_WHOLE_DOCS_SERVER, ("docs_server-search",), _gateway_serving_search("docs")) + == () + ) + + +@pytest.mark.parametrize( + ("tool_choice", "server_labels"), + [ + (_WHOLE_DOCS_SERVER, frozenset({"other"})), + (_WHOLE_DOCS_SERVER, frozenset()), + ( + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "server_label": "docs", "name": "read"}]}, + frozenset({"docs"}), + ), + (_DOCS_SEARCH, frozenset({"other"})), + (_DOCS_SEARCH, frozenset()), + ], +) +def test_an_mcp_entry_allows_no_call_outside_its_server_or_its_name( + tool_choice: object, server_labels: frozenset[str] +): + assert tool_calls_the_choice_excludes( + tool_choice, ("docs_server-search",), _gateway_serving_search(*server_labels) + ) == ("docs_server-search",) + + +def _is_web_search(tool: Mapping[str, object]) -> bool: + return tool.get("type") == "web_search" + + +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "web_search"}, {"type": "function", "name": "get_time"}]}, + { + "type": "allowed_tools", + "allowed_tools": { + "mode": "auto", + "tools": [{"type": "web_search"}, {"type": "function", "function": {"name": "get_time"}}], + }, + }, + ], +) +def test_a_builtin_entry_allows_the_function_that_replaces_it_in_the_same_layout(tool_choice: dict[str, object]): + rewritten: Final = allowed_tools_tool_choice_with_builtin_as_function( + tool_choice, _is_web_search, "litellm_web_search" + ) + + assert tool_calls_the_choice_excludes(tool_choice, ("litellm_web_search", "get_time")) == ("litellm_web_search",) + assert tool_calls_the_choice_excludes(rewritten, ("litellm_web_search", "get_time", "get_weather")) == ( + "get_weather", + ) + assert isinstance(rewritten, dict) and rewritten.keys() == tool_choice.keys() + + +def test_a_function_or_mcp_entry_is_never_taken_for_a_builtin(): + tool_choice: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "web_search"}, {"type": "mcp", "server_label": "docs", "name": "web_search"}], + } + + rewritten: Final = allowed_tools_tool_choice_with_builtin_as_function( + tool_choice, lambda tool: tool.get("name") == "web_search", "litellm_web_search" + ) + + assert parse_allowed_tools_tool_choice(rewritten) == parse_allowed_tools_tool_choice(tool_choice) + + +@pytest.mark.parametrize( + "tool_choice", + [ + "auto", + None, + {"type": "allowed_tools", "mode": "auto", "tools": []}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "web_search", "name": []}]}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "web_search"}, {"type": "function", "name": 3}]}, + {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "web_search", "name": {}}]}}, + ], +) +def test_any_other_or_malformed_choice_is_left_as_sent(tool_choice: object): + assert allowed_tools_tool_choice_with_builtin_as_function(tool_choice, _is_web_search, "litellm_web_search") is tool_choice + + +_GATEWAY_WITH_TWO_SERVERS: Final = GatewayTools( + server_labels=frozenset({"docs", "weather"}), + tools={ + "docs_server-search": GatewayTool( + names=frozenset({"docs_server-search", "search"}), server_labels=frozenset({"docs"}) + ), + "docs_server-read": GatewayTool(names=frozenset({"docs_server-read", "read"}), server_labels=frozenset({"docs"})), + "weather_server-forecast": GatewayTool( + names=frozenset({"weather_server-forecast", "forecast"}), server_labels=frozenset({"weather"}) + ), + }, +) + + +@pytest.mark.parametrize( + ("tool_choice", "expected"), + [ + ( + { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "mcp", "server_label": "docs"}, {"type": "function", "name": "get_time"}], + }, + { + "type": "allowed_tools", + "mode": "required", + "tools": [ + {"type": "function", "name": "get_time"}, + {"type": "function", "name": "docs_server-search"}, + {"type": "function", "name": "docs_server-read"}, + ], + }, + ), + ( + { + "type": "allowed_tools", + "allowed_tools": { + "mode": "auto", + "tools": [{"type": "mcp", "server_label": "weather", "name": "forecast"}], + }, + }, + { + "type": "allowed_tools", + "allowed_tools": { + "mode": "auto", + "tools": [{"type": "function", "function": {"name": "weather_server-forecast"}}], + }, + }, + ), + ], +) +def test_gateway_mcp_entries_become_the_functions_they_allow_in_the_callers_layout( + tool_choice: dict[str, object], expected: dict[str, object] +): + assert allowed_tools_tool_choice_with_gateway_tools_as_functions(tool_choice, _GATEWAY_WITH_TWO_SERVERS) == expected + + +def test_an_mcp_entry_of_a_label_the_gateway_does_not_serve_is_forwarded_as_sent(): + tool_choice: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "mcp", "server_label": "remote"}, {"type": "mcp", "server_label": "docs", "name": "read"}], + } + + assert allowed_tools_tool_choice_with_gateway_tools_as_functions(tool_choice, _GATEWAY_WITH_TWO_SERVERS) == { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "mcp", "server_label": "remote"}, {"type": "function", "name": "docs_server-read"}], + } + + +@pytest.mark.parametrize("mode", ["auto", "required"]) +def test_gateway_entries_that_allow_no_converted_tool_send_none_instead_of_an_empty_list(mode: str): + tool_choice: Final = { + "type": "allowed_tools", + "mode": mode, + "tools": [{"type": "mcp", "server_label": "docs", "name": "delete"}], + } + + assert allowed_tools_tool_choice_with_gateway_tools_as_functions(tool_choice, _GATEWAY_WITH_TWO_SERVERS) == "none" + + +@pytest.mark.parametrize("tool_choice", ["auto", None, _FLAT_CHOICE, {"type": "allowed_tools", "mode": "auto", "tools": []}]) +def test_a_choice_without_gateway_entries_is_forwarded_as_sent(tool_choice: object): + assert allowed_tools_tool_choice_with_gateway_tools_as_functions(tool_choice, _GATEWAY_WITH_TWO_SERVERS) is tool_choice + + +@pytest.mark.parametrize( + ("tool_choice", "expected"), + [ + ( + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "get_time"}]}, + { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "get_time"}, {"type": "function", "name": "retrieve"}], + }, + ), + ( + {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": [{"type": "mcp", "server_label": "x"}]}}, + { + "type": "allowed_tools", + "allowed_tools": { + "mode": "required", + "tools": [{"type": "mcp", "server_label": "x"}, {"type": "function", "function": {"name": "retrieve"}}], + }, + }, + ), + ], +) +def test_an_added_function_joins_the_choice_in_the_callers_layout( + tool_choice: dict[str, object], expected: dict[str, object] +): + assert allowed_tools_tool_choice_with_function(tool_choice, "retrieve") == expected + + +@pytest.mark.parametrize( + "tool_choice", + [ + "auto", + None, + {"type": "function", "function": {"name": "get_time"}}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "retrieve"}]}, + ], +) +def test_a_choice_that_is_not_allowed_tools_or_already_lists_the_function_is_left_as_sent(tool_choice: object): + assert allowed_tools_tool_choice_with_function(tool_choice, "retrieve") is tool_choice + + +@pytest.mark.parametrize( + ("tool_choice", "expected"), + [ + ( + { + "type": "allowed_tools", + "allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": "get_time"}}]}, + }, + { + "type": "allowed_tools", + "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": "get_time"}}]}, + }, + ), + ( + { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "get_time"}, {"type": "mcp", "server_label": "docs"}], + }, + { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "get_time"}, {"type": "mcp", "server_label": "docs"}], + }, + ), + (_CHAT_LAYOUT, _CHAT_LAYOUT), + ("required", "required"), + ( + {"type": "function", "function": {"name": "get_time"}}, + {"type": "function", "function": {"name": "get_time"}}, + ), + ], +) +def test_auto_mode_lifts_only_the_requirement_and_keeps_the_callers_layout_and_tools( + tool_choice: object, expected: object +): + assert allowed_tools_tool_choice_in_auto_mode(tool_choice) == expected + + @pytest.mark.parametrize( ("tool_choice", "expected"), [ diff --git a/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py b/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py index d26da679246..7d41cbd6628 100644 --- a/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py +++ b/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py @@ -22,8 +22,8 @@ they stay out of the body even without it. import datetime import json -from collections.abc import Mapping, Sequence -from typing import Any, Dict, Final, List, Optional, Tuple +from collections.abc import Callable, Mapping, Sequence +from typing import Any, ClassVar, Dict, Final, List, Optional, Tuple from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -38,6 +38,7 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.chat_completion_agentic_loop import ( _execute_chat_completion_agentic_plan, maybe_run_chat_completion_agentic_loop, + run_chat_completion_agentic_hooks, ) from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper @@ -67,6 +68,7 @@ _INTERNAL_CONTROL_FIELDS = ( "_code_interpreter_interception_active", "_code_interpreter_interception_sandbox_key", "_code_interpreter_interception_converted_stream", + "_agentic_loop_tool_choice", "litellm_metadata", ) @@ -132,6 +134,27 @@ def _tool_call_model_response() -> ModelResponse: ) +def _custom_tool_call_model_response() -> ModelResponse: + return ModelResponse( + choices=[ + Choices( + finish_reason="tool_calls", + message=Message( + role="assistant", + content=None, + tool_calls=[ + { + "id": "call_abc", + "type": "custom", + "custom": {"name": "litellm_code_execution", "input": "print(6*7)"}, + } + ], + ), + ) + ] + ) + + def _plain_model_response(content: str = "The answer is 42") -> ModelResponse: return ModelResponse( choices=[ @@ -1090,3 +1113,231 @@ class TestAgenticFollowUpKeepsTheProviderPrefix: response: Final = await self._run_followup("openai/gpt-4o", "openrouter", patched_model="openai/gpt-4o") assert response._hidden_params["custom_llm_provider"] == "openrouter" + + +_ALLOWS_ONLY_GET_WEATHER: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "get_weather"}], +} +_ALLOWS_THE_HOOKS_CALL: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "litellm_code_execution"}], +} + + +async def _dispatch_under_the_choice(tool_choice: object, acompletion_mock: AsyncMock) -> Any: + gate: Final = _GateOnlyLogger( + plan=AgenticLoopPlan( + run_agentic_loop=True, request_patch=AgenticLoopRequestPatch(messages=_patched_messages()) + ), + tool_calls=_REFUSED_GATE_TOOL_CALLS, + ) + with patch.object(litellm, "acompletion", acompletion_mock): + return await maybe_run_chat_completion_agentic_loop( + response=_tool_call_model_response(), + model="gpt-4o-mini", + messages=[{"role": "user", "content": "what is 6*7?"}], + optional_params={"tool_choice": tool_choice}, + kwargs={}, + logging_obj=_LoggingWithCallbacks((gate,)), + custom_llm_provider="openai", + stream=False, + ) + + +@pytest.mark.asyncio +async def test_an_intercepted_call_the_choice_excludes_ends_the_turn_without_running(restore_callbacks): + acompletion_mock: Final = AsyncMock() + + result: Final = await _dispatch_under_the_choice(_ALLOWS_ONLY_GET_WEATHER, acompletion_mock) + + acompletion_mock.assert_not_awaited() + assert isinstance(result, ModelResponse) + assert not result.choices[0].message.tool_calls + assert result.choices[0].finish_reason == "stop" + + +@pytest.mark.asyncio +async def test_an_intercepted_call_the_choice_lists_still_runs(restore_callbacks): + followup: Final = _plain_model_response("done") + acompletion_mock: Final = AsyncMock(return_value=followup) + + result: Final = await _dispatch_under_the_choice(_ALLOWS_THE_HOOKS_CALL, acompletion_mock) + + acompletion_mock.assert_awaited_once() + assert result is followup + + +class _ChatHookLogger(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.runs = 0 + + async def async_should_run_chat_completion_agentic_loop( + self, + response: object, + model: str, + messages: list[dict], + tools: list[dict] | None, + stream: bool, + custom_llm_provider: str, + kwargs: dict, + ) -> tuple[bool, dict]: + return True, {"tool_calls": [{"id": "call_abc", "name": "litellm_code_execution"}]} + + async def async_run_chat_completion_agentic_loop( + self, + tools: dict, + model: str, + messages: list[dict], + response: object, + optional_params: dict, + logging_obj: Any, + stream: bool, + kwargs: dict, + ) -> object: + self.runs += 1 + return _plain_model_response("ran the hook") + + +def _logging_with(hook: CustomLogger) -> Logging: + return Logging( + model="gpt-4o-mini", + messages=[], + stream=False, + call_type="acompletion", + start_time=datetime.datetime.now(), + litellm_call_id="call-test", + function_id="function-test", + dynamic_success_callbacks=[hook], + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_response", [_tool_call_model_response, _custom_tool_call_model_response]) +async def test_a_chat_hook_never_runs_a_call_the_choice_excludes(model_response: Callable[[], ModelResponse]): + hook: Final = _ChatHookLogger() + + result: Final = await run_chat_completion_agentic_hooks( + response=model_response(), + model="gpt-4o-mini", + messages=[{"role": "user", "content": "what is 6*7?"}], + optional_params={}, + kwargs={"_agentic_loop_tool_choice": _ALLOWS_ONLY_GET_WEATHER}, + logging_obj=_logging_with(hook), + custom_llm_provider="openai", + stream=False, + ) + + assert hook.runs == 0 + assert isinstance(result, ModelResponse) + assert not result.choices[0].message.tool_calls + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("model_response", "tool_type"), + [(_tool_call_model_response, "function"), (_custom_tool_call_model_response, "custom")], +) +async def test_a_chat_hook_runs_a_call_the_choice_lists(model_response: Callable[[], ModelResponse], tool_type: str): + hook: Final = _ChatHookLogger() + + result: Final = await run_chat_completion_agentic_hooks( + response=model_response(), + model="gpt-4o-mini", + messages=[{"role": "user", "content": "what is 6*7?"}], + optional_params={}, + kwargs={ + "_agentic_loop_tool_choice": { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": tool_type, "name": "litellm_code_execution"}], + } + }, + logging_obj=_logging_with(hook), + custom_llm_provider="openai", + stream=False, + ) + + assert hook.runs == 1 + assert isinstance(result, ModelResponse) + assert result.choices[0].message.content == "ran the hook" + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("tool_choice", "hook_runs"), [(_ALLOWS_ONLY_GET_WEATHER, 0), (_ALLOWS_THE_HOOKS_CALL, 1)]) +async def test_acompletion_hands_the_callers_choice_to_the_chat_hooks(tool_choice: dict[str, object], hook_runs: int): + hook: Final = _ChatHookLogger() + mock_client: Final = MagicMock() + mock_client.post = AsyncMock(return_value=_raw_response_for(_tool_call_model_response())) + + response: Final = await litellm.acompletion( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "what is 6*7?"}], + tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}], + tool_choice=tool_choice, + api_key="sk-test", + client=mock_client, + success_callback=[hook], + ) + + assert (hook.runs, mock_client.post.await_count, response.choices[0].message.tool_calls) == (hook_runs, 1, None) + + + +class _RetrievalHookLogger(_ChatHookLogger): + server_fulfilled_tool_names: ClassVar[frozenset[str]] = frozenset({"litellm_code_execution"}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_response", [_tool_call_model_response, _custom_tool_call_model_response]) +async def test_a_chat_hook_runs_a_tool_litellm_serves_itself_that_no_choice_can_list( + model_response: Callable[[], ModelResponse], +): + hook: Final = _RetrievalHookLogger() + + result: Final = await run_chat_completion_agentic_hooks( + response=model_response(), + model="gpt-4o-mini", + messages=[{"role": "user", "content": "what is 6*7?"}], + optional_params={}, + kwargs={"_agentic_loop_tool_choice": _ALLOWS_ONLY_GET_WEATHER}, + logging_obj=_logging_with(hook), + custom_llm_provider="openai", + stream=False, + ) + + assert hook.runs == 1 + assert isinstance(result, ModelResponse) + assert result.choices[0].message.content == "ran the hook" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "auto", "tools": []}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_weather"]}]}, + ], +) +async def test_a_malformed_choice_dropped_by_drop_params_never_holds_back_the_chat_hooks( + tool_choice: dict[str, object], +): + hook: Final = _ChatHookLogger() + mock_client: Final = MagicMock() + mock_client.post = AsyncMock(return_value=_raw_response_for(_tool_call_model_response())) + + response: Final = await litellm.acompletion( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "what is 6*7?"}], + tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}], + tool_choice=tool_choice, + drop_params=True, + api_key="sk-test", + client=mock_client, + success_callback=[hook], + ) + + assert (hook.runs, response.choices[0].message.content) == (1, "ran the hook") diff --git a/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py b/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py index 6358d6976c4..2cb8b9cf6c5 100644 --- a/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py +++ b/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py @@ -99,10 +99,21 @@ def test_map_openai_params_drop_params_still_drops_a_named_tool_choice_on_old_ap assert "tool_choice" not in mapped -def test_map_openai_params_drop_params_never_drops_allowed_tools_on_old_api_version(): - with pytest.raises(litellm.UnsupportedParamsError, match="allowed_tools") as exc: - _map_tool_choice_on_pre_tool_choice_api_version( - {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "function"}]}} +def test_map_openai_params_drop_params_drops_allowed_tools_on_old_api_version(): + mapped: Final = _map_tool_choice_on_pre_tool_choice_api_version( + {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "function"}]}} + ) + assert "tool_choice" not in mapped + + +def test_map_openai_params_allowed_tools_on_old_api_version_is_a_400_without_drop_params(): + with pytest.raises(litellm.UnsupportedParamsError, match="2023-12-01-preview") as exc: + AzureOpenAIConfig().map_openai_params( + {"tool_choice": {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "function"}]}}}, + {}, + "gpt-4o", + drop_params=False, + api_version="2023-07-01-preview", ) assert exc.value.status_code == 400 diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index ba8fb3bf107..4d8ba26d8d8 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -455,6 +455,95 @@ def test_response_api_handler_sends_no_stream_options_when_web_search_forces_a_n assert "stream_options" not in recorder.seen_kwargs +class _LookupLoop(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.runs = 0 + + async def async_should_run_agentic_loop( + self, + response: object, + model: str, + messages: list[dict[str, object]], + tools: list[dict[str, object]] | None, + stream: bool, + custom_llm_provider: str, + kwargs: dict[str, object], + ) -> tuple[bool, dict[str, object]]: + return True, {"tool_calls": [{"id": "call_lookup", "name": "lookup"}]} + + async def async_run_agentic_loop( + self, + tools: dict, + model: str, + messages: list[dict], + response: object, + anthropic_messages_provider_config: object, + anthropic_messages_optional_request_params: dict, + logging_obj: object, + stream: bool, + kwargs: dict, + ) -> object: + self.runs += 1 + return response + + +def _a_lookup_call(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "resp_1", + "object": "response", + "created_at": 0, + "status": "completed", + "model": "gpt-4o", + "output": [ + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_lookup", + "name": "lookup", + "arguments": "{}", + "status": "completed", + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + }, + ) + + +@pytest.mark.parametrize("allowed_name,runs", [("get_time", 0), ("lookup", 1)]) +def test_response_api_handler_runs_no_hook_call_the_allowed_tools_choice_excludes(allowed_name: str, runs: int): + hook: Final = _LookupLoop() + + response: Final = BaseLLMHTTPHandler().response_api_handler( + model="gpt-4o", + input="what time is it?", + responses_api_provider_config=AzureOpenAIResponsesAPIConfig(), + response_api_optional_request_params={ + "tools": [ + {"type": "function", "name": "get_time", "parameters": {"type": "object", "properties": {}}}, + {"type": "function", "name": "lookup", "parameters": {"type": "object", "properties": {}}}, + ], + "tool_choice": { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": allowed_name}], + }, + }, + custom_llm_provider="azure", + litellm_params=GenericLiteLLMParams( + api_key="sk-test", api_base="https://example.openai.azure.com", api_version="2025-04-01-preview" + ), + logging_obj=Mock(litellm_call_id="call-1", dynamic_success_callbacks=[hook], model_call_details={}), + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_a_lookup_call))), + ) + + assert hook.runs == runs + assert isinstance(response, ResponsesAPIResponse) + assert len(response.output) == runs + + @pytest.mark.asyncio async def test_async_response_api_handler_streams_when_provider_transform_adds_stream(): handler = BaseLLMHTTPHandler() diff --git a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py index cbd26618312..de30c68c3c8 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py +++ b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py @@ -2457,3 +2457,170 @@ async def test_codex_tool_search_follow_up_text_verbosity_follows_provider_param assert dropped == without_text assert allowed == {**without_text, "verbosity": "low"} + + +_FLAT_ALLOWED_WEATHER: Final = { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "get_weather"}], +} +_RESPONSES_TOOLS: Final = ( + {"type": "function", "name": "get_weather", "parameters": {"type": "object", "properties": {}}}, + {"type": "function", "name": "get_time", "parameters": {"type": "object", "properties": {}}}, +) + + +def _chat_answer() -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "chatcmpl-echo", + "object": "chat.completion", + "created": 1739462947, + "model": "the-model", + "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "done"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + + +@pytest.mark.usefixtures(httpx_transport.__name__) +@pytest.mark.parametrize("use_async", [False, True]) +@pytest.mark.asyncio +async def test_responses_reports_auto_for_an_allowed_tools_choice_it_dropped( + respx_mock: respx.MockRouter, use_async: bool +) -> None: + route: Final = respx_mock.post(_ANTHROPIC_MESSAGES_URL).mock( + return_value=_anthropic_message({"type": "text", "text": "done"}) + ) + call: Final = { + "model": "anthropic/claude-haiku-4-5", + "input": "What is the weather?", + "tools": list(_RESPONSES_TOOLS), + "tool_choice": _FLAT_ALLOWED_WEATHER, + "drop_params": True, + "api_key": "sk-ant-fake", + } + + response: Final = await litellm.aresponses(**call) if use_async else litellm.responses(**call) + + assert "tool_choice" not in json.loads(route.calls[0].request.content) + assert response.tool_choice == "auto" + + +@pytest.mark.usefixtures(httpx_transport.__name__) +@pytest.mark.asyncio +async def test_responses_drop_the_choice_beside_a_tool_the_provider_runs(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post(_ANTHROPIC_MESSAGES_URL).mock( + return_value=_anthropic_message({"type": "text", "text": "done"}) + ) + + response: Final = await litellm.aresponses( + model="anthropic/claude-haiku-4-5", + input="What is the weather?", + tools=[{"type": "mcp", "server_label": "docs", "server_url": "https://mcp.example.com/sse"}], + tool_choice=_FLAT_ALLOWED_WEATHER, + drop_params=True, + api_key="sk-ant-fake", + ) + + assert "tool_choice" not in json.loads(route.calls[0].request.content) + assert response.tool_choice == "auto" + + +@pytest.mark.usefixtures(httpx_transport.__name__) +def test_responses_report_auto_when_an_old_azure_api_version_dropped_the_choice(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post(url__regex=r"https://azure-bridge\.example\.com/.*chat/completions.*").mock( + return_value=httpx.Response( + 200, + json={ + "id": "chatcmpl-old-azure", + "object": "chat.completion", + "created": 1, + "model": "gpt-4o", + "choices": [ + {"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "done"}} + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + ) + + response: Final = litellm.responses( + model="azure/gpt-4o", + input="What is the weather?", + tools=list(_RESPONSES_TOOLS), + tool_choice=_FLAT_ALLOWED_WEATHER, + drop_params=True, + use_chat_completions_api=True, + api_key="fake", + api_base="https://azure-bridge.example.com", + api_version="2023-07-01-preview", + ) + + assert "tool_choice" not in json.loads(route.calls[0].request.content) + assert response.tool_choice == "auto" + + +def test_streamed_responses_report_auto_for_an_allowed_tools_choice_it_dropped(respx_mock: respx.MockRouter) -> None: + respx_mock.post(_ANTHROPIC_MESSAGES_URL).mock( + return_value=httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_anthropic_sse( + { + "type": "message_start", + "message": { + "id": "msg_dropped_choice", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [], + "usage": {"input_tokens": 9, "output_tokens": 1}, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "done"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 4}}, + {"type": "message_stop"}, + ), + ) + ) + + events: Final = tuple( + litellm.responses( + model="anthropic/claude-haiku-4-5", + input="What is the weather?", + tools=list(_RESPONSES_TOOLS), + tool_choice=_FLAT_ALLOWED_WEATHER, + drop_params=True, + stream=True, + api_key="sk-ant-fake", + ) + ) + + reported: Final = tuple( + event.response.tool_choice + for event in events + if event.type in (ResponsesAPIStreamEvents.RESPONSE_CREATED, ResponsesAPIStreamEvents.RESPONSE_COMPLETED) + ) + assert reported == ("auto", "auto") + + +def test_responses_through_chat_keeps_reporting_a_choice_the_provider_received(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post("http://localhost:12346/v1/chat/completions").mock(return_value=_chat_answer()) + + response: Final = litellm.responses( + model="openai/gpt-5.5", + input="What is the weather?", + tools=list(_RESPONSES_TOOLS), + tool_choice=_FLAT_ALLOWED_WEATHER, + use_chat_completions_api=True, + api_base="http://localhost:12346/v1", + api_key="sk-fake", + ) + + assert json.loads(route.calls[0].request.content)["tool_choice"]["type"] == "allowed_tools" + assert response.tool_choice["type"] == "allowed_tools" + assert list(response.tool_choice["tools"]) == _FLAT_ALLOWED_WEATHER["tools"] diff --git a/tests/unit/responses/litellm_completion_transformation/test_handler.py b/tests/unit/responses/litellm_completion_transformation/test_handler.py index 4794f3701c5..1611225dfe9 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_handler.py +++ b/tests/unit/responses/litellm_completion_transformation/test_handler.py @@ -332,6 +332,40 @@ async def test_bridged_next_turn_replays_a_turn_stored_under_the_id_the_client_h ] +@pytest.mark.asyncio +async def test_bridged_follow_up_after_a_client_tool_search_reports_auto_for_an_allowed_tools_choice_it_dropped(): + previous_response_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id( + custom_llm_provider="azure_ai", model_id="deployment-a", response_id="chatcmpl-turn-one" + ) + provider: Final = _RecordingAnthropicHandler(_ANTHROPIC_MESSAGE_PAYLOAD) + client: Final = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(provider)) + + with patch( + "litellm.proxy.proxy_server.prisma_client", + SimpleNamespace(db=_StoredTurns((_stored_mcp_turn(previous_response_id),))), + ): + response = await litellm.aresponses( + model="azure_ai/claude-sonnet-4-6", + api_base="https://fake-foundry-resource.services.ai.azure.com", + api_key="fake-api-key", + input="Double it", + tools=[ + {"type": "tool_search", "execution": "client"}, + {"type": "function", "name": "get_time", "parameters": {"type": "object", "properties": {}}}, + ], + tool_choice={"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "get_time"}]}, + drop_params=True, + previous_response_id=previous_response_id, + client=client, + ) + + assert provider.request_body is not None, "the bridged turn never reached the provider" + assert "tool_choice" not in provider.request_body + assert isinstance(response, ResponsesAPIResponse) + assert response.tool_choice == "auto" + + @pytest.mark.asyncio @pytest.mark.parametrize("client_field", ["caller_input", "names_non_string_fields"]) async def test_bridged_follow_up_after_a_client_tool_search_with_a_client_field_named_like_a_bridge_keyword_reaches_the_provider( diff --git a/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py b/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py index a39bc09cee6..24f5173d6a3 100644 --- a/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py +++ b/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py @@ -5,7 +5,7 @@ import logging import sys import warnings from collections.abc import Callable, Iterator, Mapping, Sequence -from contextlib import contextmanager, nullcontext +from contextlib import AbstractContextManager, contextmanager, nullcontext from datetime import datetime from types import MappingProxyType, SimpleNamespace from typing import Any, Final, List, cast @@ -25,16 +25,18 @@ from litellm.integrations.langfuse.langfuse import langfuse_trace_id, langfuse_t from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.xai.common_utils import xai_reported_cost_in_usd from litellm.proxy.mcp_server import mcp_server_manager -from litellm.proxy.mcp_server.faults.list_outcomes import ServerListOk +from litellm.proxy.mcp_server.faults.list_outcomes import AggregateToolListing, ServerListOk from litellm.proxy.utils import _estimate_dispatched_failure_usage, _failure_fields_to_lift from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler from litellm.responses.mcp.mcp_streaming_iterator import MCPEnhancedStreamingIterator from litellm.types.llms.openai import ( OpenAIMcpServerTool, + ResponseCompletedEvent, ResponsesAPIResponse, ResponsesAPIStreamingResponse, ToolParam, ) +from litellm.types.mcp_server.mcp_server_manager import MCPServer from litellm.types.utils import ModelResponse, StandardLoggingPayload, Usage @@ -925,7 +927,7 @@ async def test_mcp_parameter_preparation_helpers(): ), "Stream should be restored to original setting" assert ( "tool_choice" not in follow_up_params - ), "tool_choice should be removed for follow-up call" + ), "a tool_choice that is not allowed_tools should be removed for follow-up call" assert follow_up_params["temperature"] == 0.7, "Other params should be preserved" assert ( base_call_params["tool_choice"] == "required" @@ -1703,9 +1705,16 @@ async def _auto_execute_echo( return await _auto_execute(logging_obj, _ECHO_MCP_TOOL, "PING") -async def _auto_execute_through_mcp_server( - monkeypatch: pytest.MonkeyPatch, call_tool: AsyncMock, logging_obj: Logging -) -> None: +_DEMO_MCP_SERVER: Final = MCPServer( + server_id="demo", name="demo", server_name="demo", transport="http", url="https://demo.example/mcp", auth_type="none" +) + + +def _demo_server_named(name: str) -> MCPServer | None: + return _DEMO_MCP_SERVER if name == "demo" else None + + +def _served_by_a_recording_mcp_server(monkeypatch: pytest.MonkeyPatch, call_tool: AsyncMock) -> None: monkeypatch.setitem( sys.modules, "litellm.proxy.proxy_server", SimpleNamespace(proxy_logging_obj=None, prisma_client=None) ) @@ -1716,18 +1725,35 @@ async def _auto_execute_through_mcp_server( get_registry=MagicMock(return_value={}), call_tool=call_tool, get_mcp_server_from_tool_name=MagicMock(return_value=None), - get_mcp_server_by_name=MagicMock(return_value=None), + get_mcp_server_by_name=MagicMock(side_effect=_demo_server_named), + get_allowed_mcp_servers=AsyncMock(return_value=[_DEMO_MCP_SERVER.server_id]), + get_mcp_servers_from_ids=MagicMock(return_value=[_DEMO_MCP_SERVER]), ), ) - echo_tool: Final = MCPTool.model_validate( - {"name": "echo", "inputSchema": {"type": "object", "properties": {"value": {"type": "string"}}}}, - by_name=False, + + +def _echo_tool_listed() -> AbstractContextManager[object]: + return patch( + "litellm.proxy.mcp_server.server._get_tools_from_mcp_servers", + AsyncMock( + return_value=AggregateToolListing( + tools=[ + MCPTool.model_validate( + {"name": "echo", "inputSchema": {"type": "object", "properties": {"value": {"type": "string"}}}}, + by_name=False, + ) + ], + outcomes={}, + ) + ), ) - with patch.object( - LiteLLM_Proxy_MCP_Handler, - "process_mcp_tools_without_openai_transform", - AsyncMock(return_value=([echo_tool], {"echo": "demo"})), - ): + + +async def _auto_execute_through_mcp_server( + monkeypatch: pytest.MonkeyPatch, call_tool: AsyncMock, logging_obj: Logging +) -> None: + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + with _echo_tool_listed(): await litellm.aresponses( model="openai/gpt-5.6", input="Call echo with PING", @@ -1739,6 +1765,281 @@ async def _auto_execute_through_mcp_server( ) +_ALLOWED_ONLY_ANOTHER_TOOL: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "another_tool"}], +} + + +async def _call_with_a_choice_excluding_echo(stream: bool) -> tuple[object, ...]: + with _echo_tool_listed(): + response: Final = await litellm.aresponses( + model="openai/gpt-5.6", + input="Call echo with PING", + tools=[dict(_ECHO_MCP_TOOL)], + tool_choice=_ALLOWED_ONLY_ANOTHER_TOOL, + api_key="sk-mcp-rounds", + api_base="https://mcp-rounds.example.com/v1", + stream=stream, + ) + return await _drain(response) if stream else (response,) + + +@pytest.mark.asyncio +async def test_an_mcp_tool_the_choice_excludes_never_runs_when_the_model_ignored_the_choice( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + _serve_rounds(respx_mock, monkeypatch, (_ROUND_ONE_BODY,)) + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + (response,) = await _call_with_a_choice_excluding_echo(stream=False) + + call_tool.assert_not_awaited() + assert isinstance(response, ResponsesAPIResponse) + assert response.status == "completed" + assert _output_types(response.output) == ("function_call",) + + +def _anthropic_call_to_echo() -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "msg_echo", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "tool_use", "id": "toolu_echo", "name": "echo", "input": {"value": "PING"}}], + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + +async def _call_echo_on_anthropic_under_drop_params( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, rounds: Sequence[httpx.Response], tool_choice: object +) -> tuple[object, respx.Route]: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + litellm.in_memory_llm_clients_cache.flush_cache() + route: Final = respx_mock.post("https://api.anthropic.com/v1/messages").mock(side_effect=list(rounds)) + with _echo_tool_listed(): + response: Final = await litellm.aresponses( + model="anthropic/claude-haiku-4-5", + input="Call echo with PING", + tools=[dict(_ECHO_MCP_TOOL)], + tool_choice=tool_choice, + drop_params=True, + api_key="sk-ant-fake", + ) + return response, route + + +@pytest.mark.asyncio +async def test_a_dropped_choice_hands_back_an_mcp_call_it_excludes( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + response, route = await _call_echo_on_anthropic_under_drop_params( + respx_mock, monkeypatch, (_anthropic_call_to_echo(),), _ALLOWED_ONLY_ANOTHER_TOOL + ) + + call_tool.assert_not_awaited() + assert "tool_choice" not in json.loads(route.calls[0].request.content) + assert isinstance(response, ResponsesAPIResponse) + assert response.tool_choice == "auto" + assert _output_types(response.output) == ("function_call",) + + +def _anthropic_answer() -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "msg_answer", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "PING back"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "auto", "tools": []}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["echo"]}]}, + ], +) +async def test_a_malformed_choice_under_drop_params_runs_an_mcp_call_as_if_never_sent( + tool_choice: dict[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + response, route = await _call_echo_on_anthropic_under_drop_params( + respx_mock, monkeypatch, (_anthropic_call_to_echo(), _anthropic_answer()), tool_choice + ) + + assert call_tool.await_count == 1 + assert all("tool_choice" not in json.loads(call.request.content) for call in route.calls) + assert isinstance(response, ResponsesAPIResponse) + assert "message" in _output_types(response.output) + + +@pytest.mark.asyncio +async def test_a_malformed_choice_without_drop_params_answers_400_before_listing_or_calling_a_model( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + listing: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, AsyncMock()) + monkeypatch.setattr("litellm.proxy.mcp_server.server._get_tools_from_mcp_servers", listing) + + with pytest.raises(litellm.BadRequestError, match="Invalid tool choice"): + await litellm.aresponses( + model="openai/gpt-5.6", + input="Call echo with PING", + tools=[dict(_ECHO_MCP_TOOL)], + tool_choice={"type": "allowed_tools", "mode": "auto", "tools": []}, + api_key="sk-mcp-rounds", + api_base="https://mcp-rounds.example.com/v1", + ) + + listing.assert_not_awaited() + assert not respx_mock.calls + + +_DEMO_LABELED_ECHO_TOOL: Final = MappingProxyType({**_ECHO_MCP_TOOL, "server_label": "demo"}) + + +async def _call_echo_with_the_demo_label(tool_choice: object, stream: bool) -> tuple[object, ...]: + with _echo_tool_listed(): + response: Final = await litellm.aresponses( + model="openai/gpt-5.6", + input="Call echo with PING", + tools=[dict(_DEMO_LABELED_ECHO_TOOL)], + tool_choice=tool_choice, + api_key="sk-mcp-rounds", + api_base="https://mcp-rounds.example.com/v1", + stream=stream, + ) + return await _drain(response) if stream else (response,) + + +@pytest.mark.asyncio +async def test_the_provider_gets_the_converted_function_in_place_of_a_gateway_mcp_entry( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + route: Final = _serve_rounds(respx_mock, monkeypatch, (_ROUND_ONE_BODY, _ROUND_TWO_BODY)) + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + await _call_echo_with_the_demo_label( + {"type": "allowed_tools", "mode": "required", "tools": [{"type": "mcp", "server_label": "demo", "name": "echo"}]}, + stream=False, + ) + + assert json.loads(route.calls[0].request.content)["tool_choice"] == { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "echo"}], + } + assert call_tool.await_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_the_follow_up_after_a_gateway_call_keeps_the_converted_allowlist_without_forcing_a_call( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, stream: bool +) -> None: + route: Final = _serve_responses( + respx_mock, + monkeypatch, + tuple( + _streamed(body) if stream else httpx.Response(200, content=json.dumps(body, default=dict)) + for body in (_ROUND_ONE_BODY, _ROUND_TWO_BODY) + ), + ) + _served_by_a_recording_mcp_server( + monkeypatch, AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + ) + + await _call_echo_with_the_demo_label( + {"type": "allowed_tools", "mode": "required", "tools": [{"type": "mcp", "server_label": "demo", "name": "echo"}]}, + stream=stream, + ) + + assert json.loads(route.calls[1].request.content)["tool_choice"] == { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "echo"}], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_a_gateway_mcp_entry_that_allows_no_converted_tool_sends_none_and_runs_nothing( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, stream: bool +) -> None: + route: Final = _serve_responses( + respx_mock, + monkeypatch, + (_streamed(_ROUND_ONE_BODY) if stream else httpx.Response(200, content=json.dumps(_ROUND_ONE_BODY, default=dict)),), + ) + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + await _call_echo_with_the_demo_label( + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "server_label": "demo", "name": "reverse"}]}, + stream=stream, + ) + + assert json.loads(route.calls[0].request.content)["tool_choice"] == "none" + call_tool.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_an_mcp_tool_the_choice_lists_still_runs(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): + _serve_rounds(respx_mock, monkeypatch, (_ROUND_ONE_BODY, _ROUND_TWO_BODY)) + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + with _echo_tool_listed(): + await litellm.aresponses( + model="openai/gpt-5.6", + input="Call echo with PING", + tools=[dict(_ECHO_MCP_TOOL)], + tool_choice={"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "echo"}]}, + api_key="sk-mcp-rounds", + api_base="https://mcp-rounds.example.com/v1", + ) + + assert call_tool.await_count == 1 + + +@pytest.mark.asyncio +async def test_a_streamed_mcp_tool_the_choice_excludes_never_runs_when_the_model_ignored_the_choice( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + _serve_responses(respx_mock, monkeypatch, (_streamed(_ROUND_ONE_BODY),)) + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + events: Final = await _call_with_a_choice_excluding_echo(stream=True) + + call_tool.assert_not_awaited() + last: Final = events[-1] + assert isinstance(last, ResponseCompletedEvent) + assert _output_types(last.response.output) == ("function_call",) + assert not any("mcp_call" in str(getattr(event, "type", "")) for event in events) + + def _rounds_logging_obj(recorder: _RoundRecorder, call_id: str) -> Logging: return Logging( model="openai/gpt-5.6", diff --git a/tests/unit/responses/mcp/test_chat_completions_handler.py b/tests/unit/responses/mcp/test_chat_completions_handler.py index c0d32742e8a..aaeb30769fb 100644 --- a/tests/unit/responses/mcp/test_chat_completions_handler.py +++ b/tests/unit/responses/mcp/test_chat_completions_handler.py @@ -7,9 +7,9 @@ import threading import types import warnings from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from datetime import datetime -from types import MappingProxyType +from types import MappingProxyType, SimpleNamespace from typing import Final, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -17,6 +17,7 @@ import httpx import pytest import respx from httpx import Response +from mcp.types import CallToolResult, TextContent from mcp.types import Tool as MCPTool from starlette.responses import StreamingResponse from starlette.types import Message @@ -25,6 +26,7 @@ import litellm from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER +from litellm.proxy.mcp_server.faults.list_outcomes import AggregateToolListing from litellm.responses.mcp import chat_completions_handler from litellm.responses.mcp.chat_completions_handler import ( acompletion_with_mcp, @@ -33,6 +35,7 @@ from litellm.responses.mcp.litellm_proxy_mcp_handler import ( LiteLLM_Proxy_MCP_Handler, ) from litellm.responses.utils import ResponsesAPIRequestUtils +from litellm.types.mcp_server.mcp_server_manager import MCPServer from litellm.types.utils import ModelResponse, ModelResponseStream, StandardLoggingPayload, Usage from litellm.utils import CustomStreamWrapper @@ -2744,3 +2747,371 @@ def test_stream_auto_execute_read_synchronously_and_dropped_after_its_loop_close assert [raised for raised in unraisable.raised if _names_chat_stream_billing(raised)] == [] assert [str(warning.message) for warning in caught if _names_chat_stream_billing(str(warning.message))] == [] + + +_MCP_ECHO_TOOL: Final = MCPTool.model_validate( + {"name": "echo", "inputSchema": {"type": "object", "properties": {"value": {"type": "string"}}}}, + by_name=False, +) +_FLAT_ALLOWED_ECHO: Final = {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "echo"}]} + + +_ALLOWED_ONLY_ANOTHER_TOOL: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "another_tool"}], +} + + +_DEMO_MCP_SERVER: Final = MCPServer( + server_id="demo", + name="demo", + server_name="demo", + transport="http", + url="https://demo.example/mcp", + auth_type="none", +) + + +def _demo_server_named(name: str) -> MCPServer | None: + return _DEMO_MCP_SERVER if name.lower() == "demo" else None + + +def _served_by_a_recording_mcp_server( + monkeypatch: pytest.MonkeyPatch, + call_tool: AsyncMock, + listed: MCPTool = _MCP_ECHO_TOOL, + catalog: SimpleNamespace = SimpleNamespace(operation=nullcontext), +) -> None: + monkeypatch.setattr( + "litellm.proxy.mcp_server.server._get_tools_from_mcp_servers", + AsyncMock(return_value=AggregateToolListing(tools=[listed], outcomes={})), + ) + monkeypatch.setitem( + sys.modules, "litellm.proxy.proxy_server", SimpleNamespace(proxy_logging_obj=None, prisma_client=None) + ) + monkeypatch.setattr( + "litellm.proxy.mcp_server.mcp_server_manager.global_mcp_server_manager", + SimpleNamespace( + catalog=catalog, + get_registry=MagicMock(return_value={}), + call_tool=call_tool, + get_mcp_server_from_tool_name=MagicMock(return_value=None), + get_mcp_server_by_name=MagicMock(side_effect=_demo_server_named), + get_allowed_mcp_servers=AsyncMock(return_value=[_DEMO_MCP_SERVER.server_id]), + get_mcp_servers_from_ids=MagicMock(return_value=[_DEMO_MCP_SERVER]), + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_an_mcp_tool_the_choice_excludes_never_runs_when_the_model_ignored_the_choice( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, stream: bool +): + _serve_chat_rounds(respx_mock, monkeypatch, (_streamed(_CHAT_ROUND_ONE) if stream else _ok(_CHAT_ROUND_ONE),)) + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + response: Final = await litellm.acompletion( + model="openai/gpt-5.6", + messages=[{"role": "user", "content": "Call echo with PING"}], + tools=[{"type": "mcp", "server_url": "litellm_proxy/mcp/demo", "require_approval": "never"}], + tool_choice=_ALLOWED_ONLY_ANOTHER_TOOL, + api_key="sk-mcp-chat-rounds", + api_base="https://mcp-chat-rounds.example.com/v1", + stream=stream, + ) + chunks: Final = await _read_all(response) if stream else () + + handed_back: Final = ( + tuple(call.function.name for chunk in chunks for call in chunk.choices[0].delta.tool_calls or ()) + if stream + else tuple(call.function.name for call in response.choices[0].message.tool_calls) + ) + assert handed_back == ("echo",) + call_tool.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_a_required_choice_asks_the_follow_up_in_auto_mode_for_the_same_tools_once_its_mcp_call_ran( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, stream: bool +): + rounds: Final = (_CHAT_ROUND_ONE, _CHAT_ROUND_TWO) + _serve_chat_rounds(respx_mock, monkeypatch, tuple(_streamed(body) if stream else _ok(body) for body in rounds)) + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + response: Final = await litellm.acompletion( + model="openai/gpt-5.6", + messages=[{"role": "user", "content": "Call echo with PING"}], + tools=[{"type": "mcp", "server_url": "litellm_proxy/mcp/demo", "require_approval": "never"}], + tool_choice={"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "echo"}]}, + api_key="sk-mcp-chat-rounds", + api_base="https://mcp-chat-rounds.example.com/v1", + stream=stream, + ) + if stream: + await _read_all(response) + + first, follow_up = (json.loads(call.request.content)["tool_choice"] for call in respx_mock.calls) + assert call_tool.await_count == 1 + assert first["allowed_tools"]["mode"] == "required" + assert follow_up == {**first, "allowed_tools": {**first["allowed_tools"], "mode": "auto"}} + + +def _anthropic_call_to_echo(name: str = "echo") -> Response: + return Response( + 200, + json={ + "id": "msg_echo", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "tool_use", "id": "toolu_echo", "name": name, "input": {"value": "PING"}}], + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + +def _anthropic_answer() -> Response: + return Response( + 200, + json={ + "id": "msg_answer", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "PING back"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + +_WHOLE_DEMO_SERVER: Final = {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "server_label": "demo"}]} +_WHOLE_OTHER_SERVER: Final = {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "server_label": "other"}]} + + +async def _call_echo_on_anthropic_under_drop_params( + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + rounds: Sequence[Response], + tool_choice: object, + server_url: str = "litellm_proxy/mcp/demo", +) -> tuple[ModelResponse, respx.Route]: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + litellm.in_memory_llm_clients_cache.flush_cache() + route: Final = respx_mock.post("https://api.anthropic.com/v1/messages").mock(side_effect=list(rounds)) + response: Final = await litellm.acompletion( + model="anthropic/claude-haiku-4-5", + messages=[{"role": "user", "content": "Call echo with PING"}], + tools=[{"type": "mcp", "server_label": "demo", "server_url": server_url, "require_approval": "never"}], + tool_choice=tool_choice, + drop_params=True, + api_key="sk-ant-fake", + ) + return response, route + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_choice", [_ALLOWED_ONLY_ANOTHER_TOOL, _WHOLE_OTHER_SERVER]) +async def test_a_dropped_choice_hands_back_an_mcp_call_it_excludes( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tool_choice: object +): + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + response, route = await _call_echo_on_anthropic_under_drop_params( + respx_mock, monkeypatch, (_anthropic_call_to_echo(),), tool_choice + ) + + call_tool.assert_not_awaited() + assert "tool_choice" not in json.loads(route.calls[0].request.content) + assert [call.function.name for call in response.choices[0].message.tool_calls] == ["echo"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool_choice", "server_url"), + [ + (_FLAT_ALLOWED_ECHO, "litellm_proxy/mcp/demo"), + (_WHOLE_DEMO_SERVER, "litellm_proxy/mcp/demo"), + (_WHOLE_DEMO_SERVER, "litellm_proxy"), + ], +) +async def test_a_dropped_choice_still_runs_an_mcp_call_it_lists( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tool_choice: object, server_url: str +): + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + response, _route = await _call_echo_on_anthropic_under_drop_params( + respx_mock, monkeypatch, (_anthropic_call_to_echo(), _anthropic_answer()), tool_choice, server_url + ) + + assert call_tool.await_count == 1 + assert response.choices[0].message.content == "PING back" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "auto", "tools": []}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["echo"]}]}, + ], +) +async def test_a_malformed_choice_under_drop_params_runs_an_mcp_call_as_if_never_sent( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tool_choice: object +): + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + response, route = await _call_echo_on_anthropic_under_drop_params( + respx_mock, monkeypatch, (_anthropic_call_to_echo(), _anthropic_answer()), tool_choice + ) + + assert call_tool.await_count == 1 + assert response.choices[0].message.content == "PING back" + assert all("tool_choice" not in json.loads(call.request.content) for call in route.calls) + + +_DEMO_ECHO_TOOL: Final = MCPTool.model_validate( + {"name": "demo-echo", "inputSchema": {"type": "object", "properties": {"value": {"type": "string"}}}}, + by_name=False, +) +_DEMO_IN_AN_ACCESS_GROUP: Final = _DEMO_MCP_SERVER.model_copy(update={"access_groups": ["demo_group"]}) + + +def _choice_of(*tools: Mapping[str, object]) -> Mapping[str, object]: + return {"type": "allowed_tools", "mode": "auto", "tools": list(tools)} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool_choice", "server_url", "listed"), + [ + (_choice_of({"type": "mcp", "server_label": "demo", "name": "echo"}), "litellm_proxy/mcp/demo", _DEMO_ECHO_TOOL), + ( + _choice_of({"type": "mcp", "server_label": "demo", "name": "demo-echo"}), + "litellm_proxy/mcp/demo", + _DEMO_ECHO_TOOL, + ), + (_WHOLE_DEMO_SERVER, "litellm_proxy/mcp/DEMO", _MCP_ECHO_TOOL), + (_WHOLE_DEMO_SERVER, "litellm_proxy/mcp/demo_group", _MCP_ECHO_TOOL), + ], +) +async def test_an_mcp_entry_allows_the_tool_the_gateway_resolves_for_its_label_and_name( + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + tool_choice: object, + server_url: str, + listed: MCPTool, +): + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server( + monkeypatch, + call_tool, + listed, + SimpleNamespace( + operation=nullcontext, + current=lambda: SimpleNamespace(servers={_DEMO_MCP_SERVER.server_id: _DEMO_IN_AN_ACCESS_GROUP}), + ), + ) + + response, _route = await _call_echo_on_anthropic_under_drop_params( + respx_mock, monkeypatch, (_anthropic_call_to_echo(listed.name), _anthropic_answer()), tool_choice, server_url + ) + + assert call_tool.await_count == 1 + assert response.choices[0].message.content == "PING back" + + +@pytest.mark.asyncio +async def test_a_named_mcp_entry_allows_no_other_tool_on_its_server( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool, _DEMO_ECHO_TOOL) + + response, _route = await _call_echo_on_anthropic_under_drop_params( + respx_mock, + monkeypatch, + (_anthropic_call_to_echo("demo-echo"),), + _choice_of({"type": "mcp", "server_label": "demo", "name": "reverse"}), + ) + + call_tool.assert_not_awaited() + assert [call.function.name for call in response.choices[0].message.tool_calls] == ["demo-echo"] + + +async def _call_echo_on_openai_with_the_demo_server( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, rounds: Sequence[Response], tool_choice: object +) -> tuple[object, ...]: + _serve_chat_rounds(respx_mock, monkeypatch, rounds) + await litellm.acompletion( + model="openai/gpt-5.6", + messages=[{"role": "user", "content": "Call echo with PING"}], + tools=[ + { + "type": "mcp", + "server_label": "demo", + "server_url": "litellm_proxy/mcp/demo", + "require_approval": "never", + }, + {"type": "function", "function": {"name": "get_time", "parameters": {"type": "object"}}}, + ], + tool_choice=tool_choice, + api_key="sk-mcp-chat-rounds", + api_base="https://mcp-chat-rounds.example.com/v1", + ) + return tuple(json.loads(call.request.content).get("tool_choice") for call in respx_mock.calls) + + +@pytest.mark.asyncio +async def test_the_provider_gets_the_converted_function_in_place_of_a_gateway_mcp_entry( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + call_tool: Final = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="PING")])) + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + sent: Final = await _call_echo_on_openai_with_the_demo_server( + respx_mock, + monkeypatch, + (_ok(_CHAT_ROUND_ONE), _ok(_CHAT_ROUND_TWO)), + _choice_of({"type": "mcp", "server_label": "demo"}, {"type": "function", "name": "get_time"}), + ) + + assert sent[0] == { + "type": "allowed_tools", + "allowed_tools": { + "mode": "auto", + "tools": [ + {"type": "function", "function": {"name": "get_time"}}, + {"type": "function", "function": {"name": "echo"}}, + ], + }, + } + assert call_tool.await_count == 1 + + +@pytest.mark.asyncio +async def test_a_gateway_mcp_entry_that_allows_no_converted_tool_sends_none_and_runs_nothing( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + call_tool: Final = AsyncMock() + _served_by_a_recording_mcp_server(monkeypatch, call_tool) + + sent: Final = await _call_echo_on_openai_with_the_demo_server( + respx_mock, + monkeypatch, + (_ok(_CHAT_ROUND_ONE),), + _choice_of({"type": "mcp", "server_label": "demo", "name": "reverse"}), + ) + + assert sent == ("none",) + call_tool.assert_not_awaited() diff --git a/tests/unit/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/unit/responses/mcp/test_litellm_proxy_mcp_handler.py index 44ba9007d92..537fb672e16 100644 --- a/tests/unit/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/unit/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -1725,6 +1725,14 @@ TOOLSET_ROWS: Final = ( ), MCPToolset(toolset_id="ts-shouty", toolset_name="CALC", tools=[{"server_id": "srv-2", "tool_name": "lookup"}]), MCPToolset(toolset_id="ts-group", toolset_name="reference", tools=[{"server_id": "srv-1", "tool_name": "add"}]), + MCPToolset( + toolset_id="ts-prefixed", + toolset_name="prefixed-toolset", + tools=[{"server_id": "srv-1", "tool_name": "add"}, {"server_id": "srv-1", "tool_name": "calc-subtract"}], + ), + MCPToolset( + toolset_id="ts-subtract", toolset_name="subtract-toolset", tools=[{"server_id": "srv-1", "tool_name": "subtract"}] + ), ) GATEWAY_SERVERS: Final = ( MCPServer(server_id="srv-1", name="calc", alias="calc", transport=MCPTransport.http, access_groups=["wiki"]), @@ -1753,9 +1761,9 @@ def _toolset_table(lookup_error: Exception | None = None) -> types.SimpleNamespa return types.SimpleNamespace(find_first=find_first, find_many=find_many) -def _team_grants(toolset_id: str) -> GrantedToolsetIds: +def _team_grants(*toolset_ids: str) -> GrantedToolsetIds: async def team_permission(context: UserAPIKeyAuth) -> LiteLLM_ObjectPermissionTable: - return LiteLLM_ObjectPermissionTable(object_permission_id="op-team", mcp_toolsets=[toolset_id]) + return LiteLLM_ObjectPermissionTable(object_permission_id="op-team", mcp_toolsets=list(toolset_ids)) async def granted_through_team(context: UserAPIKeyAuth) -> frozenset[str]: return await granted_toolset_ids(context, team_object_permission=team_permission, require_key_access=False) @@ -1767,19 +1775,20 @@ async def _no_toolset_grants(context: UserAPIKeyAuth) -> frozenset[str]: return frozenset() -async def _tools_listed_through_the_gateway( +async def _listing_through_the_gateway( monkeypatch: pytest.MonkeyPatch, user_api_key_auth: UserAPIKeyAuth, - server_urls: tuple[str, ...], + mcp_tools: Sequence[Mapping[str, object]], granted_toolsets: GrantedToolsetIds, toolset_table: types.SimpleNamespace | None = None, agent_permission: LiteLLM_ObjectPermissionTable | None = None, -) -> list[str]: + servers: Sequence[MCPServer] = GATEWAY_SERVERS, +) -> tuple[list[MCPTool], list[str]]: async def upstream_listing(_client: object, server_name: str) -> list[MCPTool]: return list(UPSTREAM_TOOLS[server_name]) manager: Final = MCPServerManager() - manager.registry = {server.server_id: server for server in GATEWAY_SERVERS} + manager.registry = {server.server_id: server for server in servers} manager.config_mcp_servers = dict(manager.registry) manager.create_mcp_client = AsyncMock(return_value=object()) manager._fetch_tools_with_timeout = AsyncMock(side_effect=upstream_listing) @@ -1800,17 +1809,269 @@ async def _tools_listed_through_the_gateway( monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", cache) monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"supported_db_objects": []}) try: - tools, _server_names = await LiteLLM_Proxy_MCP_Handler._get_mcp_tools_from_manager( + return await LiteLLM_Proxy_MCP_Handler._get_mcp_tools_from_manager( user_api_key_auth=user_api_key_auth, - mcp_tools_with_litellm_proxy=[{"type": "mcp", "server_url": url} for url in server_urls], + mcp_tools_with_litellm_proxy=mcp_tools, granted_toolsets=granted_toolsets, ) finally: - for server in GATEWAY_SERVERS: + for server in servers: manager._drop_listed_tools(server.server_id) + + +async def _tools_listed_through_the_gateway( + monkeypatch: pytest.MonkeyPatch, + user_api_key_auth: UserAPIKeyAuth, + server_urls: tuple[str, ...], + granted_toolsets: GrantedToolsetIds, + toolset_table: types.SimpleNamespace | None = None, + agent_permission: LiteLLM_ObjectPermissionTable | None = None, +) -> list[str]: + tools, _server_names = await _listing_through_the_gateway( + monkeypatch, + user_api_key_auth, + [{"type": "mcp", "server_url": url} for url in server_urls], + granted_toolsets, + toolset_table, + agent_permission, + ) return sorted(tool.name for tool in tools) +async def _calls_the_gateway_runs( + monkeypatch: pytest.MonkeyPatch, + labeled_server_urls: Mapping[str, str], + allowed_label: str, + called: tuple[str, ...], + unresolvable_tools: Mapping[str, str] = MappingProxyType({}), +) -> tuple[str, ...]: + return await _calls_the_entries_run( + monkeypatch, + UserAPIKeyAuth(api_key="sk-team", team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER), + [{"type": "mcp", "server_label": label, "server_url": url} for label, url in labeled_server_urls.items()], + allowed_label, + called, + unresolvable_tools, + ) + + +async def _calls_the_entries_run( + monkeypatch: pytest.MonkeyPatch, + user: UserAPIKeyAuth, + mcp_tools: Sequence[Mapping[str, object]], + allowed_label: str, + called: tuple[str, ...], + unresolvable_tools: Mapping[str, str] = MappingProxyType({}), + servers: Sequence[MCPServer] = GATEWAY_SERVERS, + granted_toolsets: tuple[str, ...] = ("ts-granted",), +) -> tuple[str, ...]: + tools, server_names = await _listing_through_the_gateway( + monkeypatch, user, mcp_tools, _team_grants(*granted_toolsets), servers=servers + ) + _deduplicated, tool_server_map = LiteLLM_Proxy_MCP_Handler._deduplicate_mcp_tools(tools, server_names) + tool_choice: Final = {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "server_label": allowed_label}]} + gateway: Final = await LiteLLM_Proxy_MCP_Handler.gateway_tools( + tool_choice, {**tool_server_map, **unresolvable_tools}, mcp_tools, user, _team_grants(*granted_toolsets) + ) + return tuple( + name + for name in called + if LiteLLM_Proxy_MCP_Handler.tool_calls_to_run( + ({"type": "function_call", "name": name, "call_id": f"call_{name}", "arguments": "{}"},), + tool_choice, + gateway, + ) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("labeled_server_urls", "allowed_label", "runs"), + [ + ({"team": "litellm_proxy/mcp/team-toolset", "docs": "litellm_proxy/mcp/docs"}, "team", ("calc-add",)), + ({"team": "litellm_proxy/mcp/team-toolset", "docs": "litellm_proxy/mcp/docs"}, "docs", ("docs-lookup",)), + ({"team": "litellm_proxy/mcp/team-toolset", "other": "litellm_proxy/mcp/other-toolset"}, "other", ()), + ], + ids=["granted_toolset", "server_beside_a_toolset", "ungranted_toolset"], +) +async def test_an_mcp_entry_allows_only_the_tools_its_labels_toolset_or_server_serves( + monkeypatch: pytest.MonkeyPatch, + labeled_server_urls: Mapping[str, str], + allowed_label: str, + runs: tuple[str, ...], +): + ran: Final = await _calls_the_gateway_runs( + monkeypatch, labeled_server_urls, allowed_label, ("calc-add", "docs-lookup") + ) + + assert ran == runs + + +_READER_AND_WRITER_ON_CALC: Final = ( + {"type": "mcp", "server_label": "reader", "server_url": "litellm_proxy/mcp/calc", "allowed_tools": ["add"]}, + {"type": "mcp", "server_label": "writer", "server_url": "litellm_proxy/mcp/calc", "allowed_tools": ["subtract"]}, +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("entries", "allowed_label", "runs"), + [ + (_READER_AND_WRITER_ON_CALC, "reader", ("calc-add",)), + (_READER_AND_WRITER_ON_CALC, "writer", ("calc-subtract",)), + ( + ({"type": "mcp", "server_label": "all", "server_url": "litellm_proxy", "allowed_tools": ["add"]},), + "all", + ("calc-add",), + ), + ], + ids=["reader_on_a_shared_server", "writer_on_a_shared_server", "whole_gateway"], +) +async def test_an_mcp_entry_allows_only_the_tools_its_own_allowed_tools_lists( + monkeypatch: pytest.MonkeyPatch, + entries: tuple[Mapping[str, object], ...], + allowed_label: str, + runs: tuple[str, ...], +): + ran: Final = await _calls_the_entries_run( + monkeypatch, + _key_holding("srv-1"), + entries, + allowed_label, + ("calc-add", "calc-subtract", "ghost-remove"), + MappingProxyType({"ghost-remove": "ghost"}), + ) + + assert ran == runs + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("allowed_tools", "runs"), [(["add"], ("calc-add",)), (["subtract"], ())]) +async def test_a_toolset_entry_allows_only_the_toolset_tools_its_own_allowed_tools_lists( + monkeypatch: pytest.MonkeyPatch, allowed_tools: list[str], runs: tuple[str, ...] +): + ran: Final = await _calls_the_entries_run( + monkeypatch, + UserAPIKeyAuth(api_key="sk-team", team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER), + ( + { + "type": "mcp", + "server_label": "team", + "server_url": "litellm_proxy/mcp/team-toolset", + "allowed_tools": allowed_tools, + }, + ), + "team", + ("calc-add", "calc-subtract"), + ) + + assert ran == runs + + +@pytest.mark.asyncio +async def test_a_toolset_label_allows_no_tool_whose_prefixed_name_only_matches_a_tool_the_toolset_grants( + monkeypatch: pytest.MonkeyPatch, +): + ran: Final = await _calls_the_entries_run( + monkeypatch, + UserAPIKeyAuth(api_key="sk-team", team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER), + ( + {"type": "mcp", "server_label": "team", "server_url": "litellm_proxy/mcp/prefixed-toolset"}, + {"type": "mcp", "server_label": "writer", "server_url": "litellm_proxy/mcp/subtract-toolset"}, + ), + "team", + ("calc-add", "calc-subtract"), + granted_toolsets=("ts-prefixed", "ts-subtract"), + ) + + assert ran == ("calc-add",) + + +@pytest.mark.asyncio +async def test_an_mcp_entry_allows_a_tool_its_allowed_tools_names_by_display_name(monkeypatch: pytest.MonkeyPatch): + calc_showing_add_as_plus: Final = GATEWAY_SERVERS[0].model_copy( + update={"tool_name_to_display_name": {"add": "plus"}} + ) + + ran: Final = await _calls_the_entries_run( + monkeypatch, + _key_holding("srv-1"), + ({"type": "mcp", "server_label": "reader", "server_url": "litellm_proxy/mcp/calc", "allowed_tools": ["plus"]},), + "reader", + ("plus", "calc-subtract"), + servers=(calc_showing_add_as_plus, GATEWAY_SERVERS[1]), + ) + + assert ran == ("plus",) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("server_url", ["litellm_proxy/mcp/calc", "litellm_proxy"], ids=["one_server", "whole_gateway"]) +async def test_an_mcp_entry_allows_no_tool_whose_hyphenated_display_name_ends_in_a_name_it_lists( + monkeypatch: pytest.MonkeyPatch, server_url: str +): + calc_showing_add_as_not_subtract: Final = GATEWAY_SERVERS[0].model_copy( + update={"tool_name_to_display_name": {"add": "not-subtract"}} + ) + + ran: Final = await _calls_the_entries_run( + monkeypatch, + _key_holding("srv-1", "srv-2"), + ({"type": "mcp", "server_label": "reader", "server_url": server_url, "allowed_tools": ["subtract"]},), + "reader", + ("not-subtract", "calc-subtract"), + servers=(calc_showing_add_as_not_subtract, GATEWAY_SERVERS[1]), + ) + + assert ran == ("calc-subtract",) + + +@pytest.mark.asyncio +async def test_an_mcp_entry_allows_no_tool_whose_display_name_carries_a_server_prefix_before_a_name_it_lists( + monkeypatch: pytest.MonkeyPatch, +): + calc_showing_add_as_srv_1_subtract: Final = GATEWAY_SERVERS[0].model_copy( + update={"tool_name_to_display_name": {"add": "srv-1-subtract"}} + ) + + ran: Final = await _calls_the_entries_run( + monkeypatch, + _key_holding("srv-1", "srv-2"), + ( + { + "type": "mcp", + "server_label": "reader", + "server_url": "litellm_proxy/mcp/calc", + "allowed_tools": ["subtract"], + }, + { + "type": "mcp", + "server_label": "writer", + "server_url": "litellm_proxy/mcp/calc", + "allowed_tools": ["srv-1-subtract"], + }, + ), + "reader", + ("srv-1-subtract", "calc-subtract"), + servers=(calc_showing_add_as_srv_1_subtract, GATEWAY_SERVERS[1]), + ) + + assert ran == ("calc-subtract",) + + +@pytest.mark.asyncio +async def test_an_mcp_entry_allows_no_call_on_a_server_the_gateway_cannot_resolve(monkeypatch: pytest.MonkeyPatch): + ran: Final = await _calls_the_gateway_runs( + monkeypatch, + {"team": "litellm_proxy/mcp/team-toolset"}, + "team", + ("calc-add", "ghost-add"), + MappingProxyType({"ghost-add": "ghost"}), + ) + + assert ran == ("calc-add",) + + def _key_holding(*server_ids: str, role: LitellmUserRoles = LitellmUserRoles.INTERNAL_USER) -> UserAPIKeyAuth: return UserAPIKeyAuth( api_key="sk-team", diff --git a/tests/unit/responses/test_responses_api_bridge_flag.py b/tests/unit/responses/test_responses_api_bridge_flag.py index f6084529ed6..e483b180129 100644 --- a/tests/unit/responses/test_responses_api_bridge_flag.py +++ b/tests/unit/responses/test_responses_api_bridge_flag.py @@ -305,6 +305,44 @@ class TestUseResponsesApiBridgeFlag: assert request_body["messages"] == [{"role": "user", "content": "Hello"}] assert response.output[0].content[0].text == "Answer" + def test_bridge_sends_a_responses_layout_allowed_tools_tool_choice_in_the_chat_layout( + self, respx_mock: respx.MockRouter + ): + upstream: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock( + return_value=httpx.Response( + status_code=200, + json={ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "my-custom-model", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10}, + }, + ) + ) + + litellm.responses( + model="openai/my-custom-model", + input="What time is it?", + use_chat_completions_api=True, + tools=[ + {"type": "function", "name": name, "parameters": {"type": "object", "properties": {}}} + for name in ("get_time", "get_weather") + ], + tool_choice={"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "get_time"}]}, + api_key="fake-provider-api-key", + num_retries=0, + ) + + assert upstream.call_count == 1 + assert json.loads(upstream.calls[0].request.read())["tool_choice"] == { + "type": "allowed_tools", + "allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": "get_time"}}]}, + } + def test_bridge_merges_instructions_and_developer_input_for_databricks(self, respx_mock: respx.MockRouter): upstream: Final = respx_mock.post("https://example.databricks.test/serving-endpoints/chat/completions").mock( return_value=httpx.Response( diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index d2035b6a682..b300562b029 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -557,15 +557,21 @@ _ALLOWED_TOOLS_TOOL_CHOICE: Final = { "type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": "get_time"}}]}, } +_RESPONSES_LAYOUT_ALLOWED_TOOLS_TOOL_CHOICE: Final = { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "get_time"}], +} _TWO_TOOLS: Final = [ {"type": "function", "function": {"name": "get_time", "parameters": {"type": "object", "properties": {}}}}, {"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}}, ] +@pytest.mark.parametrize("tool_choice", [_ALLOWED_TOOLS_TOOL_CHOICE, _RESPONSES_LAYOUT_ALLOWED_TOOLS_TOOL_CHOICE]) @pytest.mark.parametrize("model", ["openai/gpt-5.5", "azure/my-gpt-deployment"]) def test_completion_forwards_allowed_tools_tool_choice_to_openai_compatible_chat( - respx_mock: respx.MockRouter, openai_api_response: dict[str, object], model: str + respx_mock: respx.MockRouter, openai_api_response: dict[str, object], model: str, tool_choice: dict[str, object] ): mock_route: Final = respx_mock.post(url__regex=r"http://localhost:12346/.*chat/completions.*").mock( return_value=httpx.Response(status_code=200, json=openai_api_response) @@ -575,7 +581,7 @@ def test_completion_forwards_allowed_tools_tool_choice_to_openai_compatible_chat model=model, messages=[{"role": "user", "content": "What time is it?"}], tools=_TWO_TOOLS, - tool_choice=_ALLOWED_TOOLS_TOOL_CHOICE, + tool_choice=tool_choice, api_base="http://localhost:12346/v1", api_key="fake_api_key", api_version="2025-04-01-preview", @@ -587,10 +593,10 @@ def test_completion_forwards_allowed_tools_tool_choice_to_openai_compatible_chat assert [tool["function"]["name"] for tool in sent["tools"]] == ["get_time", "get_weather"] -@pytest.mark.parametrize("drop_params", [False, True]) +@pytest.mark.parametrize("tool_choice", [_ALLOWED_TOOLS_TOOL_CHOICE, _RESPONSES_LAYOUT_ALLOWED_TOOLS_TOOL_CHOICE]) @pytest.mark.parametrize("model", ["anthropic/claude-sonnet-4-5", "gemini/gemini-2.5-flash", "bedrock/converse/x"]) def test_completion_allowed_tools_tool_choice_is_a_400_where_the_provider_cannot_enforce_it( - respx_mock: respx.MockRouter, model: str, drop_params: bool + respx_mock: respx.MockRouter, model: str, tool_choice: dict[str, object] ): with pytest.raises( litellm.BadRequestError, match=r"allowed_tools.*only supported for azure, litellm_proxy, openai" @@ -599,8 +605,7 @@ def test_completion_allowed_tools_tool_choice_is_a_400_where_the_provider_cannot model=model, messages=[{"role": "user", "content": "What time is it?"}], tools=_TWO_TOOLS, - tool_choice=_ALLOWED_TOOLS_TOOL_CHOICE, - drop_params=drop_params, + tool_choice=tool_choice, api_key="fake_api_key", aws_access_key_id="fake", aws_secret_access_key="fake", @@ -610,6 +615,64 @@ def test_completion_allowed_tools_tool_choice_is_a_400_where_the_provider_cannot assert not respx_mock.calls +@pytest.mark.parametrize( + ("model", "tool_choice"), + [ + ("hosted_vllm/my-model", _ALLOWED_TOOLS_TOOL_CHOICE), + ("hosted_vllm/my-model", _RESPONSES_LAYOUT_ALLOWED_TOOLS_TOOL_CHOICE), + ("openai/gpt-5.5", {"type": "allowed_tools", "mode": "required", "tools": []}), + ("openai/gpt-5.5", {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function"}]}), + ], +) +def test_completion_drop_params_drops_an_allowed_tools_tool_choice_it_cannot_send( + respx_mock: respx.MockRouter, openai_api_response: dict[str, object], model: str, tool_choice: dict[str, object] +): + mock_route: Final = respx_mock.post(url__regex=r"http://localhost:12346/.*chat/completions.*").mock( + return_value=httpx.Response(status_code=200, json=openai_api_response) + ) + + litellm.completion( + model=model, + messages=[{"role": "user", "content": "What time is it?"}], + tools=_TWO_TOOLS, + tool_choice=tool_choice, + drop_params=True, + api_base="http://localhost:12346/v1", + api_key="fake_api_key", + ) + + assert mock_route.called + sent: Final = json.loads(respx_mock.calls[0].request.content) + assert "tool_choice" not in sent + assert [tool["function"]["name"] for tool in sent["tools"]] == ["get_time", "get_weather"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "required", "tools": []}, + {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}, + {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}}, + ], +) +async def test_a_malformed_allowed_tools_tool_choice_is_a_400_on_mcp_gateway_tools_without_drop_params( + tool_choice: dict[str, object], respx_mock: respx.MockRouter +): + with pytest.raises(litellm.BadRequestError, match="Invalid tool choice") as exc: + await litellm.acompletion( + model="openai/gpt-5.5", + messages=[{"role": "user", "content": "What time is it?"}], + tools=[{"type": "mcp", "server_url": "litellm_proxy/mcp/demo", "require_approval": "never"}], + tool_choice=tool_choice, + api_base="http://localhost:12346/v1", + api_key="fake_api_key", + ) + + assert exc.value.status_code == 400 + assert not respx_mock.calls + + _HOSTED_TOOL_CHOICE: Final = {"type": "web_search"} _HOSTED_TOOLS: Final[list[dict[str, object]]] = [*_TWO_TOOLS, {"type": "web_search"}] @@ -673,6 +736,34 @@ def test_completion_hosted_tool_choice_is_a_400_where_the_provider_cannot_run_it assert not respx_mock.calls +def test_drop_params_drops_the_choice_beside_a_tool_the_provider_runs(respx_mock: respx.MockRouter): + route: Final = respx_mock.post("https://api.anthropic.com/v1/messages").mock( + return_value=httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "done"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + ) + + litellm.completion( + model="anthropic/claude-haiku-4-5", + messages=[{"role": "user", "content": "What time is it?"}], + tools=[{"type": "mcp", "server_label": "docs", "server_url": "https://mcp.example.com/sse"}], + tool_choice={"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "get_time"}]}, + drop_params=True, + api_key="sk-ant-fake", + ) + + assert "tool_choice" not in json.loads(route.calls[0].request.content) + + _CLAUDE_TOOL_SEARCH_CALL: Final = { "id": "srvtoolu_01search", "type": "function", diff --git a/tests/unit/test_tool_loop.py b/tests/unit/test_tool_loop.py index de8f62803cc..f56a88e8899 100644 --- a/tests/unit/test_tool_loop.py +++ b/tests/unit/test_tool_loop.py @@ -11,6 +11,7 @@ from litellm.types.llms.openai import ChatCompletionToolMessage from litellm.types.utils import ChatCompletionMessageToolCall OPENAI_CHAT_COMPLETIONS_URL: Final = "https://api.openai.com/v1/chat/completions" +ANTHROPIC_MESSAGES_URL: Final = "https://api.anthropic.com/v1/messages" WEATHER_TOOLS: Final = ( { "type": "function", @@ -248,6 +249,219 @@ def test_stream_rejected_before_any_request(respx_mock: respx.MockRouter) -> Non assert route.call_count == 0 +_ALLOWED_WEATHER_ONLY: Final = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "get_weather"}], +} +_EMPTY_ALLOWED_TOOLS: Final = {"type": "allowed_tools", "mode": "auto", "tools": []} +_TIME_CALL: Final = _tool_call("call_1", "get_time", {}) + + +def _anthropic_message(*content: dict[str, object]) -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "msg_tool_loop", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": list(content), + "stop_reason": "tool_use" if content[0]["type"] == "tool_use" else "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + +def _anthropic_tool_use(name: str) -> httpx.Response: + return _anthropic_message({"type": "tool_use", "id": "toolu_1", "name": name, "input": {}}) + + +def _tools_run_before_the_refusal( + tool_choice: dict[str, object], **completion_kwargs: object +) -> list[ChatCompletionMessageToolCall]: + executed: Final[list[ChatCompletionMessageToolCall]] = [] + + def execute(tool_call: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage: + executed.append(tool_call) + return _tool_result(tool_call) + + with pytest.raises(litellm.BadRequestError, match="LiteLLM did not run the call"): + litellm.run_tool_loop( + messages=[{"role": "user", "content": "hi"}], + tools=WEATHER_TOOLS, + execute_tool=execute, + tool_choice=tool_choice, + api_key="sk-test", + **completion_kwargs, + ) + return executed + + +def test_a_tool_the_dropped_choice_excludes_never_runs(respx_mock: respx.MockRouter) -> None: + respx_mock.post(ANTHROPIC_MESSAGES_URL).mock(return_value=_anthropic_tool_use("get_time")) + + assert not _tools_run_before_the_refusal( + _ALLOWED_WEATHER_ONLY, model="anthropic/claude-haiku-4-5", drop_params=True + ) + assert "tool_choice" not in _request_bodies(respx_mock)[0] + + +def test_a_tool_the_dropped_choice_lists_still_runs(respx_mock: respx.MockRouter) -> None: + respx_mock.post(ANTHROPIC_MESSAGES_URL).mock( + side_effect=[_anthropic_tool_use("get_weather"), _anthropic_message({"type": "text", "text": "done"})] + ) + + assert ( + litellm.run_tool_loop( + model="anthropic/claude-haiku-4-5", + messages=[{"role": "user", "content": "hi"}], + tools=WEATHER_TOOLS, + execute_tool=_tool_result, + tool_choice=_ALLOWED_WEATHER_ONLY, + drop_params=True, + api_key="sk-test", + ) + == "done" + ) + + +def test_a_tool_the_choice_excludes_never_runs_when_the_model_ignored_the_choice( + respx_mock: respx.MockRouter, +) -> None: + respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock( + return_value=httpx.Response(200, json=_openai_response(None, [_TIME_CALL])) + ) + + assert not _tools_run_before_the_refusal(_ALLOWED_WEATHER_ONLY, model="openai/gpt-5-mini") + + +@pytest.mark.parametrize( + "tool_choice", + [_EMPTY_ALLOWED_TOOLS, {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}], +) +def test_a_malformed_choice_is_refused_before_any_request( + tool_choice: dict[str, object], respx_mock: respx.MockRouter +) -> None: + with pytest.raises(litellm.BadRequestError, match="Invalid tool choice"): + litellm.run_tool_loop( + model="openai/gpt-5-mini", + messages=[{"role": "user", "content": "hi"}], + tools=WEATHER_TOOLS, + execute_tool=_tool_result, + tool_choice=tool_choice, + api_key="sk-test", + ) + + assert not respx_mock.calls + + +@pytest.mark.parametrize( + "tool_choice", + [_EMPTY_ALLOWED_TOOLS, {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}], +) +def test_a_malformed_choice_under_drop_params_runs_the_loop_as_if_never_sent( + tool_choice: dict[str, object], respx_mock: respx.MockRouter +) -> None: + respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock( + side_effect=[ + httpx.Response(200, json=_openai_response(None, [_TIME_CALL])), + httpx.Response(200, json=_openai_response("done")), + ] + ) + executed: Final[list[str | None]] = [] + + def execute(tool_call: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage: + executed.append(tool_call.function.name) + return _tool_result(tool_call) + + answer: Final = litellm.run_tool_loop( + model="openai/gpt-5-mini", + messages=[{"role": "user", "content": "hi"}], + tools=WEATHER_TOOLS, + execute_tool=execute, + tool_choice=tool_choice, + drop_params=True, + api_key="sk-test", + ) + + assert (answer, executed) == ("done", ["get_time"]) + assert all("tool_choice" not in body for body in _request_bodies(respx_mock)) + + +def test_a_choice_on_a_model_alias_reaches_the_loop( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "model_alias_map", {"the-alias": "openai/gpt-5-mini"}) + respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock( + return_value=httpx.Response(200, json=_openai_response("done")) + ) + + assert ( + litellm.run_tool_loop( + model="the-alias", + messages=[{"role": "user", "content": "hi"}], + tools=WEATHER_TOOLS, + execute_tool=_tool_result, + tool_choice=_ALLOWED_WEATHER_ONLY, + api_key="sk-test", + ) + == "done" + ) + + +@pytest.mark.asyncio +async def test_async_loop_never_runs_a_tool_the_choice_excludes( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "module_level_aclient", AsyncHTTPHandler()) + respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock( + return_value=httpx.Response(200, json=_openai_response(None, [_TIME_CALL])) + ) + executed: Final[list[ChatCompletionMessageToolCall]] = [] + + async def execute(tool_call: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage: + executed.append(tool_call) + return _tool_result(tool_call) + + with pytest.raises(litellm.BadRequestError, match="LiteLLM did not run the call"): + await litellm.arun_tool_loop( + model="openai/gpt-5-mini", + messages=[{"role": "user", "content": "hi"}], + tools=WEATHER_TOOLS, + execute_tool=execute, + tool_choice=_ALLOWED_WEATHER_ONLY, + drop_params=True, + api_key="sk-test", + ) + + assert not executed + + +def test_allowed_tools_choice_the_provider_enforces_reaches_it_every_round(respx_mock: respx.MockRouter) -> None: + respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock( + side_effect=[ + httpx.Response(200, json=_openai_response(None, [_tool_call("call_1", "get_weather", {"city": "Paris"})])), + httpx.Response(200, json=_openai_response("done")), + ] + ) + + answer: Final = litellm.run_tool_loop( + model="openai/gpt-5-mini", + messages=[{"role": "user", "content": "hi"}], + tools=WEATHER_TOOLS, + execute_tool=_tool_result, + tool_choice={"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "get_weather"}]}, + api_key="sk-test", + ) + + assert answer == "done" + assert [body["tool_choice"]["type"] for body in _request_bodies(respx_mock)] == ["allowed_tools", "allowed_tools"] + + def test_custom_tool_call_raises_type_error_without_executing(respx_mock: respx.MockRouter) -> None: custom_response: Final = _openai_response( None, @@ -481,7 +695,6 @@ async def test_arun_tool_loop_two_rounds(respx_mock: respx.MockRouter, monkeypat assert messages == messages_snapshot -ANTHROPIC_MESSAGES_URL: Final = "https://api.anthropic.com/v1/messages" TOOL_SEARCH_CALL: Final = { "type": "server_tool_use", "id": "srvtoolu_01Search", diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index da8c3c6a451..59c772f2993 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -8864,22 +8864,29 @@ def test_validate_tool_choice_forcing_a_hosted_tool_passes_through(tool_choice: @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.parametrize("mode", ["auto", "required"]) -def test_validate_tool_choice_allowed_tools_chat_layout_passes_through(mode: str): - tool_choice: Final = { +def test_validate_tool_choice_allowed_tools_either_layout_comes_back_in_the_chat_layout(mode: str): + chat_layout: Final = { "type": "allowed_tools", "allowed_tools": { "mode": mode, "tools": [{"type": "function", "function": {"name": "get_time"}}, {"type": "mcp", "server_label": "docs"}], }, } - assert validate_chat_completion_tool_choice(tool_choice, model=MODEL) is tool_choice + responses_layout: Final = { + "type": "allowed_tools", + "mode": mode, + "tools": [{"type": "function", "name": "get_time"}, {"type": "mcp", "server_label": "docs"}], + } + assert validate_chat_completion_tool_choice(chat_layout, model=MODEL) == chat_layout + assert validate_chat_completion_tool_choice(responses_layout, model=MODEL) == chat_layout @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.parametrize( "tool_choice", [ - {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function", "name": "get_time"}]}, + {"type": "allowed_tools", "mode": "required", "tools": [{"type": "function"}]}, + {"type": "allowed_tools", "mode": "required", "tools": []}, {"type": "allowed_tools"}, {"type": "allowed_tools", "allowed_tools": {"mode": "required"}}, {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": []}}, @@ -8896,6 +8903,18 @@ def test_validate_tool_choice_allowed_tools_malformed_is_a_400(tool_choice: dict assert '"mode": "auto" | "required"' in str(exc_info.value) +@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "allowed_tools", "mode": "required", "tools": []}, + {"type": "allowed_tools", "allowed_tools": {"mode": "none", "tools": [{"type": "function"}]}}, + ], +) +def test_validate_tool_choice_allowed_tools_malformed_is_dropped_under_drop_params(tool_choice: dict[str, object]): + assert validate_chat_completion_tool_choice(tool_choice, model=MODEL, drop_params=True) is None + + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.parametrize("tool_choice", [123, []]) def test_validate_tool_choice_invalid_type_is_a_400(tool_choice): diff --git a/tests/unit/types/test_litellm_params.py b/tests/unit/types/test_litellm_params.py index aae5dfe6472..6bedc240029 100644 --- a/tests/unit/types/test_litellm_params.py +++ b/tests/unit/types/test_litellm_params.py @@ -238,6 +238,7 @@ AGENTIC_LOOP_STATE_NAMES: Final = ( "_agentic_loop_depth", "_agentic_loop_fingerprints", "_agentic_loop_api_surface", + "_agentic_loop_tool_choice", "_code_interpreter_interception_active", "_code_interpreter_interception_sandbox_key", "_code_interpreter_interception_session_scoped", From 1d5e0c4be5d0cbbcef990242ae7e825daa114346 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:19:57 +0000 Subject: [PATCH 13/86] test(integration): keep owned peers off ephemeral ports (#51019) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/integration/README.md | 2 +- tests/integration/_support/asgi.py | 4 +++- tests/integration/_support/async_server.py | 4 +++- .../integration/_support/aws_control_plane.py | 4 +++- tests/integration/_support/device_login.py | 8 +++---- tests/integration/_support/forward_proxy.py | 6 ++++-- tests/integration/_support/idle_gateway.py | 4 +++- tests/integration/_support/mail.py | 4 +++- .../integration/_support/responses_ws_peer.py | 7 ++++++- tests/integration/_support/riva.py | 4 +++- tests/integration/_support/wire.py | 4 +++- tests/unit/integration_support/test_wire.py | 21 ++++++++++++++++++- 12 files changed, 56 insertions(+), 16 deletions(-) diff --git a/tests/integration/README.md b/tests/integration/README.md index c5d52922de6..a1d224749b3 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -28,7 +28,7 @@ Owned proxies disable `.env` loading by default. A contract testing dotenv behav Owned proxy workers configure a 32-thread executor before application startup, preserving additional worker startup hooks. Held synchronous provider calls must not consume a CPU-dependent executor limit before the rest of a fault burst can start. The proxy entrypoint imports LiteLLM inside `main`, so spawned workers begin supervision before importing the application. Worker healthchecks have a five-second deadline by default, so uvicorn kills a worker that stays paused longer than that. Tests never signal proxy processes themselves. `OwnedProxy.kill_worker` SIGKILLs a worker and returns once its replacement answers readiness with its own pid, `replacing_worker` does the same for a kill that has to land mid-burst, and `suspended` pauses a worker or the supervisor, which only works on a proxy launched with `healthcheck=WorkerHealthcheck.LENIENT`. The `kill()` that `replacing_worker` yields returns as soon as the worker is dead, so a test can release requests it holds on the surviving worker right away. Uvicorn may take a full worker healthcheck to notice the death, so the block waits for the supervisor to reap the worker and for the replacement only when it exits. Helper processes that are not the proxy stop through `stop_child`, `stop_processes` and `stop_process_group` and pause through `paused`. `scripts/check_test_quality.py` rule TQ010 rejects a raw `os.kill`, SIGKILL / SIGSTOP / SIGCONT, `.kill()`, `.terminate()`, `.suspend()` or `.send_signal()` anywhere under `tests/integration` outside `_support`, and TQ011 rejects an assertion that caps an elapsed time with a fixed number. Bound timings with `_support/timing.py` instead, relative to a measured control or to the injected delay. `run.py` measures how long this box takes to import the proxy application before it starts pytest, records it in `boot-calibration.json` beside the results and exports it as `INTEGRATION_IMPORT_SECONDS`. Readiness deadlines, graceful stops and the default per-test timeout are multiples of that measurement (`_support/budget.py`), so a slower box gets longer budgets and a two-worker proxy gets twice the readiness deadline of a one-worker one. Readiness requires responsive worker identities that are still alive and a positively identified owned listener. Unreadable socket records cannot establish ownership. Crash tests own their client tasks with `TaskGroup`, release held peers in `finally` and verify that the intended worker dies. A test requiring traffic on both workers prepares that traffic explicitly instead of assuming OS load balancing, for example by sending each half while the other worker is suspended -Any port the harness picks before the server that listens on it has started comes from `allocate_port()` in `_support/ports.py`. That covers owned proxies, scripted upstreams, fake providers, OTLP sinks, database relays and Redis. A server that binds port 0 itself and keeps that socket, such as `wire_server` without a port, needs no allocation. Each pytest process claims a block of 200 ports between 10000 and 32767 by holding an exclusive lock file per block in the system temp directory, so two runs on one machine, such as parallel worktrees, never share a block, and the OS drops the lock when the process exits. The range sits above the fixed service ports and below the Linux and macOS ephemeral ranges, so a port-0 bind or an outbound connection never lands in a block. Ports inside a block go out in order and are never handed out twice in a run, a port that something outside the harness already holds is skipped, and a process that uses up its block claims the next free one. A test that restarts a server on the same port allocates that port up front instead of reading it back from the first server. TQ012 in `scripts/check_test_quality.py` rejects reading a port back from a port-0 bind anywhere under `tests/integration` +Every owned listener's default port comes from `allocate_port()` in `_support/ports.py`. That covers owned proxies, scripted upstreams, fake providers, OTLP sinks, database relays and Redis, including peers such as `wire_server` that bind and keep their own sockets. Keeping those listeners out of the ephemeral range prevents them from taking a port Prisma selected with a bind-and-close probe before its query engine starts. Each pytest process claims a block of 200 ports between 10000 and 32767 by holding an exclusive lock file per block in the system temp directory, so two runs on one machine, such as parallel worktrees, never share a block, and the OS drops the lock when the process exits. The range sits above the fixed service ports and below the Linux and macOS ephemeral ranges, so a port-0 bind or an outbound connection never lands in a block. Ports inside a block go out in order and are never handed out twice in a run, a port that something outside the harness already holds is skipped, and a process that uses up its block claims the next free one. A test that restarts a server on the same port allocates that port up front instead of reading it back from the first server. TQ012 in `scripts/check_test_quality.py` rejects reading a port back from a port-0 bind anywhere under `tests/integration` The CircleCI workflow starts its own database and Redis, restricts test-phase egress to its owned services and writes JUnit plus an executed-node manifest. Missing setup, failed cleanup or a selected test with neither a passed call nor a skip fail qualification. Skipped nodes are listed under `skipped` in `execution.json`, so the skip reasons double as the open bug list. `GITHUB_FILES` in `run.py` lists the files excluded from the `run_integration.sh` selection diff --git a/tests/integration/_support/asgi.py b/tests/integration/_support/asgi.py index 30eb9f21760..4dcf3614836 100644 --- a/tests/integration/_support/asgi.py +++ b/tests/integration/_support/asgi.py @@ -12,13 +12,15 @@ from typing import Final import uvicorn from starlette.types import ASGIApp +from tests.integration._support.ports import allocate_port + @contextmanager def asgi_server( app: ASGIApp, *, before_stop: Callable[[], None] | None = None, port: int = 0, keepalive_seconds: int = 30 ) -> Iterator[str]: with socket.socket() as listener: - listener.bind(("127.0.0.1", port)) + listener.bind(("127.0.0.1", port or allocate_port())) bound_port: Final = listener.getsockname()[1] server: Final = uvicorn.Server( uvicorn.Config( diff --git a/tests/integration/_support/async_server.py b/tests/integration/_support/async_server.py index 53dcbc8d92d..73851b1b4a6 100644 --- a/tests/integration/_support/async_server.py +++ b/tests/integration/_support/async_server.py @@ -7,6 +7,8 @@ from contextlib import suppress from threading import Thread from typing import Final +from tests.integration._support.ports import allocate_port + Handler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]] @@ -19,7 +21,7 @@ async def close_writer(writer: asyncio.StreamWriter) -> None: class LoopbackServer: def __init__(self, port: int, handler: Handler, ssl_context: ssl.SSLContext | None = None) -> None: self.loop: Final = asyncio.new_event_loop() - self._port: Final = port + self._port: Final = port or allocate_port() self._handler: Final = handler self._ssl_context: Final = ssl_context self._server: asyncio.Server | None = None diff --git a/tests/integration/_support/aws_control_plane.py b/tests/integration/_support/aws_control_plane.py index 8cef78f8bee..1c984cdf03e 100644 --- a/tests/integration/_support/aws_control_plane.py +++ b/tests/integration/_support/aws_control_plane.py @@ -18,6 +18,8 @@ from integration._support.sigv4 import canonical_query, signature from integration._support.wire import Reply from pydantic import JsonValue +from tests.integration._support.ports import allocate_port + _AUTHORIZATION: Final = re.compile( r"^AWS4-HMAC-SHA256 Credential=(?P[^/]+)/(?P[^,]+), " r"SignedHeaders=(?P[^,]+), Signature=(?P[0-9a-f]{64})$" @@ -267,7 +269,7 @@ def control_plane(directory: Path, hosts: tuple[str, ...]) -> Generator[ControlP block_on_close = False request_queue_size = 128 - with Server(("127.0.0.1", 0), Proxy) as server: + with Server(("127.0.0.1", allocate_port()), Proxy) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() try: diff --git a/tests/integration/_support/device_login.py b/tests/integration/_support/device_login.py index 13a7169a01a..349021a99e9 100644 --- a/tests/integration/_support/device_login.py +++ b/tests/integration/_support/device_login.py @@ -22,6 +22,8 @@ from integration._support.tls import server_context, write_self_signed_cert from integration._support.wire import Reply, Request, Wire, wire_server from pydantic import JsonValue +from tests.integration._support.ports import allocate_port + CHATGPT_AUTH_HOST: Final = "auth.openai.com" GITHUB_HOST: Final = "github.com" GITHUB_API_HOST: Final = "api.github.com" @@ -366,7 +368,7 @@ def _tunnel( _pipe(upstream, self.request) outbound.join(timeout=12) - with _TunnelServer(("127.0.0.1", 0), Tunnel) as server: + with _TunnelServer(("127.0.0.1", allocate_port()), Tunnel) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() try: @@ -378,9 +380,7 @@ def _tunnel( @contextmanager -def device_login_peers( - directory: Path, provider: Callable[[Request], Reply] | None = None -) -> Generator[Peers]: +def device_login_peers(directory: Path, provider: Callable[[Request], Reply] | None = None) -> Generator[Peers]: cert, key = write_self_signed_cert(directory, (*AUTH_HOSTS, COPILOT_API_HOST)) switches: Final = Switches(threading.Event(), threading.Event(), threading.Event(), threading.Event()) authorities: Final[SimpleQueue[str]] = SimpleQueue() diff --git a/tests/integration/_support/forward_proxy.py b/tests/integration/_support/forward_proxy.py index 0d92c3d0719..98c440c3f6c 100644 --- a/tests/integration/_support/forward_proxy.py +++ b/tests/integration/_support/forward_proxy.py @@ -10,6 +10,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from queue import SimpleQueue from typing import Final +from tests.integration._support.ports import allocate_port + @dataclass(frozen=True, slots=True) class Tunnel: @@ -61,7 +63,7 @@ def refusing_forward_proxy(port: int = 0) -> Generator[ForwardProxy, None, None] def log_message(self, format: str, *args: object) -> None: pass - with ThreadingHTTPServer(("127.0.0.1", port), Handler) as server: + with ThreadingHTTPServer(("127.0.0.1", port or allocate_port()), Handler) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() try: @@ -105,7 +107,7 @@ def tunnelling_forward_proxy(destination_port: int, port: int = 0) -> Generator[ def log_message(self, format: str, *args: object) -> None: pass - with ThreadingHTTPServer(("127.0.0.1", port), Handler) as server: + with ThreadingHTTPServer(("127.0.0.1", port or allocate_port()), Handler) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() try: diff --git a/tests/integration/_support/idle_gateway.py b/tests/integration/_support/idle_gateway.py index 5a86d7e0a35..42cbf385174 100644 --- a/tests/integration/_support/idle_gateway.py +++ b/tests/integration/_support/idle_gateway.py @@ -18,6 +18,8 @@ from typing import Final from integration._support.wire import Reply, Request +from tests.integration._support.ports import allocate_port + MARKER: Final = re.compile(rb"idle-[0-9a-f]{16}") _LOOPBACK: Final = "127.0.0.1" @@ -193,7 +195,7 @@ def idle_gateway( port: int = 0, ) -> Generator[IdleGateway]: arrivals: Final[SimpleQueue[Arrival]] = SimpleQueue() - with _Server(port, respond, drop_after, drop, arrivals) as server: + with _Server(port or allocate_port(), respond, drop_after, drop, arrivals) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() try: diff --git a/tests/integration/_support/mail.py b/tests/integration/_support/mail.py index 3894baeccc3..08562d9a8c3 100644 --- a/tests/integration/_support/mail.py +++ b/tests/integration/_support/mail.py @@ -10,6 +10,8 @@ from email.message import Message from queue import SimpleQueue from typing import Final +from tests.integration._support.ports import allocate_port + @dataclass(frozen=True, slots=True) class Delivery: @@ -112,7 +114,7 @@ def smtp_sink() -> Generator[Mailbox, None, None]: allow_reuse_address = True daemon_threads = False - with OwnedServer(("127.0.0.1", 0), Handler) as server: + with OwnedServer(("127.0.0.1", allocate_port()), Handler) as server: mailbox: Final = Mailbox("127.0.0.1", server.server_address[1]) thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() diff --git a/tests/integration/_support/responses_ws_peer.py b/tests/integration/_support/responses_ws_peer.py index 1bf3e1fcbae..fb02c10f39d 100644 --- a/tests/integration/_support/responses_ws_peer.py +++ b/tests/integration/_support/responses_ws_peer.py @@ -21,6 +21,8 @@ from integration._support.tls import server_context from pydantic import JsonValue from websockets.asyncio.server import ServerConnection, serve +from tests.integration._support.ports import allocate_port + PROVIDER_MODEL: Final = "ws-peer-model" USAGE: Final = {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7} TERMINAL: Final = frozenset({"response.completed", "response.failed", "error"}) @@ -105,7 +107,10 @@ async def _serve( script: Script, ) -> None: async with serve( - lambda connection: _answer(connection, paths, credentials, frames, script), "127.0.0.1", 0, ssl=tls + lambda connection: _answer(connection, paths, credentials, frames, script), + "127.0.0.1", + allocate_port(), + ssl=tls, ) as server: ports.put(next(iter(server.sockets)).getsockname()[1]) await stop.wait() diff --git a/tests/integration/_support/riva.py b/tests/integration/_support/riva.py index 438d877386b..f50c28b705d 100644 --- a/tests/integration/_support/riva.py +++ b/tests/integration/_support/riva.py @@ -15,6 +15,8 @@ from typing import Final import grpc from riva.client.proto import riva_asr_pb2, riva_asr_pb2_grpc +from tests.integration._support.ports import allocate_port + SAMPLE_RATE_HZ: Final = 16000 OFFLINE_ONLY_REFUSAL: Final = ( "Error: Unavailable model requested given these parameters: language_code=en-US; sample_rate=16000; type=online;" @@ -139,7 +141,7 @@ def riva_server( options: Final = () if max_receive_bytes is None else (("grpc.max_receive_message_length", max_receive_bytes),) server: Final = grpc.server(futures.ThreadPoolExecutor(max_workers=8), options=options) riva_asr_pb2_grpc.add_RivaSpeechRecognitionServicer_to_server(_Asr(words, offline_only, stall, calls), server) - port: Final = server.add_insecure_port("127.0.0.1:0") + port: Final = server.add_insecure_port(f"127.0.0.1:{allocate_port()}") server.start() try: yield RivaServer(f"127.0.0.1:{port}", calls) diff --git a/tests/integration/_support/wire.py b/tests/integration/_support/wire.py index d4cbccfc43e..343430db4a6 100644 --- a/tests/integration/_support/wire.py +++ b/tests/integration/_support/wire.py @@ -15,6 +15,8 @@ from queue import SimpleQueue from types import MappingProxyType from typing import TYPE_CHECKING, BinaryIO, Final +from tests.integration._support.ports import allocate_port + if TYPE_CHECKING: from collections.abc import Buffer @@ -334,7 +336,7 @@ def wire_server( if tls is not None: self.socket = tls.wrap_socket(self.socket, server_side=True) - with OwnedHTTPServer(("127.0.0.1", port), Handler) as server: + with OwnedHTTPServer(("127.0.0.1", port or allocate_port()), Handler) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": _POLL_SECONDS}) thread.start() try: diff --git a/tests/unit/integration_support/test_wire.py b/tests/unit/integration_support/test_wire.py index c786895720f..7dd168eb212 100644 --- a/tests/unit/integration_support/test_wire.py +++ b/tests/unit/integration_support/test_wire.py @@ -7,7 +7,7 @@ import ssl import time from collections.abc import Callable, Iterator from concurrent.futures import ThreadPoolExecutor -from contextlib import ExitStack +from contextlib import ExitStack, closing from http.client import HTTPConnection, HTTPResponse, HTTPSConnection from pathlib import Path from typing import Final @@ -19,6 +19,7 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID +from tests.integration._support.ports import FIRST_PORT, LAST_PORT, allocate_port from tests.integration._support.wire import Reply, Request, wire_server PAUSE_SECONDS: Final = 6 @@ -28,6 +29,24 @@ LATE: Final = b'{"call": "late"}' UNREAD: Final = b"x" * (32 * 1024 * 1024) +@pytest.mark.parametrize("port", (None, 0), ids=("default", "zero")) +def test_default_listener_uses_the_harness_port_range(port: int | None) -> None: + with wire_server(_echo, **({} if port is None else {"port": port})) as wire: + bound_port: Final = urlsplit(wire.url).port + assert bound_port is not None and FIRST_PORT <= bound_port <= LAST_PORT + with closing(_client(wire.url)) as connection: + connection.request("POST", "/echo", FIRST) + response: Final = connection.getresponse() + assert (response.status, response.read()) == (200, FIRST) + + +def test_explicit_listener_port_is_preserved() -> None: + port: Final = allocate_port() + with wire_server(_echo, port=port) as wire: + assert urlsplit(wire.url).port == port + assert wire.drain() == () + + def _self_signed_server_context(directory: Path) -> ssl.SSLContext: key: Final = ec.generate_private_key(ec.SECP256R1()) name: Final = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "wire peer")]) From 845f10952405dce252e0ce98d5fc96ebd825009d Mon Sep 17 00:00:00 2001 From: norkelbyte <348963847+norkelbyte@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:28:49 -0800 Subject: [PATCH 14/86] fix(bedrock): forward x-pass- headers on the /bedrock/model pass-through routes (#50728) * fix(bedrock): forward x-pass- headers on the /bedrock/model pass-through routes The /bedrock/model/{model}/invoke, invoke-with-response-stream, converse and converse-stream routes built the pass-through call from the request body, so x-pass- headers never reached Bedrock. The processor now hands the caller's x-pass- headers to the pass-through route, keeps them out of the logged body snapshot, and on Converse drops the invoke-only guardrail headers when the deployment sets guardrailConfig and the request metadata header when the proxy owns request metadata * fix(bedrock): bill the served service tier on invoke pass-through and scope x-pass- forwarding to the Bedrock routes * test(bedrock): assert forwarded x-pass- headers on the request Bedrock receives * test(bedrock): gate the router-model x-pass- forwarding on the request Bedrock receives * fix(bedrock): bill a caller's forwarded invoke guardrail and keep x-pass- headers out of guardrail vendor payloads * fix(bedrock): bill a caller guardrail on every invoke model and replace a trace value Bedrock rejects * fix(bedrock): keep guardrail traces out of chat responses, forward the trace header as sent, and drop credential x-pass- headers --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../base_llm/passthrough/transformation.py | 9 +- .../bedrock/passthrough/transformation.py | 95 +++- litellm/passthrough/main.py | 16 +- litellm/passthrough/utils.py | 34 +- litellm/proxy/litellm_pre_call_utils.py | 4 + .../llm_passthrough_endpoints.py | 9 + ...drock_passthrough_guardrail_config_wire.py | 26 +- ...drock_passthrough_request_metadata_wire.py | 61 ++- .../test_base_invoke_transformation.py | 136 ++++++ ...test_bedrock_passthrough_transformation.py | 444 +++++++++++++++++- tests/unit/passthrough/test_utils.py | 78 +++ .../test_llm_pass_through_endpoints.py | 180 +++++++ .../unit/proxy/test_litellm_pre_call_utils.py | 32 ++ 13 files changed, 1081 insertions(+), 43 deletions(-) create mode 100644 tests/unit/passthrough/test_utils.py diff --git a/litellm/llms/base_llm/passthrough/transformation.py b/litellm/llms/base_llm/passthrough/transformation.py index 7e58cc6e71b..898033bff8f 100644 --- a/litellm/llms/base_llm/passthrough/transformation.py +++ b/litellm/llms/base_llm/passthrough/transformation.py @@ -4,6 +4,7 @@ import re from abc import abstractmethod from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass +from types import MappingProxyType from typing import TYPE_CHECKING, Final, Protocol, TypeAlias from pydantic import ConfigDict, TypeAdapter, ValidationError @@ -29,6 +30,7 @@ if TYPE_CHECKING: RELAYED_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object]) _DECODED_JSON: Final = TypeAdapter(object) +_NO_RESPONSE_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) class PassthroughMetadata(LiteLLMBaseModel): @@ -236,7 +238,12 @@ class BasePassthroughConfig(BaseLLMModelInfo): return None def create_stream_collector( - self, model: str, custom_llm_provider: str, endpoint: str + self, + model: str, + custom_llm_provider: str, + endpoint: str, + *, + response_headers: Mapping[str, str] = _NO_RESPONSE_HEADERS, ) -> PassthroughStreamCollector: return RawBytesStreamCollector( provider_config=self, model=model, custom_llm_provider=custom_llm_provider, endpoint=endpoint diff --git a/litellm/llms/bedrock/passthrough/transformation.py b/litellm/llms/bedrock/passthrough/transformation.py index 4a2bb57ce5a..901c16afaa7 100644 --- a/litellm/llms/bedrock/passthrough/transformation.py +++ b/litellm/llms/bedrock/passthrough/transformation.py @@ -3,16 +3,21 @@ from collections.abc import Callable, Iterator, Mapping, Sequence from functools import partial from itertools import chain from types import MappingProxyType -from typing import TYPE_CHECKING, ClassVar, Final, Optional, cast +from typing import TYPE_CHECKING, ClassVar, Final, Optional, TypeVar, cast import httpx from httpx import Response -from pydantic import TypeAdapter +from pydantic import BaseModel, TypeAdapter from litellm._logging import verbose_logger from litellm.constants import AWS_ERROR_TYPE_HEADER from litellm.litellm_core_utils.litellm_logging import Logging -from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig, PassthroughStreamCollector +from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import object_mapping +from litellm.llms.base_llm.passthrough.transformation import ( + BasePassthroughConfig, + PassthroughStreamCollector, + relayed_json_object, +) from litellm.llms.bedrock.chat.converse_to_openai import as_json_array, as_json_object from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import ( bedrock_invoke_guardrail_headers, @@ -21,7 +26,12 @@ from litellm.types.llms.openai import ChatCompletionSystemMessage, ChatCompletio from litellm.types.utils import ModelResponseStream from ..base_aws_llm import BaseAWSLLM -from ..common_utils import BedrockError, BedrockEventStreamDecoderBase, BedrockModelInfo +from ..common_utils import ( + BEDROCK_INVOKE_TRACE_FIELD, + BedrockError, + BedrockEventStreamDecoderBase, + BedrockModelInfo, +) from ..request_metadata import ( BEDROCK_REQUEST_METADATA_HEADER, BedrockRequestMetadataViolation, @@ -49,6 +59,9 @@ _BEDROCK_INVOKE_ACTIONS: Final = frozenset({"invoke", "invoke-with-response-stre _BEDROCK_INVOKE_GUARDRAIL_HEADERS: Final = frozenset( {"x-amzn-bedrock-guardrailidentifier", "x-amzn-bedrock-guardrailversion", "x-amzn-bedrock-trace"} ) +_BEDROCK_SERVICE_TIER_HEADER: Final = "x-amzn-bedrock-service-tier" +_NO_RESPONSE_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) +_TieredResponse: Final = TypeVar("_TieredResponse", bound=BaseModel) def _bedrock_endpoint_action(endpoint: str) -> str: @@ -111,7 +124,9 @@ def _with_request_metadata( litellm_params: Mapping[str, object], ) -> tuple[Mapping[str, str], Mapping[str, object]]: if action in _BEDROCK_CONVERSE_ACTIONS: - return headers, _with_body_request_metadata(action, body, litellm_params) + return _without_owned_request_metadata_header(headers), _with_body_request_metadata( + action, body, litellm_params + ) if action in _BEDROCK_INVOKE_ACTIONS and "requestMetadata" in body: return _without_owned_request_metadata_header(headers), _with_body_request_metadata( action, body, litellm_params @@ -137,6 +152,10 @@ def _converse_guardrail_config(action: str, guardrail_config: object) -> object: return {key: value for key, value in fields.items() if key != "streamProcessingMode"} +def _without_invoke_guardrail_headers(headers: Mapping[str, str]) -> Mapping[str, str]: + return {name: value for name, value in headers.items() if name.lower() not in _BEDROCK_INVOKE_GUARDRAIL_HEADERS} + + def _with_deployment_guardrail( action: str, headers: Mapping[str, str], @@ -146,7 +165,10 @@ def _with_deployment_guardrail( if guardrail_config is None: return headers, body if action in _BEDROCK_CONVERSE_ACTIONS: - return headers, {**body, "guardrailConfig": _converse_guardrail_config(action, guardrail_config)} + return _without_invoke_guardrail_headers(headers), { + **body, + "guardrailConfig": _converse_guardrail_config(action, guardrail_config), + } if action in _BEDROCK_INVOKE_ACTIONS: guardrail_headers: Final = tuple(bedrock_invoke_guardrail_headers(guardrail_config).items()) return merge_bedrock_invoke_headers( @@ -266,6 +288,19 @@ class _CoalescedChunks: return self._chunks +_BEDROCK_EVENT: Final = TypeAdapter(dict[str, object]) + + +def _with_invoke_trace(chunk: ModelResponseStream, trace: Mapping[str, object] | None) -> ModelResponseStream: + if trace is None: + return chunk + return chunk.model_copy( + update={ + "provider_specific_fields": {**(chunk.provider_specific_fields or {}), BEDROCK_INVOKE_TRACE_FIELD: trace} + } + ) + + def _translate_message(decoder: "AWSEventStreamDecoder", message: str) -> ModelResponseStream | None: from litellm.litellm_core_utils.streaming_handler import ( convert_generic_chunk_to_model_response_stream, @@ -273,11 +308,15 @@ def _translate_message(decoder: "AWSEventStreamDecoder", message: str) -> ModelR ) from litellm.types.utils import GenericStreamingChunk - translated_chunk: Final = decoder.chunk_parser(chunk_data=json.loads(message)) + event: Final = _BEDROCK_EVENT.validate_json(message) + trace: Final = object_mapping(event.get(BEDROCK_INVOKE_TRACE_FIELD)) + translated_chunk: Final = decoder.chunk_parser(chunk_data=event) if isinstance(translated_chunk, ModelResponseStream): - return translated_chunk + return _with_invoke_trace(translated_chunk, trace) if generic_chunk_has_all_required_fields(cast(dict, translated_chunk)): - return convert_generic_chunk_to_model_response_stream(cast(GenericStreamingChunk, translated_chunk)) + return _with_invoke_trace( + convert_generic_chunk_to_model_response_stream(cast(GenericStreamingChunk, translated_chunk)), trace + ) return None @@ -332,15 +371,33 @@ def _count_prompt_tokens(model: str, request_body: object) -> int: return token_counter(model=model, messages=messages) +def _invoke_guardrail_trace(httpx_response: Response) -> Mapping[str, object]: + reply: Final = relayed_json_object(httpx_response) or {} + return {BEDROCK_INVOKE_TRACE_FIELD: reply.get(BEDROCK_INVOKE_TRACE_FIELD)} + + +def _served_service_tier(response_headers: Mapping[str, str]) -> str | None: + headers: Final = httpx.Headers(response_headers) + if _BEDROCK_SERVICE_TIER_HEADER not in headers: + return None + return headers[_BEDROCK_SERVICE_TIER_HEADER] + + +def _with_served_service_tier(response: _TieredResponse, service_tier: str | None) -> _TieredResponse: + if service_tier is None: + return response + return response.model_copy(update={"service_tier": service_tier}) + + def _build_logged_response( - chunks: Sequence[ModelResponseStream], litellm_logging_obj: "LiteLLMLoggingObj" + chunks: Sequence[ModelResponseStream], litellm_logging_obj: "LiteLLMLoggingObj", service_tier: str | None ) -> Optional["CostResponseTypes"]: from litellm.litellm_core_utils.streaming_chunk_builder_utils import stream_chunk_builder if len(chunks) == 0: return None return stream_chunk_builder( - chunks=list(chunks), + chunks=[*chunks[:-1], _with_served_service_tier(chunks[-1], service_tier)], logging_obj=litellm_logging_obj, count_prompt_tokens=partial( _count_prompt_tokens, litellm_logging_obj.model, litellm_logging_obj.model_call_details.get("input") @@ -355,11 +412,13 @@ class BedrockEventStreamCollector: self, parse_event: Callable[["EventStreamMessage"], str | None], decoder: Optional["AWSEventStreamDecoder"], + service_tier: str | None = None, ) -> None: from botocore.eventstream import EventStreamBuffer self._parse_event = parse_event self._decoder = decoder + self._service_tier: Final = service_tier self._event_stream_buffer: Final[EventStreamBuffer] = EventStreamBuffer() self._chunks: Final = _CoalescedChunks() @@ -377,7 +436,7 @@ class BedrockEventStreamCollector: self._chunks.add(translated) def build_logged_response(self, litellm_logging_obj: "LiteLLMLoggingObj") -> Optional["CostResponseTypes"]: - return _build_logged_response(self._chunks.chunks(), litellm_logging_obj) + return _build_logged_response(self._chunks.chunks(), litellm_logging_obj, self._service_tier) class BedrockAgentRuntimeAWS(BaseAWSLLM): @@ -542,15 +601,23 @@ class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamD request_data=request_data, encoding=encoding, ) + if "invoke" in endpoint: + logging_obj.record_native_bedrock_guardrail(_invoke_guardrail_trace(httpx_response)) - return litellm_model_response + return _with_served_service_tier(litellm_model_response, _served_service_tier(httpx_response.headers)) def create_stream_collector( - self, model: str, custom_llm_provider: str, endpoint: str + self, + model: str, + custom_llm_provider: str, + endpoint: str, + *, + response_headers: Mapping[str, str] = _NO_RESPONSE_HEADERS, ) -> PassthroughStreamCollector: return BedrockEventStreamCollector( parse_event=self._parse_message_from_event, decoder=self._get_event_stream_decoder(model=model, endpoint=endpoint), + service_tier=_served_service_tier(response_headers), ) def _get_event_stream_decoder(self, model: str, endpoint: str) -> Optional["AWSEventStreamDecoder"]: diff --git a/litellm/passthrough/main.py b/litellm/passthrough/main.py index 4e94b1bb910..77b2cf9b5b5 100644 --- a/litellm/passthrough/main.py +++ b/litellm/passthrough/main.py @@ -57,11 +57,17 @@ def _as_generator(iterable: Iterator[bytes]) -> Generator[bytes, bytes, None]: class _SpendCollection: """Feeds relayed chunks to the provider's stream collector without letting spend tracking break the relay.""" - def __init__(self, provider_config: BasePassthroughConfig, litellm_logging_obj: LiteLLMLoggingObj) -> None: + def __init__( + self, + provider_config: BasePassthroughConfig, + litellm_logging_obj: LiteLLMLoggingObj, + response_headers: httpx.Headers, + ) -> None: self.collector: Final[PassthroughStreamCollector] = provider_config.create_stream_collector( model=litellm_logging_obj.model, custom_llm_provider=litellm_logging_obj.model_call_details.get("custom_llm_provider", ""), endpoint=litellm_logging_obj.model_call_details.get("endpoint", ""), + response_headers=response_headers, ) self.chunk_count = 0 self._failed = False @@ -97,7 +103,8 @@ class AsyncPassthroughStreamingResponse(AsyncGenerator[bytes, bytes]): self._response: httpx.Response self._iterator: AsyncGenerator[bytes, bytes] self._litellm_logging_obj = litellm_logging_obj - self._spend = _SpendCollection(provider_config, litellm_logging_obj) + self._provider_config: Final = provider_config + self._spend: _SpendCollection self._flush_scheduled = False self._background_tasks: set[asyncio.Task[bool]] = set() # mutable-ok: instance set for background task tracking self._flush: asyncio.Task[bool] | None = None @@ -129,6 +136,7 @@ class AsyncPassthroughStreamingResponse(AsyncGenerator[bytes, bytes]): self._response = await self._response_coro self.headers = self._response.headers self.status_code = self._response.status_code + self._spend = _SpendCollection(self._provider_config, self._litellm_logging_obj, self._response.headers) self._initialized = True try: raise_for_provider_status(self._response) @@ -148,7 +156,7 @@ class AsyncPassthroughStreamingResponse(AsyncGenerator[bytes, bytes]): return _init().__await__() def _start_flush(self) -> None: - if self._flush_scheduled or not self._spend.should_flush: + if self._flush_scheduled or not self._initialized or not self._spend.should_flush: return self._flush_scheduled = True @@ -237,7 +245,7 @@ class PassthroughStreamingResponse(Generator[bytes, bytes, None]): self.status_code = response.status_code self._litellm_logging_obj = litellm_logging_obj self._iterator: Generator[bytes, bytes, None] = _as_generator(response.iter_bytes()) - self._spend = _SpendCollection(provider_config, litellm_logging_obj) + self._spend = _SpendCollection(provider_config, litellm_logging_obj, response.headers) self._flush_scheduled = False def _start_flush(self) -> None: diff --git a/litellm/passthrough/utils.py b/litellm/passthrough/utils.py index 452c9c7de9d..1bf5f08b1c1 100644 --- a/litellm/passthrough/utils.py +++ b/litellm/passthrough/utils.py @@ -25,7 +25,16 @@ _PASS_THROUGH_PROTECTED_HEADERS: Final[frozenset] = frozenset( ) # Header name prefix used to block AWS SigV4 signing headers from being overridden. -_PASS_THROUGH_PROTECTED_HEADER_PREFIXES: Final[tuple] = ("x-amz-",) +_PASS_THROUGH_PROTECTED_HEADER_PREFIXES: Final[tuple[str, ...]] = ("x-amz-",) + + +def _is_forwarded_pass_through_header(header_name: str) -> bool: + if not header_name.lower().startswith(PASS_THROUGH_HEADER_PREFIX): + return False + forwarded_name: Final = header_name[len(PASS_THROUGH_HEADER_PREFIX) :].lower() + return forwarded_name not in _PASS_THROUGH_PROTECTED_HEADERS and not any( + forwarded_name.startswith(prefix) for prefix in _PASS_THROUGH_PROTECTED_HEADER_PREFIXES + ) class BasePassthroughUtils: @@ -42,18 +51,11 @@ class BasePassthroughUtils: # parse_qs returns a dict where each value is a list, so let's flatten it updated_existing_query_params: Final = {k: v[0] if len(v) == 1 else v for k, v in existing_query_params.items()} - # Start with default query params (lowest priority) - merged_params: Final = {} - if default_query_params: - merged_params.update(default_query_params) + return {**(default_query_params or {}), **updated_existing_query_params, **request_query_params} - # Override with existing URL query params (medium priority) - merged_params.update(updated_existing_query_params) - - # Override with request query params (highest priority - client can override anything) - merged_params.update(request_query_params) - - return merged_params + @staticmethod + def pass_through_prefixed_headers(request_headers: Mapping[str, str]) -> Mapping[str, str]: + return {name: value for name, value in request_headers.items() if _is_forwarded_pass_through_header(name)} @staticmethod def forward_headers_from_request( @@ -88,16 +90,14 @@ class BasePassthroughUtils: # Credential and protocol-level headers are excluded from this mechanism. for header_name, header_value in request_headers.items(): if header_name.lower().startswith(PASS_THROUGH_HEADER_PREFIX): - # Strip the 'x-pass-' prefix and normalize to lowercase - actual_header_name = header_name[len(PASS_THROUGH_HEADER_PREFIX) :].lower() - if actual_header_name in _PASS_THROUGH_PROTECTED_HEADERS or any( - actual_header_name.startswith(p) for p in _PASS_THROUGH_PROTECTED_HEADER_PREFIXES - ): + if not _is_forwarded_pass_through_header(header_name): verbose_logger.debug( "x-pass- header %s maps to a protected header name; skipping", header_name, ) continue + # Strip the 'x-pass-' prefix and normalize to lowercase + actual_header_name = header_name[len(PASS_THROUGH_HEADER_PREFIX) :].lower() headers[actual_header_name] = header_value return headers diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 22399ced718..d5eac099fb6 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1656,6 +1656,10 @@ def redact_credential_headers(headers: Mapping[str, str]) -> Mapping[str, str]: } +def without_credential_headers(headers: Mapping[str, str]) -> Mapping[str, str]: + return {header: value for header, value in headers.items() if not _is_credential_header(header)} + + class TenantLoggingMetadata(TypedDict): user_api_key_metadata: ReadOnly[Mapping[str, object] | None] user_api_key_team_metadata: ReadOnly[Mapping[str, object] | None] diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 6ab31e7d4b0..a2e2e36d458 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -86,6 +86,7 @@ from litellm.llms.oss_decision import OssDecisionProvider, oss_connection, valid from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.llms.vllm.common_utils import endpoint_under_api_base from litellm.passthrough.main import AsyncPassthroughStreamingResponse, is_provider_error +from litellm.passthrough.utils import BasePassthroughUtils from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import ( can_agent_call_model, @@ -1423,6 +1424,12 @@ async def bedrock_passthrough_error_response( raise +def _bedrock_pass_through_headers(request: Request) -> Mapping[str, str]: + from litellm.proxy.litellm_pre_call_utils import without_credential_headers + + return without_credential_headers(BasePassthroughUtils.pass_through_prefixed_headers(request.headers)) + + async def handle_bedrock_passthrough_router_model( model: str, endpoint: str, @@ -1478,6 +1485,7 @@ async def handle_bedrock_passthrough_router_model( "endpoint": endpoint, "data": request_body, "custom_llm_provider": "bedrock", + "request_headers": _bedrock_pass_through_headers(request), } data: Final[dict[str, object]] = guardrail_request_data_with_streaming( MappingProxyType(bedrock_payload), @@ -1684,6 +1692,7 @@ async def bedrock_llm_proxy_route( "endpoint": endpoint, "data": request_body, "custom_llm_provider": "bedrock", + "request_headers": _bedrock_pass_through_headers(request), } data: Final[dict[str, object]] = guardrail_request_data_with_streaming( MappingProxyType(passthrough_payload), diff --git a/tests/integration/providers/test_bedrock_passthrough_guardrail_config_wire.py b/tests/integration/providers/test_bedrock_passthrough_guardrail_config_wire.py index f995a7ac83f..d630fa00138 100644 --- a/tests/integration/providers/test_bedrock_passthrough_guardrail_config_wire.py +++ b/tests/integration/providers/test_bedrock_passthrough_guardrail_config_wire.py @@ -404,6 +404,13 @@ def _expected_on_the_wire(action: str, request: Request) -> Applied: return Applied("deployment-guardrail", "3", "ENABLED", None) +def _caller_guardrail_on_the_wire(request: Request) -> Applied: + assert {"x-amzn-bedrock-guardrailidentifier", "x-amzn-bedrock-guardrailversion", "x-amzn-bedrock-trace"} <= ( + _signed_header_names(request) + ), request.headers["authorization"] + return Applied("caller-guardrail", "9", _CALLER_INVOKE_HEADERS["x-pass-x-amzn-bedrock-trace"], None) + + @pytest.mark.parametrize("action", _ACTIONS) def test_passthrough_on_a_guardrailed_deployment_blocks_with_the_deployment_guardrail( rig: Rig, monkeypatch: pytest.MonkeyPatch, action: str @@ -478,7 +485,10 @@ def test_deployment_without_a_guardrail_leaves_the_caller_guardrail_as_sent( rig: Rig, monkeypatch: pytest.MonkeyPatch, action: str ) -> None: if action.startswith("invoke"): - pytest.skip("BUG: the router-model invoke pass-through drops the caller's X-Amzn-Bedrock-Guardrail* headers") + pytest.skip( + "BUG: the router-model invoke pass-through drops the unprefixed X-Amzn-Bedrock-Guardrail* headers" + " that boto3's native guardrailIdentifier / guardrailVersion / trace args send" + ) marker: Final = uuid.uuid4().hex runtime: Final = rig.runtime(monkeypatch) @@ -491,6 +501,20 @@ def test_deployment_without_a_guardrail_leaves_the_caller_guardrail_as_sent( ), request.body +@pytest.mark.parametrize("action", ("invoke", "invoke-with-response-stream")) +def test_deployment_without_a_guardrail_forwards_the_caller_guardrail_sent_as_x_pass_headers( + rig: Rig, monkeypatch: pytest.MonkeyPatch, action: str +) -> None: + marker: Final = uuid.uuid4().hex + runtime: Final = rig.runtime(monkeypatch, _CALLER_INVOKE_HEADERS) + + outcome: Final = _send(runtime, action, rig.unguarded_model, f"{marker} say {_BLOCKED_WORD}", caller_guardrail=False) + + assert outcome == Outcome(_BLOCKED_TEXT, intervened=True, traced_guardrail=None), outcome + (request,) = rig.received(rig.wire, marker) + assert _applied(request) == _caller_guardrail_on_the_wire(request), (request.headers, request.body) + + def test_each_load_balanced_request_carries_the_guardrail_of_the_deployment_it_reached( rig: Rig, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/integration/providers/test_bedrock_passthrough_request_metadata_wire.py b/tests/integration/providers/test_bedrock_passthrough_request_metadata_wire.py index 5fd12405ab2..992e18a6d5d 100644 --- a/tests/integration/providers/test_bedrock_passthrough_request_metadata_wire.py +++ b/tests/integration/providers/test_bedrock_passthrough_request_metadata_wire.py @@ -5,6 +5,7 @@ from urllib.parse import unquote from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from pathlib import Path +from types import MappingProxyType from typing import Final import boto3 @@ -25,6 +26,7 @@ _MODEL_ID: Final = "anthropic.claude-sonnet-5-v1:0" _EVENT_STREAM: Final = "application/vnd.amazon.eventstream" _METADATA_HEADER: Final = "x-amzn-bedrock-request-metadata" _HEADER_MAX_CHARS: Final = 8500 +_LATENCY_HEADER: Final = "x-amzn-bedrock-performanceconfig-latency" _LONG_SPEND_LOGS_METADATA: Final = tuple((f"{index:02d}".ljust(256, "k"), "\n" * 256) for index in range(12)) _CONVERSE_REPLY: Final[Mapping[str, JsonValue]] = { "output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}, @@ -105,9 +107,11 @@ class Rig: long_key: str long_identity: tuple[tuple[str, str], ...] - def runtime(self, key: str, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 # boto3 clients are untyped + def runtime( # noqa: ANN201 # boto3 clients are untyped + self, key: str, monkeypatch: pytest.MonkeyPatch, extra_headers: Mapping[str, str] = MappingProxyType({}) + ): monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", key) - return boto3.client( + client: Final = boto3.client( "bedrock-runtime", endpoint_url=f"{str(self.proxy.client.base_url).rstrip('/')}/bedrock", region_name="us-east-1", @@ -117,6 +121,11 @@ class Rig: parameter_validation=False, ), ) + client.meta.events.register( + "before-send.bedrock-runtime.*", + lambda request, **_: request.headers.update(extra_headers), + ) + return client def received(self, marker: str) -> tuple[Request, ...]: return tuple(request for request in self.wire.drain() if marker.encode() in request.body) @@ -252,6 +261,54 @@ def test_boto3_passthrough_calls_carry_the_key_identity_in_signed_request_metada ] +def test_caller_request_metadata_header_sent_through_x_pass_never_replaces_the_key_identity( + rig: Rig, monkeypatch: pytest.MonkeyPatch +) -> None: + marker: Final = f"forged-header-{uuid.uuid4().hex}" + runtime: Final = rig.runtime( + rig.key, + monkeypatch, + { + f"x-pass-{_METADATA_HEADER}": json.dumps({"user_api_key_team_alias": "forged", "ticket": "T-42"}), + f"x-pass-{_LATENCY_HEADER}": "optimized", + }, + ) + model: Final = rig.strict_model + + runtime.converse(modelId=model, messages=_messages(marker)) + tuple(runtime.converse_stream(modelId=model, messages=_messages(marker))["stream"]) + runtime.invoke_model(modelId=model, body=json.dumps(_invoke_body(marker)))["body"].read() + tuple(runtime.invoke_model_with_response_stream(modelId=model, body=json.dumps(_invoke_body(marker)))["body"]) + + received: Final = rig.received(marker) + assert [request.target.rsplit("/", 1)[-1] for request in received] == [ + "converse", + "converse-stream", + "invoke", + "invoke-with-response-stream", + ], received + assert all( + request.headers.get(_LATENCY_HEADER) == "optimized" and _LATENCY_HEADER in _signed_header_names(request) + for request in received + ), [request.headers for request in received] + converse_requests: Final = received[:2] + invoke_requests: Final = received[2:] + assert [_METADATA_HEADER in request.headers for request in converse_requests] == [False, False], [ + request.headers for request in converse_requests + ] + assert [json.loads(request.body)["requestMetadata"] for request in converse_requests] == [ + rig.identity, + rig.identity, + ], [request.body for request in converse_requests] + assert [json.loads(request.headers[_METADATA_HEADER]) for request in invoke_requests] == [ + rig.identity, + rig.identity, + ], [request.headers for request in invoke_requests] + assert all(_METADATA_HEADER in _signed_header_names(request) for request in invoke_requests), [ + request.headers["authorization"] for request in invoke_requests + ] + + def test_invoke_metadata_too_long_for_the_header_signs_the_leading_pairs_that_fit( rig: Rig, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py index c36644ba536..f6728e2ed10 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py @@ -1,4 +1,7 @@ +import base64 import json +import struct +from binascii import crc32 from collections.abc import Mapping from types import MappingProxyType from typing import Final @@ -518,3 +521,136 @@ def test_imported_llama_model_arn_is_encoded_and_max_tokens_become_max_gen_len(r assert response.choices[0].message.content == "Here's a joke..." assert str(route.calls[0].request.url) == expected_url assert json.loads(route.calls[0].request.content) == {"prompt": "Tell me a joke", "max_gen_len": 100} + + +GUARDED_INVOKE_TRACE: Final = MappingProxyType( + { + "guardrail": { + "input": {"caller-guardrail": {"invocationMetrics": {"usage": {"topicPolicyUnits": 1}}}}, + "actionReason": "No action.", + } + } +) +GUARDED_INVOKE_METRICS: Final = MappingProxyType( + {"inputTokenCount": 25, "outputTokenCount": 12, "invocationLatency": 300, "firstByteLatency": 90} +) +NOVA_INVOKE_MODEL: Final = "us.amazon.nova-2-lite-v1:0" +LLAMA_INVOKE_MODEL: Final = "meta.llama3-8b-instruct-v1:0" + + +def _event_frame(payload: Mapping[str, object]) -> bytes: + def header(name: str, value: str) -> bytes: + name_b, value_b = name.encode(), value.encode() + return ( + struct.pack("!B", len(name_b)) + name_b + struct.pack("!B", 7) + struct.pack("!H", len(value_b)) + value_b + ) + + payload_b: Final = json.dumps( + {"bytes": base64.b64encode(json.dumps(payload).encode()).decode()}, separators=(",", ":") + ).encode() + headers_b: Final = ( + header(":event-type", "chunk") + header(":content-type", "application/json") + header(":message-type", "event") + ) + prelude: Final = struct.pack("!II", 12 + len(headers_b) + len(payload_b) + 4, len(headers_b)) + prelude_crc: Final = crc32(prelude) & 0xFFFFFFFF + message: Final = struct.pack("!I", prelude_crc) + headers_b + payload_b + return prelude + message + struct.pack("!I", crc32(message, prelude_crc) & 0xFFFFFFFF) + + +def _guarded_invoke_completion(model: str, reply: httpx.Response, stream: bool) -> object: + return litellm.completion( + model=f"bedrock/invoke/{model}", + messages=[{"role": "user", "content": "hi"}], + max_tokens=20, + stream=stream, + guardrailConfig={"guardrailIdentifier": "caller-guardrail", "guardrailVersion": "1", "trace": "enabled"}, + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(lambda _request: reply))), + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + ) + + +@pytest.mark.parametrize( + ("model", "reply_body"), + [ + pytest.param( + NOVA_INVOKE_MODEL, + { + "output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 25, "outputTokens": 12, "totalTokens": 37}, + }, + id="nova", + ), + pytest.param( + LLAMA_INVOKE_MODEL, + {"generation": "hi", "prompt_token_count": 25, "generation_token_count": 12, "stop_reason": "stop"}, + id="llama", + ), + ], +) +def test_guarded_non_claude_invoke_completion_keeps_the_guardrail_trace_from_the_client( + model: str, reply_body: Mapping[str, object] +) -> None: + reply: Final = httpx.Response(200, json={**reply_body, "amazon-bedrock-trace": dict(GUARDED_INVOKE_TRACE)}) + + response: Final = _guarded_invoke_completion(model, reply, stream=False) + + assert isinstance(response, litellm.ModelResponse) + assert response.choices[0].message.content == "hi" + assert "amazon-bedrock-trace" not in response.model_dump(), response.model_dump() + + +@pytest.mark.parametrize( + ("model", "events"), + [ + pytest.param( + NOVA_INVOKE_MODEL, + ( + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "hi"}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + { + "metadata": {"usage": {"inputTokens": 25, "outputTokens": 12, "totalTokens": 37}, "metrics": {}}, + "amazon-bedrock-invocationMetrics": dict(GUARDED_INVOKE_METRICS), + "amazon-bedrock-guardrailAction": "NONE", + "amazon-bedrock-trace": dict(GUARDED_INVOKE_TRACE), + }, + ), + id="nova", + ), + pytest.param( + LLAMA_INVOKE_MODEL, + ( + {"generation": "hi", "prompt_token_count": 25, "generation_token_count": 1, "stop_reason": None}, + { + "generation": "", + "prompt_token_count": None, + "generation_token_count": 12, + "stop_reason": "stop", + "amazon-bedrock-invocationMetrics": dict(GUARDED_INVOKE_METRICS), + "amazon-bedrock-trace": dict(GUARDED_INVOKE_TRACE), + }, + ), + id="llama", + ), + ], +) +def test_guarded_non_claude_invoke_stream_yields_only_the_text_and_stop_chunks( + model: str, events: tuple[Mapping[str, object], ...] +) -> None: + reply: Final = httpx.Response( + 200, + headers={"content-type": "application/vnd.amazon.eventstream"}, + content=b"".join(_event_frame(event) for event in events), + ) + + stream: Final = _guarded_invoke_completion(model, reply, stream=True) + + assert isinstance(stream, litellm.CustomStreamWrapper) + assert [ + ([(choice.delta.content, choice.finish_reason) for choice in chunk.choices], chunk.provider_specific_fields or {}) + for chunk in stream + ] == [([("hi", None)], {}), ([(None, "stop")], {})] diff --git a/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py b/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py index 44c0e82c006..c4706b17294 100644 --- a/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py +++ b/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py @@ -23,7 +23,8 @@ from litellm.llms.bedrock.passthrough.transformation import ( BedrockPassthroughConfig, is_bedrock_streaming_endpoint, ) -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.passthrough.main import AsyncPassthroughStreamingResponse, PassthroughStreamingResponse from litellm.types.utils import ModelResponse CONVERSE_MODEL = "anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -597,9 +598,13 @@ def _invoke_chunk(payload: dict) -> bytes: return _event_frame("chunk", {"bytes": base64.b64encode(json.dumps(payload).encode()).decode()}) -def _stream_logging_obj(endpoint: str, dynamic_async_success_callbacks: list[CustomLogger] | None = None) -> Logging: +def _stream_logging_obj( + endpoint: str, + dynamic_async_success_callbacks: list[CustomLogger] | None = None, + model: str = CONVERSE_MODEL, +) -> Logging: logging_obj = Logging( - model=f"bedrock/{CONVERSE_MODEL}", + model=f"bedrock/{model}", messages=[], stream=True, call_type="pass_through_endpoint", @@ -902,6 +907,137 @@ def test_stream_collector_logs_nothing_for_an_unrecognized_endpoint(): assert collector.build_logged_response(_converse_stream_logging_obj()) is None +TIERED_MODEL: Final = "amazon.nova-pro-v1:0" +TIERED_INVOKE_ENDPOINT: Final = f"/model/{TIERED_MODEL}/invoke" +TIERED_INVOKE_STREAM_ENDPOINT: Final = f"/model/{TIERED_MODEL}/invoke-with-response-stream" +SERVICE_TIER_HEADER: Final = "x-amzn-bedrock-service-tier" +TIERED_PROMPT_TOKENS: Final = 25 +TIERED_COMPLETION_TOKENS: Final = 12 +SERVED_TIER_PRICES: Final = ( + pytest.param({SERVICE_TIER_HEADER: "priority"}, "_priority", id="priority"), + pytest.param({}, "", id="standard"), +) + + +def _tier_priced_cost(price_suffix: str) -> float: + prices: Final = litellm.model_cost[TIERED_MODEL] + return ( + TIERED_PROMPT_TOKENS * prices[f"input_cost_per_token{price_suffix}"] + + TIERED_COMPLETION_TOKENS * prices[f"output_cost_per_token{price_suffix}"] + ) + + +def _nova_invoke_text_stream(texts: list[str]) -> bytes: + return b"".join( + _invoke_chunk(event) + for event in ( + {"messageStart": {"role": "assistant"}}, + *({"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": text}}} for text in texts), + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + { + "metadata": { + "usage": { + "inputTokens": TIERED_PROMPT_TOKENS, + "outputTokens": TIERED_COMPLETION_TOKENS, + "totalTokens": TIERED_PROMPT_TOKENS + TIERED_COMPLETION_TOKENS, + }, + "metrics": {"latencyMs": 120}, + } + }, + ) + ) + + +def _provider_stream_reply(reply_headers: Mapping[str, str], stream: bytes) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "application/vnd.amazon.eventstream", **reply_headers}, + content=stream, + request=httpx.Request("POST", f"https://bedrock-runtime.us-east-1.amazonaws.com{TIERED_INVOKE_STREAM_ENDPOINT}"), + ) + + +@pytest.mark.parametrize(("reply_headers", "price_suffix"), SERVED_TIER_PRICES) +def test_invoke_reply_is_priced_at_the_service_tier_bedrock_served( + reply_headers: Mapping[str, str], price_suffix: str +) -> None: + reply: Final = httpx.Response( + 200, + json={ + "output": {"message": {"role": "assistant", "content": [{"text": "Invoke response"}]}}, + "stopReason": "end_turn", + "usage": { + "inputTokens": TIERED_PROMPT_TOKENS, + "outputTokens": TIERED_COMPLETION_TOKENS, + "totalTokens": TIERED_PROMPT_TOKENS + TIERED_COMPLETION_TOKENS, + }, + }, + headers=reply_headers, + ) + + logged: Final = BedrockPassthroughConfig().logging_non_streaming_response( + model=TIERED_MODEL, + custom_llm_provider="bedrock", + httpx_response=reply, + request_data={}, + logging_obj=_stream_logging_obj(TIERED_INVOKE_ENDPOINT, model=TIERED_MODEL), + endpoint=TIERED_INVOKE_ENDPOINT, + ) + + assert litellm.completion_cost(model=f"bedrock/{TIERED_MODEL}", completion_response=logged) == pytest.approx( + _tier_priced_cost(price_suffix) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("reply_headers", "price_suffix"), SERVED_TIER_PRICES) +async def test_invoke_stream_relay_is_priced_at_the_service_tier_bedrock_served( + reply_headers: Mapping[str, str], price_suffix: str +) -> None: + callback: Final = _CaptureAsyncSuccessCallback() + stream: Final = _nova_invoke_text_stream(["Invoke ", "response"]) + reply: Final = _provider_stream_reply(reply_headers, stream) + + async def provider_reply() -> httpx.Response: + return reply + + relay: Final = AsyncPassthroughStreamingResponse( + response=provider_reply(), + litellm_logging_obj=_stream_logging_obj(TIERED_INVOKE_STREAM_ENDPOINT, [callback], model=TIERED_MODEL), + provider_config=BedrockPassthroughConfig(), + ) + relayed: Final = b"".join([chunk async for chunk in relay]) + + assert relayed == stream + assert await relay.wait_for_spend_flush() is True + assert callback.kwargs is not None + assert callback.kwargs["response_cost"] == pytest.approx(_tier_priced_cost(price_suffix)) + + +class _ImmediateExecutor: + def submit(self, fn: Callable[..., object], /, *args: object, **kwargs: object) -> None: + fn(*args, **kwargs) + + +@pytest.mark.parametrize(("reply_headers", "price_suffix"), SERVED_TIER_PRICES) +def test_sync_invoke_stream_relay_is_priced_at_the_service_tier_bedrock_served( + reply_headers: Mapping[str, str], price_suffix: str +) -> None: + logging_obj: Final = _stream_logging_obj(TIERED_INVOKE_STREAM_ENDPOINT, model=TIERED_MODEL) + stream: Final = _nova_invoke_text_stream(["Invoke ", "response"]) + + with patch("litellm.utils.executor", _ImmediateExecutor()): + relayed: Final = b"".join( + PassthroughStreamingResponse( + _provider_stream_reply(reply_headers, stream), logging_obj, BedrockPassthroughConfig() + ) + ) + + assert relayed == stream + assert logging_obj.model_call_details["response_cost"] == pytest.approx(_tier_priced_cost(price_suffix)) + + REQUEST_METADATA_HEADER = "x-amzn-bedrock-request-metadata" REQUEST_METADATA_FIELDS = ["user_api_key_alias", "user_api_key_team_alias", "spend_logs_metadata"] PROXY_IDENTITY = { @@ -977,7 +1113,11 @@ async def test_converse_passthrough_signs_proxy_identity_into_request_metadata( caller_body: Final = {**CONVERSE_BODY, "requestMetadata": {"user_api_key_team_alias": "forged", "ticket": "T-42"}} request: Final = await _sent_passthrough_request( - action, caller_body, PROXY_IDENTITY, request_query_params=query_params + action, + caller_body, + PROXY_IDENTITY, + request_query_params=query_params, + request_headers={f"x-pass-{REQUEST_METADATA_HEADER.upper()}": '{"user_api_key_team_alias":"forged"}'}, ) assert json.loads(request.content) == { @@ -1281,12 +1421,14 @@ async def test_deployment_guardrail_replaces_the_caller_converse_guardrail( {**CONVERSE_BODY, "guardrailConfig": caller_guardrail}, {}, deployment_params={"guardrailConfig": {"guardrailIdentifier": "deployment-guardrail"}}, + request_headers=CALLER_INVOKE_GUARDRAIL_HEADERS, ) assert json.loads(request.content) == { **CONVERSE_BODY, "guardrailConfig": {"guardrailIdentifier": "deployment-guardrail"}, } + assert _invoke_guardrail_headers(request) == {} @pytest.mark.asyncio @@ -1328,6 +1470,59 @@ async def test_passthrough_without_a_deployment_guardrail_sends_the_caller_guard } +CALLER_GUARDRAIL_IDENTITY_HEADERS: Final[Mapping[str, str]] = { + "x-pass-X-Amzn-Bedrock-GuardrailIdentifier": "caller-guardrail", + "x-pass-X-Amzn-Bedrock-GuardrailVersion": "9", +} +SENT_CALLER_GUARDRAIL_IDENTITY: Final[Mapping[str, str]] = { + "x-amzn-bedrock-guardrailidentifier": "caller-guardrail", + "x-amzn-bedrock-guardrailversion": "9", +} + + +SDK_INVOKE_MODEL: Final = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + + +def _sdk_passthrough_call( + model: str, action: str, request_headers: Mapping[str, str], reply_body: Mapping[str, object] +) -> tuple[httpx.Request, object]: + sent: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + sent.append(request) + return httpx.Response(200, json=reply_body, request=request) + + response: Final = litellm.llm_passthrough_route( + model=f"bedrock/{model}", + method="POST", + endpoint=f"/model/{model}/{action}", + json=INVOKE_BODY, + request_headers=dict(request_headers), + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))), + aws_access_key_id="AKIASDKKEY", + aws_secret_access_key="sdk-secret", + aws_region_name="us-east-1", + ) + return sent[0], response + + +@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"]) +@pytest.mark.parametrize("caller_trace", [None, "DISABLED", "enabled"]) +def test_sdk_invoke_passthrough_sends_the_caller_guardrail_trace_exactly_as_sent( + action: str, caller_trace: str | None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", None) + caller_trace_header: Final = {} if caller_trace is None else {"x-pass-X-Amzn-Bedrock-Trace": caller_trace} + sent_trace_header: Final = {} if caller_trace is None else {"x-amzn-bedrock-trace": caller_trace} + + request, _ = _sdk_passthrough_call( + SDK_INVOKE_MODEL, action, {**CALLER_GUARDRAIL_IDENTITY_HEADERS, **caller_trace_header}, {"ok": True} + ) + + assert _invoke_guardrail_headers(request) == {**SENT_CALLER_GUARDRAIL_IDENTITY, **sent_trace_header} + assert json.loads(request.content) == INVOKE_BODY + + @pytest.mark.asyncio @pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"]) @pytest.mark.parametrize("guardrail_config", ["deployment-guardrail", {"guardrailVersion": "3"}]) @@ -1368,3 +1563,244 @@ async def test_passthrough_leaves_actions_without_a_guardrail_field_as_sent(monk assert json.loads(request.content) == count_tokens_body assert _invoke_guardrail_headers(request) == {} + + +GUARDRAIL_UNIT_PRICE: Final = 0.00015 +GUARDRAIL_PRICING: Final = { + "litellm_provider": "bedrock", + "mode": "guardrail", + "guardrail_cost_per_unit": {"topicPolicyUnits": GUARDRAIL_UNIT_PRICE, "contentPolicyUnits": GUARDRAIL_UNIT_PRICE}, +} +_ASSESSMENT_USAGE: Final = {"invocationMetrics": {"usage": {"topicPolicyUnits": 1, "contentPolicyUnits": 1}}} +INVOKE_GUARDRAIL_TRACE: Final = { + "guardrail": { + "input": {"caller-guardrail": _ASSESSMENT_USAGE}, + "outputs": [{"caller-guardrail": _ASSESSMENT_USAGE}], + "actionReason": "No action.", + } +} +BILLED_GUARDRAIL_USAGE: Final = {"topicPolicyUnits": 2, "contentPolicyUnits": 2} +BILLED_GUARDRAIL_COST: Final = 4 * GUARDRAIL_UNIT_PRICE +NOVA_INVOKE_MODEL: Final = "us.amazon.nova-2-lite-v1:0" +LLAMA_INVOKE_MODEL: Final = "meta.llama3-8b-instruct-v1:0" +INVOKE_METRICS: Final = { + "inputTokenCount": 25, + "outputTokenCount": 12, + "invocationLatency": 300, + "firstByteLatency": 90, +} + +INVOKE_REPLIES_WITH_A_GUARDRAIL_TRACE: Final = ( + pytest.param( + NOVA_INVOKE_MODEL, + { + "output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 25, "outputTokens": 12, "totalTokens": 37}, + "amazon-bedrock-guardrailAction": "NONE", + }, + id="nova", + ), + pytest.param( + LLAMA_INVOKE_MODEL, + {"generation": "hi", "prompt_token_count": 25, "generation_token_count": 12, "stop_reason": "stop"}, + id="llama", + ), + pytest.param( + "mistral.mistral-7b-instruct-v0:2", {"outputs": [{"text": "hi", "stop_reason": "stop"}]}, id="mistral" + ), + pytest.param( + CONVERSE_MODEL, + { + "id": "msg-1", + "type": "message", + "role": "assistant", + "model": CONVERSE_MODEL, + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 25, "output_tokens": 12}, + }, + id="claude", + ), +) + +INVOKE_STREAMS_WITH_A_GUARDRAIL_TRACE: Final = ( + pytest.param( + NOVA_INVOKE_MODEL, + ( + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "hi"}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + { + "metadata": { + "usage": {"inputTokens": 25, "outputTokens": 12, "totalTokens": 37}, + "metrics": {}, + "trace": {}, + }, + "amazon-bedrock-invocationMetrics": INVOKE_METRICS, + "amazon-bedrock-guardrailAction": "NONE", + "amazon-bedrock-trace": INVOKE_GUARDRAIL_TRACE, + }, + ), + id="nova", + ), + pytest.param( + LLAMA_INVOKE_MODEL, + ( + {"generation": "hi", "prompt_token_count": 25, "generation_token_count": 1, "stop_reason": None}, + { + "generation": "", + "prompt_token_count": None, + "generation_token_count": 12, + "stop_reason": "stop", + "amazon-bedrock-invocationMetrics": INVOKE_METRICS, + "amazon-bedrock-trace": INVOKE_GUARDRAIL_TRACE, + }, + ), + id="llama", + ), + pytest.param( + CONVERSE_MODEL, + ( + { + "type": "message_start", + "message": { + "id": "msg-1", + "type": "message", + "role": "assistant", + "model": CONVERSE_MODEL, + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 25, "output_tokens": 1}, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 12}}, + { + "type": "message_stop", + "amazon-bedrock-invocationMetrics": INVOKE_METRICS, + "amazon-bedrock-trace": INVOKE_GUARDRAIL_TRACE, + }, + ), + id="claude", + ), +) + + +def _billed_guardrails(standard_logging_object: object) -> list[tuple[object, object]]: + assert isinstance(standard_logging_object, dict) + return [ + (entry["guardrail_usage"], entry["guardrail_cost"]) + for entry in standard_logging_object["guardrail_information"] or [] + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("model", "reply_body"), INVOKE_REPLIES_WITH_A_GUARDRAIL_TRACE) +async def test_invoke_reply_bills_the_guardrail_bedrock_traced( + model: str, reply_body: Mapping[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setitem(litellm.model_cost, "bedrock/guardrails", GUARDRAIL_PRICING) + endpoint: Final = f"/model/{model}/invoke" + callback: Final = _CaptureAsyncSuccessCallback() + logging_obj: Final = Logging( + model=model, + messages=[], + stream=False, + call_type="allm_passthrough_route", + start_time=datetime(2025, 1, 1), + litellm_call_id="call-1", + function_id="fn-1", + dynamic_async_success_callbacks=[callback], + ) + logging_obj.update_environment_variables(litellm_params={}, optional_params={}) + logging_obj.model_call_details["custom_llm_provider"] = "bedrock" + logging_obj.model_call_details["endpoint"] = endpoint + reply: Final = httpx.Response( + 200, + json={**reply_body, "amazon-bedrock-trace": INVOKE_GUARDRAIL_TRACE}, + request=httpx.Request("POST", f"https://bedrock-runtime.us-east-1.amazonaws.com{endpoint}"), + ) + + await logging_obj.async_success_handler( + result=reply, start_time=datetime(2025, 1, 1), end_time=datetime(2025, 1, 1, 0, 0, 1) + ) + + assert callback.kwargs is not None + assert _billed_guardrails(callback.kwargs["standard_logging_object"]) == [ + (BILLED_GUARDRAIL_USAGE, pytest.approx(BILLED_GUARDRAIL_COST)) + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("model", "events"), INVOKE_STREAMS_WITH_A_GUARDRAIL_TRACE) +async def test_invoke_stream_relay_bills_the_guardrail_bedrock_traced( + model: str, events: tuple[dict[str, object], ...], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setitem(litellm.model_cost, "bedrock/guardrails", GUARDRAIL_PRICING) + endpoint: Final = f"/model/{model}/invoke-with-response-stream" + callback: Final = _CaptureAsyncSuccessCallback() + stream: Final = b"".join(_invoke_chunk(event) for event in events) + + async def provider_reply() -> httpx.Response: + return _provider_stream_reply({}, stream) + + relay: Final = AsyncPassthroughStreamingResponse( + response=provider_reply(), + litellm_logging_obj=_stream_logging_obj(endpoint, [callback], model=model), + provider_config=BedrockPassthroughConfig(), + ) + relayed: Final = b"".join([chunk async for chunk in relay]) + + assert relayed == stream + assert await relay.wait_for_spend_flush() is True + assert callback.kwargs is not None + assert _billed_guardrails(callback.kwargs["standard_logging_object"]) == [ + (BILLED_GUARDRAIL_USAGE, pytest.approx(BILLED_GUARDRAIL_COST)) + ] + + +class _CaptureSyncSuccessCallback(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.standard_logging_objects: list[object] = [] + + def log_success_event( + self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object + ) -> None: + self.standard_logging_objects.append(kwargs.get("standard_logging_object")) + + +@pytest.mark.parametrize( + ("model", "reply_body"), + [param for param in INVOKE_REPLIES_WITH_A_GUARDRAIL_TRACE if param.id in {"nova", "claude"}], +) +def test_sdk_invoke_passthrough_bills_the_caller_guardrail_bedrock_traced_once( + model: str, reply_body: Mapping[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", None) + monkeypatch.setitem(litellm.model_cost, "bedrock/guardrails", GUARDRAIL_PRICING) + callback: Final = _CaptureSyncSuccessCallback() + monkeypatch.setattr(litellm, "callbacks", [callback]) + traced_reply: Final = {**reply_body, "amazon-bedrock-trace": INVOKE_GUARDRAIL_TRACE} + + with patch("litellm.utils.executor", _ImmediateExecutor()): + request, response = _sdk_passthrough_call( + model, + "invoke", + {**CALLER_GUARDRAIL_IDENTITY_HEADERS, "x-pass-X-Amzn-Bedrock-Trace": "ENABLED"}, + traced_reply, + ) + + assert isinstance(response, httpx.Response) + assert response.json() == traced_reply + assert _invoke_guardrail_headers(request) == { + **SENT_CALLER_GUARDRAIL_IDENTITY, + "x-amzn-bedrock-trace": "ENABLED", + } + assert [_billed_guardrails(logged) for logged in callback.standard_logging_objects] == [ + [(BILLED_GUARDRAIL_USAGE, pytest.approx(BILLED_GUARDRAIL_COST))] + ] diff --git a/tests/unit/passthrough/test_utils.py b/tests/unit/passthrough/test_utils.py new file mode 100644 index 00000000000..02a1c70f8cb --- /dev/null +++ b/tests/unit/passthrough/test_utils.py @@ -0,0 +1,78 @@ +from typing import Final + +import httpx +import pytest + +from litellm.passthrough.utils import _PASS_THROUGH_PROTECTED_HEADERS, BasePassthroughUtils + + +def test_pass_through_prefixed_headers_keeps_only_the_x_pass_headers_forwarded_upstream() -> None: + inbound: Final = { + "authorization": "Bearer sk-litellm-key", + "x-litellm-api-key": "sk-litellm-key", + "content-type": "application/json", + "x-amzn-bedrock-guardrailidentifier": "native-guardrail", + "x-passthrough-id": "not-a-pass-header", + "x-pass-authorization": "Bearer upstream-secret", + "X-Pass-X-Api-Key": "upstream-api-key", + "x-pass-X-Amz-Security-Token": "forged-session-token", + "x-pass-x-amzn-bedrock-trace": "DISABLED", + "X-Pass-X-Amzn-Bedrock-GuardrailIdentifier": "caller-guardrail", + "X-PASS-ANTHROPIC-BETA": "beta-1", + } + + selected: Final = BasePassthroughUtils.pass_through_prefixed_headers(inbound) + + assert dict(selected) == { + "x-pass-x-amzn-bedrock-trace": "DISABLED", + "X-Pass-X-Amzn-Bedrock-GuardrailIdentifier": "caller-guardrail", + "X-PASS-ANTHROPIC-BETA": "beta-1", + } + + +@pytest.mark.parametrize( + "header_name", + [ + *(f"x-pass-{name}" for name in sorted(_PASS_THROUGH_PROTECTED_HEADERS)), + "X-Pass-Authorization", + "x-pass-X-Amz-Date", + "x-pass-x-amz-security-token", + "x-pass-X-Amzn-Bedrock-Trace", + "x-pass-anthropic-beta", + "x-pass-cookie", + "x-passthrough-id", + "authorization", + ], +) +def test_pass_through_prefixed_headers_select_exactly_the_headers_forwarded_upstream(header_name: str) -> None: + inbound: Final = {header_name: "caller-value"} + + selected: Final = BasePassthroughUtils.pass_through_prefixed_headers(inbound) + forwarded: Final = BasePassthroughUtils.forward_headers_from_request( + request_headers=dict(inbound), headers={}, forward_headers=False + ) + + assert {name[len("x-pass-") :].lower(): value for name, value in selected.items()} == forwarded + + +@pytest.mark.parametrize( + ("default_query_params", "expected"), + [ + pytest.param( + {"api-version": "default", "region": "default"}, + {"api-version": "url", "region": "default", "shared": "request", "tag": ["a", "b"]}, + id="with_defaults", + ), + pytest.param(None, {"api-version": "url", "shared": "request", "tag": ["a", "b"]}, id="without_defaults"), + ], +) +def test_merged_query_parameters_let_the_request_override_the_url_and_the_url_override_defaults( + default_query_params: dict[str, str | list] | None, expected: dict[str, str | list[str]] +) -> None: + merged: Final = BasePassthroughUtils.get_merged_query_parameters( + existing_url=httpx.URL("https://upstream.test/v1?api-version=url&shared=url&tag=a&tag=b"), + request_query_params={"shared": "request"}, + default_query_params=default_query_params, + ) + + assert merged == expected diff --git a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 6e551f8ff0a..8e88fd7449a 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -1350,11 +1350,23 @@ async def test_mistral_passthrough_accepts_multipart_without_json_parsing(): assert captured_kwargs["custom_headers"] == {"Authorization": "Bearer mistral-test-key"} +BEDROCK_PROXY_MODEL: Final = "anthropic.claude-sonnet-5-5" +BEDROCK_PROXY_CALLER_BEARER: Final = "sk-caller-virtual-key" +BEDROCK_PROXY_ROUTES: Final = pytest.mark.parametrize( + ("path_model", "uses_router"), + [ + pytest.param("bedrock-sonnet", True, id="router_model"), + pytest.param(BEDROCK_PROXY_MODEL, False, id="direct_model"), + ], +) + + class TestBedrockLLMProxyRoute: @pytest.mark.asyncio async def test_bedrock_llm_proxy_route_application_inference_profile(self): mock_request = Mock() mock_request.method = "POST" + mock_request.headers = {} mock_response = Mock() mock_user_api_key_dict = Mock() mock_request_body = {"messages": [{"role": "user", "content": "test"}]} @@ -1397,6 +1409,7 @@ class TestBedrockLLMProxyRoute: async def test_bedrock_llm_proxy_route_regular_model(self): mock_request = Mock() mock_request.method = "POST" + mock_request.headers = {} mock_response = Mock() mock_user_api_key_dict = Mock() mock_request_body = {"messages": [{"role": "user", "content": "test"}]} @@ -1444,6 +1457,7 @@ class TestBedrockLLMProxyRoute: ) -> None: mock_request: Final = Mock() mock_request.method = "POST" + mock_request.headers = {} mock_processor: Final = Mock() mock_processor.base_passthrough_process_llm_request = AsyncMock(return_value="success") request_body: Final = {"messages": [{"role": "user", "content": "test"}]} @@ -1484,6 +1498,7 @@ class TestBedrockLLMProxyRoute: ) -> None: mock_request: Final = Mock() mock_request.method = "POST" + mock_request.headers = {} mock_processor: Final = Mock() mock_processor.base_passthrough_process_llm_request = AsyncMock(return_value="success") request_body: Final = {"messages": [{"role": "user", "content": "test"}]} @@ -1515,6 +1530,91 @@ class TestBedrockLLMProxyRoute: processing_data: Final = processor_constructor.call_args.kwargs["data"] _assert_bedrock_processing_data_classification(processing_data, is_streaming) + @BEDROCK_PROXY_ROUTES + def test_invoke_signs_the_caller_x_pass_header_and_sends_no_caller_credential( + self, monkeypatch: pytest.MonkeyPatch, httpx_transport: None, path_model: str, uses_router: bool + ) -> None: + caller_cookie: Final = "proxy_session=caller-secret" + + sent, _ = _bedrock_invoke_through_proxy( + monkeypatch, + path_model, + uses_router, + { + "x-pass-X-Amzn-Bedrock-Service-Tier": "priority", + "Authorization": f"Bearer {BEDROCK_PROXY_CALLER_BEARER}", + "Cookie": caller_cookie, + }, + ) + + signed_headers: Final = sent.headers["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";") + assert sent.headers["x-amzn-bedrock-service-tier"] == "priority" + assert "x-amzn-bedrock-service-tier" in signed_headers + assert [name for name in sent.headers if name.startswith("x-pass-") or name == "cookie"] == [] + assert [ + value for value in sent.headers.values() if BEDROCK_PROXY_CALLER_BEARER in value or caller_cookie in value + ] == [] + + @BEDROCK_PROXY_ROUTES + @pytest.mark.parametrize("caller_trace", [None, "DISABLED", "enabled"]) + def test_invoke_sends_the_caller_guardrail_trace_exactly_as_sent( + self, + monkeypatch: pytest.MonkeyPatch, + httpx_transport: None, + path_model: str, + uses_router: bool, + caller_trace: str | None, + ) -> None: + trace_header: Final = {} if caller_trace is None else {"x-pass-X-Amzn-Bedrock-Trace": caller_trace} + + sent, _ = _bedrock_invoke_through_proxy( + monkeypatch, + path_model, + uses_router, + { + "x-pass-X-Amzn-Bedrock-GuardrailIdentifier": "caller-guardrail", + "x-pass-X-Amzn-Bedrock-GuardrailVersion": "9", + **trace_header, + }, + ) + + assert ( + sent.headers["x-amzn-bedrock-guardrailidentifier"], + sent.headers["x-amzn-bedrock-guardrailversion"], + sent.headers.get("x-amzn-bedrock-trace"), + ) == ("caller-guardrail", "9", caller_trace) + + @BEDROCK_PROXY_ROUTES + def test_invoke_keeps_protected_x_pass_values_from_bedrock_guardrails_and_the_logged_body( + self, monkeypatch: pytest.MonkeyPatch, httpx_transport: None, path_model: str, uses_router: bool + ) -> None: + protected_values: Final = ( + "Bearer upstream-secret", + "upstream-api-key", + "forged-session-token", + "session=upstream-cookie", + "Basic upstream-proxy-secret", + ) + + sent, guardrail_data = _bedrock_invoke_through_proxy( + monkeypatch, + path_model, + uses_router, + { + "x-pass-authorization": protected_values[0], + "x-pass-x-api-key": protected_values[1], + "x-pass-X-Amz-Security-Token": protected_values[2], + "x-pass-cookie": protected_values[3], + "x-pass-proxy-authorization": protected_values[4], + "x-pass-X-Amzn-Bedrock-GuardrailIdentifier": "caller-guardrail", + }, + ) + + logged_body: Final = json.dumps(guardrail_data["proxy_server_request"]["body"], default=str) + assert sent.headers["x-amzn-bedrock-guardrailidentifier"] == "caller-guardrail" + assert guardrail_data["request_headers"] == {"x-pass-x-amzn-bedrock-guardrailidentifier": "caller-guardrail"} + assert [value for value in protected_values if value in str(sent.headers.raw) or value in logged_body] == [] + @pytest.mark.asyncio @pytest.mark.parametrize( ("model_id", "action"), @@ -1526,6 +1626,7 @@ class TestBedrockLLMProxyRoute: async def test_bedrock_direct_model_id_does_not_imply_streaming(self, model_id: str, action: str) -> None: mock_request: Final = Mock() mock_request.method = "POST" + mock_request.headers = {} mock_processor: Final = Mock() mock_processor.base_passthrough_process_llm_request = AsyncMock(return_value="success") request_body: Final = {"messages": [{"role": "user", "content": "test"}]} @@ -1562,6 +1663,7 @@ class TestBedrockLLMProxyRoute: async def test_bedrock_router_model_id_does_not_imply_streaming(self, model_id: str, action: str) -> None: mock_request: Final = Mock() mock_request.method = "POST" + mock_request.headers = {} mock_processor: Final = Mock() mock_processor.base_passthrough_process_llm_request = AsyncMock(return_value="success") request_body: Final = {"messages": [{"role": "user", "content": "test"}]} @@ -1865,6 +1967,84 @@ class TestBedrockLLMProxyRoute: assert "Blocked by guardrail" in str(exc_info.value.detail) +class _RequestDataCaptureGuardrail(CustomGuardrail): + def __init__(self) -> None: + super().__init__(guardrail_name="request-data-capture", default_on=True, event_hook="pre_call") + self.data: dict[str, object] = {} + + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache: DualCache, + data: dict[str, object], + call_type: CallTypesLiteral, + ) -> dict[str, object]: + self.data = data + return data + + +def _bedrock_invoke_through_proxy( + monkeypatch: pytest.MonkeyPatch, path_model: str, uses_router: bool, headers: Mapping[str, str] +) -> tuple[httpx.Request, dict[str, object]]: + from litellm.proxy import proxy_server + + for ambient in (*TestBedrockAgentRuntimePassthroughVirtualKeyLeak.AMBIENT_AWS_ENV, "AWS_BEDROCK_RUNTIME_ENDPOINT"): + monkeypatch.delenv(ambient, raising=False) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIATESTACCESSKEY") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + monkeypatch.setenv("AWS_REGION_NAME", "us-east-1") + router: Final = ( + litellm.Router( + model_list=[ + { + "model_name": "bedrock-sonnet", + "litellm_params": {"model": f"bedrock/{BEDROCK_PROXY_MODEL}", "aws_region_name": "us-east-1"}, + } + ] + ) + if uses_router + else None + ) + guardrail: Final = _RequestDataCaptureGuardrail() + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(litellm, "callbacks", [guardrail]) + monkeypatch.setitem( + proxy_server.app.dependency_overrides, + user_api_key_auth, + lambda: UserAPIKeyAuth(api_key=BEDROCK_PROXY_CALLER_BEARER), + ) + + with respx.mock(assert_all_called=True) as upstream: + invoke: Final = upstream.post( + f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{BEDROCK_PROXY_MODEL}/invoke" + ).mock( + return_value=httpx.Response( + 200, + json={ + "id": "msg_bedrock", + "type": "message", + "role": "assistant", + "model": BEDROCK_PROXY_MODEL, + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 1}, + }, + ) + ) + response: Final = TestClient(proxy_server.app).post( + f"/bedrock/model/{path_model}/invoke", + json={ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}], + }, + headers=dict(headers), + ) + + assert response.status_code == 200, response.text + return invoke.calls.last.request, guardrail.data + + _THROTTLE_TYPE: Final = "ThrottlingException:http://internal.amazon.com/coral/com.amazon.bedrock/" _THROTTLE_BODY: Final = b'{"message":"Too many requests, please wait before trying again."}' diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py index 28d2a4fa7ad..e8ee31b27f5 100644 --- a/tests/unit/proxy/test_litellm_pre_call_utils.py +++ b/tests/unit/proxy/test_litellm_pre_call_utils.py @@ -934,6 +934,38 @@ async def test_add_litellm_data_to_request_body_snapshot_excludes_secret_fields( ) +@pytest.mark.asyncio +async def test_chat_completions_body_field_named_request_headers_stays_in_the_logged_body(): + from litellm.proxy.proxy_server import ProxyConfig + + client_field: Final = {"x-client-trace": "client-supplied"} + request: Final = Request( + { + "type": "http", + "method": "POST", + "path": "/chat/completions", + "scheme": "http", + "server": ("localhost", 4000), + "query_string": b"", + "headers": [(b"authorization", b"Bearer sk-litellm-key")], + } + ) + + enriched: Final = await add_litellm_data_to_request( + data={ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "hello"}], + "request_headers": client_field, + }, + request=request, + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"), + proxy_config=ProxyConfig(), + general_settings={}, + ) + + assert enriched["proxy_server_request"]["body"]["request_headers"] == client_field + + @pytest.mark.asyncio async def test_add_litellm_data_to_request_body_snapshot_excludes_proxy_server_request(): """Regression: the body snapshot used to include the proxy_server_request From a7696a2bc2fe18b18e8617a3231ebc6a01c04e68 Mon Sep 17 00:00:00 2001 From: paveln83 <349044532+paveln83@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:29:32 -0800 Subject: [PATCH 15/86] fix(proxy): send the streamed Responses fallback metadata as a typed Responses event (#50804) * fix(proxy): send the streamed Responses fallback metadata as a typed Responses event With expose_fallback_errors_to_caller on and include_fallback_errors set, a streamed /v1/responses call that fell back sent its fallback metadata as a chat.completion.chunk with no type, so the OpenAI SDK read it as an audio delta event with type None. On the Responses route the frame is now {"type": "litellm.fallback_metadata", "litellm_fallback": {...}} and goes through the same Responses framing as every other event, so it gets an event: line whenever its neighbours do. The chat route keeps its chat chunk unchanged * test(proxy): stop pinning the Responses stream terminator in the fallback metadata test Main ends Responses streams without data: [DONE] since #47980, so the test reads the event frames whether or not a terminator follows them, and passes both before and after that change merges * fix(proxy): keep the top-level model on the Responses fallback metadata event * fix(proxy): keep the fallback metadata id on the Responses event and omit an empty model * fix(proxy): leave the top-level model off the Responses fallback event when raw model names are on The complexity router's return_raw_model_name leaves every other event unstamped, so the fallback event now matches. A new test streams a raw upstream frame before the event to cover how main names it. * chore: push again after GitHub dropped the previous push event * fix(proxy): send the Responses fallback event without an event line so older OpenAI SDKs read it --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/proxy/common_request_processing.py | 2 +- litellm/proxy/proxy_server.py | 32 +++-- .../test_include_fallback_errors_wire.py | 39 +++++- .../proxy_server/test_streaming_helpers.py | 123 ++++++++++++++++++ 4 files changed, 184 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 9e2b49ef692..bfa559f86c5 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -381,7 +381,7 @@ def resolve_litellm_call_id(client_call_id: str | None) -> str: return str(uuid.uuid4()) -def should_return_raw_model_name(request_data: dict[str, object]) -> bool: +def should_return_raw_model_name(request_data: Mapping[str, object]) -> bool: return any( isinstance(metadata, dict) and metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY) is True for metadata in (request_data.get("metadata"), request_data.get("litellm_metadata")) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f69d9bd4724..be90b028cf4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -10303,23 +10303,34 @@ def _get_streaming_fallback_metadata( return True, None, fallback_errors +_FALLBACK_METADATA_ID: Final = "litellm-fallback-metadata" +_RESPONSES_FALLBACK_METADATA_EVENT_TYPE: Final = "litellm.fallback_metadata" + + def _format_fallback_metadata_sse_event( *, fallback_model: str | None, fallback_errors: list[dict[str, object]], + responses_stream: bool, + return_raw_model_name: bool, ) -> str: - import time - + litellm_fallback: Final = {"fallback_model": fallback_model, "errors": fallback_errors} + if responses_stream: + stamped_model: Final = None if return_raw_model_name else fallback_model + event: Final = { + "type": _RESPONSES_FALLBACK_METADATA_EVENT_TYPE, + "id": _FALLBACK_METADATA_ID, + **({"model": stamped_model} if stamped_model else {}), + "litellm_fallback": litellm_fallback, + } + return f"data: {json.dumps(event)}\n\n" payload: Final = { - "id": "litellm-fallback-metadata", + "id": _FALLBACK_METADATA_ID, "object": "chat.completion.chunk", "created": int(time.time()), "model": fallback_model or "", "choices": [], - "litellm_fallback": { - "fallback_model": fallback_model, - "errors": fallback_errors, - }, + "litellm_fallback": litellm_fallback, } return f"data: {json.dumps(payload)}\n\n" @@ -10328,7 +10339,7 @@ def _restamp_streaming_chunk_model( *, chunk: object, requested_model_from_client: str, - request_data: dict, + request_data: Mapping[str, object], model_mismatch_logged: bool, fallback_was_attempted: bool = False, fallback_model_from_metadata: str | None = None, @@ -10906,6 +10917,7 @@ async def async_data_generator( model_mismatch_logged = False fallback_metadata_event_sent = False include_fallback_errors: Final = _should_include_fallback_errors(request_data) + return_raw_model_name: Final = should_return_raw_model_name(request_data) # Use a running string instead of list + join to avoid O(n^2) overhead. # Previously "".join(str_so_far_parts) was called every chunk, re-joining # the entire accumulated response. String += is O(n) amortized total. @@ -11003,6 +11015,8 @@ async def async_data_generator( yield _format_fallback_metadata_sse_event( fallback_model=fallback_model_from_metadata, fallback_errors=fallback_errors, + responses_stream=error_state is not None, + return_raw_model_name=return_raw_model_name, ) fallback_metadata_event_sent = True continue @@ -11059,6 +11073,8 @@ async def async_data_generator( yield _format_fallback_metadata_sse_event( fallback_model=fallback_model_from_metadata, fallback_errors=fallback_errors, + responses_stream=error_state is not None, + return_raw_model_name=return_raw_model_name, ) fallback_metadata_event_sent = True diff --git a/tests/integration/routing/test_include_fallback_errors_wire.py b/tests/integration/routing/test_include_fallback_errors_wire.py index 2f48bc43a22..a41fefd5ce1 100644 --- a/tests/integration/routing/test_include_fallback_errors_wire.py +++ b/tests/integration/routing/test_include_fallback_errors_wire.py @@ -16,7 +16,14 @@ from urllib.parse import unquote import httpx import pytest import yaml -from integration._support.anthropic_sse import error_body, message_json, message_stream, parse_sse, stream_reply +from integration._support.anthropic_sse import ( + SseEvent, + error_body, + message_json, + message_stream, + parse_sse, + stream_reply, +) from integration._support.client import Gateway, eventually, gateway_from_environment from integration._support.database import read_rows from integration._support.openai_wire import chat_reply, responses_reply @@ -34,6 +41,7 @@ _API_KEY: Final = "synthetic-fallback-errors-key" _ROUTER_ONLY_KEYS: Final = ("include_fallback_errors", "silent_model") _ANSWER: Final = "answered by" _UNAUTHORIZED_MESSAGE: Final = "scripted 401: the primary key was revoked" +_RESPONSES_FALLBACK_METADATA_EVENT: Final = "litellm.fallback_metadata" _NONCE: Final = re.compile(r"nonce=([0-9a-f]{32})") _JSON: Final = TypeAdapter(dict[str, JsonValue]) _ERRORS: Final = TypeAdapter(list[dict[str, JsonValue]]) @@ -445,11 +453,25 @@ def exposed(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Exposed]: yield _Exposed(proxy, wire) +def _mention_the_outage(errors: list[dict[str, JsonValue]]) -> bool: + return len(errors) == 1 and _UNAUTHORIZED_MESSAGE in str(errors[0]["message"]) + + def _errors_mention_the_outage(errors_header: str | None) -> bool: if errors_header is None: return False - errors: Final = _ERRORS.validate_json(errors_header) - return len(errors) == 1 and _UNAUTHORIZED_MESSAGE in str(errors[0]["message"]) + return _mention_the_outage(_ERRORS.validate_json(errors_header)) + + +def _fallback_reports(events: tuple[SseEvent, ...]) -> tuple[SseEvent, ...]: + return tuple(event for event in events if "litellm_fallback" in event.data) + + +def _reports_the_outage_served_by_the_backup(report: SseEvent) -> bool: + fallback: Final = _JSON.validate_python(report.data["litellm_fallback"]) + return fallback["fallback_model"] == _EXPOSED_OPENAI_BACKUP and _mention_the_outage( + _ERRORS.validate_python(fallback["errors"]) + ) @pytest.mark.parametrize("stream", _STREAMING) @@ -473,6 +495,10 @@ def test_exposed_chat_fallback_reports_the_errors_without_putting_the_flag_on_th if not stream: assert observed.attempted == "1", observed assert _errors_mention_the_outage(observed.errors_header), observed + return + reports: Final = _fallback_reports(parse_sse(observed.text)) + assert tuple(report.data.get("object") for report in reports) == ("chat.completion.chunk",), observed + assert _reports_the_outage_served_by_the_backup(reports[0]), observed def test_exposed_chat_without_a_fallback_keeps_the_flag_off_the_wire_and_the_errors_header_off( @@ -561,6 +587,13 @@ def test_exposed_responses_fallback_keeps_the_flag_off_the_wire(exposed: _Expose assert observed.served_id == _identity("responses", _EXPOSED_OPENAI_BACKUP, sent.nonce), observed assert observed.hit == (_EXPOSED_OPENAI_PRIMARY, _EXPOSED_OPENAI_BACKUP), observed assert observed.leaks == (), observed + if not stream: + return + events: Final = parse_sse(observed.text) + reports: Final = _fallback_reports(events) + assert tuple(report.data.get("type") for report in reports) == (_RESPONSES_FALLBACK_METADATA_EVENT,), observed + assert _reports_the_outage_served_by_the_backup(reports[0]), observed + assert all("type" in event.data for event in events if event.data), observed @pytest.mark.parametrize("stream", _STREAMING) diff --git a/tests/unit/proxy/proxy_server/test_streaming_helpers.py b/tests/unit/proxy/proxy_server/test_streaming_helpers.py index fdf58ec2fd7..4136a4acb16 100644 --- a/tests/unit/proxy/proxy_server/test_streaming_helpers.py +++ b/tests/unit/proxy/proxy_server/test_streaming_helpers.py @@ -874,6 +874,8 @@ def test_format_fallback_metadata_sse_event(): event = _format_fallback_metadata_sse_event( fallback_model="fallback-model", fallback_errors=fallback_errors, + responses_stream=False, + return_raw_model_name=False, ) assert isinstance(event, str) @@ -1469,6 +1471,127 @@ async def test_async_data_generator_emits_fallback_error_metadata_event(monkeypa assert isinstance(metadata_payload["created"], int) +@pytest.mark.asyncio +async def test_chat_stream_sends_fallback_metadata_in_place_of_a_stripped_usage_artifact( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_logging_flags(monkeypatch) + monkeypatch.setitem(ps.general_settings, "expose_fallback_errors_to_caller", True) + fallback_errors: Final = [ + {"message": "litellm.RateLimitError: upstream limited request", "type": "RateLimitError", "code": "429"} + ] + usage_artifact: Final = ModelResponseStream( + id="chatcmpl-test", choices=[], created=0, model="openai/internal-fallback", object="chat.completion.chunk" + ) + upstream: Final = _FakeStream( + [usage_artifact, _simple_chunk(model="openai/internal-fallback", content="hello")], + hidden_params={ + "additional_headers": { + "x-litellm-attempted-fallbacks": 1, + "x-litellm-model-group": "fallback-model", + "x-litellm-fallback-errors": json.dumps(fallback_errors), + } + }, + ) + + frames: Final = tuple( + [ + frame.decode() if isinstance(frame, bytes) else frame + async for frame in async_data_generator( + response=upstream, + user_api_key_dict=_user_auth(), + request_data={ + "model": "primary-model", + "include_fallback_errors": True, + "_litellm_strip_stream_usage": True, + }, + ) + ] + ) + payloads: Final = tuple(json.loads(frame.removeprefix("data: ")) for frame in frames[:-1]) + + assert frames[-1] == "data: [DONE]\n\n" + assert len(payloads) == 2 + assert payloads[0]["object"] == "chat.completion.chunk" + assert payloads[0]["choices"] == [] + assert payloads[0]["litellm_fallback"] == {"fallback_model": "fallback-model", "errors": fallback_errors} + assert payloads[1]["choices"][0]["delta"]["content"] == "hello" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fallback_model", ["fallback-model", None]) +@pytest.mark.parametrize("return_raw_model_name", [False, True]) +async def test_responses_stream_sends_fallback_metadata_as_a_typed_event_without_an_event_line( + monkeypatch: pytest.MonkeyPatch, + fallback_model: str | None, + return_raw_model_name: bool, +) -> None: + _patch_logging_flags(monkeypatch) + monkeypatch.setitem(ps.general_settings, "expose_fallback_errors_to_caller", True) + fallback_errors: Final = [ + { + "message": "litellm.AuthenticationError: Incorrect API key provided", + "type": "AuthenticationError", + "param": None, + "code": "401", + } + ] + response: Final = ResponsesAPIResponse(id="resp_fallback", created_at=1, model="fallback-model", output=[]) + upstream: Final = _FakeStream( + [ + ResponseCreatedEvent.model_validate( + {"type": "response.created", "sequence_number": 0, "response": response} + ), + ResponseCompletedEvent.model_validate( + {"type": "response.completed", "sequence_number": 1, "response": response} + ), + ], + hidden_params={ + "additional_headers": { + "x-litellm-attempted-fallbacks": 1, + **({"x-litellm-model-group": fallback_model} if fallback_model else {}), + "x-litellm-fallback-errors": json.dumps(fallback_errors), + } + }, + ) + + frames: Final = tuple( + [ + frame.decode() if isinstance(frame, bytes) else frame + async for frame in async_data_generator( + response=upstream, + user_api_key_dict=_user_auth(), + request_data={ + "model": "primary-model", + "include_fallback_errors": True, + "metadata": {RETURN_RAW_MODEL_NAME_METADATA_KEY: return_raw_model_name}, + }, + responses_stream_errors=True, + ) + ] + ) + event_frames: Final = tuple(frame for frame in frames if frame != "data: [DONE]\n\n") + payloads: Final = tuple( + json.loads(next(line.removeprefix("data: ") for line in frame.splitlines() if line.startswith("data: "))) + for frame in event_frames + ) + + assert tuple(payload.get("type") for payload in payloads) == ( + "response.created", + "litellm.fallback_metadata", + "response.completed", + ) + stamped_model: Final = None if return_raw_model_name else fallback_model + assert payloads[1] == { + "type": "litellm.fallback_metadata", + "id": "litellm-fallback-metadata", + **({"model": stamped_model} if stamped_model else {}), + "litellm_fallback": {"fallback_model": fallback_model, "errors": fallback_errors}, + } + assert {payload.get("model") for payload in payloads} == {stamped_model} + assert event_frames[1] == f"data: {json.dumps(payloads[1])}\n\n" + + @pytest.mark.asyncio async def test_async_data_generator_skips_fallback_error_event_without_opt_in( monkeypatch, From 0fd6dd4168dd2c95c929c7d36d7100dee589dd9b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:55:53 +0000 Subject: [PATCH 16/86] fix(proxy): recheck missing users when resolving session grants (#51030) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/resolvers/grants.py | 3 ++ .../unit/proxy/auth/test_resolvers_grants.py | 42 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/resolvers/grants.py b/litellm/proxy/auth/resolvers/grants.py index c66761a8459..9e445cee090 100644 --- a/litellm/proxy/auth/resolvers/grants.py +++ b/litellm/proxy/auth/resolvers/grants.py @@ -28,6 +28,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.auth_checks import ( TeamNotFoundError, + forget_missing_user, get_team_membership, get_team_object, get_user_object, @@ -246,6 +247,8 @@ class GrantResolver: return ResolvedGrants(user_object, team_object, membership, effective_user_id) async def _user(self, lookup: UserLookup) -> LiteLLM_UserTable | None: + if lookup.user_id is not None: + forget_missing_user(lookup.user_id) return await self._load_user( user_id=lookup.user_id, prisma_client=self._prisma, diff --git a/tests/unit/proxy/auth/test_resolvers_grants.py b/tests/unit/proxy/auth/test_resolvers_grants.py index e61269ec1c7..e0e33da5191 100644 --- a/tests/unit/proxy/auth/test_resolvers_grants.py +++ b/tests/unit/proxy/auth/test_resolvers_grants.py @@ -1,6 +1,10 @@ -from fastapi import HTTPException +from types import SimpleNamespace +from typing import TYPE_CHECKING, Final, cast +from unittest.mock import AsyncMock + import httpx import pytest +from fastapi import HTTPException from litellm.proxy._types import ( LiteLLM_TeamMembership, @@ -8,8 +12,7 @@ from litellm.proxy._types import ( LiteLLM_UserTable, ProxyException, ) -from litellm.proxy.auth.auth_checks import TeamNotFoundError, UserNotFoundError -from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.proxy.auth.auth_checks import TeamNotFoundError, UserNotFoundError, forget_missing_user from litellm.proxy.auth.resolvers.grants import ( GrantResolver, LookupDegraded, @@ -21,10 +24,43 @@ from litellm.proxy.auth.resolvers.grants import ( raise_public, user_models, ) +from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache USER_ID = "user-1" TEAM_ID = "team-1" +if TYPE_CHECKING: + from litellm.proxy.utils import PrismaClient + + +@pytest.mark.parametrize("user_exists", (True, False), ids=("created-on-another-worker", "still-missing")) +async def test_session_grants_recheck_a_recently_missing_user( + user_exists: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("time.time", lambda: 1_700_000_000.0) + user_id: Final = "session-user-after-miss" + row: Final = LiteLLM_UserTable(user_id=user_id, user_role="internal_user") + read: Final = AsyncMock(return_value=None) + database: Final = cast( + "PrismaClient", + SimpleNamespace( + db=SimpleNamespace( + litellm_usertable=SimpleNamespace(find_unique=read, find_first=AsyncMock(return_value=None)) + ) + ), + ) + resolver: Final = GrantResolver(database, UserApiKeyCache()) + forget_missing_user(user_id) + try: + assert await resolver.resolve(UserLookup(user_id=user_id), team_id=None) == UserGone(user_id=user_id) + read.return_value = row if user_exists else None + + outcome: Final = await resolver.resolve(UserLookup(user_id=user_id), team_id=None) + + assert outcome == (ResolvedGrants(row, None, None, user_id) if user_exists else UserGone(user_id=user_id)) + finally: + forget_missing_user(user_id) + class _Loaders: """Fake row readers standing in for the ``auth_checks`` loaders, recording every call they receive.""" From e95bf288f395120746d1a1509b4c09bb768d8ce9 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 10 Nov 2026 14:10:24 -0800 Subject: [PATCH 17/86] feat(cli): connect a per-user GitHub Copilot credential from the command line (#50863) * feat(cli): connect a per-user GitHub Copilot credential from the command line lite credentials connect runs the GitHub device login with the caller's own key, so users can connect Copilot without opening the LiteLLM UI. * fix(cli): stop connect before the GitHub code expires and explain lost connections The connect command kept polling until the proxy rejected the expired code with a raw HTTP 400, so it now stops one interval before expiry and prints its own expired message. A dropped connection or timeout while waiting now prints a short rerun message instead of a traceback. * fix(cli): keep checking for approval until the GitHub code actually expires Connect stopped one interval early, so an approval in the last seconds was missed. The countdown now starts when the code is requested, which is never later than the proxy's own cutoff, and polling continues while another poll fits. --- litellm/proxy/client/cli/README.md | 10 ++ .../proxy/client/cli/commands/credentials.py | 103 ++++++++++- litellm/proxy/client/credentials.py | 32 ++++ .../client/cli/test_credentials_commands.py | 161 +++++++++++++++++- 4 files changed, 297 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 1fdf8da7d2b..52d367d7333 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -260,6 +260,16 @@ Delete a credential: lite credentials delete ``` +#### Connect a GitHub Copilot Credential + +Connect your own GitHub Copilot account to a per-user GitHub Copilot credential, the same as the Connect button in the UI. Run it with your own virtual key, since the connection belongs to the user that key resolves to: + +```bash +lite --api-key sk-your-key credentials connect +``` + +It prints a code and a GitHub link, then waits. Open the link, enter the code, and approve. The command exits 0 once GitHub confirms the login, and exits 1 if the code expires, the login is denied, or the GitHub account has no Copilot seat. + ### Keys Management The CLI provides commands for managing API keys on your LiteLLM proxy server: diff --git a/litellm/proxy/client/cli/commands/credentials.py b/litellm/proxy/client/cli/commands/credentials.py index 2028bdb5599..40bcc6f6eea 100644 --- a/litellm/proxy/client/cli/commands/credentials.py +++ b/litellm/proxy/client/cli/commands/credentials.py @@ -1,5 +1,8 @@ import json -from collections.abc import Sequence +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from types import MappingProxyType from typing import Final, Literal import click @@ -8,9 +11,30 @@ import rich from rich.table import Table from typing_extensions import NotRequired, ReadOnly, TypedDict +from litellm.models.credentials import UserConnectionPollResponse, UserConnectionPollStatus + from ...credentials import CredentialsManagementClient +from ...exceptions import UnauthorizedError from ._cli_context import cli_context_values +_SLOW_DOWN_STEP_SECONDS: Final = 5 +_CONNECT_FAILURES: Final[MappingProxyType[UserConnectionPollStatus, str]] = MappingProxyType( + { + "expired": "The code expired before it was approved. Run the command again for a new code", + "denied": "The GitHub login was denied", + "no_copilot_seat": "That GitHub account has no GitHub Copilot seat", + } +) + + +@dataclass(frozen=True, slots=True) +class PollClock: + now: Callable[[], float] + sleep: Callable[[float], None] + + +SYSTEM_CLOCK: Final = PollClock(now=time.monotonic, sleep=time.sleep) + class _CredentialInfo(TypedDict): custom_llm_provider: ReadOnly[NotRequired[str]] @@ -179,3 +203,80 @@ def get(ctx: click.Context, credential_name: str): client: Final = CredentialsManagementClient(context["base_url"], context["api_key"]) response: Final = client.get(credential_name) _print_json(response) + + +def _next_poll_interval(poll: UserConnectionPollResponse, current: int) -> int: + if poll.interval is not None: + return poll.interval + return current + _SLOW_DOWN_STEP_SECONDS if poll.status == "slow_down" else current + + +def _poll_until_settled( + client: CredentialsManagementClient, + credential_name: str, + flow_handle: str, + first_interval: int, + expires_at: float, + clock: PollClock, +) -> UserConnectionPollResponse: + interval = first_interval # rebind-ok: GitHub can ask for a longer wait on any poll + while clock.now() + interval < expires_at: + clock.sleep(interval) + if (poll := client.poll_user_connection(credential_name, flow_handle)).status not in ("pending", "slow_down"): + return poll + interval = _next_poll_interval(poll, interval) + return UserConnectionPollResponse(status="expired") + + +def connect_user_credential( + client: CredentialsManagementClient, + credential_name: str, + clock: PollClock, +) -> UserConnectionPollResponse: + """Run the GitHub device login for the calling key's user and return the final poll result. + + Polling stops once the next poll would land after the code expires, since the proxy rejects polls on an expired + code. The countdown starts before the code is requested, so it never runs past the proxy's own.""" + requested_at: Final = clock.now() + started: Final = client.start_user_connection(credential_name) + click.echo(f"Go to {started.verification_uri} and enter code {started.user_code}") + click.echo("Waiting for approval...") + expires_at: Final = requested_at + started.expires_in + return _poll_until_settled(client, credential_name, started.flow_handle, started.interval, expires_at, clock) + + +def _report_http_error(response: requests.Response) -> None: + click.echo(f"Error: HTTP {response.status_code}", err=True) + try: + error_body: Final[_JsonBodyView] = {"body": response.json()} + _print_json(error_body["body"]) + except json.JSONDecodeError: + click.echo(response.text, err=True) + + +@credentials.command() +@click.argument("credential_name") +@click.pass_context +def connect(ctx: click.Context, credential_name: str) -> None: + """Connect your own GitHub Copilot account to a per-user credential, without the UI""" + context: Final = cli_context_values(ctx) + client: Final = CredentialsManagementClient(context["base_url"], context["api_key"]) + try: + result: Final = connect_user_credential(client, credential_name, SYSTEM_CLOCK) + except UnauthorizedError as e: + if isinstance(e.orig_exception, requests.exceptions.HTTPError) and e.orig_exception.response is not None: + _report_http_error(e.orig_exception.response) + raise click.Abort() + except requests.exceptions.HTTPError as e: + _report_http_error(e.response) + raise click.Abort() + except requests.exceptions.RequestException as e: + click.echo( + f"Error: could not reach the proxy at {context['base_url']} ({type(e).__name__}). Run the command again", + err=True, + ) + raise click.Abort() + if result.status != "connected": + click.echo(f"Error: {_CONNECT_FAILURES.get(result.status, result.status)}", err=True) + raise click.exceptions.Exit(1) + click.echo(f"Connected as {result.github_login} for credential '{credential_name}'") diff --git a/litellm/proxy/client/credentials.py b/litellm/proxy/client/credentials.py index 920aa238ace..2bd8ee8268a 100644 --- a/litellm/proxy/client/credentials.py +++ b/litellm/proxy/client/credentials.py @@ -5,6 +5,8 @@ from urllib.parse import quote import requests from pydantic import TypeAdapter +from litellm.models.credentials import UserConnectionPollResponse, UserConnectionStartResponse + from .exceptions import UnauthorizedError _DECODED_JSON: Final = TypeAdapter(object) @@ -217,3 +219,33 @@ class CredentialsManagementClient: if e.response.status_code == 401: raise UnauthorizedError(e) raise + + def _send_json(self, request: requests.Request) -> object: + try: + response: Final = requests.Session().send(request.prepare(), timeout=self._timeout) + response.raise_for_status() + return _DECODED_JSON.validate_python(response.json()) + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise UnauthorizedError(e) + raise + + def _user_connection_url(self, credential_name: str, action: str) -> str: + return f"{self._base_url}/credentials/{quote(credential_name, safe='')}/user_connection/{action}" + + def start_user_connection(self, credential_name: str) -> UserConnectionStartResponse: + """Start the GitHub device login that connects the calling key's user to a per-user credential.""" + request: Final = requests.Request( + "POST", self._user_connection_url(credential_name, "start"), headers=self._get_headers() + ) + return UserConnectionStartResponse.model_validate(self._send_json(request)) + + def poll_user_connection(self, credential_name: str, flow_handle: str) -> UserConnectionPollResponse: + """Check once whether the user approved the device login that ``start_user_connection`` began.""" + request: Final = requests.Request( + "POST", + self._user_connection_url(credential_name, "poll"), + headers=self._get_headers(), + json={"flow_handle": flow_handle}, + ) + return UserConnectionPollResponse.model_validate(self._send_json(request)) diff --git a/tests/unit/proxy/client/cli/test_credentials_commands.py b/tests/unit/proxy/client/cli/test_credentials_commands.py index cdbd73057a0..1e319ab4de3 100644 --- a/tests/unit/proxy/client/cli/test_credentials_commands.py +++ b/tests/unit/proxy/client/cli/test_credentials_commands.py @@ -3,11 +3,12 @@ from unittest.mock import MagicMock import pytest import requests +import responses from click.testing import CliRunner - - +from litellm.proxy.client.cli.commands.credentials import PollClock, connect_user_credential from litellm.proxy.client.cli.main import cli +from litellm.proxy.client.credentials import CredentialsManagementClient @pytest.fixture @@ -136,9 +137,7 @@ def test_acreate_credential_http_error(cli_runner, mock_credentials_client): mock_error_response = MagicMock() mock_error_response.status_code = 400 mock_error_response.json.return_value = {"error": "Invalid request"} - mock_instance.create.side_effect = requests.exceptions.HTTPError( - response=mock_error_response - ) + mock_instance.create.side_effect = requests.exceptions.HTTPError(response=mock_error_response) # Run command result = cli_runner.invoke( @@ -182,9 +181,7 @@ def test_adelete_credential_http_error(cli_runner, mock_credentials_client): mock_error_response = MagicMock() mock_error_response.status_code = 404 mock_error_response.json.return_value = {"error": "Credential not found"} - mock_instance.delete.side_effect = requests.exceptions.HTTPError( - response=mock_error_response - ) + mock_instance.delete.side_effect = requests.exceptions.HTTPError(response=mock_error_response) # Run command result = cli_runner.invoke(cli, ["credentials", "delete", "test-cred"]) @@ -264,3 +261,151 @@ def test_update_credential_requires_exactly_one_display_name_flag(cli_runner, mo assert result.exit_code != 0 assert "exactly one" in result.output mock_credentials_client.return_value.update_display_name.assert_not_called() + + +PROXY = "http://proxy.test" +CONNECT_URL = f"{PROXY}/credentials/team%2Fcopilot/user_connection" + + +def _start_body(interval: int = 0, expires_in: int = 900) -> dict[str, object]: + return { + "user_code": "ABCD-1234", + "verification_uri": "https://github.com/login/device", + "expires_in": expires_in, + "interval": interval, + "flow_handle": "sealed-handle", + } + + +class _FakeClock: + def __init__(self) -> None: + self.elapsed = 0.0 + self.waits: tuple[float, ...] = () + + def sleep(self, seconds: float) -> None: + self.elapsed += seconds + self.waits = (*self.waits, seconds) + + def as_poll_clock(self) -> PollClock: + return PollClock(now=lambda: self.elapsed, sleep=self.sleep) + + +def _run_connect(cli_runner): + return cli_runner.invoke( + cli, ["--base-url", PROXY, "--api-key", "sk-user", "credentials", "connect", "team/copilot"] + ) + + +@responses.activate +def test_connect_shows_the_github_code_and_reports_the_connected_login(cli_runner): + responses.add(responses.POST, f"{CONNECT_URL}/start", json=_start_body()) + responses.add(responses.POST, f"{CONNECT_URL}/poll", json={"status": "pending"}) + responses.add(responses.POST, f"{CONNECT_URL}/poll", json={"status": "connected", "github_login": "octocat"}) + + result = _run_connect(cli_runner) + + assert result.exit_code == 0, result.output + assert "https://github.com/login/device" in result.output + assert "ABCD-1234" in result.output + assert "Connected as octocat" in result.output + polls = [call for call in responses.calls if call.request.url.endswith("/poll")] + assert len(polls) == 2 + assert all(json.loads(call.request.body) == {"flow_handle": "sealed-handle"} for call in polls) + assert all(call.request.headers["Authorization"] == "Bearer sk-user" for call in responses.calls) + + +@pytest.mark.parametrize( + ("status", "message"), + [("expired", "expired"), ("denied", "denied"), ("no_copilot_seat", "no GitHub Copilot seat")], +) +@responses.activate +def test_connect_exits_nonzero_when_the_login_does_not_complete(cli_runner, status, message): + responses.add(responses.POST, f"{CONNECT_URL}/start", json=_start_body()) + responses.add(responses.POST, f"{CONNECT_URL}/poll", json={"status": status}) + + result = _run_connect(cli_runner) + + assert result.exit_code == 1 + assert message in result.output + assert "Connected as" not in result.output + + +@pytest.mark.parametrize( + ("status_code", "detail"), + [(401, "An authenticated user is required"), (404, "Credential not found")], +) +@responses.activate +def test_connect_surfaces_the_proxy_error_and_never_polls(cli_runner, status_code, detail): + responses.add(responses.POST, f"{CONNECT_URL}/start", status=status_code, json={"detail": detail}) + + result = _run_connect(cli_runner) + + assert result.exit_code != 0 + assert f"HTTP {status_code}" in result.output + assert detail in result.output + assert not any(call.request.url.endswith("/poll") for call in responses.calls) + + +@responses.activate +def test_connect_waits_the_interval_github_asks_for_and_backs_off_on_slow_down(): + responses.add(responses.POST, f"{CONNECT_URL}/start", json=_start_body(interval=5)) + for poll in ( + {"status": "slow_down"}, + {"status": "pending"}, + {"status": "slow_down", "interval": 20}, + {"status": "connected", "github_login": "octocat"}, + ): + responses.add(responses.POST, f"{CONNECT_URL}/poll", json=poll) + clock = _FakeClock() + + result = connect_user_credential( + CredentialsManagementClient(PROXY, "sk-user"), "team/copilot", clock.as_poll_clock() + ) + + assert result.status == "connected" + assert clock.waits == (5, 10, 10, 20) + + +@responses.activate +def test_connect_stops_polling_once_the_next_poll_would_land_after_the_code_expires(): + responses.add(responses.POST, f"{CONNECT_URL}/start", json=_start_body(interval=5, expires_in=19)) + for _ in range(3): + responses.add(responses.POST, f"{CONNECT_URL}/poll", json={"status": "pending"}) + responses.add(responses.POST, f"{CONNECT_URL}/poll", status=400, json={"detail": "invalid or expired flow_handle"}) + clock = _FakeClock() + + result = connect_user_credential( + CredentialsManagementClient(PROXY, "sk-user"), "team/copilot", clock.as_poll_clock() + ) + + assert result.status == "expired" + assert clock.waits == (5, 5, 5) + assert sum(call.request.url.endswith("/poll") for call in responses.calls) == 3 + + +@responses.activate +def test_connect_still_collects_an_approval_on_the_last_poll_before_the_code_expires(): + responses.add(responses.POST, f"{CONNECT_URL}/start", json=_start_body(interval=5, expires_in=19)) + responses.add(responses.POST, f"{CONNECT_URL}/poll", json={"status": "pending"}) + responses.add(responses.POST, f"{CONNECT_URL}/poll", json={"status": "pending"}) + responses.add(responses.POST, f"{CONNECT_URL}/poll", json={"status": "connected", "github_login": "octocat"}) + clock = _FakeClock() + + result = connect_user_credential( + CredentialsManagementClient(PROXY, "sk-user"), "team/copilot", clock.as_poll_clock() + ) + + assert result.status == "connected" + assert clock.elapsed == 15 + + +@responses.activate +def test_connect_reports_a_lost_connection_while_waiting_and_asks_to_rerun(cli_runner): + responses.add(responses.POST, f"{CONNECT_URL}/start", json=_start_body()) + responses.add(responses.POST, f"{CONNECT_URL}/poll", body=requests.exceptions.ConnectionError("connection reset")) + + result = _run_connect(cli_runner) + + assert result.exit_code == 1 + assert "ABCD-1234" in result.output + assert f"could not reach the proxy at {PROXY} (ConnectionError). Run the command again" in result.output From 8207a57d8440e2035292958431cc65be8604c97f Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 10 Nov 2026 14:24:15 -0800 Subject: [PATCH 18/86] fix(responses): keep the namespace == name shape for deferred top-level tools (#50602) * fix(responses): keep the namespace == name shape for deferred top-level tools The OpenAI Agents SDK matches a call to a deferred top-level function (defer_loading=True) only when the function_call carries namespace == name, the shape it reserves for deferred top-level tools. The Responses bridge returned no namespace, so after a native tool search the SDK failed with "Tool not found". A client that adds namespace == name then hit the echo path, which joined it into "__" for the provider. - namespace_tool_name_map maps deferred top-level functions to (name, name), so non-streaming and streaming replies carry namespace == name - the function_call echo does not qualify a name whose namespace equals it Co-Authored-By: Claude Opus 5.5 * fix(responses): keep the qualified name for a namespace member that shares its namespace's name A namespace "search" with a member "search" is advertised as the chat tool "search__search" and comes back as name == namespace == "search". The echo rule from the previous commit skipped the join for every namespace == name, so that replay reached the provider as "search", a tool it was never given. The bare-name echo now applies only to the request's deferred top-level tools (deferred_top_level_names), and the reply map uses the same set. A deferred top-level name that a self-named namespace member also uses is left out of both directions, so the namespace member keeps its round trip. The lookup reads defer_loading is True instead of validating it, so callers that only read the input (token counting under drop_params) do not raise on a malformed flag. Co-Authored-By: Claude Opus 5.5 * fix(responses): keep the bare name for a namespace == name echo replayed without request tools Stored history (previous_response_id) is rebuilt with no request tools, so the echo of a deferred call carrying namespace == name used to join to name__name. Only a namespace that holds a member of its own name still joins. The lookup also tolerates a tools value that is not a list, and the comprehensions stay within one for and one if clause. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): read request tools through one validated accessor The deferred-name lookup and the response-side tool maps take the request's tools from one TypeAdapter-backed accessor, so a tools value that is not a list of objects reads as no tools and the type gate does not grow. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): type the request mapping handed to the input transformer transform_responses_api_input_to_messages takes dict[str, object] instead of a bare dict, so the tools accessor's argument is known to the type gate and the instructions value is narrowed to str. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): build the namespace tool map from the validated member names namespace_tool_name_map reads each namespace's members through the same validated accessor as the self-named check, which keeps the type gate's per-rule counts at their merge-base values. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): replay stored turns with the tools they were sent with Stored previous_response_id history is rebuilt with each turn's own tools, so a namespace member that shares its namespace's name keeps the joined name__name form on replay, while a deferred top-level call keeps the bare name. Adds the session handler regression. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): keep the object entries of a tools list and read them on the streaming path too One accessor hands the request's tools to the request path and the streaming iterator, dropping only the entries that are not objects instead of the whole list, so both paths see the same custom, namespace and deferred tools. Trims the explanatory comments. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): count input tokens with the request's tools in hand /v1/responses/input_tokens hands the request params, tools included, to the input transformer, so an echoed call for a namespace member named after its namespace counts as name__name there too. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): hand the request tools to every input conversion that reads an echoed call The guardrail provenance pass, the prompt caching pre-call check and the Rust precall token count convert Responses input with the request's tools, so a namespace member named after its namespace keeps name__name there as it does on the request path. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): keep namespace == name for deferred tools loaded by a client-side tool search A tool the client's own tool search loaded is still a deferred top-level tool to the Agents SDK, so the bridge keeps its deferral for the response-side name map and the route strips it again before the provider sees the request. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Homen Shum <317650355+homenshum-cheiron@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../guardrail_translation/handler.py | 21 +- .../proxy/response_api_endpoints/endpoints.py | 14 +- .../client_tool_search.py | 25 +- .../session_handler.py | 10 +- .../streaming_iterator.py | 4 +- .../transformation.py | 95 +++++- litellm/router_rust/steps/precall.py | 2 +- .../prompt_caching_deployment_check.py | 2 +- ...test_openai_responses_guardrail_handler.py | 20 ++ .../response_api_endpoints/test_endpoints.py | 22 +- .../test_client_tool_search.py | 23 ++ .../test_litellm_completion_responses.py | 279 +++++++++++++++++- .../test_session_handler.py | 56 +++- 13 files changed, 528 insertions(+), 45 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 5b3b8e88834..8e9f2a0a0bd 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -111,7 +111,6 @@ from litellm.types.llms.openai import ( ResponseAPIUsage, ResponseCompactionItem, ResponseCompletedEvent, - ResponsesAPIOptionalRequestParams, ResponsesAPIResponse, ResponsesAPIStreamEvents, ResponsesAPIStreamingResponse, @@ -299,8 +298,6 @@ _PATCHABLE_ITEM_FIELDS: Final[Mapping[str, str]] = MappingProxyType( {"function_call_output": "output", "custom_tool_call_output": "output", "message": "content"} ) -_EMPTY_RESPONSES_REQUEST: Final[ResponsesAPIOptionalRequestParams] = {} - def _item_rewrite_field(item: Mapping[str, object]) -> str | None: item_type: Final = item.get("type") @@ -522,13 +519,15 @@ def _provenance_unit_bounds( def _input_item_provenance( raw_input: Sequence[object], expected_messages: Sequence[object], + request_tools: object = None, ) -> tuple[Mapping[int, int], frozenset[int]] | None: if not all(isinstance(item, Mapping) for item in raw_input): return None + request_params: Final = {"tools": request_tools} solo_conversions: Final = tuple( LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( input=cast("ResponseInputParam", [item]), # cast-ok: items checked as Mappings above - responses_api_request=_EMPTY_RESPONSES_REQUEST, + responses_api_request=request_params, replay_tool_search=True, ) for item in raw_input @@ -536,7 +535,7 @@ def _input_item_provenance( full_conversion: Final = tuple( LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( input=cast("ResponseInputParam", list(raw_input)), # cast-ok: items checked as Mappings above - responses_api_request=_EMPTY_RESPONSES_REQUEST, + responses_api_request=request_params, replay_tool_search=True, ) ) @@ -549,7 +548,7 @@ def _input_item_provenance( else tuple( LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( input=cast("ResponseInputParam", list(raw_input[start:end])), # cast-ok: checked as Mappings above - responses_api_request=_EMPTY_RESPONSES_REQUEST, + responses_api_request=request_params, replay_tool_search=True, ) ) @@ -708,11 +707,12 @@ def _patched_request_fields( instructions: object, original_messages: Sequence[object], structured_messages: Sequence[object], + request_tools: object = None, ) -> _RequestFields | None: if not isinstance(raw_input, list) or len(original_messages) != len(structured_messages): return None offset: Final = 1 if instructions else 0 - provenance: Final = _input_item_provenance(raw_input, tuple(original_messages)[offset:]) + provenance: Final = _input_item_provenance(raw_input, tuple(original_messages)[offset:], request_tools) if provenance is None: return None item_for_message, tainted = provenance @@ -755,10 +755,13 @@ def _patch_or_convert_request_fields( instructions: object, original_messages: Sequence[object], structured_messages: Sequence[AllMessageValues], + request_tools: object = None, ) -> _RequestFields | None: if not isinstance(structured_messages, list): return None - patched: Final = _patched_request_fields(raw_input, instructions, original_messages, structured_messages) + patched: Final = _patched_request_fields( + raw_input, instructions, original_messages, structured_messages, request_tools + ) if patched is not None: return patched input_items, converted_instructions = ( @@ -1042,7 +1045,7 @@ class OpenAIResponsesHandler(BaseTranslation): ) ) return _patch_or_convert_request_fields( - data.get("input"), data.get("instructions"), structured_messages, merged + data.get("input"), data.get("instructions"), structured_messages, merged, data.get("tools") ) def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]: diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index 574a677061d..11a0b947abe 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -110,7 +110,6 @@ from litellm.router import Router from litellm.types.llms.base import LiteLLMBaseModel from litellm.types.llms.openai import ( REASONING_EFFORT, - ResponsesAPIOptionalRequestParams, ResponsesAPIResponse, ) from litellm.types.responses.main import DeleteResponseResult @@ -1709,16 +1708,18 @@ def _missing_responses_param_response(param: str) -> JSONResponse: def _responses_input_as_token_count_messages( input_value: str | ResponseInputParam, - instructions: str | None, + request_params: Mapping[str, object], ) -> tuple[Mapping[str, object], ...]: from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) - request_params: Final[ResponsesAPIOptionalRequestParams] = {"instructions": instructions} transformed: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( input=input_value, - responses_api_request=request_params, + responses_api_request={ + "instructions": request_params.get("instructions"), + "tools": request_params.get("tools"), + }, only_since_compaction=True, ) return tuple( @@ -1799,10 +1800,7 @@ async def responses_input_tokens( try: payload: Final[_TokenCountPayload] = { "model": model_name, - "messages": _responses_input_as_token_count_messages( - input_value=input_value, - instructions=data.get("instructions"), - ), + "messages": _responses_input_as_token_count_messages(input_value=input_value, request_params=data), "tools": token_fields.get("tools"), } token_request: Final = TokenCountRequest.model_validate(payload) diff --git a/litellm/responses/litellm_completion_transformation/client_tool_search.py b/litellm/responses/litellm_completion_transformation/client_tool_search.py index 030bb014b7a..1c715fc1191 100644 --- a/litellm/responses/litellm_completion_transformation/client_tool_search.py +++ b/litellm/responses/litellm_completion_transformation/client_tool_search.py @@ -143,20 +143,33 @@ def _without_deferral(spec: Mapping[str, object]) -> Mapping[str, object]: return {key: value for key, value in spec.items() if key != "defer_loading"} -def _loaded_tools(tool: Mapping[str, object]) -> tuple[_LoadedTool, ...]: +def _loaded_tools(tool: Mapping[str, object], deferred_names: frozenset[str] = frozenset()) -> tuple[_LoadedTool, ...]: spec: Final = _tool_spec(tool) if spec is None: return () if spec.type != "namespace": - return (_LoadedTool(namespace=None, namespace_description="", spec=_without_deferral(tool)),) + # A loaded deferred tool keeps its deferral so its calls read back as namespace == name + deferred: Final = spec.defer_loading is True or spec.name in deferred_names + loaded_spec: Final = {**_without_deferral(tool), "defer_loading": True} if deferred else _without_deferral(tool) + return (_LoadedTool(namespace=None, namespace_description="", spec=loaded_spec),) return tuple( _LoadedTool(namespace=spec.name, namespace_description=spec.description, spec=_without_deferral(member)) for member in spec.tools ) -def _all_loaded_tools(tools: Iterable[Mapping[str, object]]) -> tuple[_LoadedTool, ...]: - return tuple(chain.from_iterable(map(_loaded_tools, tools))) +def _all_loaded_tools( + tools: Iterable[Mapping[str, object]], deferred_names: frozenset[str] = frozenset() +) -> tuple[_LoadedTool, ...]: + return tuple(chain.from_iterable(_loaded_tools(tool, deferred_names) for tool in tools)) + + +def _deferred_top_level_names(tools: Iterable[Mapping[str, object]]) -> frozenset[str]: + return frozenset( + spec.name + for tool in tools + if (spec := _tool_spec(tool)) is not None and spec.type != "namespace" and _is_deferred(tool) + ) def _callable_names(tool: object) -> tuple[str, ...]: @@ -256,7 +269,9 @@ def bridge_client_tool_search( if searches_on_client else existing ) - loaded: Final = _all_loaded_tools((*loaded_earlier, *client_tool_search_loaded_tools(input))) + loaded: Final = _all_loaded_tools( + (*loaded_earlier, *client_tool_search_loaded_tools(input)), _deferred_top_level_names(existing) + ) taken: Final = frozenset(chain.from_iterable(map(_callable_names, eager))) bridged_input: Final = ( input if isinstance(input, str) else [bridged for item in input if (bridged := _bridged_item(item)) is not None] diff --git a/litellm/responses/litellm_completion_transformation/session_handler.py b/litellm/responses/litellm_completion_transformation/session_handler.py index 6dbd7d69d11..2d40092c7b1 100644 --- a/litellm/responses/litellm_completion_transformation/session_handler.py +++ b/litellm/responses/litellm_completion_transformation/session_handler.py @@ -279,6 +279,11 @@ def _instruction_items_before( ) +def _stored_request_tools(proxy_server_request: Mapping[str, object] | None) -> object | None: + """The stored turn's ``tools``, so its calls read the same namespace shapes it was sent with.""" + return proxy_server_request.get("tools") if proxy_server_request is not None else None + + def _stored_instructions(proxy_server_request: Mapping[str, object] | None) -> str | None: instructions: Final = None if proxy_server_request is None else proxy_server_request.get("instructions") return instructions if isinstance(instructions, str) and instructions else None @@ -443,6 +448,7 @@ class ResponsesSessionHandler: ) response_input_param: str | ResponseInputParam | None = None + stored_tools: Final = _stored_request_tools(proxy_server_request_dict) ############################################################ # Add Input messages for this Spend Log @@ -463,7 +469,7 @@ class ResponsesSessionHandler: chat_completion_message_history.extend( LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( input=bridge_client_tool_search(response_input_param, None).input, - responses_api_request={}, + responses_api_request={"tools": stored_tools} if stored_tools is not None else {}, replay_reasoning=True, item_cache_control=item_cache_control, tool_call_provider_fields=tool_call_provider_fields, @@ -480,7 +486,7 @@ class ResponsesSessionHandler: chat_completion_message_history.extend( LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( input=bridge_client_tool_search(responses_api_output, None).input, - responses_api_request={}, + responses_api_request={"tools": stored_tools} if stored_tools is not None else {}, replay_reasoning=True, item_cache_control=item_cache_control, tool_call_provider_fields=tool_call_provider_fields, diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 040e2af2a08..489ab8a317d 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -241,7 +241,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._accumulated_provider_specific_fields: dict[str, object] = {} request_tools: Final = self.responses_api_request.get("tools") self._custom_tool_names: set[str] = extract_custom_tool_names(request_tools) - self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools) + self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map( + LiteLLMCompletionResponsesConfig.request_tools(self.responses_api_request) + ) self._web_search_calls: dict[str, object] = {} # mutable-ok: latest call by provider id self._queued_web_search_call_ids: set[str] = set() # mutable-ok: emitted call ids self._tool_search = tool_search_routing(request_tools) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3c1abbfe492..22f4c218879 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -185,6 +185,7 @@ from .tool_search import ( answers_tool_search, anthropic_tool_search_chat_tool, build_tool_search_call, + has_client_tool_search, has_server_tool_search, holds_tool_search_use, is_anthropic_tool_search_chat_tool, @@ -317,6 +318,21 @@ _TEXT_ADAPTER: Final = TypeAdapter(str) _RESPONSES_API_TOOL_CHOICE_ADAPTER: Final = TypeAdapter(ToolChoice) +def _tool_mapping(entry: object) -> Mapping[str, object] | None: + try: + return _STR_KEY_DICT_ADAPTER.validate_python(entry) + except ValidationError: + return None + + +def _tool_mappings(raw_tools: object) -> tuple[Mapping[str, object], ...]: + try: + entries: Final = _OBJECT_LIST_ADAPTER.validate_python(raw_tools) + except ValidationError: + return () + return tuple(tool for entry in entries if (tool := _tool_mapping(entry)) is not None) + + def _validate_responses_media(adapter: TypeAdapter[_MediaT], value: object) -> _MediaT: try: return adapter.validate_python(value) @@ -1182,6 +1198,8 @@ class LiteLLMCompletionResponsesConfig: ) -> Sequence[ChatToolParam]: if not any("defer_loading" in tool for tool in tools): return tools + if has_client_tool_search(callers_tools): + return [LiteLLMCompletionResponsesConfig._without_deferral(tool) for tool in tools] if any(is_anthropic_tool_search_chat_tool(tool) for tool in tools): return tools if not has_server_tool_search(callers_tools) and provider_builds_anthropic_request(model, custom_llm_provider): @@ -1498,6 +1516,9 @@ class LiteLLMCompletionResponsesConfig: LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message, replay_reasoning=replay_reasoning, native_compaction=native_compaction, + self_named_namespace_members=LiteLLMCompletionResponsesConfig.self_named_namespace_members( + LiteLLMCompletionResponsesConfig.request_tools(responses_api_request) + ), ) try: converted: Final = ( @@ -1679,6 +1700,7 @@ class LiteLLMCompletionResponsesConfig: input: str | ResponseInputParam, replay_reasoning: bool = False, native_compaction: bool = False, + self_named_namespace_members: frozenset[str] = frozenset(), ) -> list[ AllMessageValues | GenericChatCompletionMessage | ChatCompletionMessageToolCall | ChatCompletionResponseMessage ]: @@ -1705,6 +1727,7 @@ class LiteLLMCompletionResponsesConfig: input_item=_input, replay_reasoning=replay_reasoning, native_compaction=native_compaction, + self_named_namespace_members=self_named_namespace_members, ) ) @@ -2417,6 +2440,7 @@ class LiteLLMCompletionResponsesConfig: input_item: Mapping[str, object], replay_reasoning: bool = False, native_compaction: bool = False, + self_named_namespace_members: frozenset[str] = frozenset(), ) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]: """ Transform a Responses API input item into a Chat Completion message @@ -2470,7 +2494,8 @@ class LiteLLMCompletionResponsesConfig: return LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( function_call=cast( # cast-ok: callee coerces every field it reads with `or ""` / str() Mapping[str, str], input_item - ) + ), + self_named_namespace_members=self_named_namespace_members, ) elif input_item.get("type") == "compaction": return list( @@ -2739,6 +2764,7 @@ class LiteLLMCompletionResponsesConfig: @staticmethod def _transform_responses_api_function_call_to_chat_completion_message( function_call: Mapping[str, str], + self_named_namespace_members: frozenset[str] = frozenset(), ) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]: """ Transform a Responses API function_call into a Chat Completion message with tool calls @@ -2768,7 +2794,8 @@ class LiteLLMCompletionResponsesConfig: raw_arguments = json.dumps({"content": raw_input}) if raw_input else "" raw_name: Final = function_call.get("name") or "" namespace: Final = function_call.get("namespace") or "" - qualify: Final = bool(namespace) and function_call.get("type") != "custom_tool_call" + deferred_top_level: Final = namespace == raw_name and raw_name not in self_named_namespace_members + qualify: Final = bool(namespace) and not deferred_top_level and function_call.get("type") != "custom_tool_call" tool_call_id: Final = function_call.get("call_id") or function_call.get("id") or "" tool_call_function: Final = ChatCompletionToolCallFunctionChunk( name=f"{namespace}__{raw_name}" if qualify else raw_name, @@ -3151,15 +3178,57 @@ class LiteLLMCompletionResponsesConfig: return result @staticmethod - def namespace_tool_name_map(tools: ResponseTools) -> NamespaceNameMap: - namespace_entries: Final = tuple( - (str(tool.get("name") or ""), str(namespace_tool.get("name") or "")) + def request_tools(responses_api_request: Mapping[str, object] | None) -> ResponseTools: + """The request's tools, keeping the entries that are objects.""" + raw_tools: Final = responses_api_request.get("tools") if responses_api_request is not None else None + return _tool_mappings(raw_tools) if raw_tools is not None else None + + @staticmethod + def _namespace_member_names(tool: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + str(member.get("name") or "") + for member in _tool_mappings(tool.get("tools")) + if member.get("type") == "function" + ) + + @staticmethod + def self_named_namespace_members(tools: ResponseTools) -> frozenset[str]: + """Names of ``namespace`` tools that hold a function tool of their own name. + + Such a call comes back as namespace == name too, so it keeps the joined ``name__name`` form, + and a deferred top-level tool of that name does not get the namespace == name shape. + """ + return frozenset( + str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "namespace" - for namespace_tools in (tool.get("tools"),) - if isinstance(namespace_tools, Sequence) and not isinstance(namespace_tools, (str, bytes)) - for namespace_tool in namespace_tools - if isinstance(namespace_tool, Mapping) and namespace_tool.get("type") == "function" + and str(tool.get("name") or "") in LiteLLMCompletionResponsesConfig._namespace_member_names(tool) + ) + + @staticmethod + def deferred_top_level_names(tools: ResponseTools) -> frozenset[str]: + """Names of deferred top-level function tools that travel as namespace == name. + + Only ``defer_loading is True`` counts here: the request path rejects other values, and + callers that only read the input (token counting under drop_params) must not raise. + """ + self_named: Final = LiteLLMCompletionResponsesConfig.self_named_namespace_members(tools) + return frozenset( + str(tool.get("name") or "") + for tool in tools or () + if tool.get("type") == "function" + and tool.get("defer_loading") is True + and tool.get("name") + and str(tool.get("name") or "") not in self_named + ) + + @staticmethod + def namespace_tool_name_map(tools: ResponseTools) -> NamespaceNameMap: + namespace_entries: Final = tuple( + (str(tool.get("name") or ""), member_name) + for tool in tools or () + if tool.get("type") == "namespace" + for member_name in LiteLLMCompletionResponsesConfig._namespace_member_names(tool) ) top_level_function_names: Final = frozenset( str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "function" @@ -3178,7 +3247,11 @@ class LiteLLMCompletionResponsesConfig: qualified_entries: Final = tuple( (f"{namespace}__{tool_name}", (namespace, tool_name)) for namespace, tool_name in namespace_entries ) - return MappingProxyType(dict(qualified_entries + unambiguous_entries)) + # The OpenAI Agents SDK reserves namespace == name for a deferred top-level tool (agents/_tool_identity.py) + deferred_top_level_entries: Final = tuple( + (name, (name, name)) for name in LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools) + ) + return MappingProxyType(dict(qualified_entries + unambiguous_entries + deferred_top_level_entries)) @staticmethod def _restore_namespace_tool_name(tool_name: str, names: NamespaceNameMap) -> tuple[str, str | None]: @@ -3219,7 +3292,7 @@ class LiteLLMCompletionResponsesConfig: value=tool_call, ) - request_tools: Final = responses_api_request.get("tools") if responses_api_request is not None else None + request_tools: Final = LiteLLMCompletionResponsesConfig.request_tools(responses_api_request) custom_tool_names: Final = extract_custom_tool_names(request_tools) namespace_tool_names: Final = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools) tool_search: Final = tool_search_routing(request_tools) diff --git a/litellm/router_rust/steps/precall.py b/litellm/router_rust/steps/precall.py index c68c5fd5982..9de5c47b820 100644 --- a/litellm/router_rust/steps/precall.py +++ b/litellm/router_rust/steps/precall.py @@ -115,7 +115,7 @@ def _input_tokens( instructions: Final = raw_instructions if isinstance(raw_instructions, str) else None input_messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( # pyright: ignore[reportUnknownMemberType] # its input type is untyped input=text_input, # pyright: ignore[reportArgumentType] # str | list, as the request carries it - responses_api_request={"instructions": instructions} if instructions is not None else {}, + responses_api_request={"instructions": instructions, "tools": extras.get("tools")}, only_since_compaction=True, ) return counter(model=tokenizer_model, messages=input_messages, tools=_tools(extras)) diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index 2f0cbc0d474..47818f997b3 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -119,7 +119,7 @@ def _responses_prompt(request_data: Mapping[str, object]) -> _ResponsesPrompt | ) messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( # pyright: ignore[reportUnknownMemberType] # its input type is untyped input=cast(str | ResponseInputParam, request_input), # cast-ok: a bad item raises and is caught here - responses_api_request={"instructions": request_data.get("instructions")}, + responses_api_request={"instructions": request_data.get("instructions"), "tools": request_tools}, item_cache_control=True, only_since_compaction=True, ) diff --git a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py index 8902cc59f0f..b2bc886a6b7 100644 --- a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -4224,6 +4224,26 @@ class TestProvenancePatching: assert _input_item_provenance(["not a mapping"], []) is None + def test_provenance_reads_a_self_named_namespace_echo_with_the_request_tools(self): + from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance + from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, + ) + + tools = [{"type": "namespace", "name": "search", "tools": [{"type": "function", "name": "search"}]}] + raw_input = [ + {"role": "user", "content": "Search for the release notes."}, + {"type": "function_call", "name": "search", "namespace": "search", "call_id": "call_1", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "found"}, + ] + expected = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=raw_input, responses_api_request={"tools": tools} + ) + + assert expected[1]["tool_calls"][0]["function"]["name"] == "search__search" + assert _input_item_provenance(raw_input, expected, tools) is not None + assert _input_item_provenance(raw_input, expected) is None + def test_provenance_bails_when_expected_messages_disagree(self): from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance diff --git a/tests/unit/proxy/response_api_endpoints/test_endpoints.py b/tests/unit/proxy/response_api_endpoints/test_endpoints.py index 2c23305d5c9..e4e4b27e1c2 100644 --- a/tests/unit/proxy/response_api_endpoints/test_endpoints.py +++ b/tests/unit/proxy/response_api_endpoints/test_endpoints.py @@ -6173,6 +6173,24 @@ _SUMMARY_INPUT: Final = [{"type": "message", "role": "user", "content": _COMPACT def test_input_token_count_reads_a_compaction_item_as_its_summary(): from litellm.proxy.response_api_endpoints.endpoints import _responses_input_as_token_count_messages - assert _responses_input_as_token_count_messages(_COMPACTED_INPUT, None) == ( - _responses_input_as_token_count_messages(_SUMMARY_INPUT, None) + assert _responses_input_as_token_count_messages(_COMPACTED_INPUT, {}) == ( + _responses_input_as_token_count_messages(_SUMMARY_INPUT, {}) ) + + +def test_input_token_count_keeps_the_joined_name_of_a_self_named_namespace_member(): + from litellm.proxy.response_api_endpoints.endpoints import _responses_input_as_token_count_messages + + echo: Final = [ + {"role": "user", "content": "Search for the release notes."}, + {"type": "function_call", "name": "search", "namespace": "search", "call_id": "call_1", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "found"}, + ] + namespace_tools: Final = [{"type": "namespace", "name": "search", "tools": [{"type": "function", "name": "search"}]}] + + def called_names(tools: object) -> list[str]: + messages = _responses_input_as_token_count_messages(cast(Any, echo), {"tools": tools}) + return [call["function"]["name"] for message in messages for call in message.get("tool_calls") or []] + + assert called_names(namespace_tools) == ["search__search"] + assert called_names([{"type": "function", "name": "search", "defer_loading": True}]) == ["search"] diff --git a/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py b/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py index dc975b3d112..03027d07976 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py +++ b/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py @@ -1,6 +1,7 @@ from typing import Final from litellm.responses.litellm_completion_transformation.client_tool_search import bridge_client_tool_search +from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponseInputParam _CODEX_TOOL_SEARCH: Final = {"type": "tool_search", "execution": "client"} @@ -66,3 +67,25 @@ def test_deferred_request_tools_are_held_back_only_when_the_client_searches() -> assert with_search.tools == (_CODEX_TOOL_SEARCH, _SHELL) assert without_search.tools == (_SHELL, deferred) + + +def test_a_loaded_deferred_tool_keeps_its_deferral_so_its_calls_read_back_as_namespace_equals_name() -> None: + deferred: Final = {**_SHELL, "name": "deploy", "defer_loading": True} + loaded_plain: Final = {**_SHELL, "name": "deploy", "description": "loaded copy"} + + bridged: Final = bridge_client_tool_search( + [_SEARCH_CALL, _search_output(loaded_plain)], [_CODEX_TOOL_SEARCH, _SHELL, deferred] + ) + + assert bridged.tools == (_CODEX_TOOL_SEARCH, _SHELL, {**loaded_plain, "defer_loading": True}) + assert LiteLLMCompletionResponsesConfig.namespace_tool_name_map( + LiteLLMCompletionResponsesConfig.request_tools({"tools": list(bridged.tools)}) + ) == {"deploy": ("deploy", "deploy")} + + +def test_a_loaded_tool_that_was_never_deferred_stays_undeferred() -> None: + loaded_lint: Final = {**_SHELL, "name": "lint"} + + bridged: Final = bridge_client_tool_search([_SEARCH_CALL, _search_output(loaded_lint)], [_CODEX_TOOL_SEARCH]) + + assert bridged.tools == (_CODEX_TOOL_SEARCH, loaded_lint) diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 8fb066d437f..0e16276ffa5 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -910,6 +910,118 @@ class TestLiteLLMCompletionResponsesConfig: assert tool_calls[0].namespace == "collaboration" assert tool_calls[0].arguments == '{"message":"hello"}' + def test_transform_chat_completion_response_deferred_top_level_tool_call_has_namespace_equal_to_name(self): + """A deferred top-level function (defer_loading=True) comes back with namespace == name, + the shape the OpenAI Agents SDK reserves for it. Without it the SDK raises + "Tool get_reporting_categories not found" after a native tool search.""" + tool_call_id = "call_deferred_top_level" + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="bedrock/invoke/anthropic.claude-opus-5", + object="chat.completion", + choices=[ + Choices( + finish_reason="tool_calls", + index=0, + message=Message( + content=None, + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id=tool_call_id, + type="function", + function=Function(name="get_reporting_categories", arguments='{"market":"TR-TITCK"}'), + ) + ], + ), + ) + ], + ) + + try: + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Is CHG-TR-0042 Type IB in TR-TITCK?", + responses_api_request={ + "tools": [ + { + "type": "function", + "name": "get_reporting_categories", + "defer_loading": True, + "parameters": {"type": "object", "properties": {"market": {"type": "string"}}}, + } + ] + }, + chat_completion_response=chat_completion_response, + ) + ) + finally: + TOOL_CALLS_CACHE.delete_cache(key=tool_call_id) + + tool_calls = [item for item in responses_api_response.output if item.type == "function_call"] + assert [(c.name, c.namespace, c.arguments) for c in tool_calls] == [ + ("get_reporting_categories", "get_reporting_categories", '{"market":"TR-TITCK"}') + ] + + def test_transform_chat_completion_response_tool_loaded_by_client_side_search_has_namespace_equal_to_name(self): + """A deferred tool the client's own tool search loaded is still a deferred top-level tool to the + Agents SDK, so its call comes back as namespace == name through the client-search bridge too.""" + from litellm.responses.litellm_completion_transformation.client_tool_search import bridge_client_tool_search + + tool_call_id = "call_client_searched_deploy" + bridged = bridge_client_tool_search( + [ + {"type": "tool_search_call", "call_id": "s1", "execution": "client", "arguments": {"query": "deploy"}}, + { + "type": "tool_search_output", + "call_id": "s1", + "status": "completed", + "execution": "client", + "tools": [{"type": "function", "name": "deploy", "parameters": {"type": "object"}}], + }, + ], + [ + {"type": "tool_search", "execution": "client"}, + {"type": "function", "name": "deploy", "defer_loading": True, "parameters": {"type": "object"}}, + ], + ) + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="bedrock/invoke/anthropic.claude-opus-5", + object="chat.completion", + choices=[ + Choices( + finish_reason="tool_calls", + index=0, + message=Message( + content=None, + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id=tool_call_id, type="function", function=Function(name="deploy", arguments="{}") + ) + ], + ), + ) + ], + ) + + try: + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input=bridged.input, + responses_api_request={"tools": list(bridged.tools)}, + chat_completion_response=chat_completion_response, + ) + ) + finally: + TOOL_CALLS_CACHE.delete_cache(key=tool_call_id) + + tool_calls = [item for item in responses_api_response.output if item.type == "function_call"] + assert [(c.name, c.namespace) for c in tool_calls] == [("deploy", "deploy")] + def test_transform_chat_completion_response_plain_tool_call_has_no_namespace(self): """A non-namespace function call must not gain a namespace attribute, matching the streaming path which only sets it when a namespace was restored.""" @@ -1999,6 +2111,30 @@ class TestToolTransformation: assert [tool["function"]["name"] for tool in result["tools"]] == ["get_weather", "finance__get_stock_price"] assert all("defer_loading" not in tool for tool in result["tools"]) + @pytest.mark.parametrize( + ("model", "provider"), + [ + ("anthropic/claude-sonnet-5-5", "anthropic"), + ("bedrock/invoke/us.anthropic.claude-opus-5", "bedrock"), + ("gemini/gemini-2.5-flash", "gemini"), + ], + ) + def test_tools_loaded_by_a_client_side_search_go_out_undeferred(self, model: str, provider: str) -> None: + tools: Final = [ + {"type": "tool_search", "execution": "client"}, + {"type": "function", "name": "deploy", "defer_loading": True, "parameters": {"type": "object"}}, + ] + + result: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( + model=model, + input="Deploy the service.", + responses_api_request={"tools": tools}, + custom_llm_provider=provider, + ) + + assert [tool["function"]["name"] for tool in result["tools"]] == ["tool_search", "deploy"] + assert all("defer_loading" not in tool for tool in result["tools"]) + def test_native_bm25_search_tool_keeps_the_other_tools_deferred(self) -> None: tools: Final = [ {"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"}, @@ -3144,6 +3280,104 @@ class TestToolTransformation: assert messages[0]["tool_calls"][0]["function"]["name"] == "mcp__everything__get_sum" + def test_function_call_echo_with_namespace_equal_to_name_keeps_bare_name(self): + """The Agents SDK echoes a deferred top-level call with namespace == name. The provider + was given the bare tool name, so the outbound chat tool_call must not become + "get_reporting_categories__get_reporting_categories".""" + messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=[ + { + "type": "function_call", + "name": "get_reporting_categories", + "namespace": "get_reporting_categories", + "call_id": "call_3", + "arguments": '{"market": "KR-MFDS"}', + } + ], + responses_api_request={ + "tools": [{"type": "function", "name": "get_reporting_categories", "defer_loading": True}] + }, + ) + + assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["get_reporting_categories"] + + def test_function_call_echo_with_namespace_equal_to_name_keeps_bare_name_without_request_tools(self): + """Stored history (previous_response_id) is rebuilt with no request tools. A deferred call echoed + there still carries namespace == name and must go to the provider under its bare name, not + "get_reporting_categories__get_reporting_categories".""" + messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=[ + { + "type": "function_call", + "name": "get_reporting_categories", + "namespace": "get_reporting_categories", + "call_id": "call_7", + "arguments": "{}", + } + ], + responses_api_request={}, + ) + + assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["get_reporting_categories"] + + def test_function_call_echo_from_namespace_with_member_of_same_name_keeps_qualified_name(self): + """A namespace "search" with a member "search" is advertised as the chat tool + "search__search" and comes back as name == namespace == "search". Its echo must + still join, or the provider gets a call to a tool it was never given.""" + tools = [ + { + "type": "namespace", + "name": "search", + "description": "Search tools", + "tools": [{"type": "function", "name": "search", "defer_loading": True}], + } + ] + messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=[ + { + "type": "function_call", + "name": "search", + "namespace": "search", + "call_id": "call_4", + "arguments": "{}", + } + ], + responses_api_request={"tools": tools}, + ) + + assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["search__search"] + assert LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools) == frozenset() + + def test_deferred_top_level_name_shared_with_self_named_namespace_member_stays_qualified(self): + """When a deferred top-level "search" and a namespace "search" with a member "search" + are both in the request, namespace == name cannot tell them apart. Neither direction + uses that shape for the top-level tool, so the namespace member keeps its round trip.""" + tools = [ + {"type": "function", "name": "search", "defer_loading": True}, + {"type": "function", "name": "lookup", "defer_loading": True}, + { + "type": "namespace", + "name": "search", + "description": "Search tools", + "tools": [{"type": "function", "name": "search"}], + }, + ] + + assert LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools) == frozenset({"lookup"}) + assert dict(LiteLLMCompletionResponsesConfig.namespace_tool_name_map(tools)) == { + "search__search": ("search", "search"), + "lookup": ("lookup", "lookup"), + } + messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=[ + {"type": "function_call", "name": "search", "namespace": "search", "call_id": "c5", "arguments": "{}"}, + {"type": "function_call", "name": "lookup", "namespace": "lookup", "call_id": "c6", "arguments": "{}"}, + ], + responses_api_request={"tools": tools}, + ) + + assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["search__search", "lookup"] + def test_custom_tool_call_echo_keeps_short_name(self): """Custom tools stay advertised under their short name, so a namespace on a custom_tool_call echo is routing metadata and must not be prefixed.""" @@ -3302,6 +3536,27 @@ class TestToolTransformation: assert result["gamma__run"] == ("gamma", "run") assert "run" not in result + def test_namespace_tool_name_map_keeps_the_object_tools_beside_a_malformed_entry(self): + tools = LiteLLMCompletionResponsesConfig.request_tools( + { + "tools": [ + { + "type": "namespace", + "name": "ns", + "tools": [{"type": "function", "name": "a"}, "junk"], + }, + "junk", + {"type": "function", "name": "top", "defer_loading": True}, + ] + } + ) + + assert LiteLLMCompletionResponsesConfig.namespace_tool_name_map(tools) == { + "ns__a": ("ns", "a"), + "a": ("ns", "a"), + "top": ("top", "top"), + } + def test_namespace_tool_name_map_drops_top_level_function_collision(self): tools = [ {"type": "function", "name": "run", "parameters": {"type": "object"}}, @@ -5051,7 +5306,8 @@ class TestEnsureOutputItemContentPartAdded: iterator._responses_namespace_tool_call_fields("admin__run") iterator._responses_namespace_tool_call_fields("admin__run") - namespace_map.assert_called_once_with(request["tools"]) + namespace_map.assert_called_once() + assert namespace_map.call_args.args[0] == tuple(request["tools"]) def test_emit_terminal_event_uses_stream_finish_reason(self): """ @@ -5453,11 +5709,21 @@ class TestHostedWebSearchReplay: ("server_tool_use", "expected_input"), ( ( - {"type": "server_tool_use", "id": "srvtoolu_01Pending", "name": "web_fetch", "input": {"url": _FETCHED_URL}}, + { + "type": "server_tool_use", + "id": "srvtoolu_01Pending", + "name": "web_fetch", + "input": {"url": _FETCHED_URL}, + }, {"url": _FETCHED_URL}, ), ( - {"type": "server_tool_use", "id": "srvtoolu_01Pending", "name": "web_search", "input": {"query": "python"}}, + { + "type": "server_tool_use", + "id": "srvtoolu_01Pending", + "name": "web_search", + "input": {"query": "python"}, + }, {"query": "python"}, ), ), @@ -5486,7 +5752,12 @@ class TestHostedWebSearchReplay: assert [turn["role"] for turn in anthropic_turns] == ["user", "assistant", "user"] assert [block for block in anthropic_turns[1]["content"] if block["type"] != "text"] == [ - {"type": "server_tool_use", "id": "srvtoolu_01Pending", "name": server_tool_use["name"], "input": expected_input}, + { + "type": "server_tool_use", + "id": "srvtoolu_01Pending", + "name": server_tool_use["name"], + "input": expected_input, + }, {"type": "tool_use", "id": "toolu_01Weather", "name": "get_weather", "input": {"city": "Paris"}}, ] assert [(block["type"], block["tool_use_id"]) for block in anthropic_turns[2]["content"]] == [ diff --git a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py index 9a8fa19e2e4..3e2a2c2cf34 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py +++ b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py @@ -888,6 +888,57 @@ async def test_message_history_replays_real_key_named_tool_payloads() -> None: assert json.loads(tool_message["content"]) == function_output +async def test_message_history_keeps_the_joined_name_of_a_stored_self_named_namespace_member() -> None: + request_id: Final = "chatcmpl-self-named-namespace" + responses_request_body: Final = { + "model": "anthropic/claude-sonnet-4-5", + "tools": [ + { + "type": "namespace", + "name": "search", + "tools": [{"type": "function", "name": "search", "parameters": {"type": "object", "properties": {}}}], + } + ], + "input": [ + {"role": "user", "content": "Search for the release notes."}, + {"type": "function_call", "call_id": "call_1", "name": "search", "namespace": "search", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "release notes found"}, + ], + } + + with patch( + "litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs", + return_value=True, + ): + proxy_server_request: Final = json.loads( + _get_proxy_server_request_for_spend_logs_payload( + metadata={}, + litellm_params={"proxy_server_request": {"body": responses_request_body}}, + kwargs={}, + ) + ) + + spend_log: Final = { + "request_id": request_id, + "call_type": "aresponses", + "session_id": "session-self-named-namespace", + "proxy_server_request": proxy_server_request, + "response": _chat_completion_response(request_id, "Found them."), + } + + with patch.object( + ResponsesSessionHandler, + "get_all_spend_logs_for_previous_response_id", + new_callable=AsyncMock, + ) as mock_get_spend_logs: + mock_get_spend_logs.return_value = [spend_log] + result: Final = await ResponsesSessionHandler.get_chat_completion_message_history_for_previous_response_id( + request_id + ) + + assert [call["function"]["name"] for call in result["messages"][1]["tool_calls"]] == ["search__search"] + + def _turn_spend_log( turn_input: str | list[dict[str, str]], answer: str, compaction_blocks: list[dict[str, str]] | None = None ) -> dict[str, object]: @@ -1162,7 +1213,10 @@ async def test_an_item_a_poll_caught_half_written_replays_its_finished_text_once "resp_poll_done", "aget_responses", "completed", - [_identified_output_message("msg_answer", "One, two, three"), _identified_output_message("msg_more", "Done")], + [ + _identified_output_message("msg_answer", "One, two, three"), + _identified_output_message("msg_more", "Done"), + ], ), ) From ac0d2fcd68405310baaafbf32334d3d1ffc27877 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:40:03 +0000 Subject: [PATCH 19/86] chore(cost-map): add fal_ai image and video rows priced from the fal.ai model pages (first fal-ai sync) (#51035) * chore(cost-map): add fal_ai image and video rows priced from the fal.ai model pages (first fal-ai sync) * chore(cost-map): add 18 more fal_ai rows whose page states a per-image, per-megapixel or per-second price despite a generic API unit * chore(cost-map): add 11 promotional fal_ai video rows at the rate live on 2026-11-10 (promotional rates apply until the promotion ends) * test: exempt the three fal_ai seedance-2.5/us rows whose 1080p rate is above $1 per second from the cost ceiling check --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 8350 ++++++++++++++++- model_prices_and_context_window.json | 8350 ++++++++++++++++- tests/unit/test_utils.py | 3 + 3 files changed, 16681 insertions(+), 22 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 662909b5cb7..40ff675de19 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -26158,8 +26158,11 @@ }, "fal_ai/fal-ai/flux-pro/v1.1": { "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1.1 at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1", "supported_endpoints": [ "/v1/images/generations" ] @@ -26168,14 +26171,18 @@ "litellm_provider": "fal_ai", "mode": "image_generation", "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1-ultra", "supported_endpoints": [ "/v1/images/generations" ] }, "fal_ai/fal-ai/flux/schnell": { "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/schnell at $0.003 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.003, + "output_cost_per_pixel": 2.86102294921875e-09, + "source": "https://fal.ai/models/fal-ai/flux/schnell", "supported_endpoints": [ "/v1/images/generations" ] @@ -26251,6 +26258,7 @@ }, "fal_ai/minimax/h3/text-to-video": { "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, "mode": "video_generation", "output_cost_per_second": 0.13, "output_cost_per_second_480p": 0.05, @@ -26360,6 +26368,7 @@ "litellm_provider": "fal_ai", "mode": "image_generation", "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3", "supported_endpoints": [ "/v1/images/generations" ] @@ -26398,8 +26407,11 @@ }, "fal_ai/fal-ai/stable-diffusion-v35-medium": { "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/stable-diffusion-v35-medium at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", - "output_cost_per_image": 0.0398, + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/stable-diffusion-v35-medium", "supported_endpoints": [ "/v1/images/generations" ] @@ -26407,7 +26419,8 @@ "fal_ai/fal-ai/nano-banana": { "litellm_provider": "fal_ai", "mode": "image_generation", - "output_cost_per_image": 0.039, + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/nano-banana", "supported_endpoints": [ "/v1/images/generations" ] @@ -26415,7 +26428,8 @@ "fal_ai/fal-ai/gemini-25-flash-image": { "litellm_provider": "fal_ai", "mode": "image_generation", - "output_cost_per_image": 0.039, + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/gemini-25-flash-image", "supported_endpoints": [ "/v1/images/generations" ] @@ -28346,9 +28360,7 @@ }, "fal_ai/fal-ai/flux/dev": { "litellm_provider": "fal_ai", - "metadata": { - "notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price is the fallback when dimensions are unavailable" - }, + "metadata": {"notes":"fal bills fal-ai/flux/dev at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.025, "output_cost_per_pixel": 2.384185791015625e-08, @@ -28380,16 +28392,16 @@ }, "fal_ai/fal-ai/flux-lora-depth": { "litellm_provider": "fal_ai", - "metadata": { - "notes": "fal bills fal-ai/flux-lora-depth at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries" - }, + "metadata": {"notes":"fal bills fal-ai/flux-lora-depth at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.035, "output_cost_per_pixel": 3.337860107421875e-08, "source": "https://fal.ai/models/fal-ai/flux-lora-depth", "supported_endpoints": [ - "/v1/images/edits" - ] + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true }, "fal_ai/fal-ai/moondream3-preview/query": { "input_cost_per_token": 4e-07, @@ -88728,5 +88740,8321 @@ "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_vision": true + }, + "fal_ai/alibaba/happy-horse/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/v1.1/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/v1.1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/v1.1/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/v1.1/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/v1.1/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/v1.1/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0-prime/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/wan-3.0-prime/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0-prime/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/wan-3.0-prime/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0-prime/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/wan-3.0-prime/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/alibaba/wan-3.0/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/alibaba/wan-3.0/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/alibaba/wan-3.0/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/draft-enhance": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.29, + "output_cost_per_second_1080p": 0.29, + "source": "https://fal.ai/models/blackforestlabs/flux-3/draft-enhance", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/edit-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_720p": 0.03, + "source": "https://fal.ai/models/blackforestlabs/flux-3/edit-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.41, + "output_cost_per_second_1080p": 0.53, + "output_cost_per_second_720p": 0.41, + "source": "https://fal.ai/models/blackforestlabs/flux-3/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/extend-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.12, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/first-last-frame-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/first-last-frame-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/image-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/image-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/keyframes-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/keyframes-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/klein/base/edit-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/base/edit-image/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/base/edit-image/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/klein/base/text-to-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/base/text-to-image/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/base/text-to-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/klein/edit-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/edit-image/lora at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/edit-image/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/klein/text-to-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/text-to-image/lora at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/text-to-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/text-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/text-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/bria_video_eraser/erase/keypoints": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/bria_video_eraser/erase/keypoints", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/bria_video_eraser/erase/mask": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/bria_video_eraser/erase/mask", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/bria_video_eraser/erase/prompt": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/bria_video_eraser/erase/prompt", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/embed-product": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/embed-product", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/extract-object": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://fal.ai/models/bria/extract-object", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-2/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-bbq-preview/generate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-bbq-preview/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/fibo-edit-1.5/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit-1.5/expand": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/expand", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit-1.5/product-holding": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/product-holding", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit-1.5/virtual-try-on": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/virtual-try-on", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/add_object_by_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/add_object_by_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/blend": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/blend", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/colorize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/colorize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/erase_by_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/erase_by_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/relight": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/relight", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/replace_object_by_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/replace_object_by_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/reseason": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/reseason", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/restore": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/restore", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/restyle": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/restyle", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/rewrite_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/rewrite_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/sketch_to_colored_image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/sketch_to_colored_image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-gen-1.5/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-gen-1.5/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/fibo-lite/generate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.036, + "source": "https://fal.ai/models/bria/fibo-lite/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/fibo/generate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/genfill/v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills bria/genfill/v2 at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/bria/genfill/v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/guided-remove-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/bria/guided-remove-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/increase-resolution": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/increase-resolution", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/product-dimensions": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/product-dimensions", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/replace-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/replace-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/upscale/creative": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/upscale/creative", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/video/background-removal": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/background-removal", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/background-removal/green-screen-despill": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/bria/video/background-removal/green-screen-despill", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/background-removal/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0042, + "source": "https://fal.ai/models/bria/video/background-removal/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/background-removal/v3": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/bria/video/background-removal/v3", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/erase/keypoints": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/erase/keypoints", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/erase/mask": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/erase/mask", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/erase/prompt": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/erase/prompt", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/increase-resolution": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/increase-resolution", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/omnihuman/v2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.105, + "source": "https://fal.ai/models/bytedance/omnihuman/v2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/fast/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2419, + "output_cost_per_second_720p": 0.2419, + "source": "https://fal.ai/models/bytedance/seedance-2.0/fast/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/fast/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2419, + "output_cost_per_second_720p": 0.2419, + "source": "https://fal.ai/models/bytedance/seedance-2.0/fast/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/mini/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1547, + "output_cost_per_second_480p": 0.0721, + "output_cost_per_second_720p": 0.1547, + "source": "https://fal.ai/models/bytedance/seedance-2.0/mini/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/mini/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.007, + "output_cost_per_second_480p": 0.007, + "source": "https://fal.ai/models/bytedance/seedance-2.0/mini/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/mini/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1547, + "output_cost_per_second_480p": 0.0721, + "output_cost_per_second_720p": 0.1547, + "source": "https://fal.ai/models/bytedance/seedance-2.0/mini/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/us/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.3034, + "output_cost_per_second_1080p": 0.682, + "output_cost_per_second_720p": 0.3034, + "source": "https://fal.ai/models/bytedance/seedance-2.0/us/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/us/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.37, + "output_cost_per_second_480p": 0.1731, + "output_cost_per_second_720p": 0.37, + "source": "https://fal.ai/models/bytedance/seedance-2.0/us/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/draft/complete": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 1.164, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/draft/complete", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.473, + "output_cost_per_second_1080p": 1.164, + "output_cost_per_second_480p": 0.2205, + "output_cost_per_second_720p": 0.473, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.473, + "output_cost_per_second_1080p": 1.164, + "output_cost_per_second_480p": 0.2205, + "output_cost_per_second_720p": 0.473, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedream/v5/flash/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.027, + "source": "https://fal.ai/models/bytedance/seedream/v5/flash/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/flash/layerize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.027, + "source": "https://fal.ai/models/bytedance/seedream/v5/flash/layerize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/flash/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.027, + "source": "https://fal.ai/models/bytedance/seedream/v5/flash/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bytedance/seedream/v5/lite/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/bytedance/seedream/v5/lite/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/lite/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/bytedance/seedream/v5/lite/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/clarityai/crystal-upscaler": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills clarityai/crystal-upscaler at $0.016 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.016, + "output_cost_per_pixel": 1.52587890625e-08, + "source": "https://fal.ai/models/clarityai/crystal-upscaler", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/decart/lucy-2-5/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/decart/lucy-2-5/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/decart/lucy-2-6/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/decart/lucy-2-6/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/decart/lucy-restyle": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/decart/lucy-restyle", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/decart/lucy2-vton/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/decart/lucy2-vton/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ai-avatar/multi": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/ai-avatar/multi", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ai-avatar/multi-text": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/ai-avatar/multi-text", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ai-avatar/single-text": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/ai-avatar/single-text", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bagel": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/bagel", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bagel/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/bagel/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ben/v2/image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ben/v2/image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/ben/v2/image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/boogu-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/boogu-image at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/fal-ai/boogu-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/boogu-image/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/boogu-image/edit at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/fal-ai/boogu-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/background/replace": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/background/replace", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/eraser": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/eraser", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/expand": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/expand", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/genfill": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/genfill", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/product-shot": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/product-shot", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/reimagine": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/reimagine", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/text-to-image/base": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/text-to-image/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bria/text-to-image/fast": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/bria/text-to-image/fast", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bria/text-to-image/hd": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/text-to-image/hd", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bytedance/dreamactor/v2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/bytedance/dreamactor/v2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bytedance/omnihuman": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/bytedance/omnihuman", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bytedance/omnihuman/v1.5": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.16, + "source": "https://fal.ai/models/fal-ai/bytedance/omnihuman/v1.5", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bytedance/seedream/v4.5/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4.5/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bytedance/seedream/v4.5/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4.5/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bytedance/seedream/v4/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bytedance/seedream/v4/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/clarity-upscaler": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/clarity-upscaler at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/clarity-upscaler", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/codeformer": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/codeformer at $0.0021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0021, + "output_cost_per_pixel": 2.002716064453125e-09, + "source": "https://fal.ai/models/fal-ai/codeformer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/control-light": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/control-light at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/control-light", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/davinci-magihuman": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/davinci-magihuman", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ddcolor": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ddcolor at $0.001 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.001, + "output_cost_per_pixel": 9.5367431640625e-10, + "source": "https://fal.ai/models/fal-ai/ddcolor", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/depth-anything-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/depth-anything-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/echomimic-v3": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/echomimic-v3", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ernie-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/ernie-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ernie-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/ernie-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ernie-image/lora/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image/lora/turbo at $0.015 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.015, + "output_cost_per_pixel": 1.430511474609375e-08, + "source": "https://fal.ai/models/fal-ai/ernie-image/lora/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ernie-image/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image/turbo at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/ernie-image/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/fashn/tryon/v1.5": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/fashn/tryon/v1.5", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/fashn/tryon/v1.6": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/fashn/tryon/v1.6", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/feynobg": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/feynobg", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ffmpeg-api/compose": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0002, + "source": "https://fal.ai/models/fal-ai/ffmpeg-api/compose", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ffmpeg-api/merge-audio-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0002, + "source": "https://fal.ai/models/fal-ai/ffmpeg-api/merge-audio-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/finegrain-eraser": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.27, + "source": "https://fal.ai/models/fal-ai/finegrain-eraser", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/finegrain-eraser/bbox": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.18, + "source": "https://fal.ai/models/fal-ai/finegrain-eraser/bbox", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/finegrain-eraser/mask": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.13, + "source": "https://fal.ai/models/fal-ai/finegrain-eraser/mask", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/firered-image-edit-v1.1": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/firered-image-edit-v1.1 at $0.0325 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0325, + "output_cost_per_pixel": 3.0994415283203126e-08, + "source": "https://fal.ai/models/fal-ai/firered-image-edit-v1.1", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flashhead": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/flashhead", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/flux-1/dev": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/dev at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/dev", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/dev/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/dev/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/dev/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/dev/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/dev/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/dev/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/krea": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/krea at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/krea", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/krea/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/krea/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/krea/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/krea/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/krea/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/krea/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/schnell": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/schnell at $0.003 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.003, + "output_cost_per_pixel": 2.86102294921875e-09, + "source": "https://fal.ai/models/fal-ai/flux-1/schnell", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/schnell/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/schnell/redux at $0.003 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.003, + "output_cost_per_pixel": 2.86102294921875e-09, + "source": "https://fal.ai/models/fal-ai/flux-1/schnell/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/srpo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/srpo at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/srpo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/srpo/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/srpo/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/srpo/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2 at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-flex": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-flex at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-flex", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-flex/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-flex/edit at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-flex/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/add-background": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/add-background at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/add-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/apartment-staging": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/apartment-staging at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/apartment-staging", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/ballpoint-pen-sketch": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/ballpoint-pen-sketch at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/ballpoint-pen-sketch", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/digital-comic-art": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/digital-comic-art at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/digital-comic-art", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/face-to-full-portrait": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/face-to-full-portrait at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/face-to-full-portrait", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/hdr-style": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/hdr-style at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/hdr-style", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/multiple-angles at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/realism": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/realism at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/realism", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/satellite-view-style": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/satellite-view-style at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/satellite-view-style", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/sepia-vintage": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/sepia-vintage at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/sepia-vintage", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/virtual-tryon": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/virtual-tryon at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/virtual-tryon", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/edit at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/flash": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/flash at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/flash", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/flash/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/flash/edit at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/flash/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/4b/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base at $0.009 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_pixel": 8.58306884765625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/4b/base/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base/edit at $0.009 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_pixel": 8.58306884765625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/base/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base/edit/lora at $0.016 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.016, + "output_cost_per_pixel": 1.52587890625e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/base/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base/lora at $0.016 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.016, + "output_cost_per_pixel": 1.52587890625e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/4b/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/edit at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/edit/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b at $0.006 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.006, + "output_cost_per_pixel": 5.7220458984375e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base at $0.011 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.011, + "output_cost_per_pixel": 1.049041748046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b/base/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base/edit at $0.011 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.011, + "output_cost_per_pixel": 1.049041748046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/base/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base/edit/lora at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/base/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base/lora at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/edit at $0.011 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.011, + "output_cost_per_pixel": 1.049041748046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/edit/lora at $0.015 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.015, + "output_cost_per_pixel": 1.430511474609375e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/lora at $0.015 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.015, + "output_cost_per_pixel": 1.430511474609375e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/lora at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/lora/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/lora/edit at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/lora/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/turbo at $0.008 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.008, + "output_cost_per_pixel": 7.62939453125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/turbo/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/turbo/edit at $0.008 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.008, + "output_cost_per_pixel": 7.62939453125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/turbo/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-general": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-general at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-general", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-general/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-general/image-to-image at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-general/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-general/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-general/inpainting at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-general/inpainting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-kontext-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-kontext-lora/inpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext-lora/inpaint at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext-lora/inpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-kontext-lora/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext-lora/text-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext-lora/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-kontext/dev": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext/dev at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext/dev", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-krea-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-krea-lora/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora/image-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-krea-lora/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora/inpainting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora/inpainting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-krea-lora/stream": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora/stream at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora/stream", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-lora-canny": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora-canny at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora-canny", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-lora-fill": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora-fill at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora-fill", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-lora/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora/image-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-lora/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora/inpainting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora/inpainting", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-lora/stream": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora/stream at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora/stream", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/kontext": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/max": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/max", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/max/multi": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/max/multi", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/max/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/max/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/kontext/multi": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/multi", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/v1.1-ultra-finetuned": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1-ultra-finetuned", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/v1.1-ultra/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1.1-ultra/redux at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1-ultra/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1.1/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1.1/redux at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1/fill": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1/fill at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1/fill", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1/fill-finetuned": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1/fill-finetuned at $0.06 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.06, + "output_cost_per_pixel": 5.7220458984375e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1/fill-finetuned", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pulid": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pulid at $0.0333 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0333, + "output_cost_per_pixel": 3.175735473632813e-08, + "source": "https://fal.ai/models/fal-ai/flux-pulid", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-vision-upscaler": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-vision-upscaler at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/flux-vision-upscaler", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/dev/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/dev/image-to-image at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/flux/dev/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/dev/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/dev/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/dev/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/krea": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/krea at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/krea", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux/krea/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/krea/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/krea/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/krea/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/krea/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/krea/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/schnell/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/schnell/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/schnell/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/srpo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/srpo at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/srpo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux/srpo/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/srpo/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/srpo/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/framepack": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://fal.ai/models/fal-ai/framepack", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/framepack/f1": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://fal.ai/models/fal-ai/framepack/f1", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/framepack/flf2v": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://fal.ai/models/fal-ai/framepack/flf2v", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/gemini-25-flash-image/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/gemini-25-flash-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/gemini-3-pro-image-preview": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/gemini-3-pro-image-preview", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/gemini-3-pro-image-preview/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/gemini-3-pro-image-preview/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/gemini-3.1-flash-image-preview": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/gemini-3.1-flash-image-preview", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/gemini-3.1-flash-image-preview/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/gemini-3.1-flash-image-preview/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/glm-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/glm-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/glm-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/glm-image/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/glm-image/image-to-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/glm-image/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/heygen/avatar3/digital-twin": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.034, + "source": "https://fal.ai/models/fal-ai/heygen/avatar3/digital-twin", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/avatar4/digital-twin": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/avatar4/digital-twin", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/avatar4/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/avatar4/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/avatar5/digital-twin": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/avatar5/digital-twin", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v2/translate/precision": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/v2/translate/precision", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v2/translate/speed": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/heygen/v2/translate/speed", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v2/video-agent": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.034, + "source": "https://fal.ai/models/fal-ai/heygen/v2/video-agent", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/filler-word-removal": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/fal-ai/heygen/v3/filler-word-removal", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/lipsync/precision": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/v3/lipsync/precision", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/lipsync/speed": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/heygen/v3/lipsync/speed", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/video-agent": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.034, + "source": "https://fal.ai/models/fal-ai/heygen/v3/video-agent", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hidream-i1-fast": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-i1-fast at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/hidream-i1-fast", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-i1-full": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-i1-full at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/hidream-i1-full", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-i1-full/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-i1-full/image-to-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/hidream-i1-full/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hidream-o1-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-o1-image/dev": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image/dev at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image/dev", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-o1-image/dev/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image/dev/edit at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image/dev/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hidream-o1-image/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image/edit at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hunyuan_world": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/hunyuan_world", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hunyuan-image/v2.1/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v2.1/text-to-image at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v2.1/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hunyuan-image/v3/instruct/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v3/instruct/edit at $0.09 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.09, + "output_cost_per_pixel": 8.58306884765625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v3/instruct/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hunyuan-image/v3/instruct/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v3/instruct/text-to-image at $0.09 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.09, + "output_cost_per_pixel": 8.58306884765625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v3/instruct/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hunyuan-image/v3/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v3/text-to-image at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hunyuan-video-v1.5/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://fal.ai/models/fal-ai/hunyuan-video-v1.5/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hunyuan-video-v1.5/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://fal.ai/models/fal-ai/hunyuan-video-v1.5/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/iclight-v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/iclight-v2 at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/iclight-v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/id-v2v": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/id-v2v", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/id-v2v/relight": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/id-v2v/relight", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ideogram/character": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/ideogram/character", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/character/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/ideogram/character/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/character/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/ideogram/character/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/object-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/ideogram/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/remove-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/ideogram/remove-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/upscale": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/upscale", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/ideogram/v2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2/turbo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2/turbo/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/turbo/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2/turbo/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/turbo/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2a": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2a/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2a/turbo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2a/turbo/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a/turbo/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/generate-transparent": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/generate-transparent", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v3/layerize-text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.09, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/layerize-text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/reframe": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/reframe", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/replace-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/replace-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/age-modify": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/age-modify", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/city-teleport": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/city-teleport", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/expression-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/expression-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/hair-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/hair-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/headshot-photo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/headshot-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/makeup-application": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/makeup-application", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/object-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/outpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/image-apps-v2/outpaint at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/outpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/perspective": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/perspective", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/photo-restoration": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/photo-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/photography-effects": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.4, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/photography-effects", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/portrait-enhance": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.4, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/portrait-enhance", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/product-holding": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/product-holding", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/product-photography": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/product-photography", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/relighting": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/relighting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/style-transfer": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/style-transfer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/texture-transform": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/texture-transform", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/virtual-try-on": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/virtual-try-on", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/age-progression": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/age-progression", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/baby-version": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/baby-version", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/background-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/background-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/broccoli-haircut": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/broccoli-haircut", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/cartoonify": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/cartoonify", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/color-correction": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/color-correction", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/expression-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/expression-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/face-enhancement": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/face-enhancement", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/hair-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/hair-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/object-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/photo-restoration": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/photo-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/plushie-style": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/plushie-style", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/professional-photo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/professional-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/realism": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/realism", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/reframe": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/reframe", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/retouch": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/retouch", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/scene-composition": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/scene-composition", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/style-transfer": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/style-transfer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/text-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/text-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/time-of-day": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/time-of-day", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/weather-effect": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/weather-effect", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/wojak-style": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/wojak-style", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/youtube-thumbnails": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/youtube-thumbnails", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image2svg": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://fal.ai/models/fal-ai/image2svg", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/instant-character": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/instant-character at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/instant-character", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/o1": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/o1", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/o3/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/o3/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/o3/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/o3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/kling-image/v3/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/v3/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/v3/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/v3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/kling-video/ai-avatar/v2/pro": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.115, + "source": "https://fal.ai/models/fal-ai/kling-video/ai-avatar/v2/pro", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/ai-avatar/v2/standard": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0562, + "source": "https://fal.ai/models/fal-ai/kling-video/ai-avatar/v2/standard", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.084, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.084, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/pro/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/pro/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/pro/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/pro/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/standard/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/standard/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/standard/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/standard/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1.5/pro/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/kling-video/v1.5/pro/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1.6/pro/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.098, + "source": "https://fal.ai/models/fal-ai/kling-video/v1.6/pro/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1.6/standard/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.056, + "source": "https://fal.ai/models/fal-ai/kling-video/v1.6/standard/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1/pro/ai-avatar": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.115, + "source": "https://fal.ai/models/fal-ai/kling-video/v1/pro/ai-avatar", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1/standard/ai-avatar": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0562, + "source": "https://fal.ai/models/fal-ai/kling-video/v1/standard/ai-avatar", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1/standard/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.045, + "source": "https://fal.ai/models/fal-ai/kling-video/v1/standard/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.6/pro/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.6/pro/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.6/standard/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.6/standard/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/4k/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/4k/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/4k/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/4k/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/pro/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/pro/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/standard/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/standard/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/pro/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/standard/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/standard/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/standard/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/krea-2/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/krea-2/turbo at $0.008 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.008, + "output_cost_per_pixel": 7.62939453125e-09, + "source": "https://fal.ai/models/fal-ai/krea-2/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/krea-2/turbo/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/krea-2/turbo/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/krea-2/turbo/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/krea-2/turbo/style": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/krea-2/turbo/style at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/krea-2/turbo/style", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/krea-wan-14b/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/krea-wan-14b/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/krea-wan-14b/video-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/krea-wan-14b/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/latentsync": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/latentsync", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/leffa/pose-transfer": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/leffa/pose-transfer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/leffa/virtual-tryon": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/leffa/virtual-tryon", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/lightx/recamera": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/lightx/recamera", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/lightx/relight": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/lightx/relight", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/longcat-image at $0.13 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.13, + "output_cost_per_pixel": 1.239776611328125e-07, + "source": "https://fal.ai/models/fal-ai/longcat-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/longcat-image/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/longcat-image/edit at $0.15 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.15, + "output_cost_per_pixel": 1.430511474609375e-07, + "source": "https://fal.ai/models/fal-ai/longcat-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/longcat-video/distilled/image-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/image-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/distilled/image-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/image-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/distilled/text-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/text-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/distilled/text-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/text-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/image-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/longcat-video/image-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/image-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/longcat-video/image-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/text-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/longcat-video/text-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/text-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/longcat-video/text-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/audio-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/audio-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/extend-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.16, 2160p $0.32"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.08, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/image-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.12, 2160p $0.24"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/image-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/retake-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/retake-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.16, 2160p $0.32"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.08, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/text-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.12, 2160p $0.24"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/text-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled/extend": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled/extend", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled/multiconditioning": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled/multiconditioning", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/lumina-image/v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/lumina-image/v2 at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/lumina-image/v2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/marigold-v2": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/marigold-v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/minimax/hailuo-02-fast/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 512p rate; tiers without a catalog field: 512p $0.017"}, + "mode": "video_generation", + "output_cost_per_second": 0.017, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02-fast/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/pro/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/standard/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.045, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/standard/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/image-01/subject-reference": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/minimax/image-01/subject-reference", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/mmaudio-v2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.001, + "source": "https://fal.ai/models/fal-ai/mmaudio-v2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/moss-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/moss-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/moss-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/nafnet/deblur": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/nafnet/deblur at $0.0225 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0225, + "output_cost_per_pixel": 2.1457672119140624e-08, + "source": "https://fal.ai/models/fal-ai/nafnet/deblur", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nafnet/denoise": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/nafnet/denoise at $0.0225 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0225, + "output_cost_per_pixel": 2.1457672119140624e-08, + "source": "https://fal.ai/models/fal-ai/nafnet/denoise", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nano-banana-2/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/nano-banana-2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nano-banana-pro/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/nano-banana-pro/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nano-banana/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/nano-banana/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nucleus-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/nucleus-image at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/nucleus-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/omnigen-v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/omnigen-v2 at $0.15 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.15, + "output_cost_per_pixel": 1.430511474609375e-07, + "source": "https://fal.ai/models/fal-ai/omnigen-v2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/one-to-all-animation/1.3b": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; tiers without a catalog field: 580p $0.045"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_480p": 0.03, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/fal-ai/one-to-all-animation/1.3b", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/one-to-all-animation/14b": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; tiers without a catalog field: 580p $0.09"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_480p": 0.06, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/fal-ai/one-to-all-animation/14b", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/patina": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/patina at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/patina", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/phota/enhance": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.13, + "source": "https://fal.ai/models/fal-ai/phota/enhance", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/blur": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/blur", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/chromatic-aberration": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/chromatic-aberration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/color-correction": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/color-correction", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/color-tint": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/color-tint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/desaturate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/desaturate", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/dissolve": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/dissolve", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/dodge-burn": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/dodge-burn", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/grain": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/grain", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/parabolize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/parabolize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/sharpen": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/sharpen", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/solarize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/solarize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/vignette": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/vignette", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/qwen-image-2512": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-2512 at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-2512", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/qwen-image-2512/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-2512/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-2512/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/qwen-image-edit-2509": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509 at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/add-background": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/add-background at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/add-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/face-to-full-portrait": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/face-to-full-portrait at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/face-to-full-portrait", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/group-photo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/group-photo at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/group-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/integrate-product": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/integrate-product at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/integrate-product", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/lighting-restoration": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/lighting-restoration at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/lighting-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/multiple-angles at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/next-scene": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/next-scene at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/next-scene", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/remove-element": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/remove-element at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/remove-element", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/remove-lighting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/remove-lighting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/remove-lighting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/shirt-design": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/shirt-design at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/shirt-design", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2511": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2511 at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2511", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2511-multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2511-multiple-angles at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2511/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2511/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2511/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/add-background": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/add-background at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/add-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/face-to-full-portrait": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/face-to-full-portrait at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/face-to-full-portrait", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/group-photo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/group-photo at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/group-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/integrate-product": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/integrate-product at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/integrate-product", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/lighting-restoration": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/lighting-restoration at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/lighting-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/multiple-angles at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/next-scene": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/next-scene at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/next-scene", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/remove-element": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/remove-element at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/remove-element", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/remove-lighting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/remove-lighting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/remove-lighting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/shirt-design": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/shirt-design at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/shirt-design", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit/image-to-image at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit/inpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit/inpaint at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit/inpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-layered": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/qwen-image-layered", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-layered/lora": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/qwen-image-layered/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image/image-to-image at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/upscale/creative": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://fal.ai/models/fal-ai/recraft/upscale/creative", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/upscale/crisp": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.004, + "source": "https://fal.ai/models/fal-ai/recraft/upscale/crisp", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/v4.1/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.21, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/pro/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/pro/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/utility/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.21, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/utility/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/utility/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/utility/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://fal.ai/models/fal-ai/recraft/v4/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/pro/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/recraft/v4/pro/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/recraft/v4/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/recraft/v4/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/vectorize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/recraft/vectorize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/sana": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana at $0.001 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.001, + "output_cost_per_pixel": 9.5367431640625e-10, + "source": "https://fal.ai/models/fal-ai/sana", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/sana/sprint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana/sprint at $0.0025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0025, + "output_cost_per_pixel": 2.384185791015625e-09, + "source": "https://fal.ai/models/fal-ai/sana/sprint", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/sana/v1.5/1.6b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana/v1.5/1.6b at $0.0075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0075, + "output_cost_per_pixel": 7.152557373046875e-09, + "source": "https://fal.ai/models/fal-ai/sana/v1.5/1.6b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/sana/v1.5/4.8b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana/v1.5/4.8b at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/sana/v1.5/4.8b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/seedvr/upscale/image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/seedvr/upscale/image at $0.001 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.001, + "output_cost_per_pixel": 9.5367431640625e-10, + "source": "https://fal.ai/models/fal-ai/seedvr/upscale/image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/seedvr/upscale/image/seamless": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/seedvr/upscale/image/seamless at $0.0025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0025, + "output_cost_per_pixel": 2.384185791015625e-09, + "source": "https://fal.ai/models/fal-ai/seedvr/upscale/image/seamless", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/stable-diffusion-v3-medium": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/fal-ai/stable-diffusion-v3-medium", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/stable-diffusion-v35-large": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/stable-diffusion-v35-large at $0.065 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.065, + "output_cost_per_pixel": 6.198883056640625e-08, + "source": "https://fal.ai/models/fal-ai/stable-diffusion-v35-large", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/stepx-edit2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/stepx-edit2 at $0.2 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.2, + "output_cost_per_pixel": 1.9073486328125e-07, + "source": "https://fal.ai/models/fal-ai/stepx-edit2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/telestyle-v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/telestyle-v2 at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/telestyle-v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/uso": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/uso at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/uso", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/vidu/q2/reference-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/q2/reference-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/vidu/q2/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/q2/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/vidu/reference-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/reference-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/depth": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/depth", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/inpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/outpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/outpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-25-preview/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan-25-preview/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-25-preview/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan-25-preview/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/depth": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/depth", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/inpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/outpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/outpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/pose": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/pose", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-apps/video-edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-vace-apps/video-edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-14b/speech-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; tiers without a catalog field: 580p $0.15"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.1, + "output_cost_per_second_720p": 0.2, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-14b/speech-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-5b/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.016, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-5b/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan/v2.2-a14b/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/image-to-video/lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/image-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-image/lora": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-video/lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan/v2.7/edit-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/edit-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/pro/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/pro/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan/v2.7/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.7/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.7/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/z-image/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/base at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/base/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/base/lora at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/fal-ai/z-image/base/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo/controlnet": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/controlnet at $0.0065 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0065, + "output_cost_per_pixel": 6.198883056640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/controlnet", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/controlnet/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/controlnet/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/controlnet/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/image-to-image at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/image-to-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/image-to-image/lora at $0.0085 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0085, + "output_cost_per_pixel": 8.106231689453126e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/image-to-image/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/inpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/inpaint at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/inpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/inpaint/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/inpaint/lora at $0.0115 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0115, + "output_cost_per_pixel": 1.0967254638671875e-08, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/inpaint/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/lora at $0.0085 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0085, + "output_cost_per_pixel": 8.106231689453126e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo/tiling": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/tiling at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/tiling", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo/tiling/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/tiling/lora at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/tiling/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/flash/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/flash/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/flash/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/flash/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/flash/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.063, + "output_cost_per_second_720p": 0.063, + "source": "https://fal.ai/models/kling/v4/flash/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.235, + "output_cost_per_second_1080p": 0.235, + "output_cost_per_second_720p": 0.157, + "source": "https://fal.ai/models/kling/v4/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.235, + "output_cost_per_second_1080p": 0.235, + "output_cost_per_second_720p": 0.157, + "source": "https://fal.ai/models/kling/v4/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/video-to-video/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.141, + "output_cost_per_second_1080p": 0.141, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/krea/v3/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills krea/v3/edit at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/krea/v3/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/krea/v3/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills krea/v3/text-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/krea/v3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/luma/agent/uni-1/v1/max": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.102, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/max", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/luma/agent/uni-1/v1/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.042, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/meta/muse-image/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/meta/muse-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/meta/muse-image/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/meta/muse-image/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/minimax/h3-max-turbo/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_1080p": 0.08, + "output_cost_per_second_2k": 0.16, + "output_cost_per_second_480p": 0.025, + "output_cost_per_second_768p": 0.04, + "source": "https://fal.ai/models/minimax/h3-max-turbo/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max-turbo/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max-turbo/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/insert-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.06, + "source": "https://fal.ai/models/minimax/h3-max/insert-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/lip-sync/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/lip-sync/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_2k": 0.13, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_4k": 0.16, + "output_cost_per_second_768p": 0.06, + "source": "https://fal.ai/models/minimax/h3/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/image-to-video/lora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1625, + "output_cost_per_second_2k": 0.1625, + "output_cost_per_second_480p": 0.0625, + "output_cost_per_second_4k": 0.2, + "output_cost_per_second_768p": 0.075, + "source": "https://fal.ai/models/minimax/h3/image-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/reference-to-video/lora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1625, + "output_cost_per_second_2k": 0.1625, + "output_cost_per_second_480p": 0.0625, + "output_cost_per_second_4k": 0.2, + "output_cost_per_second_768p": 0.075, + "source": "https://fal.ai/models/minimax/h3/reference-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/text-to-video/lora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1625, + "output_cost_per_second_2k": 0.1625, + "output_cost_per_second_480p": 0.0625, + "output_cost_per_second_4k": 0.2, + "output_cost_per_second_768p": 0.075, + "source": "https://fal.ai/models/minimax/h3/text-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/nvidia/cosmos-3-super/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/nvidia/cosmos-3-super/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixelcut/background-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.016, + "source": "https://fal.ai/models/pixelcut/background-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/pixelcut/looping-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.32, + "output_cost_per_second_1080p": 0.32, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_768p": 0.16, + "source": "https://fal.ai/models/pixelcut/looping-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixelcut/product-photo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/pixelcut/product-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/pixverse/music-video/vibemv": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.09, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/pixverse/music-video/vibemv", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/recraft/v4.1/flash/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.007, + "source": "https://fal.ai/models/recraft/v4.1/flash/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/base at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/base/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/base/image-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/base/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/lightning": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/lightning at $0.006 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.006, + "output_cost_per_pixel": 5.7220458984375e-09, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/lightning", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/pro": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/pro at $0.055 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.055, + "output_cost_per_pixel": 5.245208740234375e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/pro", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/pro/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/pro/image-to-image at $0.055 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.055, + "output_cost_per_pixel": 5.245208740234375e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/pro/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/rundiffusion-fal/rundiffusion-photo-flux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/rundiffusion-photo-flux at $0.045 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.045, + "output_cost_per_pixel": 4.291534423828125e-08, + "source": "https://fal.ai/models/rundiffusion-fal/rundiffusion-photo-flux", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/smoretalk-ai/rembg-enhance": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.006, + "source": "https://fal.ai/models/smoretalk-ai/rembg-enhance", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/sonilo/v1.1/video-to-video-music": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.009, + "source": "https://fal.ai/models/sonilo/v1.1/video-to-video-music", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/sonilo/v1.1/video-to-video-sound-effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.009, + "source": "https://fal.ai/models/sonilo/v1.1/video-to-video-sound-effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/tbc/mischief-1/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "output_cost_per_second_480p": 0.005, + "output_cost_per_second_720p": 0.01, + "source": "https://fal.ai/models/tbc/mischief-1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/veed/fabric-1.0": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p)"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.15, + "source": "https://fal.ai/models/veed/fabric-1.0", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/veed/fabric-1.0/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p)"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_480p": 0.1, + "output_cost_per_second_720p": 0.2, + "source": "https://fal.ai/models/veed/fabric-1.0/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/veed/fabric-1.0/text": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p)"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.15, + "source": "https://fal.ai/models/veed/fabric-1.0/text", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/wan/v2.6/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/wan/v2.6/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/image-to-video/flash": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.075, + "output_cost_per_second_1080p": 0.075, + "output_cost_per_second_720p": 0.05, + "source": "https://fal.ai/models/wan/v2.6/image-to-video/flash", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/wan/v2.6/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/wan/v2.6/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://fal.ai/models/xai/grok-imagine-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.25, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/creatify/boreal": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "output_cost_per_second_1080p": 0.03, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_720p": 0.01, + "source": "https://fal.ai/models/creatify/boreal", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/creatify/aurora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_480p": 0.07, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/fal-ai/creatify/aurora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/q4/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 540p $0.0315"}, + "mode": "video_generation", + "output_cost_per_second": 0.0665, + "output_cost_per_second_1080p": 0.084, + "output_cost_per_second_2k": 0.133, + "output_cost_per_second_4k": 0.273, + "output_cost_per_second_720p": 0.0665, + "source": "https://fal.ai/models/fal-ai/vidu/q4/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/q4/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 540p $0.0315"}, + "mode": "video_generation", + "output_cost_per_second": 0.0665, + "output_cost_per_second_1080p": 0.084, + "output_cost_per_second_2k": 0.133, + "output_cost_per_second_4k": 0.273, + "output_cost_per_second_720p": 0.0665, + "source": "https://fal.ai/models/fal-ai/vidu/q4/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/ideogram/v4.5": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/ideogram/v4.5", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/ideogram/v4.5/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/ideogram/v4.5/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/ideogram/v4/instant": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills ideogram/v4/instant at $0.0075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0075, + "output_cost_per_pixel": 7.152557373046875e-09, + "source": "https://fal.ai/models/ideogram/v4/instant", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/lightricks/ltx-2.5/image-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.19"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_1080p": 0.13, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.09, + "source": "https://fal.ai/models/lightricks/ltx-2.5/image-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/lightricks/ltx-2.5/image-to-video/pro": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.17, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/lightricks/ltx-2.5/image-to-video/pro", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/lightricks/ltx-2.5/text-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.19"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_1080p": 0.13, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.09, + "source": "https://fal.ai/models/lightricks/ltx-2.5/text-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/lightricks/ltx-2.5/text-to-video/pro": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.17, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/lightricks/ltx-2.5/text-to-video/pro", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/recast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.45, + "output_cost_per_second_1080p": 0.45, + "output_cost_per_second_768p": 0.3, + "source": "https://fal.ai/models/minimax/h3-max/recast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/reframe-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/reframe-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/relight": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/relight", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/reference-to-video/flash": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/reference-to-video/flash", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/lite/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_720p": 0.03, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/lite/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/lite/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_720p": 0.03, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/lite/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3-max/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; promotional 50% off rate through 2026-11-22 23:59 UTC per the page; regular rates $0.17 (720p) and $0.29 (1080p) per second resume 2026-11-23 00:00 UTC (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.085, + "output_cost_per_second_1080p": 0.145, + "output_cost_per_second_720p": 0.085, + "source": "https://fal.ai/models/blackforestlabs/flux-3-max/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3-max/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; promotional 50% off rate through 2026-11-22 23:59 UTC per the page; regular rates $0.17 (720p) and $0.29 (1080p) per second resume 2026-11-23 00:00 UTC (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.085, + "output_cost_per_second_1080p": 0.145, + "output_cost_per_second_720p": 0.085, + "source": "https://fal.ai/models/blackforestlabs/flux-3-max/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3-max/keyframes-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; regular rate; the page's 75% promotion ran October 15 to November 7, 2026 and has ended (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3-max/keyframes-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/heygen/heygen-video/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; regular rate; the page's 50% promotion ended October 31, 2026 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.06, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_768p": 0.02, + "source": "https://fal.ai/models/heygen/heygen-video/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/heygen/heygen-video/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; regular rate; the page's 50% promotion ended October 31, 2026 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.06, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_768p": 0.02, + "source": "https://fal.ai/models/heygen/heygen-video/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/heygen/heygen-video/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; regular rate; the page's 50% promotion ended October 31, 2026 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.06, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_768p": 0.02, + "source": "https://fal.ai/models/heygen/heygen-video/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max-turbo/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.025 (480p), $0.04 (768p), $0.08 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.028, + "output_cost_per_second_1080p": 0.056, + "output_cost_per_second_480p": 0.0175, + "output_cost_per_second_768p": 0.028, + "source": "https://fal.ai/models/minimax/h3-max-turbo/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max-turbo/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.025 (480p), $0.04 (768p), $0.08 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.028, + "output_cost_per_second_1080p": 0.056, + "output_cost_per_second_480p": 0.0175, + "output_cost_per_second_768p": 0.028, + "source": "https://fal.ai/models/minimax/h3-max-turbo/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/camera-controls": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.05 (480p), $0.08 (768p), $0.16 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.035, + "output_cost_per_second_1080p": 0.112, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_768p": 0.056, + "source": "https://fal.ai/models/minimax/h3-max/camera-controls", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.05 (480p), $0.08 (768p), $0.16 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.056, + "output_cost_per_second_1080p": 0.112, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_768p": 0.056, + "source": "https://fal.ai/models/minimax/h3-max/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.05 (480p), $0.08 (768p), $0.16 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.056, + "output_cost_per_second_1080p": 0.112, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_768p": 0.056, + "source": "https://fal.ai/models/minimax/h3-max/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 662909b5cb7..40ff675de19 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -26158,8 +26158,11 @@ }, "fal_ai/fal-ai/flux-pro/v1.1": { "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1.1 at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1", "supported_endpoints": [ "/v1/images/generations" ] @@ -26168,14 +26171,18 @@ "litellm_provider": "fal_ai", "mode": "image_generation", "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1-ultra", "supported_endpoints": [ "/v1/images/generations" ] }, "fal_ai/fal-ai/flux/schnell": { "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/schnell at $0.003 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.003, + "output_cost_per_pixel": 2.86102294921875e-09, + "source": "https://fal.ai/models/fal-ai/flux/schnell", "supported_endpoints": [ "/v1/images/generations" ] @@ -26251,6 +26258,7 @@ }, "fal_ai/minimax/h3/text-to-video": { "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, "mode": "video_generation", "output_cost_per_second": 0.13, "output_cost_per_second_480p": 0.05, @@ -26360,6 +26368,7 @@ "litellm_provider": "fal_ai", "mode": "image_generation", "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3", "supported_endpoints": [ "/v1/images/generations" ] @@ -26398,8 +26407,11 @@ }, "fal_ai/fal-ai/stable-diffusion-v35-medium": { "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/stable-diffusion-v35-medium at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", - "output_cost_per_image": 0.0398, + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/stable-diffusion-v35-medium", "supported_endpoints": [ "/v1/images/generations" ] @@ -26407,7 +26419,8 @@ "fal_ai/fal-ai/nano-banana": { "litellm_provider": "fal_ai", "mode": "image_generation", - "output_cost_per_image": 0.039, + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/nano-banana", "supported_endpoints": [ "/v1/images/generations" ] @@ -26415,7 +26428,8 @@ "fal_ai/fal-ai/gemini-25-flash-image": { "litellm_provider": "fal_ai", "mode": "image_generation", - "output_cost_per_image": 0.039, + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/gemini-25-flash-image", "supported_endpoints": [ "/v1/images/generations" ] @@ -28346,9 +28360,7 @@ }, "fal_ai/fal-ai/flux/dev": { "litellm_provider": "fal_ai", - "metadata": { - "notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price is the fallback when dimensions are unavailable" - }, + "metadata": {"notes":"fal bills fal-ai/flux/dev at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.025, "output_cost_per_pixel": 2.384185791015625e-08, @@ -28380,16 +28392,16 @@ }, "fal_ai/fal-ai/flux-lora-depth": { "litellm_provider": "fal_ai", - "metadata": { - "notes": "fal bills fal-ai/flux-lora-depth at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries" - }, + "metadata": {"notes":"fal bills fal-ai/flux-lora-depth at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, "mode": "image_generation", "output_cost_per_image": 0.035, "output_cost_per_pixel": 3.337860107421875e-08, "source": "https://fal.ai/models/fal-ai/flux-lora-depth", "supported_endpoints": [ - "/v1/images/edits" - ] + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true }, "fal_ai/fal-ai/moondream3-preview/query": { "input_cost_per_token": 4e-07, @@ -88728,5 +88740,8321 @@ "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_vision": true + }, + "fal_ai/alibaba/happy-horse/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/v1.1/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/v1.1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/v1.1/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/v1.1/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/v1.1/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/v1.1/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0-prime/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/wan-3.0-prime/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0-prime/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/wan-3.0-prime/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0-prime/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/wan-3.0-prime/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/alibaba/wan-3.0/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/alibaba/wan-3.0/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/wan-3.0/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/alibaba/wan-3.0/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/draft-enhance": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.29, + "output_cost_per_second_1080p": 0.29, + "source": "https://fal.ai/models/blackforestlabs/flux-3/draft-enhance", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/edit-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_720p": 0.03, + "source": "https://fal.ai/models/blackforestlabs/flux-3/edit-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.41, + "output_cost_per_second_1080p": 0.53, + "output_cost_per_second_720p": 0.41, + "source": "https://fal.ai/models/blackforestlabs/flux-3/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/extend-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.12, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/first-last-frame-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/first-last-frame-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/image-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/image-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/keyframes-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/keyframes-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/klein/base/edit-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/base/edit-image/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/base/edit-image/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/klein/base/text-to-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/base/text-to-image/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/base/text-to-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/klein/edit-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/edit-image/lora at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/edit-image/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/klein/text-to-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills blackforestlabs/flux-3/klein/text-to-image/lora at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/text-to-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/text-to-video/draft": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/blackforestlabs/flux-3/text-to-video/draft", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/bria_video_eraser/erase/keypoints": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/bria_video_eraser/erase/keypoints", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/bria_video_eraser/erase/mask": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/bria_video_eraser/erase/mask", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/bria_video_eraser/erase/prompt": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/bria_video_eraser/erase/prompt", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/embed-product": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/embed-product", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/extract-object": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://fal.ai/models/bria/extract-object", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-2/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-bbq-preview/generate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-bbq-preview/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/fibo-edit-1.5/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit-1.5/expand": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/expand", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit-1.5/product-holding": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/product-holding", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit-1.5/virtual-try-on": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit-1.5/virtual-try-on", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/add_object_by_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/add_object_by_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/blend": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/blend", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/colorize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/colorize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/erase_by_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/erase_by_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/relight": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/relight", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/replace_object_by_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/replace_object_by_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/reseason": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/reseason", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/restore": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/restore", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/restyle": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/restyle", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/rewrite_text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/rewrite_text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-edit/sketch_to_colored_image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-edit/sketch_to_colored_image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/fibo-gen-1.5/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-gen-1.5/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/fibo-lite/generate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.036, + "source": "https://fal.ai/models/bria/fibo-lite/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/fibo/generate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bria/genfill/v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills bria/genfill/v2 at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/bria/genfill/v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/guided-remove-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/bria/guided-remove-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/increase-resolution": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/increase-resolution", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/product-dimensions": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/product-dimensions", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/replace-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/replace-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/upscale/creative": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/upscale/creative", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bria/video/background-removal": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/background-removal", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/background-removal/green-screen-despill": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/bria/video/background-removal/green-screen-despill", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/background-removal/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0042, + "source": "https://fal.ai/models/bria/video/background-removal/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/background-removal/v3": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/bria/video/background-removal/v3", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/erase/keypoints": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/erase/keypoints", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/erase/mask": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/erase/mask", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/erase/prompt": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/erase/prompt", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/video/increase-resolution": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/bria/video/increase-resolution", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/omnihuman/v2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.105, + "source": "https://fal.ai/models/bytedance/omnihuman/v2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/fast/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2419, + "output_cost_per_second_720p": 0.2419, + "source": "https://fal.ai/models/bytedance/seedance-2.0/fast/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/fast/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2419, + "output_cost_per_second_720p": 0.2419, + "source": "https://fal.ai/models/bytedance/seedance-2.0/fast/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/mini/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1547, + "output_cost_per_second_480p": 0.0721, + "output_cost_per_second_720p": 0.1547, + "source": "https://fal.ai/models/bytedance/seedance-2.0/mini/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/mini/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.007, + "output_cost_per_second_480p": 0.007, + "source": "https://fal.ai/models/bytedance/seedance-2.0/mini/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/mini/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1547, + "output_cost_per_second_480p": 0.0721, + "output_cost_per_second_720p": 0.1547, + "source": "https://fal.ai/models/bytedance/seedance-2.0/mini/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/us/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.3034, + "output_cost_per_second_1080p": 0.682, + "output_cost_per_second_720p": 0.3034, + "source": "https://fal.ai/models/bytedance/seedance-2.0/us/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/us/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.37, + "output_cost_per_second_480p": 0.1731, + "output_cost_per_second_720p": 0.37, + "source": "https://fal.ai/models/bytedance/seedance-2.0/us/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/draft/complete": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 1.164, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/draft/complete", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.473, + "output_cost_per_second_1080p": 1.164, + "output_cost_per_second_480p": 0.2205, + "output_cost_per_second_720p": 0.473, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.473, + "output_cost_per_second_1080p": 1.164, + "output_cost_per_second_480p": 0.2205, + "output_cost_per_second_720p": 0.473, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedream/v5/flash/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.027, + "source": "https://fal.ai/models/bytedance/seedream/v5/flash/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/flash/layerize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.027, + "source": "https://fal.ai/models/bytedance/seedream/v5/flash/layerize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/flash/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.027, + "source": "https://fal.ai/models/bytedance/seedream/v5/flash/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bytedance/seedream/v5/lite/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/bytedance/seedream/v5/lite/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/lite/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/bytedance/seedream/v5/lite/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/clarityai/crystal-upscaler": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills clarityai/crystal-upscaler at $0.016 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.016, + "output_cost_per_pixel": 1.52587890625e-08, + "source": "https://fal.ai/models/clarityai/crystal-upscaler", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/decart/lucy-2-5/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/decart/lucy-2-5/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/decart/lucy-2-6/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/decart/lucy-2-6/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/decart/lucy-restyle": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/decart/lucy-restyle", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/decart/lucy2-vton/realtime": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/decart/lucy2-vton/realtime", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ai-avatar/multi": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/ai-avatar/multi", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ai-avatar/multi-text": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/ai-avatar/multi-text", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ai-avatar/single-text": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/ai-avatar/single-text", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bagel": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/bagel", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bagel/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/bagel/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ben/v2/image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ben/v2/image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/ben/v2/image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/boogu-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/boogu-image at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/fal-ai/boogu-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/boogu-image/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/boogu-image/edit at $0.04 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "output_cost_per_pixel": 3.814697265625e-08, + "source": "https://fal.ai/models/fal-ai/boogu-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/background/replace": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/background/replace", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/eraser": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/eraser", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/expand": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/expand", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/genfill": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/genfill", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/product-shot": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/product-shot", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/reimagine": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/reimagine", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bria/text-to-image/base": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/text-to-image/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bria/text-to-image/fast": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/bria/text-to-image/fast", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bria/text-to-image/hd": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bria/text-to-image/hd", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bytedance/dreamactor/v2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/bytedance/dreamactor/v2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bytedance/omnihuman": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/bytedance/omnihuman", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bytedance/omnihuman/v1.5": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.16, + "source": "https://fal.ai/models/fal-ai/bytedance/omnihuman/v1.5", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bytedance/seedream/v4.5/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4.5/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bytedance/seedream/v4.5/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4.5/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/bytedance/seedream/v4/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bytedance/seedream/v4/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/bytedance/seedream/v4/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/clarity-upscaler": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/clarity-upscaler at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/clarity-upscaler", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/codeformer": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/codeformer at $0.0021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0021, + "output_cost_per_pixel": 2.002716064453125e-09, + "source": "https://fal.ai/models/fal-ai/codeformer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/control-light": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/control-light at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/control-light", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/davinci-magihuman": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/davinci-magihuman", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ddcolor": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ddcolor at $0.001 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.001, + "output_cost_per_pixel": 9.5367431640625e-10, + "source": "https://fal.ai/models/fal-ai/ddcolor", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/depth-anything-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/depth-anything-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/echomimic-v3": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/echomimic-v3", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ernie-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/ernie-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ernie-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/ernie-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ernie-image/lora/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image/lora/turbo at $0.015 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.015, + "output_cost_per_pixel": 1.430511474609375e-08, + "source": "https://fal.ai/models/fal-ai/ernie-image/lora/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ernie-image/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/ernie-image/turbo at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/ernie-image/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/fashn/tryon/v1.5": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/fashn/tryon/v1.5", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/fashn/tryon/v1.6": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/fashn/tryon/v1.6", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/feynobg": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/feynobg", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ffmpeg-api/compose": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0002, + "source": "https://fal.ai/models/fal-ai/ffmpeg-api/compose", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ffmpeg-api/merge-audio-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0002, + "source": "https://fal.ai/models/fal-ai/ffmpeg-api/merge-audio-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/finegrain-eraser": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.27, + "source": "https://fal.ai/models/fal-ai/finegrain-eraser", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/finegrain-eraser/bbox": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.18, + "source": "https://fal.ai/models/fal-ai/finegrain-eraser/bbox", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/finegrain-eraser/mask": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.13, + "source": "https://fal.ai/models/fal-ai/finegrain-eraser/mask", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/firered-image-edit-v1.1": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/firered-image-edit-v1.1 at $0.0325 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0325, + "output_cost_per_pixel": 3.0994415283203126e-08, + "source": "https://fal.ai/models/fal-ai/firered-image-edit-v1.1", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flashhead": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/flashhead", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/flux-1/dev": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/dev at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/dev", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/dev/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/dev/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/dev/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/dev/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/dev/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/dev/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/krea": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/krea at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/krea", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/krea/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/krea/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/krea/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/krea/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/krea/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/krea/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/schnell": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/schnell at $0.003 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.003, + "output_cost_per_pixel": 2.86102294921875e-09, + "source": "https://fal.ai/models/fal-ai/flux-1/schnell", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/schnell/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/schnell/redux at $0.003 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.003, + "output_cost_per_pixel": 2.86102294921875e-09, + "source": "https://fal.ai/models/fal-ai/flux-1/schnell/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-1/srpo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/srpo at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/srpo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-1/srpo/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-1/srpo/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-1/srpo/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2 at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-flex": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-flex at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-flex", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-flex/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-flex/edit at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-flex/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/add-background": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/add-background at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/add-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/apartment-staging": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/apartment-staging at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/apartment-staging", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/ballpoint-pen-sketch": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/ballpoint-pen-sketch at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/ballpoint-pen-sketch", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/digital-comic-art": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/digital-comic-art at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/digital-comic-art", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/face-to-full-portrait": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/face-to-full-portrait at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/face-to-full-portrait", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/hdr-style": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/hdr-style at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/hdr-style", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/multiple-angles at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-lora-gallery/realism": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/realism at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/realism", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/satellite-view-style": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/satellite-view-style at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/satellite-view-style", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/sepia-vintage": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/sepia-vintage at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/sepia-vintage", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-lora-gallery/virtual-tryon": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2-lora-gallery/virtual-tryon at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2-lora-gallery/virtual-tryon", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/edit at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/flash": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/flash at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/flash", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/flash/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/flash/edit at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/flash/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/4b/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base at $0.009 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_pixel": 8.58306884765625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/4b/base/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base/edit at $0.009 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_pixel": 8.58306884765625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/base/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base/edit/lora at $0.016 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.016, + "output_cost_per_pixel": 1.52587890625e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/base/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/base/lora at $0.016 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.016, + "output_cost_per_pixel": 1.52587890625e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/base/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/4b/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/edit at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/edit/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/4b/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/4b/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/4b/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b at $0.006 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.006, + "output_cost_per_pixel": 5.7220458984375e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base at $0.011 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.011, + "output_cost_per_pixel": 1.049041748046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b/base/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base/edit at $0.011 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.011, + "output_cost_per_pixel": 1.049041748046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/base/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base/edit/lora at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/base/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/base/lora at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/base/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/klein/9b/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/edit at $0.011 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.011, + "output_cost_per_pixel": 1.049041748046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/edit/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/edit/lora at $0.015 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.015, + "output_cost_per_pixel": 1.430511474609375e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/edit/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/klein/9b/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/klein/9b/lora at $0.015 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.015, + "output_cost_per_pixel": 1.430511474609375e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/klein/9b/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/lora at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/lora/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/lora/edit at $0.021 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "output_cost_per_pixel": 2.002716064453125e-08, + "source": "https://fal.ai/models/fal-ai/flux-2/lora/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/turbo at $0.008 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.008, + "output_cost_per_pixel": 7.62939453125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2/turbo/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-2/turbo/edit at $0.008 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries (input megapixels are billed too and are not modeled)"}, + "mode": "image_generation", + "output_cost_per_image": 0.008, + "output_cost_per_pixel": 7.62939453125e-09, + "source": "https://fal.ai/models/fal-ai/flux-2/turbo/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-general": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-general at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-general", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-general/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-general/image-to-image at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-general/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-general/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-general/inpainting at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/flux-general/inpainting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-kontext-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-kontext-lora/inpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext-lora/inpaint at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext-lora/inpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-kontext-lora/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext-lora/text-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext-lora/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-kontext/dev": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-kontext/dev at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux-kontext/dev", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-krea-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-krea-lora/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora/image-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-krea-lora/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora/inpainting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora/inpainting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-krea-lora/stream": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-krea-lora/stream at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-krea-lora/stream", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-lora-canny": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora-canny at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora-canny", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-lora-fill": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora-fill at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora-fill", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-lora/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora/image-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-lora/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora/inpainting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora/inpainting", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-lora/stream": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-lora/stream at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/flux-lora/stream", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/kontext": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/max": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/max", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/max/multi": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/max/multi", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/max/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/max/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/kontext/multi": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/multi", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/kontext/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/flux-pro/kontext/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/v1.1-ultra-finetuned": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1-ultra-finetuned", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-pro/v1.1-ultra/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1.1-ultra/redux at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1-ultra/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1.1/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1.1/redux at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1.1/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1/fill": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1/fill at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1/fill", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1/fill-finetuned": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pro/v1/fill-finetuned at $0.06 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.06, + "output_cost_per_pixel": 5.7220458984375e-08, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1/fill-finetuned", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pulid": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-pulid at $0.0333 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0333, + "output_cost_per_pixel": 3.175735473632813e-08, + "source": "https://fal.ai/models/fal-ai/flux-pulid", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-vision-upscaler": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux-vision-upscaler at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/flux-vision-upscaler", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/dev/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/dev/image-to-image at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/flux/dev/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/dev/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/dev/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/dev/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/krea": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/krea at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/krea", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux/krea/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/krea/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/krea/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/krea/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/krea/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/krea/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/schnell/redux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/schnell/redux at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/schnell/redux", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux/srpo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/srpo at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/srpo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux/srpo/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/flux/srpo/image-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/flux/srpo/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/framepack": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://fal.ai/models/fal-ai/framepack", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/framepack/f1": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://fal.ai/models/fal-ai/framepack/f1", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/framepack/flf2v": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://fal.ai/models/fal-ai/framepack/flf2v", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/gemini-25-flash-image/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/gemini-25-flash-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/gemini-3-pro-image-preview": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/gemini-3-pro-image-preview", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/gemini-3-pro-image-preview/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/gemini-3-pro-image-preview/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/gemini-3.1-flash-image-preview": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/gemini-3.1-flash-image-preview", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/gemini-3.1-flash-image-preview/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/gemini-3.1-flash-image-preview/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/glm-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/glm-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/glm-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/glm-image/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/glm-image/image-to-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/glm-image/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/heygen/avatar3/digital-twin": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.034, + "source": "https://fal.ai/models/fal-ai/heygen/avatar3/digital-twin", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/avatar4/digital-twin": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/avatar4/digital-twin", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/avatar4/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/avatar4/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/avatar5/digital-twin": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/avatar5/digital-twin", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v2/translate/precision": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/v2/translate/precision", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v2/translate/speed": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/heygen/v2/translate/speed", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v2/video-agent": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.034, + "source": "https://fal.ai/models/fal-ai/heygen/v2/video-agent", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/filler-word-removal": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/fal-ai/heygen/v3/filler-word-removal", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/lipsync/precision": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/heygen/v3/lipsync/precision", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/lipsync/speed": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/heygen/v3/lipsync/speed", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/heygen/v3/video-agent": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.034, + "source": "https://fal.ai/models/fal-ai/heygen/v3/video-agent", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hidream-i1-fast": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-i1-fast at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/hidream-i1-fast", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-i1-full": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-i1-full at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/hidream-i1-full", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-i1-full/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-i1-full/image-to-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/hidream-i1-full/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hidream-o1-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-o1-image/dev": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image/dev at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image/dev", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hidream-o1-image/dev/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image/dev/edit at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image/dev/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hidream-o1-image/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hidream-o1-image/edit at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/hidream-o1-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hunyuan_world": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/hunyuan_world", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hunyuan-image/v2.1/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v2.1/text-to-image at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v2.1/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hunyuan-image/v3/instruct/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v3/instruct/edit at $0.09 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.09, + "output_cost_per_pixel": 8.58306884765625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v3/instruct/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/hunyuan-image/v3/instruct/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v3/instruct/text-to-image at $0.09 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.09, + "output_cost_per_pixel": 8.58306884765625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v3/instruct/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hunyuan-image/v3/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/hunyuan-image/v3/text-to-image at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/hunyuan-image/v3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/hunyuan-video-v1.5/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://fal.ai/models/fal-ai/hunyuan-video-v1.5/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hunyuan-video-v1.5/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://fal.ai/models/fal-ai/hunyuan-video-v1.5/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/iclight-v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/iclight-v2 at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/iclight-v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/id-v2v": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/id-v2v", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/id-v2v/relight": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/id-v2v/relight", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ideogram/character": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/ideogram/character", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/character/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/ideogram/character/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/character/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/ideogram/character/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/object-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/ideogram/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/remove-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/ideogram/remove-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/upscale": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/upscale", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/ideogram/v2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2/turbo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2/turbo/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/turbo/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2/turbo/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/ideogram/v2/turbo/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2a": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2a/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v2a/turbo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v2a/turbo/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://fal.ai/models/fal-ai/ideogram/v2a/turbo/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/generate-transparent": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/generate-transparent", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/ideogram/v3/layerize-text": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.09, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/layerize-text", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/reframe": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/reframe", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/remix": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/remix", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/ideogram/v3/replace-background": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/ideogram/v3/replace-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/age-modify": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/age-modify", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/city-teleport": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/city-teleport", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/expression-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/expression-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/hair-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/hair-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/headshot-photo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/headshot-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/makeup-application": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/makeup-application", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/object-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/outpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/image-apps-v2/outpaint at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/outpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/perspective": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/perspective", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/photo-restoration": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/photo-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/photography-effects": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.4, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/photography-effects", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/portrait-enhance": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.4, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/portrait-enhance", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/product-holding": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/product-holding", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/product-photography": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/product-photography", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/relighting": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/relighting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/style-transfer": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/style-transfer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/texture-transform": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/texture-transform", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-apps-v2/virtual-try-on": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-apps-v2/virtual-try-on", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/age-progression": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/age-progression", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/baby-version": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/baby-version", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/background-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/background-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/broccoli-haircut": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/broccoli-haircut", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/cartoonify": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/cartoonify", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/color-correction": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/color-correction", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/expression-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/expression-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/face-enhancement": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/face-enhancement", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/hair-change": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/hair-change", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/object-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/photo-restoration": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/photo-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/plushie-style": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/plushie-style", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/professional-photo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/professional-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/realism": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/realism", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/reframe": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/reframe", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/retouch": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/retouch", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/scene-composition": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/scene-composition", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/style-transfer": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/style-transfer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/text-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/text-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/time-of-day": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/time-of-day", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/weather-effect": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/weather-effect", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/wojak-style": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/wojak-style", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image-editing/youtube-thumbnails": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/image-editing/youtube-thumbnails", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/image2svg": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://fal.ai/models/fal-ai/image2svg", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/instant-character": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/instant-character at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/instant-character", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/o1": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/o1", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/o3/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/o3/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/o3/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/o3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/kling-image/v3/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/v3/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/kling-image/v3/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.028, + "source": "https://fal.ai/models/fal-ai/kling-image/v3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/kling-video/ai-avatar/v2/pro": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.115, + "source": "https://fal.ai/models/fal-ai/kling-video/ai-avatar/v2/pro", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/ai-avatar/v2/standard": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0562, + "source": "https://fal.ai/models/fal-ai/kling-video/ai-avatar/v2/standard", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.084, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.084, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/standard/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/standard/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o1/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o1/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/4k/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/4k/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/pro/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/pro/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/pro/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/pro/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/standard/video-to-video/edit": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/standard/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/o3/standard/video-to-video/reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/o3/standard/video-to-video/reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1.5/pro/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/kling-video/v1.5/pro/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1.6/pro/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.098, + "source": "https://fal.ai/models/fal-ai/kling-video/v1.6/pro/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1.6/standard/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.056, + "source": "https://fal.ai/models/fal-ai/kling-video/v1.6/standard/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1/pro/ai-avatar": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.115, + "source": "https://fal.ai/models/fal-ai/kling-video/v1/pro/ai-avatar", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1/standard/ai-avatar": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.0562, + "source": "https://fal.ai/models/fal-ai/kling-video/v1/standard/ai-avatar", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v1/standard/effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.045, + "source": "https://fal.ai/models/fal-ai/kling-video/v1/standard/effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.6/pro/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.6/pro/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.6/standard/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.6/standard/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/4k/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/4k/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/4k/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.42, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/4k/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/pro/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.168, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/pro/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/standard/motion-control": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.126, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/standard/motion-control", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/pro/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/standard/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v3/turbo/standard/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.112, + "source": "https://fal.ai/models/fal-ai/kling-video/v3/turbo/standard/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/krea-2/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/krea-2/turbo at $0.008 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.008, + "output_cost_per_pixel": 7.62939453125e-09, + "source": "https://fal.ai/models/fal-ai/krea-2/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/krea-2/turbo/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/krea-2/turbo/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/krea-2/turbo/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/krea-2/turbo/style": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/krea-2/turbo/style at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/krea-2/turbo/style", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/krea-wan-14b/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/krea-wan-14b/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/krea-wan-14b/video-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/krea-wan-14b/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/latentsync": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/latentsync", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/leffa/pose-transfer": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/leffa/pose-transfer", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/leffa/virtual-tryon": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/leffa/virtual-tryon", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/lightx/recamera": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/lightx/recamera", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/lightx/relight": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/lightx/relight", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/longcat-image at $0.13 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.13, + "output_cost_per_pixel": 1.239776611328125e-07, + "source": "https://fal.ai/models/fal-ai/longcat-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/longcat-image/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/longcat-image/edit at $0.15 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.15, + "output_cost_per_pixel": 1.430511474609375e-07, + "source": "https://fal.ai/models/fal-ai/longcat-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/longcat-video/distilled/image-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/image-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/distilled/image-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/image-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/distilled/text-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.005, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/text-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/distilled/text-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/fal-ai/longcat-video/distilled/text-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/image-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/longcat-video/image-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/image-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/longcat-video/image-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/text-to-video/480p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "source": "https://fal.ai/models/fal-ai/longcat-video/text-to-video/480p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/longcat-video/text-to-video/720p": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/longcat-video/text-to-video/720p", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/audio-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/audio-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/extend-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.16, 2160p $0.32"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.08, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/image-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.12, 2160p $0.24"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/image-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/retake-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/retake-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.16, 2160p $0.32"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.08, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-2.3/text-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.12, 2160p $0.24"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "source": "https://fal.ai/models/fal-ai/ltx-2.3/text-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled/extend": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled/extend", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltxv-13b-098-distilled/multiconditioning": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://fal.ai/models/fal-ai/ltxv-13b-098-distilled/multiconditioning", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/lumina-image/v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/lumina-image/v2 at $0.075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.075, + "output_cost_per_pixel": 7.152557373046875e-08, + "source": "https://fal.ai/models/fal-ai/lumina-image/v2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/marigold-v2": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/marigold-v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/minimax/hailuo-02-fast/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 512p rate; tiers without a catalog field: 512p $0.017"}, + "mode": "video_generation", + "output_cost_per_second": 0.017, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02-fast/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/pro/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/standard/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.045, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/standard/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/image-01/subject-reference": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/minimax/image-01/subject-reference", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/mmaudio-v2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.001, + "source": "https://fal.ai/models/fal-ai/mmaudio-v2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/moss-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/moss-image at $0.05 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "output_cost_per_pixel": 4.76837158203125e-08, + "source": "https://fal.ai/models/fal-ai/moss-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/nafnet/deblur": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/nafnet/deblur at $0.0225 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0225, + "output_cost_per_pixel": 2.1457672119140624e-08, + "source": "https://fal.ai/models/fal-ai/nafnet/deblur", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nafnet/denoise": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/nafnet/denoise at $0.0225 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0225, + "output_cost_per_pixel": 2.1457672119140624e-08, + "source": "https://fal.ai/models/fal-ai/nafnet/denoise", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nano-banana-2/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/nano-banana-2/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nano-banana-pro/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://fal.ai/models/fal-ai/nano-banana-pro/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nano-banana/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.0398, + "source": "https://fal.ai/models/fal-ai/nano-banana/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/nucleus-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/nucleus-image at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/nucleus-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/omnigen-v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/omnigen-v2 at $0.15 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.15, + "output_cost_per_pixel": 1.430511474609375e-07, + "source": "https://fal.ai/models/fal-ai/omnigen-v2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/one-to-all-animation/1.3b": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; tiers without a catalog field: 580p $0.045"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_480p": 0.03, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/fal-ai/one-to-all-animation/1.3b", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/one-to-all-animation/14b": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; tiers without a catalog field: 580p $0.09"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_480p": 0.06, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/fal-ai/one-to-all-animation/14b", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/patina": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/patina at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/patina", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/phota/enhance": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.13, + "source": "https://fal.ai/models/fal-ai/phota/enhance", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/blur": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/blur", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/chromatic-aberration": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/chromatic-aberration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/color-correction": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/color-correction", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/color-tint": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/color-tint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/desaturate": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/desaturate", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/dissolve": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/dissolve", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/dodge-burn": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/dodge-burn", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/grain": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/grain", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/parabolize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/parabolize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/sharpen": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/sharpen", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/solarize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/solarize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/post-processing/vignette": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://fal.ai/models/fal-ai/post-processing/vignette", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/qwen-image-2512": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-2512 at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-2512", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/qwen-image-2512/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-2512/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-2512/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/qwen-image-edit-2509": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509 at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/add-background": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/add-background at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/add-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/face-to-full-portrait": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/face-to-full-portrait at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/face-to-full-portrait", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/group-photo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/group-photo at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/group-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/integrate-product": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/integrate-product at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/integrate-product", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/lighting-restoration": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/lighting-restoration at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/lighting-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/multiple-angles at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/next-scene": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/next-scene at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/next-scene", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/remove-element": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/remove-element at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/remove-element", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/remove-lighting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/remove-lighting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/remove-lighting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2509-lora-gallery/shirt-design": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2509-lora-gallery/shirt-design at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2509-lora-gallery/shirt-design", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2511": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2511 at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2511", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2511-multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2511-multiple-angles at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-2511/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-2511/lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-2511/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/add-background": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/add-background at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/add-background", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/face-to-full-portrait": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/face-to-full-portrait at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/face-to-full-portrait", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/group-photo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/group-photo at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/group-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/integrate-product": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/integrate-product at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/integrate-product", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/lighting-restoration": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/lighting-restoration at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/lighting-restoration", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/multiple-angles": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/multiple-angles at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/multiple-angles", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/next-scene": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/next-scene at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/next-scene", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/remove-element": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/remove-element at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/remove-element", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/remove-lighting": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/remove-lighting at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/remove-lighting", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit-plus-lora-gallery/shirt-design": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit-plus-lora-gallery/shirt-design at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit-plus-lora-gallery/shirt-design", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit/image-to-image at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-edit/inpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image-edit/inpaint at $0.03 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "output_cost_per_pixel": 2.86102294921875e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image-edit/inpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-layered": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/qwen-image-layered", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image-layered/lora": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/fal-ai/qwen-image-layered/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/qwen-image/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/qwen-image/image-to-image at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/qwen-image/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/upscale/creative": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://fal.ai/models/fal-ai/recraft/upscale/creative", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/upscale/crisp": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.004, + "source": "https://fal.ai/models/fal-ai/recraft/upscale/crisp", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/v4.1/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.21, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/pro/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/pro/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/utility/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.21, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/utility/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4.1/utility/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/fal-ai/recraft/v4.1/utility/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://fal.ai/models/fal-ai/recraft/v4/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/pro/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/recraft/v4/pro/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/recraft/v4/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/v4/text-to-vector": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://fal.ai/models/fal-ai/recraft/v4/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/recraft/vectorize": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/recraft/vectorize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/sana": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana at $0.001 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.001, + "output_cost_per_pixel": 9.5367431640625e-10, + "source": "https://fal.ai/models/fal-ai/sana", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/sana/sprint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana/sprint at $0.0025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0025, + "output_cost_per_pixel": 2.384185791015625e-09, + "source": "https://fal.ai/models/fal-ai/sana/sprint", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/sana/v1.5/1.6b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana/v1.5/1.6b at $0.0075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0075, + "output_cost_per_pixel": 7.152557373046875e-09, + "source": "https://fal.ai/models/fal-ai/sana/v1.5/1.6b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/sana/v1.5/4.8b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/sana/v1.5/4.8b at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/sana/v1.5/4.8b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/seedvr/upscale/image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/seedvr/upscale/image at $0.001 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.001, + "output_cost_per_pixel": 9.5367431640625e-10, + "source": "https://fal.ai/models/fal-ai/seedvr/upscale/image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/seedvr/upscale/image/seamless": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/seedvr/upscale/image/seamless at $0.0025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0025, + "output_cost_per_pixel": 2.384185791015625e-09, + "source": "https://fal.ai/models/fal-ai/seedvr/upscale/image/seamless", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/stable-diffusion-v3-medium": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/fal-ai/stable-diffusion-v3-medium", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/stable-diffusion-v35-large": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/stable-diffusion-v35-large at $0.065 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.065, + "output_cost_per_pixel": 6.198883056640625e-08, + "source": "https://fal.ai/models/fal-ai/stable-diffusion-v35-large", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/stepx-edit2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/stepx-edit2 at $0.2 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.2, + "output_cost_per_pixel": 1.9073486328125e-07, + "source": "https://fal.ai/models/fal-ai/stepx-edit2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/telestyle-v2": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/telestyle-v2 at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/fal-ai/telestyle-v2", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/uso": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/uso at $0.1 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "output_cost_per_pixel": 9.5367431640625e-08, + "source": "https://fal.ai/models/fal-ai/uso", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/vidu/q2/reference-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/q2/reference-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/vidu/q2/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/q2/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/vidu/reference-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/reference-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/depth": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/depth", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/inpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/outpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/outpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-22-vace-fun-a14b/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-22-vace-fun-a14b/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-25-preview/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan-25-preview/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-25-preview/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan-25-preview/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-25-preview/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/depth": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/depth", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/inpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/inpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/outpainting": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/outpainting", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/pose": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/pose", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-14b/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan-vace-14b/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-apps/video-edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan-vace-apps/video-edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-14b/speech-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; tiers without a catalog field: 580p $0.15"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_480p": 0.1, + "output_cost_per_second_720p": 0.2, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-14b/speech-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-5b/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.016, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-5b/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan/v2.2-a14b/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/image-to-video/lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/image-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-image/lora": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-image/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/text-to-video/lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/text-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-a14b/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 580p $0.06"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-a14b/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan/v2.7/edit-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/edit-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/pro/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/pro/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/wan/v2.7/pro/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.7/reference-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.7/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/wan/v2.7/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/fal-ai/wan/v2.7/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/z-image/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/base at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/base/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/base/lora at $0.012 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.012, + "output_cost_per_pixel": 1.1444091796875e-08, + "source": "https://fal.ai/models/fal-ai/z-image/base/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo/controlnet": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/controlnet at $0.0065 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0065, + "output_cost_per_pixel": 6.198883056640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/controlnet", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/controlnet/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/controlnet/lora at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/controlnet/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/image-to-image at $0.005 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.005, + "output_cost_per_pixel": 4.76837158203125e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/image-to-image/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/image-to-image/lora at $0.0085 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0085, + "output_cost_per_pixel": 8.106231689453126e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/image-to-image/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/inpaint": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/inpaint at $0.01 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "output_cost_per_pixel": 9.5367431640625e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/inpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/inpaint/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/inpaint/lora at $0.0115 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0115, + "output_cost_per_pixel": 1.0967254638671875e-08, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/inpaint/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/z-image/turbo/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/lora at $0.0085 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0085, + "output_cost_per_pixel": 8.106231689453126e-09, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo/tiling": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/tiling at $0.02 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "output_cost_per_pixel": 1.9073486328125e-08, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/tiling", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/z-image/turbo/tiling/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills fal-ai/z-image/turbo/tiling/lora at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/fal-ai/z-image/turbo/tiling/lora", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.1/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 360p $0.03"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.1/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/flash/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/flash/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/flash/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/flash/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/flash/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.063, + "output_cost_per_second_720p": 0.063, + "source": "https://fal.ai/models/kling/v4/flash/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.235, + "output_cost_per_second_1080p": 0.235, + "output_cost_per_second_720p": 0.157, + "source": "https://fal.ai/models/kling/v4/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.235, + "output_cost_per_second_1080p": 0.235, + "output_cost_per_second_720p": 0.157, + "source": "https://fal.ai/models/kling/v4/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/video-to-video/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.141, + "output_cost_per_second_1080p": 0.141, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/video-to-video/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/krea/v3/edit": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills krea/v3/edit at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/krea/v3/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/krea/v3/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills krea/v3/text-to-image at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.025, + "output_cost_per_pixel": 2.384185791015625e-08, + "source": "https://fal.ai/models/krea/v3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/luma/agent/uni-1/v1/max": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.102, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/max", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/luma/agent/uni-1/v1/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.042, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/meta/muse-image/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/meta/muse-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/meta/muse-image/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/meta/muse-image/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/minimax/h3-max-turbo/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_1080p": 0.08, + "output_cost_per_second_2k": 0.16, + "output_cost_per_second_480p": 0.025, + "output_cost_per_second_768p": 0.04, + "source": "https://fal.ai/models/minimax/h3-max-turbo/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max-turbo/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max-turbo/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/insert-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.06, + "source": "https://fal.ai/models/minimax/h3-max/insert-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/lip-sync/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/lip-sync/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_2k": 0.13, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_4k": 0.16, + "output_cost_per_second_768p": 0.06, + "source": "https://fal.ai/models/minimax/h3/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/image-to-video/lora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1625, + "output_cost_per_second_2k": 0.1625, + "output_cost_per_second_480p": 0.0625, + "output_cost_per_second_4k": 0.2, + "output_cost_per_second_768p": 0.075, + "source": "https://fal.ai/models/minimax/h3/image-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/reference-to-video/lora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1625, + "output_cost_per_second_2k": 0.1625, + "output_cost_per_second_480p": 0.0625, + "output_cost_per_second_4k": 0.2, + "output_cost_per_second_768p": 0.075, + "source": "https://fal.ai/models/minimax/h3/reference-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3/text-to-video/lora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 2k rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.1625, + "output_cost_per_second_2k": 0.1625, + "output_cost_per_second_480p": 0.0625, + "output_cost_per_second_4k": 0.2, + "output_cost_per_second_768p": 0.075, + "source": "https://fal.ai/models/minimax/h3/text-to-video/lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/nvidia/cosmos-3-super/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/nvidia/cosmos-3-super/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixelcut/background-removal": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.016, + "source": "https://fal.ai/models/pixelcut/background-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/pixelcut/looping-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.32, + "output_cost_per_second_1080p": 0.32, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_768p": 0.16, + "source": "https://fal.ai/models/pixelcut/looping-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixelcut/product-photo": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/pixelcut/product-photo", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/pixverse/music-video/vibemv": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.09, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/pixverse/music-video/vibemv", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/recraft/v4.1/flash/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.007, + "source": "https://fal.ai/models/recraft/v4.1/flash/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/base": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/base at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/base", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/base/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/base/image-to-image at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "output_cost_per_pixel": 3.337860107421875e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/base/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/lightning": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/lightning at $0.006 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.006, + "output_cost_per_pixel": 5.7220458984375e-09, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/lightning", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/pro": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/pro at $0.055 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.055, + "output_cost_per_pixel": 5.245208740234375e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/pro", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/rundiffusion-fal/juggernaut-flux/pro/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/juggernaut-flux/pro/image-to-image at $0.055 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.055, + "output_cost_per_pixel": 5.245208740234375e-08, + "source": "https://fal.ai/models/rundiffusion-fal/juggernaut-flux/pro/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/rundiffusion-fal/rundiffusion-photo-flux": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills rundiffusion-fal/rundiffusion-photo-flux at $0.045 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.045, + "output_cost_per_pixel": 4.291534423828125e-08, + "source": "https://fal.ai/models/rundiffusion-fal/rundiffusion-photo-flux", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/smoretalk-ai/rembg-enhance": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.006, + "source": "https://fal.ai/models/smoretalk-ai/rembg-enhance", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/sonilo/v1.1/video-to-video-music": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.009, + "source": "https://fal.ai/models/sonilo/v1.1/video-to-video-music", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/sonilo/v1.1/video-to-video-sound-effects": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.009, + "source": "https://fal.ai/models/sonilo/v1.1/video-to-video-sound-effects", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/tbc/mischief-1/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "output_cost_per_second_480p": 0.005, + "output_cost_per_second_720p": 0.01, + "source": "https://fal.ai/models/tbc/mischief-1/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/veed/fabric-1.0": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p)"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.15, + "source": "https://fal.ai/models/veed/fabric-1.0", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/veed/fabric-1.0/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p)"}, + "mode": "video_generation", + "output_cost_per_second": 0.2, + "output_cost_per_second_480p": 0.1, + "output_cost_per_second_720p": 0.2, + "source": "https://fal.ai/models/veed/fabric-1.0/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/veed/fabric-1.0/text": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p)"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.15, + "source": "https://fal.ai/models/veed/fabric-1.0/text", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/wan/v2.6/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/wan/v2.6/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/image-to-video/flash": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.075, + "output_cost_per_second_1080p": 0.075, + "output_cost_per_second_720p": 0.05, + "source": "https://fal.ai/models/wan/v2.6/image-to-video/flash", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/text-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/wan/v2.6/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/wan/v2.6/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://fal.ai/models/xai/grok-imagine-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.25, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/alibaba/happy-horse/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/alibaba/happy-horse/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/creatify/boreal": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "output_cost_per_second_1080p": 0.03, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_720p": 0.01, + "source": "https://fal.ai/models/creatify/boreal", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/creatify/aurora": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_480p": 0.07, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/fal-ai/creatify/aurora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/q4/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 540p $0.0315"}, + "mode": "video_generation", + "output_cost_per_second": 0.0665, + "output_cost_per_second_1080p": 0.084, + "output_cost_per_second_2k": 0.133, + "output_cost_per_second_4k": 0.273, + "output_cost_per_second_720p": 0.0665, + "source": "https://fal.ai/models/fal-ai/vidu/q4/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/q4/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; tiers without a catalog field: 540p $0.0315"}, + "mode": "video_generation", + "output_cost_per_second": 0.0665, + "output_cost_per_second_1080p": 0.084, + "output_cost_per_second_2k": 0.133, + "output_cost_per_second_4k": 0.273, + "output_cost_per_second_720p": 0.0665, + "source": "https://fal.ai/models/fal-ai/vidu/q4/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/ideogram/v4.5": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/ideogram/v4.5", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/ideogram/v4.5/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://fal.ai/models/ideogram/v4.5/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/ideogram/v4/instant": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"fal bills ideogram/v4/instant at $0.0075 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"}, + "mode": "image_generation", + "output_cost_per_image": 0.0075, + "output_cost_per_pixel": 7.152557373046875e-09, + "source": "https://fal.ai/models/ideogram/v4/instant", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/lightricks/ltx-2.5/image-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.19"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_1080p": 0.13, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.09, + "source": "https://fal.ai/models/lightricks/ltx-2.5/image-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/lightricks/ltx-2.5/image-to-video/pro": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.17, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/lightricks/ltx-2.5/image-to-video/pro", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/lightricks/ltx-2.5/text-to-video/fast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate; tiers without a catalog field: 1440p $0.19"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_1080p": 0.13, + "output_cost_per_second_4k": 0.3, + "output_cost_per_second_720p": 0.09, + "source": "https://fal.ai/models/lightricks/ltx-2.5/text-to-video/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/lightricks/ltx-2.5/text-to-video/pro": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.17, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/lightricks/ltx-2.5/text-to-video/pro", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/recast": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.45, + "output_cost_per_second_1080p": 0.45, + "output_cost_per_second_768p": 0.3, + "source": "https://fal.ai/models/minimax/h3-max/recast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/reframe-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/reframe-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/relight": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/relight", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/wan/v2.6/reference-to-video/flash": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 1080p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://fal.ai/models/wan/v2.6/reference-to-video/flash", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/lite/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_720p": 0.03, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/lite/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/lite/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_720p": 0.03, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/lite/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3-max/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; promotional 50% off rate through 2026-11-22 23:59 UTC per the page; regular rates $0.17 (720p) and $0.29 (1080p) per second resume 2026-11-23 00:00 UTC (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.085, + "output_cost_per_second_1080p": 0.145, + "output_cost_per_second_720p": 0.085, + "source": "https://fal.ai/models/blackforestlabs/flux-3-max/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3-max/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; promotional 50% off rate through 2026-11-22 23:59 UTC per the page; regular rates $0.17 (720p) and $0.29 (1080p) per second resume 2026-11-23 00:00 UTC (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.085, + "output_cost_per_second_1080p": 0.145, + "output_cost_per_second_720p": 0.085, + "source": "https://fal.ai/models/blackforestlabs/flux-3-max/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3-max/keyframes-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; regular rate; the page's 75% promotion ran October 15 to November 7, 2026 and has ended (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3-max/keyframes-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/heygen/heygen-video/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; regular rate; the page's 50% promotion ended October 31, 2026 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.06, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_768p": 0.02, + "source": "https://fal.ai/models/heygen/heygen-video/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/heygen/heygen-video/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; regular rate; the page's 50% promotion ended October 31, 2026 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.06, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_768p": 0.02, + "source": "https://fal.ai/models/heygen/heygen-video/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/heygen/heygen-video/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; regular rate; the page's 50% promotion ended October 31, 2026 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.06, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_768p": 0.02, + "source": "https://fal.ai/models/heygen/heygen-video/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max-turbo/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.025 (480p), $0.04 (768p), $0.08 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.028, + "output_cost_per_second_1080p": 0.056, + "output_cost_per_second_480p": 0.0175, + "output_cost_per_second_768p": 0.028, + "source": "https://fal.ai/models/minimax/h3-max-turbo/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max-turbo/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.025 (480p), $0.04 (768p), $0.08 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.028, + "output_cost_per_second_1080p": 0.056, + "output_cost_per_second_480p": 0.0175, + "output_cost_per_second_768p": 0.028, + "source": "https://fal.ai/models/minimax/h3-max-turbo/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/camera-controls": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 480p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.05 (480p), $0.08 (768p), $0.16 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.035, + "output_cost_per_second_1080p": 0.112, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_768p": 0.056, + "source": "https://fal.ai/models/minimax/h3-max/camera-controls", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.05 (480p), $0.08 (768p), $0.16 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.056, + "output_cost_per_second_1080p": 0.112, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_768p": 0.056, + "source": "https://fal.ai/models/minimax/h3-max/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768p rate; promotional 30% off rate through 2026-11-15 per the page; standard rates $0.05 (480p), $0.08 (768p), $0.16 (1080p) per second from 2026-11-16 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10)"}, + "mode": "video_generation", + "output_cost_per_second": 0.056, + "output_cost_per_second_1080p": 0.112, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_768p": 0.056, + "source": "https://fal.ai/models/minimax/h3-max/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] } } diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 59c772f2993..8ff7f8bd251 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1453,6 +1453,9 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "fal_ai/bytedance/seedance-2.0/text-to-video", "fal_ai/bytedance/seedance-2.0/image-to-video", "fal_ai/bytedance/seedance-2.0/reference-to-video", + "fal_ai/bytedance/seedance-2.5/us/draft/complete", # 1080p is $1.164/second per fal.ai + "fal_ai/bytedance/seedance-2.5/us/image-to-video", # 1080p is $1.164/second per fal.ai + "fal_ai/bytedance/seedance-2.5/us/text-to-video", # 1080p is $1.164/second per fal.ai ] is_valid, violations = validate_model_cost_values(actual_json, exceptions) From 53e1e9b87b36dabed967b17338948535020b7abc Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:41:59 -0800 Subject: [PATCH 20/86] fix(vertex_ai): bill gemini image edits by token usage (#51038) * fix(vertex_ai): bill Gemini image edit token usage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(vertex_ai): validate image edit usage before normalization Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: kenneth Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../gemini/image_generation/transformation.py | 2 +- .../llms/gemini/image_usage_transformation.py | 6 +- .../vertex_gemini_transformation.py | 18 ++++- ...est_vertex_ai_image_edit_transformation.py | 40 ++++++++++ ...tex_ai_image_generation_cost_calculator.py | 78 +++++++++++++++++++ 5 files changed, 137 insertions(+), 7 deletions(-) diff --git a/litellm/llms/gemini/image_generation/transformation.py b/litellm/llms/gemini/image_generation/transformation.py index 1f986b11af2..6d1e1cf3931 100644 --- a/litellm/llms/gemini/image_generation/transformation.py +++ b/litellm/llms/gemini/image_generation/transformation.py @@ -170,7 +170,7 @@ class GoogleImageGenConfig(BaseImageGenerationConfig): return request_body_obj.model_dump(exclude_none=True) def _transform_image_usage(self, usage_metadata: dict): - return transform_gemini_image_usage(usage_metadata) + return transform_gemini_image_usage(_JSON_DICT.validate_python(usage_metadata)) def transform_image_generation_response( self, diff --git a/litellm/llms/gemini/image_usage_transformation.py b/litellm/llms/gemini/image_usage_transformation.py index 5f08d8403af..3ef403f79ee 100644 --- a/litellm/llms/gemini/image_usage_transformation.py +++ b/litellm/llms/gemini/image_usage_transformation.py @@ -1,5 +1,7 @@ from typing import Final +from pydantic import TypeAdapter + from litellm.types.utils import ImageUsage, ImageUsageInputTokensDetails @@ -34,12 +36,12 @@ def _sum_modality_token_details(usage_metadata: dict, *details_keys: str) -> Ima return tokens_details -def transform_gemini_image_usage(usage_metadata: dict) -> ImageUsage: +def transform_gemini_image_usage(usage_metadata: dict[str, object]) -> ImageUsage: """ Transform Gemini usageMetadata to ImageUsage format. """ input_tokens_details = _sum_modality_token_details(usage_metadata, "promptTokensDetails", "prompt_tokens_details") - output_tokens: Final = usage_metadata.get("candidatesTokenCount", 0) + output_tokens: Final = TypeAdapter(int).validate_python(usage_metadata.get("candidatesTokenCount", 0)) output_tokens_details: Final = _sum_modality_token_details( usage_metadata, "candidatesTokensDetails", "candidates_tokens_details" ) diff --git a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py index 3633bb68b50..8e9bef97085 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py @@ -6,10 +6,12 @@ from typing import TYPE_CHECKING, Any, Final, Protocol, cast import httpx from httpx._types import RequestFiles +from pydantic import ConfigDict, TypeAdapter import litellm from litellm.images.utils import ImageEditRequestUtils from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig +from litellm.llms.gemini.image_usage_transformation import transform_gemini_image_usage from litellm.llms.vertex_ai.common_utils import get_vertex_base_url, map_vertex_image_size from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str @@ -20,7 +22,9 @@ from litellm.types.llms.vertex_ai import ( HttpxPartType, ) from litellm.types.router import GenericLiteLLMParams -from litellm.types.utils import FileTypes, ImageObject, ImageResponse, OpenAIImage +from litellm.types.utils import FileTypes, ImageObject, ImageResponse + +_JSON_DICT: Final = TypeAdapter(dict[str, object], config=ConfigDict(hide_input_in_errors=True)) if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -202,7 +206,6 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, ) -> ImageResponse: - model_response: Final = ImageResponse() try: response_json: Final = _generate_content_payload(raw_response) except Exception as exc: @@ -223,8 +226,15 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): if (inline_data := part.get("inlineData")) and (b64_json := inline_data.get("data")) ] - model_response.data = cast(list[OpenAIImage], data_list) - return model_response + usage_metadata: Final = _JSON_DICT.validate_python(response_json.get("usageMetadata") or {}) + traffic_type: Final = usage_metadata.get("trafficType") if usage_metadata else None + return ImageResponse( + data=data_list, + usage=transform_gemini_image_usage(usage_metadata) if usage_metadata else None, + hidden_params={"provider_specific_fields": {"traffic_type": traffic_type}} + if isinstance(traffic_type, str) and traffic_type + else {}, + ) def _prepare_inline_image_parts(self, image: FileTypes | list[FileTypes]) -> list[HttpxPartType]: images: Final[list[FileTypes]] = image if isinstance(image, list) else [image] diff --git a/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py b/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py index f36480ae7df..e311313c136 100644 --- a/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py +++ b/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py @@ -24,6 +24,46 @@ from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( from litellm.types.router import GenericLiteLLMParams +@pytest.mark.parametrize("traffic_type", ("ON_DEMAND_PRIORITY", "", None)) +def test_vertex_gemini_edit_preserves_usage_and_traffic_type(traffic_type: str | None) -> None: + response: Final = VertexAIGeminiImageEditConfig().transform_image_edit_response( + model="gemini-3.1-flash-image", + raw_response=httpx.Response( + 200, + json={ + "candidates": [], + "usageMetadata": { + "promptTokenCount": 1131, + "candidatesTokenCount": 0, + "totalTokenCount": 1131, + "promptTokensDetails": [ + {"modality": "TEXT", "tokenCount": 7}, + {"modality": "IMAGE", "tokenCount": 1124}, + ], + **({"trafficType": traffic_type} if traffic_type is not None else {}), + }, + }, + ), + logging_obj=Logging( + model="gemini-3.1-flash-image", + messages=[], + stream=False, + call_type="image_edit", + start_time=datetime(2025, 1, 1), + litellm_call_id="edit", + function_id="edit", + ), + ) + assert response.usage is not None + assert response.usage.input_tokens == 1131 + assert response.usage.output_tokens == 0 + assert response.usage.input_tokens_details.text_tokens == 7 + assert response.usage.input_tokens_details.image_tokens == 1124 + assert response.hidden_params.get("provider_specific_fields", {}) == ( + {"traffic_type": traffic_type} if traffic_type else {} + ) + + @pytest.mark.parametrize("size", ("1536x1024", "1024x1536", "3072x2048", "256x256", "512x512", "4096x4096")) @pytest.mark.parametrize("model", ("gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-3.1-flash-lite-image")) def test_vertex_gemini_edit_size_reaches_request(size: str, model: str) -> None: diff --git a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py index e54e25cbd18..65d6e97f8e5 100644 --- a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py +++ b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py @@ -1,7 +1,16 @@ import os +from datetime import datetime +from typing import Final + +import httpx +import pytest import litellm +from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.vertex_ai.gemini.cost_calculator import cost_per_web_search_request +from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( + VertexAIGeminiImageEditConfig, +) from litellm.llms.vertex_ai.image_generation.cost_calculator import ( cost_calculator as vertex_image_generation_cost_calculator, ) @@ -15,6 +24,75 @@ from litellm.types.utils import ( ) +@pytest.mark.parametrize("call_type", ("image_edit", "aimage_edit")) +@pytest.mark.parametrize("has_image, has_usage", ((True, True), (False, True), (True, False))) +def test_vertex_image_edit_cost_uses_input_tokens_and_retains_image_fallback( + call_type: str, + has_image: bool, + has_usage: bool, +) -> None: + model: Final = "gemini-3.1-flash-image" + model_info: Final = litellm.get_model_info(model=model, custom_llm_provider="vertex_ai") + response: Final = VertexAIGeminiImageEditConfig().transform_image_edit_response( + model=model, + raw_response=httpx.Response( + 200, + json={ + "candidates": [ + { + "finishReason": "STOP" if has_image else "IMAGE_RECITATION", + **( + {"content": {"parts": [{"inlineData": {"mimeType": "image/png", "data": "aW1n"}}]}} + if has_image + else {} + ), + } + ], + **( + { + "usageMetadata": { + "promptTokenCount": 1131, + "candidatesTokenCount": 100 if has_image else 0, + "totalTokenCount": 1231 if has_image else 1131, + "promptTokensDetails": [ + {"modality": "TEXT", "tokenCount": 7}, + {"modality": "IMAGE", "tokenCount": 1124}, + ], + } + } + if has_usage + else {} + ), + }, + ), + logging_obj=Logging( + model=model, + messages=[], + stream=False, + call_type=call_type, + start_time=datetime(2025, 1, 1), + litellm_call_id="edit", + function_id="edit", + ), + ) + cost: Final = litellm.completion_cost( + model=model, + custom_llm_provider="vertex_ai", + completion_response=response, + call_type=call_type, + ) + expected: Final = ( + 7 * (model_info["input_cost_per_token"] or 0) + + 1124 * (model_info.get("input_cost_per_image_token") or model_info["input_cost_per_token"] or 0) + + (100 if has_image else 0) * (model_info.get("output_cost_per_image_token") or 0) + if has_usage + else model_info["output_cost_per_image"] + ) + assert expected is not None and expected > 0 + assert cost == pytest.approx(expected) + assert vertex_image_generation_cost_calculator(model=model, image_response=response) == pytest.approx(expected) + + def _image_response_with_web_search(web_search_requests): usage = ImageUsage( input_tokens=20, From 9ef55423392dd042e6e2b256b328520d3f77349f Mon Sep 17 00:00:00 2001 From: giladk1221 Date: Wed, 11 Nov 2026 00:43:36 +0200 Subject: [PATCH 21/86] feat(search): add Microsoft Web IQ provider (#50883) * feat(search): add Microsoft Web IQ provider * fix(search): require HTTPS for Web IQ credentials * fix(search): include Web IQ in the packaged endpoint catalog * fix(search): register Microsoft Web IQ query pricing * test(search): fix Web IQ exception assertion lint * fix(search): type Web IQ response passthrough and search error headers The type-check gate failed on 8 new unknown-type errors in the Web IQ transformation. Build passthrough fields from a typed helper and validate the response models directly, and type the base search error headers as dict[str, str] so subclasses no longer inherit an unknown parameter. * fix(search): raise Web IQ parse errors without widening the base header type Typing the base search error headers as dict[str, str] made the shared HTTP handler report one more argument-type error. Restore the base signature and raise the same BaseLLMException directly from Web IQ. --------- Co-authored-by: Gilad Katz <29776916+giladk1221@users.noreply.github.com> Co-authored-by: ryan-crabbe-berri --- .github/workflows/test-unit.yml | 1 + litellm/llms/webiq/__init__.py | 0 litellm/llms/webiq/search/__init__.py | 0 litellm/llms/webiq/search/transformation.py | 156 ++++++++ ...odel_prices_and_context_window_backup.json | 5 + .../provider_endpoints_support_backup.json | 26 ++ litellm/types/utils.py | 1 + litellm/utils.py | 2 + model_prices_and_context_window.json | 5 + provider_endpoints_support.json | 26 ++ tests/unit/llms/webiq/__init__.py | 0 tests/unit/llms/webiq/search/__init__.py | 0 .../test_webiq_search_transformation.py | 351 ++++++++++++++++++ .../test_search_tool_management.py | 11 + .../public_endpoints/test_public_endpoints.py | 16 +- tests/unit/search/test_main.py | 13 + 16 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 litellm/llms/webiq/__init__.py create mode 100644 litellm/llms/webiq/search/__init__.py create mode 100644 litellm/llms/webiq/search/transformation.py create mode 100644 tests/unit/llms/webiq/__init__.py create mode 100644 tests/unit/llms/webiq/search/__init__.py create mode 100644 tests/unit/llms/webiq/search/test_webiq_search_transformation.py diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index fb29a92e306..d3f4821a05b 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -397,6 +397,7 @@ jobs: tests/unit/llms/voyage tests/unit/llms/wandb tests/unit/llms/watsonx + tests/unit/llms/webiq tests/unit/llms/xai tests/unit/llms/xinference tests/unit/llms/you_com diff --git a/litellm/llms/webiq/__init__.py b/litellm/llms/webiq/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/webiq/search/__init__.py b/litellm/llms/webiq/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/webiq/search/transformation.py b/litellm/llms/webiq/search/transformation.py new file mode 100644 index 00000000000..85d122a21af --- /dev/null +++ b/litellm/llms/webiq/search/transformation.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING, Final +from urllib.parse import urlsplit + +import httpx +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse, SearchResult + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + + +class _WebIQResult(BaseModel): + model_config = ConfigDict(extra="allow", frozen=True) + + title: str + url: str + content: str + lastUpdatedAt: str | None = None + + +class _WebIQResponse(BaseModel): + model_config = ConfigDict(extra="allow", frozen=True) + + webResults: tuple[_WebIQResult, ...] + + +_DOMAINS: Final = TypeAdapter(tuple[str, ...]) + + +def _extra_fields(model: BaseModel, reserved: Mapping[str, object]) -> MappingProxyType[str, object]: + extra: Final[Mapping[str, object]] = model.model_extra or {} + return MappingProxyType({key: value for key, value in extra.items() if key not in reserved}) + + +def _query_with_domains(query: str, domains: object) -> str: + if domains is None: + return query + parsed: Final = _DOMAINS.validate_python(domains) + included: Final = tuple(f"site:{domain}" for domain in parsed if domain and not domain.startswith("-")) + excluded: Final = tuple(f"-site:{domain[1:]}" for domain in parsed if domain.startswith("-") and len(domain) > 1) + include_clause: Final = f"({' OR '.join(included)})" if included else "" + if not included and not excluded: + return query + return " ".join(part for part in (f"({query})", include_clause, *excluded) if part) + + +class WebIQSearchConfig(BaseSearchConfig): + DEFAULT_API_BASE: Final = "https://api.microsoft.ai/v3" + + @staticmethod + def ui_friendly_name() -> str: + return "Microsoft Web IQ" + + def validate_environment( + self, + headers: dict[str, str], # mutable-ok: BaseSearchConfig interface + api_key: str | None = None, + api_base: str | None = None, + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> dict[str, str]: # mutable-ok: HTTP handler requires a dict + if urlsplit(api_base or self.DEFAULT_API_BASE).scheme != "https": + raise ValueError("Microsoft Web IQ search requires an HTTPS api_base") + resolved_key: Final = self.resolve_server_api_key( + caller_api_key=api_key, + caller_api_base=api_base, + key_env_vars=("WEBIQ_API_KEY",), + base_env_var=None, + default_api_base=self.DEFAULT_API_BASE, + ) + if not resolved_key: + raise ValueError("Set WEBIQ_API_KEY or pass api_key for Microsoft Web IQ search") + return { + **headers, + "x-apikey": resolved_key, + "Content-Type": "application/json", + } + + def get_complete_url( + self, + api_base: str | None, + optional_params: dict[str, object], # mutable-ok: BaseSearchConfig interface + data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: BaseSearchConfig interface + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> str: + base: Final = (api_base or self.DEFAULT_API_BASE).rstrip("/") + return base if base.endswith("/search/web") else f"{base}/search/web" + + def transform_search_request( + self, + query: str | list[str], # mutable-ok: BaseSearchConfig interface + optional_params: dict[str, object], # mutable-ok: BaseSearchConfig interface + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> dict[str, object]: # mutable-ok: HTTP handler requires a JSON dict + country: Final = optional_params.get("country") + mapped: Final = MappingProxyType( + { + "maxResults": optional_params.get("max_results"), + "region": country.upper() if isinstance(country, str) else None, + } + ) + native: Final = MappingProxyType( + { + key: value + for key, value in optional_params.items() + if key not in self.get_supported_perplexity_optional_params() + } + ) + return { + "contentFormat": "passage", + "maxLength": 5000, + **MappingProxyType({key: value for key, value in mapped.items() if value is not None}), + **native, + "query": _query_with_domains( + " ".join(query) if isinstance(query, list) else query, + optional_params.get("search_domain_filter"), + ), + } + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> SearchResponse: + try: + parsed: Final = _WebIQResponse.model_validate_json(raw_response.content) + except ValidationError as exc: + raise BaseLLMException( + status_code=502, + message=f"Microsoft Web IQ returned an invalid search response: {exc}", + headers=dict(raw_response.headers), + ) from exc + return SearchResponse.model_validate( + { + **_extra_fields(parsed, SearchResponse.model_fields), + "results": [ + SearchResult.model_validate( + { + **_extra_fields(result, SearchResult.model_fields), + "title": result.title, + "url": result.url, + "snippet": result.content, + "date": result.lastUpdatedAt or None, + "last_updated": result.lastUpdatedAt or None, + } + ) + for result in parsed.webResults + ], + } + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 40ff675de19..83ab91f2bc0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25306,6 +25306,11 @@ "notes": "Firecrawl search pricing: $83 for 100,000 credits, 2 credits per 10 results. Cost = ceiling(limit/10) * 2 * $0.00083" } }, + "webiq/search": { + "input_cost_per_query": 0.007, + "litellm_provider": "webiq", + "mode": "search" + }, "perplexity/search": { "input_cost_per_query": 0.005, "litellm_provider": "perplexity", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 00fee0a0d5a..3431cc97969 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -4098,6 +4098,32 @@ "generateContent": false } }, + "webiq": { + "display_name": "Microsoft Web IQ (`webiq`)", + "url": "https://learn.microsoft.com/en-us/web-iq/reference/web-search", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": true, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "tavily": { "display_name": "Tavily (`tavily`)", "url": "https://docs.litellm.ai/docs/search/tavily", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6325de96839..addd642eca7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4869,6 +4869,7 @@ class SearchProviders(str, Enum): AGENTCORE = "agentcore" NIMBLE = "nimble" BING_GROUNDING = "bing_grounding" + WEBIQ = "webiq" DEEPSEEK = "deepseek" XAI = "xai" diff --git a/litellm/utils.py b/litellm/utils.py index a3a1fe5ede4..4a4f2e8edfe 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -11357,6 +11357,7 @@ class ProviderConfigManager: from litellm.llms.serper.search.transformation import SerperSearchConfig from litellm.llms.tavily.search.transformation import TavilySearchConfig from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig + from litellm.llms.webiq.search.transformation import WebIQSearchConfig from litellm.llms.xai.search.transformation import XAISearchConfig from litellm.llms.you_com.search.transformation import YouComSearchConfig @@ -11381,6 +11382,7 @@ class ProviderConfigManager: SearchProviders.AGENTCORE: AgentCoreSearchConfig, SearchProviders.NIMBLE: NimbleSearchConfig, SearchProviders.BING_GROUNDING: BingGroundingSearchConfig, + SearchProviders.WEBIQ: WebIQSearchConfig, SearchProviders.DEEPSEEK: DeepSeekSearchConfig, SearchProviders.XAI: XAISearchConfig, } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 40ff675de19..83ab91f2bc0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25306,6 +25306,11 @@ "notes": "Firecrawl search pricing: $83 for 100,000 credits, 2 credits per 10 results. Cost = ceiling(limit/10) * 2 * $0.00083" } }, + "webiq/search": { + "input_cost_per_query": 0.007, + "litellm_provider": "webiq", + "mode": "search" + }, "perplexity/search": { "input_cost_per_query": 0.005, "litellm_provider": "perplexity", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 00fee0a0d5a..3431cc97969 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -4098,6 +4098,32 @@ "generateContent": false } }, + "webiq": { + "display_name": "Microsoft Web IQ (`webiq`)", + "url": "https://learn.microsoft.com/en-us/web-iq/reference/web-search", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": true, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "tavily": { "display_name": "Tavily (`tavily`)", "url": "https://docs.litellm.ai/docs/search/tavily", diff --git a/tests/unit/llms/webiq/__init__.py b/tests/unit/llms/webiq/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/webiq/search/__init__.py b/tests/unit/llms/webiq/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/webiq/search/test_webiq_search_transformation.py b/tests/unit/llms/webiq/search/test_webiq_search_transformation.py new file mode 100644 index 00000000000..64ef26bacae --- /dev/null +++ b/tests/unit/llms/webiq/search/test_webiq_search_transformation.py @@ -0,0 +1,351 @@ +import json +from collections.abc import Iterator +from typing import Final + +import openai +import pytest +import respx + +import litellm +from litellm.llms.base_llm.search.transformation import SearchResponse +from litellm.llms.webiq.search.transformation import WebIQSearchConfig + + +@pytest.fixture(autouse=True) +def webiq_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.delenv("WEBIQ_API_KEY", raising=False) + monkeypatch.setattr( # test-quality-ok: select HTTPX so respx can intercept the external HTTP boundary + litellm, "disable_aiohttp_transport", True + ) + litellm.in_memory_llm_clients_cache.flush_cache() + yield + litellm.in_memory_llm_clients_cache.flush_cache() + + +def test_search_maps_request_and_preserves_sources(respx_mock: respx.MockRouter) -> None: + endpoint: Final = f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web" + route: Final = respx_mock.post(endpoint).respond( + json={ + "webResults": [ + { + "title": "A source", + "url": "https://example.com/article", + "content": "Relevant passage", + "lastUpdatedAt": "2026-01-02T00:00:00Z", + "crawledAt": "2026-01-03T00:00:00Z", + "language": "en", + "contentTier": "standard", + "instrumentationSuffix": "source-1", + } + ], + "traceId": "trace-1", + "querySignals": {"freshness": True}, + } + ) + + response: Final = litellm.search( + query="RAG research", + search_provider="webiq", + api_key="test-key", + max_results=3, + country="gb", + search_domain_filter=["example.com", "example.org", "-excluded.example"], + language="en", + ) + + assert json.loads(route.calls.last.request.content) == { + "query": "(RAG research) (site:example.com OR site:example.org) -site:excluded.example", + "maxResults": 3, + "region": "GB", + "contentFormat": "passage", + "maxLength": 5000, + "language": "en", + } + assert route.calls.last.request.headers["x-apikey"] == "test-key" + assert response.model_dump() == { + "object": "search", + "results": [ + { + "title": "A source", + "url": "https://example.com/article", + "snippet": "Relevant passage", + "date": "2026-01-02T00:00:00Z", + "last_updated": "2026-01-02T00:00:00Z", + "crawledAt": "2026-01-03T00:00:00Z", + "language": "en", + "contentTier": "standard", + "instrumentationSuffix": "source-1", + } + ], + "traceId": "trace-1", + "querySignals": {"freshness": True}, + } + + +@pytest.mark.parametrize("api_base", [None, "https://api.microsoft.ai/v3", "https://api.microsoft.ai/v3/search/web"]) +async def test_asearch_uses_environment_credentials_at_default_endpoint( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, api_base: str | None +) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "environment-key") + route: Final = respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(json={"webResults": []}) + + response: Final = await litellm.asearch(query="no matches", search_provider="webiq", api_base=api_base) + + assert response.model_dump() == {"object": "search", "results": []} + assert route.calls.last.request.headers["x-apikey"] == "environment-key" + assert json.loads(route.calls.last.request.content) == { + "query": "no matches", + "contentFormat": "passage", + "maxLength": 5000, + } + + +async def test_asearch_uses_explicit_credentials_at_custom_endpoint(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post("https://webiq.example/v3/search/web").respond(json={"webResults": []}) + + response: Final = await litellm.asearch( + query="no matches", search_provider="webiq", api_key="custom-key", api_base="https://webiq.example/v3/" + ) + + assert response.model_dump() == {"object": "search", "results": []} + assert route.calls.last.request.headers["x-apikey"] == "custom-key" + assert json.loads(route.calls.last.request.content) == { + "query": "no matches", + "contentFormat": "passage", + "maxLength": 5000, + } + + +@pytest.mark.parametrize("updated", [None, "", "2026-02-01T00:00:00Z"]) +def test_optional_updated_date_never_uses_crawl_time(respx_mock: respx.MockRouter, updated: str | None) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + json={ + "webResults": [ + { + "title": "Title", + "url": "https://example.com", + "content": "Passage", + "lastUpdatedAt": updated, + "crawledAt": "2026-03-01T00:00:00Z", + } + ] + } + ) + response: Final = litellm.search(query="query", search_provider="webiq", api_key="key") + assert (response.results[0].date, response.results[0].last_updated) == (updated or None, updated or None) + + +def test_missing_optional_dates_and_result_order(respx_mock: respx.MockRouter) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + json={ + "webResults": [ + {"title": title, "url": f"https://example.com/{title}", "content": title} + for title in ("first", "second") + ] + } + ) + response: Final = litellm.search(query="query", search_provider="webiq", api_key="key") + assert [(r.title, r.snippet, r.date, r.last_updated) for r in response.results] == [ + ("first", "first", None, None), + ("second", "second", None, None), + ] + + +@pytest.mark.parametrize("body", ["not json", "{}", '{"webResults": null}', '{"webResults": [{}]}']) +def test_malformed_response_is_not_reported_as_empty_success(respx_mock: respx.MockRouter, body: str) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(text=body) + with pytest.raises(openai.APIError, match="invalid search response") as error: + litellm.search(query="query", search_provider="webiq", api_key="key") + assert error.value.status_code == 502 + + +@pytest.mark.parametrize("status", [401, 403, 429, 503]) +def test_upstream_http_errors_keep_status(respx_mock: respx.MockRouter, status: int) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + status_code=status, json={"error": {"message": "upstream failure"}}, headers={"Retry-After": "2"} + ) + with pytest.raises(openai.APIError, match="upstream failure") as error: + litellm.search(query="query", search_provider="webiq", api_key="key") + assert error.value.status_code == status + + +def test_native_options_override_mapped_defaults_without_mutating_input() -> None: + params: Final = { + "max_results": 3, + "maxResults": 7, + "country": "us", + "region": "JP", + "maxLength": 2000, + "max_tokens_per_page": 100, + "contentFormat": "markdown", + "safeSearch": "strict", + } + before: Final = params.copy() + assert WebIQSearchConfig().transform_search_request(["first", "second"], params) == { + "query": "first second", + "maxResults": 7, + "region": "JP", + "maxLength": 2000, + "contentFormat": "markdown", + "safeSearch": "strict", + } + assert params == before + + +@pytest.mark.parametrize( + "domains, expected", + [ + ([], "query"), + ([""], "query"), + (["-"], "query"), + (["-example.com"], "(query) -site:example.com"), + (["example.com"], "(query) (site:example.com)"), + ], +) +def test_domain_filter_queries(domains: list[str], expected: str) -> None: + assert WebIQSearchConfig().transform_search_request("query", {"search_domain_filter": domains}) == { + "query": expected, + "contentFormat": "passage", + "maxLength": 5000, + } + + +@pytest.mark.parametrize("suffix", ["", "/", "/search/web", "/search/web/"]) +def test_endpoint_is_appended_once(suffix: str) -> None: + assert WebIQSearchConfig().get_complete_url(f"https://webiq.example/v3{suffix}", {}) == ( + "https://webiq.example/v3/search/web" + ) + + +def test_environment_key_cannot_be_sent_to_caller_selected_host(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "server-key") + with pytest.raises(ValueError, match="Refusing to send"): + WebIQSearchConfig().validate_environment({}, api_base="https://untrusted.example/v3") + + +@pytest.mark.parametrize("use_async", [False, True]) +@pytest.mark.parametrize("api_key", [None, "caller-key"]) +async def test_http_endpoint_rejected_before_sending_credentials( + monkeypatch: pytest.MonkeyPatch, use_async: bool, api_key: str | None +) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "server-key") + with respx.mock(assert_all_called=False) as transport: + transport.post("http://api.microsoft.ai/v3/search/web").respond(json={"webResults": []}) + if use_async: + with pytest.raises(openai.APIError, match="HTTPS"): + await litellm.asearch( + query="query", search_provider="webiq", api_base="http://api.microsoft.ai/v3", api_key=api_key + ) + else: + with pytest.raises(openai.APIError, match="HTTPS"): + litellm.search( + query="query", search_provider="webiq", api_base="http://api.microsoft.ai/v3", api_key=api_key + ) + assert len(transport.calls) == 0 + + +def test_explicit_key_wins_and_headers_are_not_mutated(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "server-key") + config: Final = WebIQSearchConfig() + original: Final = {"X-Request-ID": "request-1"} + result: Final = config.validate_environment(original, api_key="caller-key", api_base="https://custom.example/v3") + assert result == {"X-Request-ID": "request-1", "x-apikey": "caller-key", "Content-Type": "application/json"} + assert original == {"X-Request-ID": "request-1"} + assert config.validate_environment(result, api_key="caller-key") == result + + +def test_missing_key_fails_before_network() -> None: + with pytest.raises(ValueError, match="WEBIQ_API_KEY"): + WebIQSearchConfig().validate_environment({}) + + +@pytest.mark.parametrize( + "result_metadata, response_metadata", + [ + ({"snippet": "upstream", "date": "upstream", "last_updated": "upstream"}, {}), + ({}, {"results": "upstream", "object": "upstream"}), + ], +) +def test_metadata_cannot_replace_standard_search_fields( + respx_mock: respx.MockRouter, result_metadata: dict[str, str], response_metadata: dict[str, str] +) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + json={ + "webResults": [ + { + "title": "Title", + "url": "https://example.com", + "content": "Passage", + "lastUpdatedAt": "2026-01-02T00:00:00Z", + "language": "en", + **result_metadata, + } + ], + "traceId": "trace-1", + **response_metadata, + } + ) + + response: Final = litellm.search(query="query", search_provider="webiq", api_key="key") + + assert response.model_dump() == { + "object": "search", + "results": [ + { + "title": "Title", + "url": "https://example.com", + "snippet": "Passage", + "date": "2026-01-02T00:00:00Z", + "last_updated": "2026-01-02T00:00:00Z", + "language": "en", + } + ], + "traceId": "trace-1", + } + + +@pytest.mark.parametrize("tool_parameter", ["model", "search_tool_name"]) +async def test_router_resolves_named_webiq_tool_and_forwards_its_configuration( + respx_mock: respx.MockRouter, tool_parameter: str +) -> None: + route: Final = respx_mock.post("https://webiq.example/v3/search/web").respond( + json={"webResults": [{"title": "Source", "url": "https://example.com", "content": "Grounding"}]} + ) + router: Final = litellm.Router( + model_list=[], + search_tools=[ + { + "search_tool_name": "webiq-search", + "litellm_params": { + "search_provider": "webiq", + "api_key": "tool-key", + "api_base": "https://webiq.example/v3", + "maxLength": 1500, + }, + } + ], + num_retries=0, + ) + + response: Final = await router.asearch(**{tool_parameter: "webiq-search"}, query="ground this", max_results=2) + + assert isinstance(response, SearchResponse) + assert response.model_dump() == { + "object": "search", + "results": [ + { + "title": "Source", + "url": "https://example.com", + "snippet": "Grounding", + "date": None, + "last_updated": None, + } + ], + } + assert route.calls.last.request.headers["x-apikey"] == "tool-key" + assert json.loads(route.calls.last.request.content) == { + "query": "ground this", + "contentFormat": "passage", + "maxLength": 1500, + "maxResults": 2, + } diff --git a/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py b/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py index 65ebece406a..39d8f12be53 100644 --- a/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py +++ b/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py @@ -3,6 +3,7 @@ import json from collections.abc import Callable from datetime import datetime from types import SimpleNamespace +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -27,6 +28,16 @@ from tests.unit.proxy.router_entry_point_stubs import route_entry_points_to_stub client = TestClient(app) +def test_dashboard_discovers_webiq_with_its_display_name() -> None: + with _override_auth(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)): + response: Final = client.get("/search_tools/ui/available_providers") + + assert response.status_code == 200, response.text + assert [provider for provider in response.json()["providers"] if provider["provider_name"] == "webiq"] == [ + {"provider_name": "webiq", "ui_friendly_name": "Microsoft Web IQ"} + ] + + @pytest.mark.asyncio async def test_list_search_tools_db_only(monkeypatch): """Test listing search tools when only DB tools exist""" diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 27ba8968200..66096feb570 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1,5 +1,3 @@ -from litellm.proxy.deps.repositories import get_plugin_upload_repository -from litellm.repositories.plugin_upload_repository import PluginUploadRepository import asyncio import json import re @@ -20,7 +18,9 @@ from pydantic import TypeAdapter import litellm from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.health_check_latest import LatestHealthCheckRow +from litellm.proxy.deps.repositories import get_plugin_upload_repository from litellm.proxy.public_endpoints import router +from litellm.repositories.plugin_upload_repository import PluginUploadRepository from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_presets from litellm.types.proxy.common_proxy_errors import CommonProxyErrors from litellm.types.proxy.management_endpoints.model_management_endpoints import ( @@ -1285,6 +1285,18 @@ def test_get_supported_endpoints_lists_streamlake_on_chat_messages_and_responses assert "embeddings" not in streamlake_endpoints +def test_get_supported_endpoints_lists_webiq_search(reset_endpoints_cache: None) -> None: + app: Final = FastAPI() + app.include_router(router) + with TestClient(app) as client: + response: Final = client.get("/public/endpoints") + assert response.status_code == 200, response.text + + search: Final = next(endpoint for endpoint in response.json()["endpoints"] if endpoint["key"] == "search") + assert search["endpoint"] == "/search" + assert {"slug": "webiq", "display_name": "Microsoft Web IQ"} in search["providers"] + + def test_get_supported_endpoints_display_names_have_no_slug_suffix( reset_endpoints_cache, ): diff --git a/tests/unit/search/test_main.py b/tests/unit/search/test_main.py index e30fd2eb6f8..0201311d04b 100644 --- a/tests/unit/search/test_main.py +++ b/tests/unit/search/test_main.py @@ -56,6 +56,19 @@ def _proxy_style_logging_obj() -> Logging: ) +@pytest.mark.usefixtures("_local_model_cost_map") +def test_webiq_search_reports_the_catalog_query_cost(respx_mock: respx.MockRouter) -> None: + respx_mock.post("https://api.microsoft.ai/v3/search/web").respond( + json={"webResults": [{"title": "A source", "url": "https://example.com", "content": "A passage"}]} + ) + response: Final = litellm.search(query="litellm release notes", search_provider="webiq", api_key="test-webiq-key") + + assert response._hidden_params["response_cost"] > 0 + assert response._hidden_params["response_cost"] == pytest.approx( + litellm.model_cost["webiq/search"]["input_cost_per_query"] + ) + + @pytest.mark.usefixtures("_local_model_cost_map") def test_search_bills_search_tool_input_cost_per_query(respx_mock: respx.MockRouter) -> None: respx_mock.post(EXA_SEARCH_URL).respond(json={"results": [{"title": "t", "url": "https://example.com"}]}) From bc3a69ce35077d19ace47b24b34c5c691ad56883 Mon Sep 17 00:00:00 2001 From: taxfree <42464993+taxfree-python@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:50:39 +0900 Subject: [PATCH 22/86] fix(otel): serialize pydantic Message objects in langfuse_otel, weave_otel and langtrace (#46845) langfuse_otel, weave_otel and langtrace now encode litellm.Message objects, and any value JSON cannot encode, when they write the prompt, so spans keep their token usage and model parameters. Under LITELLM_OTEL_V2 the payload builder dumps Message objects instead of dropping them --- .../langfuse/langfuse_otel_attributes.py | 3 +- litellm/integrations/langtrace.py | 3 +- litellm/integrations/otel/model/payloads.py | 25 +++++- litellm/integrations/weave/weave_otel.py | 8 +- litellm/litellm_core_utils/safe_json_dumps.py | 13 +++- .../otel/test_otel_v2_vendor_mappers.py | 74 ++++++++++++++++++ tests/unit/integrations/test_langfuse_otel.py | 62 ++++++++++++++- tests/unit/integrations/test_langtrace.py | 76 +++++++++++++++++++ tests/unit/integrations/test_weave_otel.py | 69 ++++++++++++++++- 9 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 tests/unit/integrations/test_langtrace.py diff --git a/litellm/integrations/langfuse/langfuse_otel_attributes.py b/litellm/integrations/langfuse/langfuse_otel_attributes.py index 28530076c57..a4677683d59 100644 --- a/litellm/integrations/langfuse/langfuse_otel_attributes.py +++ b/litellm/integrations/langfuse/langfuse_otel_attributes.py @@ -17,6 +17,7 @@ from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes impor safe_set_attribute, ) from litellm.litellm_core_utils.core_helpers import require_attributes +from litellm.litellm_core_utils.safe_json_dumps import jsonable_or_str from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIResponse from litellm.types.utils import ( EmbeddingResponse, @@ -103,7 +104,7 @@ class LangfuseLLMObsOTELAttributes(BaseLLMObsOTELAttributes): prompt["tools"] = tools input: Final = prompt - safe_set_attribute(span, "langfuse.observation.input", json.dumps(input)) + safe_set_attribute(span, "langfuse.observation.input", json.dumps(input, default=jsonable_or_str)) @staticmethod @override diff --git a/litellm/integrations/langtrace.py b/litellm/integrations/langtrace.py index ea411baee7e..8556880f7c6 100644 --- a/litellm/integrations/langtrace.py +++ b/litellm/integrations/langtrace.py @@ -1,6 +1,7 @@ import json from typing import TYPE_CHECKING, Any, Final +from litellm.litellm_core_utils.safe_json_dumps import jsonable_or_str from litellm.types.integrations.otel_span_attributes import SpanAttributes if TYPE_CHECKING: @@ -60,7 +61,7 @@ class LangtraceAttributes: if prompts: span.add_event( name="gen_ai.content.prompt", - attributes={SpanAttributes.LLM_PROMPTS.value: json.dumps(prompts)}, + attributes={SpanAttributes.LLM_PROMPTS.value: json.dumps(prompts, default=jsonable_or_str)}, ) self.set_span_attributes(span, span_attributes) diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 4238d72fc5e..aa05691dc28 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, ClassVar, Final, Literal, Protocol, cast from urllib.parse import urlsplit from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict from litellm.integrations.otel.model.metadata import ( @@ -519,7 +519,7 @@ class LLMCallSpanData: promoted_metadata=promoted_metadata, is_streaming=as_bool(payload.get("stream")), tools=_extract_tools(params), - messages_in=_dicts(payload.get("messages")) if capture_content else (), + messages_in=_message_dicts(payload.get("messages")) if capture_content else (), choices_out=choices_out if capture_content else (), system_fingerprint=as_str(response.get("system_fingerprint")), time_to_first_chunk_seconds=time_to_first_chunk_seconds, @@ -783,6 +783,27 @@ def _dicts(value: object) -> tuple[Mapping[str, object], ...]: return tuple(item for item in value if isinstance(item, dict)) +_OBJECTS: Final = TypeAdapter(tuple[object, ...]) +_ANY_KEY_MAPPING: Final = TypeAdapter(Mapping[object, object]) + + +def _message_dict(message: object) -> Mapping[str, object] | None: + if isinstance(message, dict): + return {str(key): value for key, value in _ANY_KEY_MAPPING.validate_python(message).items()} + if not isinstance(message, BaseModel): + return None + try: + return message.model_dump(mode="json", fallback=str) + except ValueError: + return None + + +def _message_dicts(value: object) -> tuple[Mapping[str, object], ...]: + if not isinstance(value, list): + return () + return tuple(message for item in _OBJECTS.validate_python(value) if (message := _message_dict(item)) is not None) + + def _finish_reasons(choices: tuple[Mapping[str, object], ...]) -> tuple[str, ...]: """Non-empty ``finish_reason`` of each response choice.""" return tuple(r for c in choices if (r := as_str(c.get("finish_reason")))) diff --git a/litellm/integrations/weave/weave_otel.py b/litellm/integrations/weave/weave_otel.py index 68a9dc3e162..eb8472dbd16 100644 --- a/litellm/integrations/weave/weave_otel.py +++ b/litellm/integrations/weave/weave_otel.py @@ -21,7 +21,7 @@ from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes impor safe_set_attribute, ) from litellm.litellm_core_utils.core_helpers import has_attributes, require_attributes -from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.litellm_core_utils.safe_json_dumps import jsonable_or_str, safe_dumps from litellm.types.integrations.weave_otel import WeaveOtelConfig, WeaveSpanAttributes from litellm.types.utils import StandardCallbackDynamicParams @@ -74,7 +74,11 @@ class WeaveLLMObsOTELAttributes(BaseLLMObsOTELAttributes): prompt["functions"] = functions if tools is not None: prompt["tools"] = tools - safe_set_attribute(span, OpenInferenceSpanAttributes.INPUT_VALUE, json.dumps(prompt)) + safe_set_attribute( + span, + OpenInferenceSpanAttributes.INPUT_VALUE, + json.dumps(prompt, default=jsonable_or_str), + ) def _set_weave_specific_attributes(span: Span, kwargs: Mapping[str, object], response_obj: object): diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index ea26d549326..b6783327884 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -3,7 +3,8 @@ import math from collections.abc import Callable from typing import Any, Final -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter +from pydantic_core import to_jsonable_python from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH @@ -113,3 +114,13 @@ def safe_dumps( return json.dumps( safe_json_structure(data, max_depth, value_transform, non_finite_as_null=non_finite_as_null), default=str ) + + +_JSONABLE: Final = TypeAdapter(object) + + +def jsonable_or_str(value: object) -> object: + try: + return _JSONABLE.validate_python(to_jsonable_python(value, fallback=str)) + except ValueError: + return str(value) diff --git a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py index 611e828cf84..9551c22a185 100644 --- a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py @@ -15,6 +15,7 @@ import pytest from litellm.integrations.otel import GenAIOperation from litellm.integrations.otel.mappers import ( + GenAIMapper, LangfuseMapper, LangtraceMapper, OpenInferenceMapper, @@ -33,6 +34,7 @@ from litellm.integrations.otel.model.payloads import ( ToolDefinition, ) from litellm.integrations.otel.model.trace_controls import TraceControls +from litellm.types.utils import Message from tests.unit.integrations.otel.test_otel_v2_sources_of_truth import _responses_payload @@ -708,6 +710,78 @@ def test_langfuse_mapper_keeps_chat_output_when_no_embedding_summary(): assert json.loads(attrs["langfuse.observation.output"]) == [{"role": "assistant", "content": "Sunny."}] +LITELLM_USER_MESSAGE: Final = Message(role="user", content="weather in sf?") +NOT_JSON_ENCODABLE: Final = object() + + +@pytest.mark.parametrize( + ("message", "expected_prompt"), + [ + ({"role": "user", "content": "weather in sf?"}, {"role": "user", "content": "weather in sf?"}), + ( + {"role": "user", "content": "weather in sf?", 1: "one"}, + {"role": "user", "content": "weather in sf?", "1": "one"}, + ), + (LITELLM_USER_MESSAGE, LITELLM_USER_MESSAGE.model_dump(mode="json")), + ( + Message(role="user", content="weather in sf?", trace_handle=NOT_JSON_ENCODABLE), + {**LITELLM_USER_MESSAGE.model_dump(mode="json"), "trace_handle": str(NOT_JSON_ENCODABLE)}, + ), + ], + ids=[ + "dict_message", + "dict_message_with_int_key", + "litellm_message_object", + "litellm_message_with_value_json_cannot_encode", + ], +) +def test_vendor_mappers_keep_the_prompt_of_litellm_message_objects( + message: Mapping[str, object] | Message, expected_prompt: Mapping[str, object] +) -> None: + payload: Final = { + "call_type": "acompletion", + "custom_llm_provider": "openai", + "model": "gpt-5.4-nano", + "messages": [message], + "response": {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "Sunny."}}]}, + } + data: Final = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True) + + assert { + "genai": json.loads(str(GenAIMapper().map(data)["gen_ai.input.messages"])), + "langfuse": json.loads(str(LangfuseMapper().map(data)["langfuse.observation.input"])), + "langtrace": json.loads(str(LangtraceMapper().map(data)["llm.prompts"])), + "openinference": json.loads(str(OpenInferenceMapper().map(data)["input.value"])), + } == { + "genai": [expected_prompt], + "langfuse": [expected_prompt], + "langtrace": [expected_prompt], + "openinference": [{"role": "user", "content": "weather in sf?"}], + } + + +def test_vendor_mappers_keep_the_span_when_a_litellm_message_cannot_be_dumped() -> None: + payload: Final = { + "call_type": "acompletion", + "custom_llm_provider": "openai", + "model": "gpt-5.4-nano", + "messages": [ + {"role": "system", "content": "be brief"}, + Message(role="user", content="weather in sf?", audio_bytes=b"\xff\xfe"), + ], + "response": {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "Sunny."}}]}, + } + attrs: Final = LangfuseMapper().map(LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True)) + + assert { + "input": json.loads(str(attrs["langfuse.observation.input"])), + "output": json.loads(str(attrs["langfuse.observation.output"])), + } == { + "input": [{"role": "system", "content": "be brief"}], + "output": [{"role": "assistant", "content": "Sunny."}], + } + + def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_payload(): payload = { "call_type": "aresponses", diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index aa9a51a93ee..e35adca7fd8 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -20,6 +20,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE from opentelemetry.trace import Span import litellm +from litellm.integrations._types.open_inference import SpanAttributes from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL, REDACTED_BY_LITELLM from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger from litellm.integrations.langfuse.langfuse_otel_attributes import LangfuseLLMObsOTELAttributes @@ -28,7 +29,20 @@ from litellm.integrations.otel.model.destination import OtelDestination from litellm.integrations.otel.plumbing.context import set_request_destinations from litellm.proxy._types import UserAPIKeyAuth from litellm.types.llms.openai import ResponsesAPIResponse -from litellm.types.utils import Choices, ModelResponse, StandardCallbackDynamicParams +from litellm.types.utils import Choices, Message, ModelResponse, StandardCallbackDynamicParams + +CHAT_MESSAGE_DICTS: Final = ({"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi back"}) +CHAT_MESSAGE_OBJECTS: Final = tuple( + Message(role=message["role"], content=message["content"]) for message in CHAT_MESSAGE_DICTS +) +NOT_JSON_ENCODABLE: Final = object() +NON_UTF8_BYTES: Final = b"\x80" +GENERATION_USAGE_AND_PARAM_KEYS: Final = ( + SpanAttributes.LLM_TOKEN_COUNT_PROMPT, + SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, + SpanAttributes.LLM_TOKEN_COUNT_TOTAL, + SpanAttributes.LLM_INVOCATION_PARAMETERS, +) class TestLangfuseOtelIntegration: @@ -140,6 +154,52 @@ class TestLangfuseOtelIntegration: "langfuse.observation.type", "generation" ) + @pytest.mark.parametrize( + ("messages", "expected_input"), + [ + (CHAT_MESSAGE_DICTS, list(CHAT_MESSAGE_DICTS)), + (CHAT_MESSAGE_OBJECTS, [message.model_dump() for message in CHAT_MESSAGE_OBJECTS]), + ( + (Message(role="user", content="hello", trace_handle=NOT_JSON_ENCODABLE),), + [{**CHAT_MESSAGE_OBJECTS[0].model_dump(), "trace_handle": str(NOT_JSON_ENCODABLE)}], + ), + ( + (Message(role="user", content="hello", audio_bytes=NON_UTF8_BYTES),), + [{**CHAT_MESSAGE_OBJECTS[0].model_dump(), "audio_bytes": str(NON_UTF8_BYTES)}], + ), + ], + ids=[ + "dict_messages", + "litellm_message_objects", + "litellm_message_with_value_json_cannot_encode", + "litellm_message_with_non_utf8_bytes", + ], + ) + def test_generation_keeps_input_model_params_and_token_usage( + self, messages: Sequence[object], expected_input: Sequence[object] + ) -> None: + span: Final = TracerProvider().get_tracer("langfuse-otel-tests").start_span("litellm_request") + response: Final = ModelResponse( + model="gpt-4o", + choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "done"}}], + usage={"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}, + ) + + LangfuseOtelLogger.set_langfuse_otel_attributes( + span, + {"messages": messages, "standard_logging_object": {"model_parameters": {"temperature": 0.2}}}, + response, + ) + + attributes: Final = span.attributes or {} + assert json.loads(str(attributes["langfuse.observation.input"])) == expected_input + assert {key: attributes.get(key) for key in GENERATION_USAGE_AND_PARAM_KEYS} == { + SpanAttributes.LLM_TOKEN_COUNT_PROMPT: 7, + SpanAttributes.LLM_TOKEN_COUNT_COMPLETION: 3, + SpanAttributes.LLM_TOKEN_COUNT_TOTAL: 10, + SpanAttributes.LLM_INVOCATION_PARAMETERS: json.dumps({"temperature": 0.2}), + } + def test_set_langfuse_environment_attribute(self): """Test that Langfuse environment is set correctly when environment variable is present.""" mock_span = MagicMock() diff --git a/tests/unit/integrations/test_langtrace.py b/tests/unit/integrations/test_langtrace.py new file mode 100644 index 00000000000..a5615b58eb4 --- /dev/null +++ b/tests/unit/integrations/test_langtrace.py @@ -0,0 +1,76 @@ +import json +from collections.abc import Sequence +from typing import Final + +import pytest +from opentelemetry.sdk.trace import TracerProvider + +from litellm.integrations.langtrace import LangtraceAttributes +from litellm.types.integrations.otel_span_attributes import SpanAttributes +from litellm.types.utils import Message, ModelResponse + +CHAT_MESSAGE_DICTS: Final = ({"role": "user", "content": "weather in SF?"}, {"role": "assistant", "content": "Sunny."}) +CHAT_MESSAGE_OBJECTS: Final = tuple( + Message(role=message["role"], content=message["content"]) for message in CHAT_MESSAGE_DICTS +) +NOT_JSON_ENCODABLE: Final = object() +NON_UTF8_BYTES: Final = b"\x80" + + +@pytest.mark.parametrize( + ("messages", "expected_prompts"), + [ + (CHAT_MESSAGE_DICTS, list(CHAT_MESSAGE_DICTS)), + (CHAT_MESSAGE_OBJECTS, [message.model_dump() for message in CHAT_MESSAGE_OBJECTS]), + ( + ({"role": "user", "content": "before\x00after", "metadata": {1: "one"}},), + [{"role": "user", "content": "before\x00after", "metadata": {"1": "one"}}], + ), + ( + (Message(role="user", content="weather in SF?", trace_handle=NOT_JSON_ENCODABLE),), + [{**CHAT_MESSAGE_OBJECTS[0].model_dump(), "trace_handle": str(NOT_JSON_ENCODABLE)}], + ), + ( + (Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES),), + [str(Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES))], + ), + ], + ids=[ + "dict_messages", + "litellm_message_objects", + "dict_message_with_nul_and_int_key", + "litellm_message_with_value_json_cannot_encode", + "litellm_message_with_non_utf8_bytes", + ], +) +def test_langtrace_span_keeps_prompt_and_token_usage( + messages: Sequence[object], expected_prompts: Sequence[object] +) -> None: + span: Final = TracerProvider().get_tracer("langtrace-tests").start_span("litellm_request") + response: Final = ModelResponse( + model="gpt-4o", + choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Still sunny."}}], + usage={"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}, + ) + + LangtraceAttributes().set_langtrace_attributes( + span, {"messages": messages, "litellm_params": {"custom_llm_provider": "openai"}}, response + ) + + assert [ + json.loads(str((event.attributes or {})[SpanAttributes.LLM_PROMPTS.value])) + for event in span.events + if event.name == "gen_ai.content.prompt" + ] == [expected_prompts] + assert { + key: (span.attributes or {}).get(key) + for key in ( + SpanAttributes.LLM_USAGE_PROMPT_TOKENS.value, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS.value, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS.value, + ) + } == { + SpanAttributes.LLM_USAGE_PROMPT_TOKENS.value: 7, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS.value: 3, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS.value: 10, + } diff --git a/tests/unit/integrations/test_weave_otel.py b/tests/unit/integrations/test_weave_otel.py index a3a022d514b..e279abbd1d9 100644 --- a/tests/unit/integrations/test_weave_otel.py +++ b/tests/unit/integrations/test_weave_otel.py @@ -1,6 +1,6 @@ import json import os -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Final from unittest.mock import MagicMock, patch @@ -12,9 +12,10 @@ from litellm.integrations.weave.weave_otel import ( WeaveLLMObsOTELAttributes, _set_weave_specific_attributes, get_weave_otel_config, + set_weave_otel_attributes, ) from litellm.types.integrations.weave_otel import WeaveOtelConfig, WeaveSpanAttributes -from litellm.types.utils import ModelResponse +from litellm.types.utils import Message, ModelResponse def test_get_weave_otel_config(): @@ -205,3 +206,67 @@ def test_weave_output_value_is_the_dumped_model_response() -> None: output: Final = json.loads(str(span.attributes[OpenInferenceSpanAttributes.OUTPUT_VALUE])) assert output["id"] == "chatcmpl-1" assert output["choices"][0]["message"]["content"] == "Sunny." + + +NOT_JSON_ENCODABLE: Final = object() +NON_UTF8_BYTES: Final = b"\x80" + + +@pytest.mark.parametrize( + ("messages", "expected_messages"), + [ + ( + (Message(role="user", content="weather in SF?"), Message(role="assistant", content="Sunny.")), + [ + Message(role="user", content="weather in SF?").model_dump(), + Message(role="assistant", content="Sunny.").model_dump(), + ], + ), + ( + (Message(role="user", content="weather in SF?", trace_handle=NOT_JSON_ENCODABLE),), + [{**Message(role="user", content="weather in SF?").model_dump(), "trace_handle": str(NOT_JSON_ENCODABLE)}], + ), + ( + (Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES),), + [str(Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES))], + ), + ], + ids=["litellm_messages", "litellm_message_with_value_json_cannot_encode", "litellm_message_with_non_utf8_bytes"], +) +def test_weave_span_keeps_input_and_token_usage_when_messages_are_litellm_messages( + messages: tuple[Message, ...], expected_messages: Sequence[object] +) -> None: + span: Final = _TRACER.start_span("litellm_request") + response: Final = ModelResponse( + model="gpt-4o", + choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Still sunny."}}], + usage={"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}, + ) + + set_weave_otel_attributes(span, {"messages": messages, "standard_logging_object": {}}, response) + + attributes: Final = span.attributes or {} + assert json.loads(str(attributes[OpenInferenceSpanAttributes.INPUT_VALUE])) == {"messages": expected_messages} + assert { + key: attributes.get(key) + for key in ( + OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_PROMPT, + OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_COMPLETION, + OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_TOTAL, + ) + } == { + OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_PROMPT: 7, + OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_COMPLETION: 3, + OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_TOTAL: 10, + } + + +def test_weave_span_input_keeps_dict_message_content_exactly_as_sent() -> None: + span: Final = _TRACER.start_span("litellm_request") + messages: Final = ({"role": "user", "content": "before\x00after", "metadata": {1: "one"}},) + + set_weave_otel_attributes(span, {"messages": messages, "standard_logging_object": {}}, ModelResponse(model="gpt-4o")) + + assert json.loads(str((span.attributes or {})[OpenInferenceSpanAttributes.INPUT_VALUE])) == { + "messages": [{"role": "user", "content": "before\x00after", "metadata": {"1": "one"}}] + } From 05c04d6261df9369d880bcda8d9c21b4e5ee113b Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 10 Nov 2026 15:04:08 -0800 Subject: [PATCH 23/86] perf(spend): narrow user and team spend log reads to the owner's api_keys so the (api_key, startTime) index is used (#51026) * perf(spend): narrow user and team spend log reads to the owner's api_keys so the (api_key, startTime) index is used Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(spend): satisfy the type discipline gate for the api_key scope helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(spend): run the api_key lookup under the statement timeout, skip it for search and empty key lists Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 20 +- .../spend_tracking/spend_log_api_key_scope.py | 83 +++++++ .../spend_management_endpoints.py | 32 +++ .../test_team_endpoints.py | 62 ++++- .../test_spend_management_endpoints.py | 214 +++++++++++++++++- 5 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 litellm/proxy/spend_tracking/spend_log_api_key_scope.py diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f14f3d93b79..0704d2c2444 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -18,7 +18,7 @@ import traceback from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from itertools import chain from types import MappingProxyType from typing import ( @@ -228,6 +228,7 @@ from litellm.proxy.management_helpers.utils import ( management_endpoint_wrapper, ) from litellm.proxy.spend_tracking.committed_spend import drop_committed_spend +from litellm.proxy.spend_tracking.spend_log_api_key_scope import spend_log_api_keys_for_teams from litellm.proxy.utils import ( PrismaClient, ProxyLogging, @@ -7463,9 +7464,11 @@ def aggregated_date_range_error(start_date: str | None, end_date: str | None) -> return None -def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str: +def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool, narrow_to_api_keys: bool) -> str: team_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + team_count)) user_clause: Final = f' AND sl."user" = ${3 + team_count}' if restrict_to_user else "" + api_key_placeholder: Final = 3 + team_count + (1 if restrict_to_user else 0) + api_key_clause: Final = f" AND sl.api_key = ANY(${api_key_placeholder}::text[])" if narrow_to_api_keys else "" return f""" SELECT sl.team_id, @@ -7483,7 +7486,7 @@ def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str: LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = sl."user" WHERE sl."startTime" >= $1::timestamp AND sl."startTime" < $2::timestamp + INTERVAL '1 day' - AND sl.team_id IN ({team_placeholders}){user_clause} + AND sl.team_id IN ({team_placeholders}){user_clause}{api_key_clause} GROUP BY sl.team_id, sl."user", u.user_email, u.user_alias ORDER BY spend DESC, sl.team_id, sl."user" """ @@ -7555,15 +7558,24 @@ async def get_team_spend_by_user( own_user_only: Final = scope.api_key_filter is not None user_param: Final = (user_api_key_dict.user_id or "",) if own_user_only else () + scoped_api_keys: Final = await spend_log_api_keys_for_teams( + prisma_client, scoped_team_ids, date.fromisoformat(start_date), date.fromisoformat(end_date) + ) + api_key_param: Final = (list(scoped_api_keys),) if scoped_api_keys is not None else () async with db_span("team_user_spend", "LiteLLM_SpendLogs"): rows: Final = _TEAM_USER_SPEND_ROWS.validate_python( await query_raw_within_statement_timeout( prisma_client, - _team_user_spend_sql(team_count=len(scoped_team_ids), restrict_to_user=own_user_only), + _team_user_spend_sql( + team_count=len(scoped_team_ids), + restrict_to_user=own_user_only, + narrow_to_api_keys=scoped_api_keys is not None, + ), start_date, end_date, *scoped_team_ids, *user_param, + *api_key_param, what="Spend per user", ) ) diff --git a/litellm/proxy/spend_tracking/spend_log_api_key_scope.py b/litellm/proxy/spend_tracking/spend_log_api_key_scope.py new file mode 100644 index 00000000000..6507011a977 --- /dev/null +++ b/litellm/proxy/spend_tracking/spend_log_api_key_scope.py @@ -0,0 +1,83 @@ +"""``LiteLLM_SpendLogs`` has no index on ``"user"`` or ``team_id``, so a read filtered by owner scans the +whole date window. The owner's ``LiteLLM_Daily*Spend`` rows (kept after key deletion) plus their live keys +name every api_key that produced those logs, and ``(api_key, "startTime")`` is indexed.""" + +from collections.abc import Mapping, Sequence +from datetime import date, timedelta +from typing import Final + +from pydantic import ConfigDict, TypeAdapter + +from litellm.proxy.utils import PrismaClient, query_raw_within_statement_timeout +from litellm.types.llms.base import LiteLLMBaseModel + +SPEND_LOG_API_KEY_SCOPE_MAX_KEYS: Final = 5000 +_WHAT: Final = "Spend log key lookup" + +_USER_API_KEYS_SQL: Final = """ +SELECT DISTINCT api_key FROM ( + SELECT api_key FROM "LiteLLM_DailyUserSpend" + WHERE user_id = $1 AND date >= $2 AND date <= $3 + UNION ALL + SELECT token AS api_key FROM "LiteLLM_VerificationToken" WHERE user_id = $1 +) owned +LIMIT $4 +""" + +_TEAM_API_KEYS_SQL: Final = """ +SELECT DISTINCT api_key FROM ( + SELECT api_key FROM "LiteLLM_DailyTeamSpend" + WHERE team_id = ANY($1::text[]) AND date >= $2 AND date <= $3 + UNION ALL + SELECT token AS api_key FROM "LiteLLM_VerificationToken" WHERE team_id = ANY($1::text[]) +) owned +LIMIT $4 +""" + + +class _ApiKeyRow(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + api_key: str + + +_API_KEY_ROWS: Final = TypeAdapter(tuple[_ApiKeyRow, ...]) + + +def _narrowing_keys(rows: Sequence[Mapping[str, object]]) -> tuple[str, ...] | None: + """None when the list cannot narrow the read: nothing known about the owner, or too many keys to help.""" + keys: Final = tuple(row.api_key for row in _API_KEY_ROWS.validate_python(rows)) + return None if not keys or len(keys) > SPEND_LOG_API_KEY_SCOPE_MAX_KEYS else keys + + +def _daily_dates_around(start: date, end: date) -> tuple[str, str]: + return (start - timedelta(days=1)).isoformat(), (end + timedelta(days=1)).isoformat() + + +async def spend_log_api_keys_for_user( + prisma_client: PrismaClient, user_id: str, start: date, end: date +) -> tuple[str, ...] | None: + """The api_keys that can carry this user's spend logs in the window, or None when there are too many to help.""" + first, last = _daily_dates_around(start, end) + return _narrowing_keys( + await query_raw_within_statement_timeout( + prisma_client, _USER_API_KEYS_SQL, user_id, first, last, SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + 1, what=_WHAT + ) + ) + + +async def spend_log_api_keys_for_teams( + prisma_client: PrismaClient, team_ids: Sequence[str], start: date, end: date +) -> tuple[str, ...] | None: + first, last = _daily_dates_around(start, end) + return _narrowing_keys( + await query_raw_within_statement_timeout( + prisma_client, + _TEAM_API_KEYS_SQL, + list(team_ids), + first, + last, + SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + 1, + what=_WHAT, + ) + ) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 7d9a0dcc971..6d87b39afb3 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -70,6 +70,10 @@ from litellm.proxy.spend_tracking.spend_capture_rate import ( # NOTE: Avoid module-level import from common_utils: proxy_server imports this # module while common_utils may pull proxy_server during init, which can leave # those names undefined. Import the helpers locally where they are used. +from litellm.proxy.spend_tracking.spend_log_api_key_scope import ( + spend_log_api_keys_for_teams, + spend_log_api_keys_for_user, +) from litellm.proxy.spend_tracking.spend_tracking_utils import ( get_spend_by_team, get_spend_by_team_and_customer, @@ -257,6 +261,22 @@ async def _query_raw(prisma_client: PrismaClient, sql_query: str, *args: object) return await prisma_client.db.query_raw(sql_query, *args) +async def _scope_api_keys( + prisma_client: PrismaClient, *, where_conditions: Mapping[str, object], start: datetime | None, end: datetime | None +) -> tuple[str, ...] | None: + """The api_keys behind a ``"user"`` / ``team_id`` filter, so the unindexed column is only checked on rows the + ``(api_key, "startTime")`` index already picked.""" + if start is None or end is None or isinstance(where_conditions.get("api_key"), str): + return None + user_id: Final = where_conditions.get("user") + if isinstance(user_id, str): + return await spend_log_api_keys_for_user(prisma_client, user_id, start.date(), end.date()) + team_id: Final = where_conditions.get("team_id") + if isinstance(team_id, str): + return await spend_log_api_keys_for_teams(prisma_client, (team_id,), start.date(), end.date()) + return None + + async def _query_raw_or_none(prisma_client: PrismaClient, sql_query: str, *args: object) -> Sequence[_RowT] | None: """``_query_raw`` for the call sites that guard the result against ``None``.""" return await _query_raw(prisma_client, sql_query, *args) @@ -2950,6 +2970,18 @@ async def ui_view_spend_logs( sql_params.append(f"%{like_escaped_session_id}%") p += 1 + scope_api_keys: Final = ( + await _scope_api_keys( + prisma_client, where_conditions=where_conditions, start=start_date_obj, end=end_date_obj + ) + if not search_owns_window + else None + ) + if scope_api_keys is not None: + sql_conditions.append(f"api_key = ANY(${p}::text[])") + sql_params.append(list(scope_api_keys)) + p += 1 # rebind-ok: advances the file's shared $N placeholder counter + # Status filter if status_filter is not None and not (group_by_session is True and not is_search_lookup): if status_filter == "success": diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index a79ebd307fe..ac46ccc1cfc 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -17341,6 +17341,19 @@ def _team_spend_by_user_caller(user_id: str, teams: list[str]) -> LiteLLM_UserTa ) +def _team_spend_reads(api_keys: list[str], spend_rows: list[dict] | Exception) -> AsyncMock: + """Raw reads inside the statement-timeout transaction: the key lookup, then the spend log aggregate.""" + + async def read(sql: str, *params: object) -> list[dict]: + if 'FROM "LiteLLM_DailyTeamSpend"' in sql: + return [{"api_key": api_key} for api_key in api_keys] + if isinstance(spend_rows, Exception): + raise spend_rows + return spend_rows + + return AsyncMock(side_effect=read) + + def _team_spend_by_user_db_row(team_id: str, user_id: str, spend: float, requests: int) -> dict: return { "team_id": team_id, @@ -17367,12 +17380,13 @@ async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(m mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha, beta]) transaction = statement_timeout_reads( mock_db_client, - AsyncMock( - return_value=[ + _team_spend_reads( + ["sk-alpha", "sk-beta"], + [ _team_spend_by_user_db_row("team-alpha", "alice", 0.5, 3), _team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2), _team_spend_by_user_db_row("team-beta", "alice", 0.1, 1), - ] + ], ), ) @@ -17386,11 +17400,15 @@ async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(m assert any("statement_timeout = 30000" in statement for statement in transaction.statements) mock_db_client.db.tx.assert_not_called() sql, *params = transaction.query_raw.call_args.args - assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta"] + assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta", ["sk-alpha", "sk-beta"]] assert 'FROM "LiteLLM_SpendLogs" sl' in sql assert 'sl."startTime" >= $1::timestamp' in sql assert "sl.\"startTime\" < $2::timestamp + INTERVAL '1 day'" in sql assert "sl.team_id IN ($3, $4)" in sql + assert "sl.api_key = ANY($5::text[])" in sql + lookup_sql, *lookup_params = transaction.query_raw.call_args_list[0].args + assert 'FROM "LiteLLM_DailyTeamSpend"' in lookup_sql and 'FROM "LiteLLM_VerificationToken"' in lookup_sql + assert lookup_params == [["team-alpha", "team-beta"], "2026-08-31", "2026-09-05", 5001] assert 'GROUP BY sl.team_id, sl."user"' in sql assert 'sl."user" = $' not in sql @@ -17412,7 +17430,7 @@ async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_clien caller = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER) alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), []) mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha]) - transaction = statement_timeout_reads(mock_db_client, AsyncMock(return_value=[])) + transaction = statement_timeout_reads(mock_db_client, _team_spend_reads(["sk-alpha"], [])) mock_db_client.writer_db.litellm_usertable.find_unique = AsyncMock( return_value=_team_spend_by_user_caller("alice", ["team-alpha"]) ) @@ -17422,8 +17440,9 @@ async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_clien ) sql, *params = transaction.query_raw.call_args.args - assert params == ["2026-09-01", "2026-09-04", "team-alpha"] + assert params == ["2026-09-01", "2026-09-04", "team-alpha", ["sk-alpha"]] assert 'sl."user" = $' not in sql + assert "sl.api_key = ANY($4::text[])" in sql @pytest.mark.asyncio @@ -17435,7 +17454,7 @@ async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_cli mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha]) mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) transaction = statement_timeout_reads( - mock_db_client, AsyncMock(return_value=[_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2)]) + mock_db_client, _team_spend_reads(["sk-bob"], [_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2)]) ) mock_db_client.writer_db.litellm_usertable.find_unique = AsyncMock( return_value=_team_spend_by_user_caller("bob", ["team-alpha"]) @@ -17446,9 +17465,10 @@ async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_cli ) sql, *params = transaction.query_raw.call_args.args - assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob"] + assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob", ["sk-bob"]] assert "sl.team_id IN ($3)" in sql assert 'AND sl."user" = $4' in sql + assert "sl.api_key = ANY($5::text[])" in sql assert [(r.user_id, r.spend) for r in response.results] == [("bob", 0.25)] @@ -17506,7 +17526,7 @@ async def test_get_team_spend_by_user_statement_timeout_returns_503(mock_db_clie admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), []) mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha]) - statement_timeout_reads(mock_db_client, AsyncMock(side_effect=statement_timeout_error())) + statement_timeout_reads(mock_db_client, _team_spend_reads(["sk-alpha"], statement_timeout_error())) with pytest.raises(HTTPException) as exc_info: await get_team_spend_by_user( @@ -17520,6 +17540,30 @@ async def test_get_team_spend_by_user_statement_timeout_returns_503(mock_db_clie assert "longer than 30s" in str(exc_info.value.detail) +@pytest.mark.asyncio +@pytest.mark.parametrize("known_key_count", [0, 5001]) +async def test_get_team_spend_by_user_without_a_usable_key_list_keeps_the_plain_team_filter( + mock_db_client, known_key_count: int +): + from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user + + admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), []) + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha]) + transaction = statement_timeout_reads( + mock_db_client, _team_spend_reads([f"sk-{i}" for i in range(known_key_count)], []) + ) + + await get_team_spend_by_user( + user_api_key_dict=admin, team_ids="team-alpha", start_date="2026-09-01", end_date="2026-09-04" + ) + + sql, *params = transaction.query_raw.call_args.args + assert params == ["2026-09-01", "2026-09-04", "team-alpha"] + assert "sl.team_id IN ($3)" in sql + assert "sl.api_key = ANY(" not in sql + + class _TeamRowWithOrganization(LiteLLM_TeamTable): litellm_organization_table: LiteLLM_OrganizationTable | None = None diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index 4a418d87f22..29e2f97d11c 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -6,7 +6,7 @@ import hashlib import json import re import sqlite3 -from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from datetime import timezone from functools import partial from types import SimpleNamespace @@ -141,6 +141,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond) status = re.fullmatch(r"status = \$(\d+)", cond) api_key_not_in = re.fullmatch(r"api_key NOT IN \(\$(\d+), \$(\d+)\)", cond) + api_key_any = re.fullmatch(r"api_key = ANY\(\$(\d+)::text\[\]\)", cond) req_or_call = re.fullmatch(r"\(request_id = \$(\d+) OR litellm_call_id = \$\1\)", cond) if req_or_call: where["request_id_or_call_id"] = params[int(req_or_call.group(1)) - 1] @@ -169,6 +170,8 @@ def _reconstruct_ui_where_from_sql(sql_query, params): where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")} elif status: where["status"] = {"equals": params[int(status.group(1)) - 1]} + elif api_key_any: + where["api_key_in"] = list(params[int(api_key_any.group(1)) - 1]) elif api_key_not_in: where["api_key_not_in"] = [ params[int(api_key_not_in.group(1)) - 1], @@ -218,7 +221,18 @@ def _reconstruct_ui_where_from_sql(sql_query, params): return where -def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=None, query_observer=None): +def _emulate_owner_api_key_lookup(mock_spend_logs: list[dict], sql_query: str, params: tuple) -> list[dict]: + """The daily spend tables and key table name every api_key an owner's requests ran under, so the + lookup answers with the distinct api_keys of the owner's logs.""" + owner_col = "user" if 'FROM "LiteLLM_DailyUserSpend"' in sql_query else "team_id" + owners = {params[0]} if isinstance(params[0], str) else set(params[0]) + api_keys = {log["api_key"] for log in mock_spend_logs if log.get(owner_col) in owners} + return [{"api_key": api_key} for api_key in sorted(api_keys)] + + +def make_ui_spend_logs_mock_prisma( + mock_spend_logs, filter_fn, team_lookup_fn=None, query_observer=None, owner_api_keys_fn=None +): """ Create a MockPrismaClient for /spend/logs/ui endpoint tests. @@ -228,6 +242,8 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No where_conditions, returns the filtered list of logs. team_lookup_fn: Optional async callable for team RBAC (find_unique). If provided, adds litellm_teamtable to db. + owner_api_keys_fn: Optional Callable[[tuple], list[dict]] answering the + user/team -> api_key lookup instead of deriving it from the logs. """ class MockDB: @@ -251,6 +267,10 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No return [] if 'SELECT DISTINCT "user", team_id' in sql_query: return _emulate_spend_log_owner_lookup(mock_spend_logs, sql_query, params) + if 'FROM "LiteLLM_DailyUserSpend"' in sql_query or 'FROM "LiteLLM_DailyTeamSpend"' in sql_query: + if owner_api_keys_fn is not None: + return owner_api_keys_fn(params) + return _emulate_owner_api_key_lookup(mock_spend_logs, sql_query, params) filtered = filter_fn(_reconstruct_ui_where_from_sql(sql_query, params)) total = len(filtered) if "COUNT(*)" in sql_query: @@ -845,6 +865,196 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch): assert data["data"][0]["user"] == "test_user_1" +def _owned_log(request_id: str, api_key: str, user: str, team_id: str) -> dict: + return { + "id": request_id, + "request_id": request_id, + "api_key": api_key, + "user": user, + "team_id": team_id, + "spend": 0.01, + "startTime": "2026-09-02T10:00:00+00:00", + "model": "gpt-4o", + } + + +def _owner_filtered_logs(logs: list[dict]) -> Callable[[dict], list[dict]]: + """What Postgres returns for the page query: the owner filter and, when present, the api_key list both apply.""" + + def filter_fn(where: dict) -> list[dict]: + return [ + log + for log in logs + if log["user"] == where.get("user", log["user"]) + and log["team_id"] == where.get("team_id", log["team_id"]) + and log["api_key"] in where.get("api_key_in", [log["api_key"]]) + ] + + return filter_fn + + +def _owner_api_keys(daily_rows: dict[str, set[str]], live_keys: dict[str, set[str]]) -> Callable[[tuple], list[dict]]: + """The key lookup's two sources, keyed by owner: api_keys on daily spend rows and api_keys of live keys.""" + + def lookup(params: tuple) -> list[dict]: + owners = {params[0]} if isinstance(params[0], str) else set(params[0]) + api_keys = set().union(*(daily_rows.get(o, set()) | live_keys.get(o, set()) for o in owners)) + return [{"api_key": api_key} for api_key in sorted(api_keys)] + + return lookup + + +def _spend_logs_page_query(queries: list[tuple[str, tuple]]) -> tuple[str, tuple, list[str] | None]: + sql, params = next((sql, params) for sql, params in queries if '"LiteLLM_SpendLogs"' in sql and "ORDER BY" in sql) + key_param = re.search(r"api_key = ANY\(\$(\d+)::text\[\]\)", sql) + return sql, params, None if key_param is None else params[int(key_param.group(1)) - 1] + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_user_filter_reads_spend_logs_through_the_users_api_keys(client, monkeypatch): + from litellm.proxy.spend_tracking.spend_log_api_key_scope import SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + + logs = [ + _owned_log("req1", "sk-live", "alice", "team1"), + _owned_log("req2", "sk-deleted", "alice", "team1"), + _owned_log("req3", "sk-bob", "bob", "team1"), + ] + queries: list[tuple[str, tuple]] = [] + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma( + logs, + _owner_filtered_logs(logs), + query_observer=lambda sql, params: queries.append((sql, params)), + owner_api_keys_fn=_owner_api_keys( + daily_rows={"alice": {"sk-deleted"}, "bob": {"sk-bob"}}, live_keys={"alice": {"sk-live"}} + ), + ), + ) + + response = client.get( + "/spend/logs/ui", + params={"user_id": "alice", "start_date": "2026-09-01 00:00:00", "end_date": "2026-09-03 23:59:59"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200, response.text + assert {row["request_id"] for row in response.json()["data"]} == {"req1", "req2"} + lookup_sql, lookup_params = next((sql, params) for sql, params in queries if '"LiteLLM_DailyUserSpend"' in sql) + assert '"LiteLLM_VerificationToken"' in lookup_sql + assert lookup_params == ("alice", "2026-08-31", "2026-09-04", SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + 1) + page_sql, _, scoped_api_keys = _spend_logs_page_query(queries) + assert scoped_api_keys == ["sk-deleted", "sk-live"] + assert '"user" = $' in page_sql + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_team_filter_reads_spend_logs_through_the_teams_api_keys(client, monkeypatch): + logs = [ + _owned_log("req1", "sk-alpha-1", "alice", "team-alpha"), + _owned_log("req2", "sk-alpha-2", "bob", "team-alpha"), + _owned_log("req3", "sk-beta", "carol", "team-beta"), + ] + queries: list[tuple[str, tuple]] = [] + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma( + logs, + _owner_filtered_logs(logs), + query_observer=lambda sql, params: queries.append((sql, params)), + owner_api_keys_fn=_owner_api_keys( + daily_rows={"team-alpha": {"sk-alpha-1"}, "team-beta": {"sk-beta"}}, + live_keys={"team-alpha": {"sk-alpha-2"}}, + ), + ), + ) + monkeypatch.setattr( + "litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe", + lambda user_api_key_dict: True, + ) + monkeypatch.setitem( + app.dependency_overrides, + ps.user_api_key_auth, + lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"), + ) + + response = client.get( + "/spend/logs/ui", + params={"team_id": "team-alpha", "start_date": "2026-09-01 00:00:00", "end_date": "2026-09-03 23:59:59"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200, response.text + assert {row["request_id"] for row in response.json()["data"]} == {"req1", "req2"} + _, lookup_params = next((sql, params) for sql, params in queries if '"LiteLLM_DailyTeamSpend"' in sql) + assert lookup_params[:3] == (["team-alpha"], "2026-08-31", "2026-09-04") + page_sql, _, scoped_api_keys = _spend_logs_page_query(queries) + assert scoped_api_keys == ["sk-alpha-1", "sk-alpha-2"] + assert "team_id = $" in page_sql + + +@pytest.mark.asyncio +@pytest.mark.parametrize("known_keys", [set(), {f"sk-{i}" for i in range(5001)}]) +async def test_ui_view_spend_logs_without_a_usable_key_list_keeps_the_plain_user_filter( + client, monkeypatch, known_keys: set[str] +): + logs = [_owned_log("req1", "sk-live", "alice", "team1")] + queries: list[tuple[str, tuple]] = [] + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma( + logs, + _owner_filtered_logs(logs), + query_observer=lambda sql, params: queries.append((sql, params)), + owner_api_keys_fn=_owner_api_keys(daily_rows={"alice": known_keys}, live_keys={}), + ), + ) + + response = client.get( + "/spend/logs/ui", + params={"user_id": "alice", "start_date": "2026-09-01 00:00:00", "end_date": "2026-09-03 23:59:59"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200, response.text + assert [row["request_id"] for row in response.json()["data"]] == ["req1"] + page_sql, _, scoped_api_keys = _spend_logs_page_query(queries) + assert scoped_api_keys is None + assert '"user" = $' in page_sql + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_search_with_user_filter_is_not_narrowed_to_the_windows_keys(client, monkeypatch): + logs = [_owned_log("req-old", "sk-retired", "alice", "team1")] + queries: list[tuple[str, tuple]] = [] + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma( + logs, + _owner_filtered_logs(logs), + query_observer=lambda sql, params: queries.append((sql, params)), + owner_api_keys_fn=_owner_api_keys(daily_rows={}, live_keys={"alice": {"sk-live"}}), + ), + ) + + response = client.get( + "/spend/logs/ui", + params={ + "user_id": "alice", + "search": "req-old", + "start_date": "2026-09-01 00:00:00", + "end_date": "2026-09-03 23:59:59", + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200, response.text + assert [row["request_id"] for row in response.json()["data"]] == ["req-old"] + page_sql, _, scoped_api_keys = _spend_logs_page_query(queries) + assert scoped_api_keys is None + assert "request_id = $" in page_sql and '"user" = $' in page_sql + + @pytest.mark.asyncio @pytest.mark.parametrize( "session_id_query,expected_request_ids", From 37144b5e27ca2acbe41b3b99118ca8c36a5f1b66 Mon Sep 17 00:00:00 2001 From: shrey Date: Tue, 10 Nov 2026 15:41:04 -0800 Subject: [PATCH 24/86] fix(mcp): challenge a single-server listing whose credential the upstream rejected (#50389) --- litellm/proxy/mcp_server/catalog.py | 33 +- litellm/proxy/mcp_server/exceptions.py | 17 +- litellm/proxy/mcp_server/legacy_callbacks.py | 7 + litellm/proxy/mcp_server/scope_challenge.py | 31 +- litellm/proxy/mcp_server/server.py | 74 ++++- .../types/mcp_server/mcp_server_manager.py | 6 + tests/unit/proxy/mcp_server/test_catalog.py | 303 +++++++++++++++++- .../test_mcp_oauth_passthrough_tools.py | 77 ++++- .../unit/proxy/mcp_server/test_mcp_server.py | 137 ++++++++ .../test_mcp_server_tool_calls_and_headers.py | 23 ++ 10 files changed, 665 insertions(+), 43 deletions(-) diff --git a/litellm/proxy/mcp_server/catalog.py b/litellm/proxy/mcp_server/catalog.py index d2ff322ba5c..e30f26abf41 100644 --- a/litellm/proxy/mcp_server/catalog.py +++ b/litellm/proxy/mcp_server/catalog.py @@ -826,6 +826,20 @@ async def prepare_tool_listing_headers( return server_auth_header, extra_headers, catalog_auth_header +def challenge_rejected_credential(server: MCPServer, outcome: ServerOutcome, listed: Sequence[MCPServer]) -> None: + """Answer a listing of this one server with the gateway's 401 when the caller can fix the rejection.""" + from litellm.proxy.mcp_server.legacy_callbacks import get_listing_scope_response + + scope_response: Final = get_listing_scope_response() + if ( + scope_response is not None + and len(listed) == 1 + and outcome.tag == "auth_required" + and server.reauthorizes_in_client + ): + scope_response.deny_reauth() + + async def get_filtered_server_tools( server: MCPServer, *, @@ -943,14 +957,11 @@ async def get_filtered_server_tools( ) return page.model_copy(update={"tools": filtered_tools}), ServerListOk(tool_count=len(filtered_tools)) except MCPUpstreamAuthError as e: - # Absorb so one unauthenticated server does not empty every other server's - # tools. Surfacing the upstream 401 to the client as a re-auth challenge is - # intentionally not done here: raising from this list handler cannot produce a - # 401 + WWW-Authenticate (the MCP session manager serializes it as a JSON-RPC - # error). Single-server routes surface it via the request-scope preemptive - # check in _raise_preemptive_401_for_unauthenticated_servers instead. + # Absorbed so peers keep their tools; a raise here would reach the client as a JSON-RPC error, not a 401 verbose_logger.debug("MCP list_tools: omitting %s; it needs upstream auth", server.name) - return ListToolsResult(tools=[]), classify_list_exception(e) + outcome: Final = classify_list_exception(e) + challenge_rejected_credential(server, outcome, allowed_mcp_servers) + return ListToolsResult(tools=[]), outcome except Exception as e: verbose_logger.exception("Error getting tools from server %s: %s", server.name, e) return ListToolsResult(tools=[]), classify_list_exception(e) @@ -1199,6 +1210,8 @@ async def list_gateway_catalog( update={"meta": {key: value for key, value in page_meta.items() if key != SERVER_OUTCOMES_META_KEY}} ) except Exception as error: + outcome: Final = classify_list_exception(error) + challenge_rejected_credential(server, outcome, allowed) if cursor is not None: raise MCPError( code=INVALID_PARAMS, message="Upstream continuation failed; start a fresh listing" @@ -1207,11 +1220,7 @@ async def list_gateway_catalog( request, (), None, - { - "litellm.ai/server_outcomes": { - _aggregate_server_key(server): classify_list_exception(error).model_dump(mode="json") - } - }, + {"litellm.ai/server_outcomes": {_aggregate_server_key(server): outcome.model_dump(mode="json")}}, ) pages, next_cursor, outcomes = await paginate_catalog( diff --git a/litellm/proxy/mcp_server/exceptions.py b/litellm/proxy/mcp_server/exceptions.py index 8be3ae78d15..db9bc4fc0ee 100644 --- a/litellm/proxy/mcp_server/exceptions.py +++ b/litellm/proxy/mcp_server/exceptions.py @@ -1,10 +1,11 @@ """Exceptions raised by the LiteLLM MCP proxy.""" from typing import Final +from urllib.parse import quote from fastapi import HTTPException -from litellm.proxy.mcp_server.oauth_utils import encode_upstream_scope +from litellm.proxy.mcp_server.oauth_utils import encode_upstream_scope, well_known_root_suffix TOKEN_EXCHANGE_SUBJECT_ERROR: Final = ( "Missing or invalid subject token. Authenticate with your identity provider and send its token " @@ -101,12 +102,14 @@ class MCPUpstreamAuthError(Exception): challenge: str | None = self.www_authenticate if challenge is None and (self.status_code == 401 or self.required_scope is not None) and base_url: prefix: Final = base_url.rstrip("/") - if aggregate_step_up: - resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource" - elif request_path and request_path.startswith(f"/{self.server_name}/mcp"): - resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/{self.server_name}/mcp" - else: - resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}" + name: Final = quote(self.server_name, safe="/:@!$&'()*+,;=") + resource_metadata_url: Final = ( + f"{prefix}/.well-known/oauth-protected-resource" + if aggregate_step_up + else f"{prefix}/.well-known/oauth-protected-resource{well_known_root_suffix()}/{name}/mcp" + if request_path and request_path.startswith(f"/{self.server_name}/mcp") + else f"{prefix}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp/{name}" + ) challenge = f'Bearer resource_metadata="{resource_metadata_url}"' scoped_challenge: Final = ( (f'{challenge}, error="insufficient_scope"' if challenge else 'Bearer error="insufficient_scope"') diff --git a/litellm/proxy/mcp_server/legacy_callbacks.py b/litellm/proxy/mcp_server/legacy_callbacks.py index 3d2f97492bb..82d98f39604 100644 --- a/litellm/proxy/mcp_server/legacy_callbacks.py +++ b/litellm/proxy/mcp_server/legacy_callbacks.py @@ -17,6 +17,7 @@ from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.mcp_server.contracts import OperationContext from litellm.proxy.mcp_server.mcp_context import get_active_mcp_request_ctx from litellm.proxy.mcp_server.scope_challenge import ( + LISTING_METHODS, OAuthScopeResponse, is_tool_call_request, scope_response_from_request, @@ -113,6 +114,12 @@ def get_scope_response() -> OAuthScopeResponse | None: return scope_response_from_request(context.request) if context is not None else None +def get_listing_scope_response() -> OAuthScopeResponse | None: + """The response gate of the active request, when the SDK parsed that request as a listing.""" + context: Final = get_active_mcp_request_ctx() + return get_scope_response() if context is not None and context.method in LISTING_METHODS else None + + async def record_upstream_tool_authorization(response: httpx2.Response, *, pending: OAuthScopeResponse | None) -> None: if pending is None or not pending.pending or not response.is_success or response.request.method != "POST": return diff --git a/litellm/proxy/mcp_server/scope_challenge.py b/litellm/proxy/mcp_server/scope_challenge.py index 128b27b0f91..927cb38c42e 100644 --- a/litellm/proxy/mcp_server/scope_challenge.py +++ b/litellm/proxy/mcp_server/scope_challenge.py @@ -19,18 +19,31 @@ SCOPE_RESPONSE_KEY: Final = "litellm.mcp.scope_response" _REQUEST: Final = TypeAdapter[dict[str, object]](dict[str, object]) -def is_tool_call_request(body: bytes) -> bool: +LISTING_METHODS: Final = frozenset({"tools/list", "prompts/list", "resources/list", "resources/templates/list"}) + + +def _request_method(body: bytes) -> object: try: - return _REQUEST.validate_json(body).get("method") == "tools/call" + return _REQUEST.validate_json(body).get("method") except ValidationError: - return False + return None + + +def is_tool_call_request(body: bytes) -> bool: + return _request_method(body) == "tools/call" + + +def is_listing_request(body: bytes) -> bool: + method: Final = _request_method(body) + return isinstance(method, str) and method in LISTING_METHODS class OAuthScopeResponse: - def __init__(self, send: Send, base_url: str, request_path: str | None) -> None: + def __init__(self, send: Send, base_url: str, request_path: str | None, reauthorize_as: str | None = None) -> None: self._send = send self._base_url = base_url self._request_path = request_path + self._reauthorize_as = reauthorize_as self._ready = asyncio.Event() self._lock = asyncio.Lock() self._challenge: MCPUpstreamAuthError | None = None @@ -44,7 +57,15 @@ class OAuthScopeResponse: self._ready.set() def deny(self, error: MCPUpstreamAuthError) -> None: - if self.pending and error.status_code == 403 and error.required_scope is not None: + if error.status_code == 403 and error.required_scope is not None: + self._refuse(error) + + def deny_reauth(self) -> None: + if self._reauthorize_as is not None: + self._refuse(MCPUpstreamAuthError(status_code=401, www_authenticate=None, server_name=self._reauthorize_as)) + + def _refuse(self, error: MCPUpstreamAuthError) -> None: + if self.pending: self._challenge = error self._ready.set() diff --git a/litellm/proxy/mcp_server/server.py b/litellm/proxy/mcp_server/server.py index bbbbd09e527..c255eddb214 100644 --- a/litellm/proxy/mcp_server/server.py +++ b/litellm/proxy/mcp_server/server.py @@ -10,6 +10,7 @@ import contextvars import hashlib import json import os +import re import time import types from collections import Counter @@ -81,9 +82,11 @@ from litellm.proxy.mcp_server.oauth_utils import ( well_known_root_suffix, ) from litellm.proxy.mcp_server.scope_challenge import ( + LISTING_METHODS, SCOPE_RESPONSE_KEY, OAuthScopeResponse, finish_scope_response, + is_listing_request, is_tool_call_request, ) from litellm.proxy.mcp_server.ui_session_utils import ( @@ -225,16 +228,13 @@ _SESSION_MANAGERS_INITIALIZED = False _INITIALIZATION_LOCK: Final = asyncio.Lock() -def _jsonrpc_text_has_top_level_method(text: str) -> bool: - """Whether a (possibly truncated) JSON-RPC envelope has a ``method`` key at - the root object's top level. +def _jsonrpc_text_top_level_key_end(text: str, key: str) -> int | None: + """Where the root object's top-level ``key`` ends in a (possibly truncated) JSON-RPC envelope. - Used to tell a request/notification (carries ``method``) apart from a - response (carries ``result``/``error`` and no top-level ``method``). A - response payload can itself nest a ``method`` field, so only keys at the + A response payload can itself nest a ``method`` field, so only keys at the root object's depth are inspected rather than searching the whole string. - Returns ``True`` only when a top-level ``method`` key is positively found; - truncation that hides it yields ``False``. + Returns an index only when the key is positively found; truncation that + hides it yields ``None``. """ depth = 0 in_string = False @@ -243,7 +243,7 @@ def _jsonrpc_text_has_top_level_method(text: str) -> bool: reading_key = False expect_key = False key_chars: list[str] = [] - for ch in text: + for index, ch in enumerate(text): if in_string: if escaped: escaped = False @@ -251,8 +251,8 @@ def _jsonrpc_text_has_top_level_method(text: str) -> bool: escaped = True elif ch == '"': in_string = False - if reading_key and depth == 1 and "".join(key_chars) == "method": - return True + if reading_key and depth == 1 and "".join(key_chars) == key: + return index + 1 elif reading_key: key_chars.append(ch) continue @@ -276,7 +276,32 @@ def _jsonrpc_text_has_top_level_method(text: str) -> bool: expect_key = bool(in_object) and in_object[-1] elif ch == ":": expect_key = False - return False + return None + + +def _jsonrpc_text_has_top_level_method(text: str) -> bool: + """Whether a (possibly truncated) JSON-RPC envelope has a top-level ``method`` key. + + Used to tell a request/notification (carries ``method``) apart from a + response (carries ``result``/``error`` and no top-level ``method``). + """ + return _jsonrpc_text_top_level_key_end(text, "method") is not None + + +_JSONRPC_STRING_VALUE: Final = re.compile(r'\s*:\s*("(?:[^"\\]|\\.)*")') + + +def _jsonrpc_text_method(text: str) -> str | None: + """The top-level ``method`` of a (possibly truncated) JSON-RPC envelope, when the cut leaves it whole.""" + key_end: Final = _jsonrpc_text_top_level_key_end(text, "method") + value: Final = _JSONRPC_STRING_VALUE.match(text, key_end) if key_end is not None else None + if value is None: + return None + try: + method: Final = json.loads(value.group(1)) + except ValueError: + return None + return method if isinstance(method, str) else None def _mcp_meta_trace_carrier(req_ctx: object) -> dict[str, str] | None: @@ -2470,6 +2495,27 @@ if MCP_AVAILABLE: ), ) + def _is_listing_body(body: bytes) -> bool: + """A listing request, including one the routing peek cut off after its method.""" + if is_listing_request(body): + return True + peek_cut: Final = len(body) >= _MCP_ROUTING_PEEK_MAX_BYTES + return peek_cut and _jsonrpc_text_method(body.decode("utf-8", errors="replace")) in LISTING_METHODS + + def _server_to_reauthorize( + mcp_servers: Sequence[str] | None, scoped_server_endpoint: bool, body: bytes, client_ip: str | None + ) -> str | None: + """The server a client addressed alone for a listing it can fix by authenticating again.""" + if not scoped_server_endpoint or mcp_servers is None or len(mcp_servers) != 1 or not _is_listing_body(body): + return None + server: Final = operations.global_mcp_server_manager.get_mcp_server_by_identifier( + mcp_servers[0], client_ip=client_ip + ) + reauthorizes: Final = ( + server is not None and MCPServerManager.resolve_oauth2_flow_for_request(server).reauthorizes_in_client + ) + return mcp_servers[0] if reauthorizes else None + async def handle_streamable_http_mcp(scope: Scope, receive: Receive, send: Send) -> None: """Handle MCP requests through StreamableHTTP.""" try: @@ -2638,13 +2684,15 @@ if MCP_AVAILABLE: await rejection(scope, receive, send) return + reauthorize_as: Final = _server_to_reauthorize(mcp_servers, scoped_server_endpoint, body, _client_ip) scope_response: Final = ( OAuthScopeResponse( send, get_request_base_url(StarletteRequest(scope)), get_route_relative_request_path(scope), + reauthorize_as=reauthorize_as, ) - if is_tool_call_request(body) or len(body) >= _MCP_ROUTING_PEEK_MAX_BYTES + if is_tool_call_request(body) or len(body) >= _MCP_ROUTING_PEEK_MAX_BYTES or reauthorize_as is not None else None ) if scope_response is not None: diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 1d3d5911eb1..ed0f050a76a 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -383,6 +383,12 @@ class MCPServer(LiteLLMBaseModel): door and is never a precondition for opening a session.""" return self.is_true_passthrough or self.is_oauth_delegate + @property + def reauthorizes_in_client(self) -> bool: + """True when a caller can fix an upstream 401 by authenticating again: the client-forwarded + token modes and gateway-vaulted per-user OAuth.""" + return self.is_client_forwarded_token or (self.is_gateway_managed_oauth2 and self.needs_user_oauth_token) + @property def is_dcr_bridge(self) -> bool: """True when this client-forwarded-token server serves the gateway-hosted DCR front door diff --git a/tests/unit/proxy/mcp_server/test_catalog.py b/tests/unit/proxy/mcp_server/test_catalog.py index 45c648b81d2..3c78e8f2561 100644 --- a/tests/unit/proxy/mcp_server/test_catalog.py +++ b/tests/unit/proxy/mcp_server/test_catalog.py @@ -2,9 +2,11 @@ import asyncio from collections.abc import Mapping, Sequence from types import SimpleNamespace from typing import Final, Literal -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock +import httpx2 import pytest +from mcp.server.context import ServerRequestContext from mcp.shared.exceptions import MCPError from mcp.types import ( ListPromptsRequest, @@ -18,17 +20,24 @@ from mcp.types import ( PaginatedRequestParams, Tool, ) +from starlette.requests import Request from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError from litellm.proxy.mcp_server import catalog +from litellm.proxy.mcp_server import operations as mcp_operations from litellm.proxy.mcp_server.contracts import OperationContext +from litellm.proxy.mcp_server.exceptions import MCPUpstreamAuthError from litellm.proxy.mcp_server.faults.list_outcomes import ( SERVER_OUTCOMES_META_KEY, AggregateToolListing, + ServerListFault, ServerListOk, ) -from litellm.types.mcp import MCPTransport +from litellm.proxy.mcp_server.mcp_context import active_mcp_request_ctx_var, mcp_gateway_server_name +from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager +from litellm.proxy.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse +from litellm.types.mcp import MCPAuth, MCPTransport from litellm.types.mcp_server.mcp_server_manager import MCPServer CatalogKind = Literal["tools", "prompts", "resources", "templates"] @@ -772,3 +781,293 @@ def test_partial_optional_catalog_never_advertises_freshness() -> None: assert result.ttl_ms == 0 assert result.cache_scope == "private" + +_GATEWAY: Final = "https://gateway.example" +_UNAUTHORIZED: Final = ServerListFault(tag="auth_required", status_code=401) + + +def _server( + auth_type: MCPAuth, + name: str = "docs", + oauth2_flow: str | None = None, + delegate_auth_to_upstream: bool = False, + authentication_token: str | None = None, + alias: str | None = None, +) -> MCPServer: + return MCPServer( + server_id=f"{name}-id", + name=name, + alias=alias or name, + url="https://upstream.example/mcp", + transport=MCPTransport.http, + auth_type=auth_type, + oauth2_flow=oauth2_flow, + delegate_auth_to_upstream=delegate_auth_to_upstream, + authentication_token=authentication_token, + ) + + +_VAULTED: Final = _server(MCPAuth.oauth2, oauth2_flow="authorization_code") +_PEER: Final = _server(MCPAuth.none, name="wiki") +_STATIC: Final = _server(MCPAuth.api_key, authentication_token="k") +_REAUTHORIZABLE: Final = [ + pytest.param(_VAULTED, id="vaulted-per-user-oauth"), + pytest.param(_server(MCPAuth.true_passthrough), id="true-passthrough"), + pytest.param(_server(MCPAuth.oauth_delegate), id="oauth-delegate"), +] +_NOT_REAUTHORIZABLE: Final = [ + pytest.param(_server(MCPAuth.none), id="no-auth"), + pytest.param(_STATIC, id="static-api-key"), + pytest.param(_server(MCPAuth.bearer_token, authentication_token="k"), id="static-bearer"), + pytest.param(_server(MCPAuth.oauth2, oauth2_flow="client_credentials"), id="client-credentials"), + pytest.param(_server(MCPAuth.oauth2, delegate_auth_to_upstream=True), id="legacy-upstream-delegate"), +] + + +def _request_context(gate: OAuthScopeResponse, method: str) -> ServerRequestContext: + return ServerRequestContext( + session=MagicMock(), + lifespan_context={}, + protocol_version="2025-11-25", + method=method, + request=Request({"type": "http", SCOPE_RESPONSE_KEY: gate}), + ) + + +def _challenged( + server: MCPServer, + outcome: ServerListFault | ServerListOk, + listed: Sequence[MCPServer] | None = None, + reauthorize_as: str | None = "docs", + method: str = "tools/list", +) -> bool: + gate: Final = OAuthScopeResponse(AsyncMock(), _GATEWAY, "/docs/mcp", reauthorize_as=reauthorize_as) + token: Final = active_mcp_request_ctx_var.set(_request_context(gate, method)) + try: + catalog.challenge_rejected_credential(server, outcome, [server] if listed is None else listed) + finally: + active_mcp_request_ctx_var.reset(token) + return not gate.pending + + +@pytest.mark.parametrize("server", _REAUTHORIZABLE) +def test_a_rejected_credential_the_caller_can_renew_is_challenged(server: MCPServer) -> None: + assert _challenged(server, _UNAUTHORIZED) + + +@pytest.mark.parametrize("server", _NOT_REAUTHORIZABLE) +def test_a_rejected_credential_the_caller_cannot_renew_stays_absorbed(server: MCPServer) -> None: + assert not _challenged(server, _UNAUTHORIZED) + + +@pytest.mark.parametrize( + "outcome", + [ + ServerListOk(tool_count=3), + ServerListFault(tag="forbidden", status_code=403), + ServerListFault(tag="upstream_error", status_code=500), + ServerListFault(tag="unreachable"), + ], +) +def test_only_an_unauthorized_outcome_is_challenged(outcome: ServerListFault | ServerListOk) -> None: + assert not _challenged(_VAULTED, outcome) + + +def test_a_listing_that_spans_several_servers_is_not_challenged() -> None: + assert not _challenged(_VAULTED, _UNAUTHORIZED, [_VAULTED, _PEER]) + + +def test_a_request_that_is_not_a_single_server_listing_is_not_challenged() -> None: + assert not _challenged(_VAULTED, _UNAUTHORIZED, reauthorize_as=None) + + +@pytest.mark.parametrize("method", ["tools/call", "server/discover", "initialize"]) +def test_a_request_the_sdk_parsed_as_something_other_than_a_listing_is_not_challenged(method: str) -> None: + assert not _challenged(_VAULTED, _UNAUTHORIZED, method=method) + + +@pytest.mark.asyncio +async def test_a_rejected_stored_token_is_answered_with_the_gateways_own_challenge() -> None: + send: Final = AsyncMock() + gate: Final = OAuthScopeResponse(send, _GATEWAY, "/docs/mcp", reauthorize_as="docs") + token: Final = active_mcp_request_ctx_var.set(_request_context(gate, "tools/list")) + try: + started: Final = asyncio.create_task(gate({"type": "http.response.start", "status": 200, "headers": []})) + await asyncio.sleep(0) + catalog.challenge_rejected_credential(_VAULTED, _UNAUTHORIZED, [_VAULTED]) + await asyncio.wait_for(started, 1) + finally: + active_mcp_request_ctx_var.reset(token) + + start: Final = send.await_args_list[0].args[0] + assert start["status"] == 401 + assert dict(start["headers"])[b"www-authenticate"].decode() == ( + 'Bearer resource_metadata="https://gateway.example/.well-known/oauth-protected-resource/docs/mcp"' + ) + + +def _upstream_rejecting_the_token(_request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(401, headers={"www-authenticate": 'Bearer error="invalid_token"'}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("server", "peers", "challenged"), + [ + pytest.param(_server(MCPAuth.true_passthrough), (), True, id="true-passthrough"), + pytest.param(_server(MCPAuth.oauth_delegate), (), True, id="oauth-delegate"), + pytest.param(_STATIC, (), False, id="static-api-key"), + pytest.param(_server(MCPAuth.true_passthrough), (_PEER,), False, id="selected-group-of-two"), + ], +) +async def test_tools_listing_challenges_a_single_server_whose_token_the_upstream_rejected( + monkeypatch: pytest.MonkeyPatch, server: MCPServer, peers: Sequence[MCPServer], challenged: bool +) -> None: + manager: Final = MCPServerManager(upstream_transport=httpx2.MockTransport(_upstream_rejecting_the_token)) + monkeypatch.setattr(mcp_operations, "global_mcp_server_manager", manager) + gate: Final = OAuthScopeResponse(AsyncMock(), _GATEWAY, "/docs/mcp", reauthorize_as="docs") + context: Final = mcp_operations.prepare_context( + UserAPIKeyAuth(token="h", user_id="u1"), oauth2_headers={"Authorization": "Bearer rejected"} + ) + request_token: Final = active_mcp_request_ctx_var.set(_request_context(gate, "tools/list")) + name_token: Final = mcp_gateway_server_name.set("docs") + try: + listing, outcome = await catalog.get_filtered_server_tools( + server, context=context, allowed_mcp_servers=[server, *peers], prefetched_oauth_creds={} + ) + finally: + mcp_gateway_server_name.reset(name_token) + active_mcp_request_ctx_var.reset(request_token) + + assert listing.tools == [] + assert outcome.tag == "auth_required" + assert (not gate.pending) is challenged + + +@pytest.mark.asyncio +@pytest.mark.parametrize("request_name", ["ListPromptsRequest", "ListResourcesRequest", "ListResourceTemplatesRequest"]) +@pytest.mark.parametrize( + ("server", "challenged"), + [ + pytest.param(_VAULTED, True, id="vaulted-per-user-oauth"), + pytest.param(_STATIC, False, id="static-api-key"), + ], +) +async def test_optional_listing_challenges_a_server_whose_stored_token_was_rejected( + monkeypatch: pytest.MonkeyPatch, request_name: str, server: MCPServer, challenged: bool +) -> None: + from mcp import types + + from litellm.proxy import proxy_server + + monkeypatch.setenv("LITELLM_SALT_KEY", "listing-reauth-test") + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(mcp_operations.global_mcp_server_manager, "registry", {server.server_id: server}) + monkeypatch.setattr(mcp_operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[server])) + monkeypatch.setattr( + catalog, "fetch_optional_catalog_page", AsyncMock(side_effect=MCPUpstreamAuthError(401, None, "docs")) + ) + gate: Final = OAuthScopeResponse(AsyncMock(), _GATEWAY, "/docs/mcp", reauthorize_as="docs") + token: Final = active_mcp_request_ctx_var.set(_request_context(gate, "prompts/list")) + try: + result: Final = await catalog.list_gateway_catalog( + mcp_operations.prepare_context(), getattr(types, request_name)() + ) + finally: + active_mcp_request_ctx_var.reset(token) + + assert next(iter(result.meta[SERVER_OUTCOMES_META_KEY].values())) == {"status": "auth_required", "http_status": 401} + assert (not gate.pending) is challenged + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("request_name", "result_name", "items"), + [ + ("ListPromptsRequest", "ListPromptsResult", "prompts"), + ("ListResourcesRequest", "ListResourcesResult", "resources"), + ("ListResourceTemplatesRequest", "ListResourceTemplatesResult", "resource_templates"), + ], +) +async def test_optional_listing_challenges_when_the_token_is_rejected_on_a_later_page( + monkeypatch: pytest.MonkeyPatch, request_name: str, result_name: str, items: str +) -> None: + from mcp import types + + from litellm.proxy import proxy_server + + monkeypatch.setenv("LITELLM_SALT_KEY", "listing-reauth-test") + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(mcp_operations.global_mcp_server_manager, "registry", {_VAULTED.server_id: _VAULTED}) + monkeypatch.setattr(mcp_operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[_VAULTED])) + first_page: Final = getattr(types, result_name)(**{items: [], "next_cursor": "next"}) + monkeypatch.setattr( + catalog, + "fetch_optional_catalog_page", + AsyncMock(side_effect=[first_page, MCPUpstreamAuthError(401, None, "docs")]), + ) + gate: Final = OAuthScopeResponse(AsyncMock(), _GATEWAY, "/docs/mcp", reauthorize_as="docs") + token: Final = active_mcp_request_ctx_var.set(_request_context(gate, "prompts/list")) + request: Final = getattr(types, request_name) + try: + first: Final = await catalog.list_gateway_catalog(mcp_operations.prepare_context(), request()) + assert first.next_cursor + assert gate.pending + with pytest.raises(MCPError, match="Upstream continuation failed"): + await catalog.list_gateway_catalog( + mcp_operations.prepare_context(), + request(params=types.PaginatedRequestParams(cursor=first.next_cursor)), + ) + finally: + active_mcp_request_ctx_var.reset(token) + + assert not gate.pending + + +@pytest.mark.parametrize( + ("request_path", "resource"), + [("/mcp/docs", "litellm/mcp/docs"), ("/docs/mcp", "litellm/docs/mcp")], +) +def test_the_challenge_names_the_metadata_route_under_a_server_root_path( + monkeypatch: pytest.MonkeyPatch, request_path: str, resource: str +) -> None: + monkeypatch.setenv("SERVER_ROOT_PATH", "/litellm") + + challenge: Final = MCPUpstreamAuthError(401, None, "docs").to_http_exception(_GATEWAY, request_path) + + assert (challenge.headers or {})["www-authenticate"] == ( + f'Bearer resource_metadata="https://gateway.example/.well-known/oauth-protected-resource/{resource}"' + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("request_path", "addressed", "resource"), + [ + pytest.param("/docs/mcp", "docs", "docs/mcp", id="per-server-path"), + pytest.param("/Docs/mcp", "Docs", "Docs/mcp", id="other-case"), + pytest.param("/docs_internal/mcp", "docs_internal", "docs_internal/mcp", id="name-not-alias"), + pytest.param("/mcp/Docs", "Docs", "mcp/Docs", id="standard-path-other-case"), + pytest.param("/mcp/a:b@c+d,e", "a:b@c+d,e", "mcp/a:b@c+d,e", id="path-legal-characters-stay-as-typed"), + pytest.param("/mcp/a b%c", "a b%c", "mcp/a%20b%25c", id="path-illegal-characters-are-encoded"), + pytest.param("/mcp/日本", "日本", "mcp/%E6%97%A5%E6%9C%AC", id="non-ascii-name"), + pytest.param("/日本/mcp", "日本", "%E6%97%A5%E6%9C%AC/mcp", id="non-ascii-name-legacy-path"), + ], +) +async def test_the_challenge_names_the_url_the_client_addressed(request_path: str, addressed: str, resource: str) -> None: + send: Final = AsyncMock() + gate: Final = OAuthScopeResponse(send, _GATEWAY, request_path, reauthorize_as=addressed) + aliased: Final = _server(MCPAuth.oauth2, name="docs_internal", oauth2_flow="authorization_code", alias="docs") + token: Final = active_mcp_request_ctx_var.set(_request_context(gate, "tools/list")) + try: + started: Final = asyncio.create_task(gate({"type": "http.response.start", "status": 200, "headers": []})) + await asyncio.sleep(0) + catalog.challenge_rejected_credential(aliased, _UNAUTHORIZED, [aliased]) + await asyncio.wait_for(started, 1) + finally: + active_mcp_request_ctx_var.reset(token) + + start: Final = send.await_args_list[0].args[0] + assert dict(start["headers"])[b"www-authenticate"].decode() == ( + f'Bearer resource_metadata="https://gateway.example/.well-known/oauth-protected-resource/{resource}"' + ) diff --git a/tests/unit/proxy/mcp_server/test_mcp_oauth_passthrough_tools.py b/tests/unit/proxy/mcp_server/test_mcp_oauth_passthrough_tools.py index 03a7373c908..5054af4a2d4 100644 --- a/tests/unit/proxy/mcp_server/test_mcp_oauth_passthrough_tools.py +++ b/tests/unit/proxy/mcp_server/test_mcp_oauth_passthrough_tools.py @@ -25,6 +25,7 @@ from litellm.proxy.mcp_server.mcp_server_manager import ( MCPServerManager, _extract_upstream_auth_failure, ) +from litellm.proxy.mcp_server.scope_challenge import OAuthScopeResponse, is_listing_request from litellm.types.mcp import MCPAuth from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -375,12 +376,10 @@ async def test_aggregate_list_tools_absorbs_one_unauthenticated_server(): @pytest.mark.asyncio -async def test_single_server_route_also_absorbs_upstream_auth_error(): +async def test_single_server_route_also_absorbs_upstream_auth_error() -> None: """A single-server route (//mcp) absorbs an upstream-auth error just like the aggregate: the failing server contributes no tools and an "auth_required" outcome rather than re-raising. - Surfacing it to the client as a 401 + WWW-Authenticate challenge cannot be done from this list - handler — the MCP session manager serializes a raise into a JSON-RPC error, not an HTTP 401 — so - re-auth surfacing is handled by a request-scope preemptive check, tracked separately.""" + The 401 a single-server request receives is sent through its scope response, not raised from here.""" from unittest.mock import patch from litellm.proxy._types import UserAPIKeyAuth @@ -836,3 +835,73 @@ def test_managed_step_up_metadata_matches_the_sdk_configured_resource(path): requested_resource=f"https://gateway.test{path}", configured_resource=f"https://gateway.test{resource_path}", ), "the SDK must accept the advertised resource before it can reauthorize" + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + (b'{"jsonrpc":"2.0","id":1,"method":"tools/list"}', True), + (b'{"jsonrpc":"2.0","id":1,"method":"prompts/list"}', True), + (b'{"jsonrpc":"2.0","id":1,"method":"resources/list"}', True), + (b'{"jsonrpc":"2.0","id":1,"method":"resources/templates/list"}', True), + (b'{"jsonrpc":"2.0","id":1,"method":"tools/call"}', False), + (b'{"jsonrpc":"2.0","id":1,"method":"initialize"}', False), + (b'{"jsonrpc":"2.0","id":1,"method":["tools/list"]}', False), + (b'{"jsonrpc":"2.0","id":1}', False), + (b"not json", False), + ], +) +def test_listing_requests_are_recognised_by_method(body: bytes, expected: bool) -> None: + assert is_listing_request(body) is expected + + +async def _pending_listing_response(gate: OAuthScopeResponse, send: AsyncMock) -> "asyncio.Task[None]": + task: Final = asyncio.create_task(gate({"type": "http.response.start", "status": 200, "headers": []})) + await asyncio.sleep(0) + send.assert_not_awaited() + return task + + +@pytest.mark.asyncio +async def test_reauth_denial_replaces_the_pending_listing_response_with_the_gateway_challenge() -> None: + send: Final = AsyncMock() + gate: Final = OAuthScopeResponse(send, "https://gateway.example", "/docs/mcp", reauthorize_as="docs") + started: Final = await _pending_listing_response(gate, send) + + gate.deny_reauth() + await asyncio.wait_for(started, 1) + + start, body = [call.args[0] for call in send.await_args_list] + assert start["status"] == 401 + assert dict(start["headers"])[b"www-authenticate"].decode() == ( + 'Bearer resource_metadata="https://gateway.example/.well-known/oauth-protected-resource/docs/mcp"' + ) + assert body["body"] == b'{"detail":"Unauthorized"}' + + +@pytest.mark.asyncio +async def test_a_gate_that_does_not_reauthorize_ignores_a_reauth_denial() -> None: + send: Final = AsyncMock() + gate: Final = OAuthScopeResponse(send, "https://gateway.example", "/docs/mcp") + started: Final = await _pending_listing_response(gate, send) + + gate.deny_reauth() + assert gate.pending + gate.allow() + await asyncio.wait_for(started, 1) + + assert [call.args[0]["status"] for call in send.await_args_list] == [200] + + +@pytest.mark.asyncio +async def test_scope_step_up_denial_still_ignores_an_unauthorized_response() -> None: + send: Final = AsyncMock() + gate: Final = OAuthScopeResponse(send, "https://gateway.example", "/docs/mcp", reauthorize_as="docs") + started: Final = await _pending_listing_response(gate, send) + + gate.deny(MCPUpstreamAuthError(401, None, "docs")) + assert gate.pending + gate.allow() + await asyncio.wait_for(started, 1) + + assert [call.args[0]["status"] for call in send.await_args_list] == [200] diff --git a/tests/unit/proxy/mcp_server/test_mcp_server.py b/tests/unit/proxy/mcp_server/test_mcp_server.py index 456b1994e2d..b42f913f078 100644 --- a/tests/unit/proxy/mcp_server/test_mcp_server.py +++ b/tests/unit/proxy/mcp_server/test_mcp_server.py @@ -2,6 +2,8 @@ import asyncio import json import os +import queue +from collections.abc import Sequence from contextlib import asynccontextmanager, nullcontext from typing import Final from unittest.mock import ANY, AsyncMock, MagicMock, patch @@ -17,6 +19,7 @@ from litellm.proxy.mcp_server.mcp_server_manager import ( MCPServerManager, MCPTransport, ) +from litellm.types.mcp import MCPAuth mcp_server_manager = MCPServerManager() @@ -3209,6 +3212,140 @@ async def test_mcp_routing_stashes_only_method_for_auth_and_replays_full_body(me ) +def _docs_server(auth_type: str, legacy_client_credentials: bool = False) -> MCPServer: + return MCPServer( + server_id="docs-id", + name="docs", + alias="docs", + url="https://upstream.example/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth(auth_type), + token_url="https://idp.example/token" if legacy_client_credentials else None, + client_id="client" if legacy_client_credentials else None, + client_secret="secret" if legacy_client_credentials else None, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "method", "shape", "selected_servers", "docs", "expected"), + [ + ("/mcp/docs", "tools/list", "plain", ["docs"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/docs"), + ("/mcp/docs", "prompts/list", "plain", ["docs"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/docs"), + ("/mcp/docs", "resources/list", "plain", ["docs"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/docs"), + ("/mcp/docs", "resources/templates/list", "plain", ["docs"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/docs"), + ("/mcp/DOCS", "tools/list", "plain", ["DOCS"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/DOCS"), + ("/mcp/docs", "tools/list", "plain", ["docs"], _docs_server("api_key"), "none"), + ("/mcp/docs", "tools/list", "plain", ["docs"], _docs_server("oauth2", legacy_client_credentials=True), "none"), + ("/mcp/docs", "tools/call", "plain", ["docs"], _docs_server("oauth_delegate"), "holds"), + ("/mcp/docs", "tools/list", "plain", ["docs", "wiki"], _docs_server("oauth_delegate"), "none"), + ("/mcp/docs", "tools/list", "plain", ["unregistered_group"], _docs_server("oauth_delegate"), "none"), + ("/mcp/docs", "tools/list", "plain", None, _docs_server("oauth_delegate"), "none"), + ("/mcp/docs", "ping", "plain", ["docs"], _docs_server("oauth_delegate"), "none"), + ("/docs/mcp", "tools/list", "plain", ["docs"], _docs_server("oauth_delegate"), "reauthorizes:/docs/mcp"), + ("/Docs/mcp", "tools/list", "plain", ["Docs"], _docs_server("oauth_delegate"), "reauthorizes:/Docs/mcp"), + ("/mcp/docs-id", "tools/list", "plain", ["docs-id"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/docs-id"), + ("/mcp", "tools/list", "plain", ["docs"], _docs_server("oauth_delegate"), "none"), + ("/toolset/team/mcp", "tools/list", "plain", ["docs"], _docs_server("oauth_delegate"), "none"), + ("/mcp/docs", "tools/list", "cursor-after-method", ["docs"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/docs"), + ("/mcp/docs", "prompts/list", "cursor-after-method", ["docs"], _docs_server("oauth_delegate"), "reauthorizes:/mcp/docs"), + ("/mcp/docs", "tools/list", "cursor-before-method", ["docs"], _docs_server("oauth_delegate"), "holds"), + ("/mcp/docs", "tools/call", "cursor-after-method", ["docs"], _docs_server("oauth_delegate"), "holds"), + ("/mcp/docs", "ping", "duplicate-method", ["docs"], _docs_server("oauth_delegate"), "none"), + ], +) +async def test_mcp_routing_holds_a_single_servers_listing_response_for_a_reauth_challenge( + path: str, method: str, shape: str, selected_servers: Sequence[str] | None, docs: MCPServer, expected: str +) -> None: + try: + from litellm.proxy.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse + from litellm.proxy.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateful, + session_manager_stateless, + ) + except ImportError: + pytest.skip("MCP server not available") + + async def gate_kind(gate: object) -> str: + if not isinstance(gate, OAuthScopeResponse): + return "none" + gate.deny_reauth() + if gate.pending: + return "holds" + await gate({"type": "http.response.start", "status": 200, "headers": []}) + challenge: Final = dict(sent.await_args_list[0].args[0]["headers"])[b"www-authenticate"].decode() + return "reauthorizes:" + challenge.split("oauth-protected-resource")[1].rstrip('"') + + from litellm.proxy.mcp_server import operations + + envelope: Final = {"jsonrpc": "2.0", "id": 2, "method": method, "params": {"cursor": "c" * 6000}} + bodies: Final = { + "plain": json.dumps({**envelope, "params": {}}).encode(), + "cursor-after-method": json.dumps(envelope).encode(), + "cursor-before-method": json.dumps({"params": envelope["params"], **envelope}).encode(), + "duplicate-method": b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{},"method":"ping"}', + } + jsonrpc_body: Final = bodies[shape] + scope: Final = { + "type": "http", + "method": "POST", + "path": path, + "scheme": "https", + "server": ("gateway.example", 443), + "headers": [ + (b"host", b"gateway.example"), + (b"content-type", b"application/json"), + (b"authorization", b"Bearer test-key"), + ], + } + receive: Final = AsyncMock(side_effect=[{"type": "http.request", "body": jsonrpc_body, "more_body": False}]) + held: Final = queue.SimpleQueue[object]() + sent: Final = AsyncMock() + + async def stateless_handle(handled_scope: Scope, _receive: Receive, _send: Send) -> None: + held.put(handled_scope.get(SCOPE_RESPONSE_KEY)) + + with ( + patch( # test-quality-ok: the ASGI handler reads auth from a module-level helper; the suite's only seam + "litellm.proxy.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(MagicMock(), None, selected_servers, None, None, None), + ), + patch( # test-quality-ok: registry is empty in unit tests; key owns one server + "litellm.proxy.mcp_server.server._get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=[MagicMock()], + ), + patch( # test-quality-ok: init flag is a module global; no injection seam + "litellm.proxy.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "handle_request", side_effect=stateless_handle + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateful, "handle_request", side_effect=stateless_handle + ), + patch.object( # test-quality-ok: the registry is a module-level singleton's attribute; the suite's only seam + operations.global_mcp_server_manager, "registry", {docs.server_id: docs} + ), + patch( # test-quality-ok: the missing-token challenge runs before the gate and is not under test here + "litellm.proxy.mcp_server.server._raise_preemptive_401_for_unauthenticated_servers", + new_callable=AsyncMock, + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "_server_instances", {} + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateful, "_server_instances", {} + ), + ): + await handle_streamable_http_mcp(scope, receive, sent) + + assert await gate_kind(held.get_nowait()) == expected + + @pytest.mark.asyncio async def test_mcp_routing_batch_body_is_not_stashed_for_auth(): try: diff --git a/tests/unit/proxy/mcp_server/test_mcp_server_tool_calls_and_headers.py b/tests/unit/proxy/mcp_server/test_mcp_server_tool_calls_and_headers.py index 621a7b65005..4b08fa918cf 100644 --- a/tests/unit/proxy/mcp_server/test_mcp_server_tool_calls_and_headers.py +++ b/tests/unit/proxy/mcp_server/test_mcp_server_tool_calls_and_headers.py @@ -4190,6 +4190,29 @@ def test_jsonrpc_text_has_top_level_method_ignores_nested_method(): assert _jsonrpc_text_has_top_level_method(truncated) is False +@pytest.mark.parametrize( + ("text", "method"), + [ + pytest.param('{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', "tools/list", id="complete"), + pytest.param('{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":"' + "c" * 5000, "tools/list", id="cut-after-method"), + pytest.param('{"jsonrpc":"2.0","params":{"cursor":"' + "c" * 5000, None, id="cut-before-method"), + pytest.param('{"jsonrpc":"2.0","id":1,"method":"tools/li', None, id="cut-inside-method"), + pytest.param('{"jsonrpc":"2.0","params":{"method":"tools/list"},"method":"ping"}', "ping", id="nested-method-first"), + pytest.param('{"jsonrpc":"2.0","id":1,"method":"tools\\/list","params":{"cursor":"' + "c" * 5000, "tools/list", id="escaped-slash"), + pytest.param('{"jsonrpc":"2.0","id":1,"method":"\\u0074ools/list","params":{"cursor":"' + "c" * 5000, "tools/list", id="unicode-escape"), + pytest.param('{"jsonrpc":"2.0","id":1,"method":"bad\\x","params":{}}', None, id="invalid-escape"), + pytest.param('{"jsonrpc":"2.0","id":1,"method":7,"params":{}}', None, id="not-a-string"), + pytest.param('{"jsonrpc":"2.0","id":1,"result":{"method":"tools/list"}}', None, id="response"), + ], +) +def test_jsonrpc_text_method_reads_the_top_level_method_when_the_cut_leaves_it_whole( + text: str, method: str | None +) -> None: + from litellm.proxy.mcp_server.server import _jsonrpc_text_method + + assert _jsonrpc_text_method(text) == method + + @pytest.mark.asyncio @pytest.mark.parametrize("response_field", ("result", "error")) @pytest.mark.parametrize(("character", "bytes_before_cap"), (("", 0), ("x", 0), ("é", 1), ("中", 2), ("😀", 3))) From ebd5a15b22ba012d2f5a5ee654afc93cd51add5e Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:52:07 -0800 Subject: [PATCH 25/86] fix(ci): install tzdata so zoneinfo tests pass on runners without legacy tz aliases (#51057) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b7db2cc1d7b..6145db57902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -277,6 +277,7 @@ proxy-dev = [ ] ci = [ "psutil==7.2.2", + "tzdata==2026.2", # These are lazily imported at call sites; keep them out of core deps to # avoid bloating the base SDK install (google-generativeai pulls grpcio + # protobuf, Pillow is a compiled C extension). diff --git a/uv.lock b/uv.lock index 62187d596ec..2dec937800c 100644 --- a/uv.lock +++ b/uv.lock @@ -5230,6 +5230,7 @@ ci = [ { name = "pytest-retry" }, { name = "tenacity" }, { name = "traceloop-sdk" }, + { name = "tzdata" }, ] dev = [ { name = "basedpyright" }, @@ -5468,6 +5469,7 @@ ci = [ { name = "pytest-retry", specifier = "==1.7.0" }, { name = "tenacity", specifier = "==8.5.0" }, { name = "traceloop-sdk", specifier = "==0.34.0" }, + { name = "tzdata", specifier = "==2026.2" }, ] dev = [ { name = "basedpyright", specifier = "==1.39.7" }, From 2c9068dbab010603f2367f765000217156342e30 Mon Sep 17 00:00:00 2001 From: Joseph Barker <156112794+seph-barker@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:30:41 -0800 Subject: [PATCH 26/86] fix(guardrails/rubrik): moderate text-completion prompts and forward rac_agent_id (#49912) * fix(guardrails/rubrik): moderate text-completion prompts and forward rac_agent_id _moderate_prompt synthesized a user message from texts for /v1/completions requests but built the payload from structured_messages, so the webhook received an empty messages list and the prompt went unmoderated. Pass the resolved messages into _build_prompt_moderation_payload. Forward metadata.rac_agent_id to the before_prompt and after_completion webhooks so agent-scoped policies apply. Only that key is forwarded; the rest of request_data metadata carries non-serializable objects and inbound headers * fix(guardrails/rubrik): also read rac_agent_id from litellm_metadata /v1/messages, /v1/responses and other LITELLM_METADATA_ROUTES keep proxy metadata under litellm_metadata, so a caller on those routes may set rac_agent_id there. Read metadata first, then litellm_metadata, forwarding only a non-empty string * fix(guardrails/rubrik): take rac_agent_id from key or team metadata only The Rubrik webhooks use rac_agent_id to choose which agent-scoped policies apply. Forwarding it from the request body let a caller pick any agent's policies, including one a policy exempts. Read it from the caller's admin-configured key metadata, falling back to team metadata, via CustomGuardrail._get_admin_metadata. The proxy writes those from the authenticated key and strips client-supplied user_api_key_* fields from both metadata dicts, so a caller cannot set or override the value --------- Co-authored-by: yucheng-berri --- litellm/integrations/rubrik.py | 42 +++++- tests/unit/integrations/test_rubrik.py | 179 ++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 12 deletions(-) diff --git a/litellm/integrations/rubrik.py b/litellm/integrations/rubrik.py index 57e8323cdc3..7d04b1df95e 100644 --- a/litellm/integrations/rubrik.py +++ b/litellm/integrations/rubrik.py @@ -31,7 +31,7 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk, ChatCompletionUserMessage from litellm.types.utils import ( ChatCompletionMessageToolCall, Function, @@ -103,6 +103,8 @@ class _RequestContextSource(Protocol): @overload def get(self, key: Literal["optional_params"], /) -> Mapping[str, object] | None: ... @overload + def get(self, key: Literal["metadata", "litellm_metadata"], /) -> Mapping[str, object] | None: ... + @overload def get(self, key: str, /) -> object | None: ... def __contains__(self, key: object, /) -> bool: ... def __getitem__(self, key: str, /) -> object: ... @@ -441,7 +443,7 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): ) -> GenericGuardrailAPIInputs: """Send the (normalized) prompt to the before_prompt webhook and raise if the prompt is blocked.""" - messages = inputs.get("structured_messages") + messages: Sequence[AllMessageValues] | None = inputs.get("structured_messages") if not messages: # For non-chat request types (e.g. /v1/completions), litellm # supplies the prompt as ``texts`` with no structured_messages. @@ -450,11 +452,11 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): if texts: joined: Final = "\n".join(t for t in texts if t) if joined: - messages = [{"role": "user", "content": joined}] + messages = (ChatCompletionUserMessage(role="user", content=joined),) if not messages: return inputs - payload: Final = self._build_prompt_moderation_payload(inputs, request_data) + payload: Final = self._build_prompt_moderation_payload(inputs, messages, request_data) service_response: Final = await self._post_to_prompt_moderation_endpoint(payload) refusal: Final = self._extract_prompt_refusal(service_response) if refusal is None: @@ -616,7 +618,8 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): @staticmethod def _build_prompt_moderation_payload( inputs: GenericGuardrailAPIInputs, - request_data: Mapping[str, object], + messages: Sequence[AllMessageValues | None], + request_data: _RequestContextSource, ) -> Mapping[str, object]: """Build the bare OpenAI request the before_prompt webhook consumes. @@ -637,10 +640,11 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): correlation_key: Final = request_data.get("correlation_key") or request_data.get("litellm_call_id") return { "model": inputs.get("model") or request_data.get("model") or "", - "messages": RubrikLogger._flatten_messages_for_moderation(inputs.get("structured_messages")), + "messages": RubrikLogger._flatten_messages_for_moderation(messages), **({"tools": tools} if tools is not None else _EMPTY_MAPPING), **({"user": user} if user else _EMPTY_MAPPING), **({"correlation_key": correlation_key} if correlation_key else _EMPTY_MAPPING), + **RubrikLogger._rac_agent_metadata(request_data), } @staticmethod @@ -671,7 +675,8 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): else: tools = optional_params.get("tools") - # The response moderation service consumes only messages/model/tools. + # The response moderation service consumes only messages/model/tools + # (plus metadata.rac_agent_id when set). # Don't forward proxy_server_request -- in litellm >=1.83 its ``body`` # snapshot carries a UserAPIKeyAuth instance that breaks json.dumps, # silently fail-opening the guardrail. @@ -679,8 +684,31 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): "messages": call_details.get("messages"), "model": call_details.get("model"), "tools": tools, + **RubrikLogger._rac_agent_metadata(request_data), } + @staticmethod + def _rac_agent_metadata(request_data: _RequestContextSource) -> Mapping[str, object]: + """Forward ``rac_agent_id`` from the caller's key or team metadata. + + The webhooks use it to choose which agent-scoped policies apply, so it + must come from configuration an admin controls, never from the request + body: a caller who could set it would select any agent's policies, + including one a policy exempts. ``_get_admin_metadata`` reads only the + ``user_api_key_metadata`` / ``user_api_key_team_metadata`` the proxy + writes from the authenticated key (key overrides team); the proxy strips + client-supplied ``user_api_key_*`` fields from both metadata dicts. + """ + rac_agent_id: Final[object] = RubrikLogger._admin_rac_agent_id(request_data) + if not isinstance(rac_agent_id, str) or not rac_agent_id: + return _EMPTY_MAPPING + return {"metadata": {"rac_agent_id": rac_agent_id}} + + @staticmethod + def _admin_rac_agent_id(request_data: _RequestContextSource) -> object: + """``rac_agent_id`` from the merged key/team admin metadata, untyped.""" + return RubrikLogger._get_admin_metadata(request_data).get("rac_agent_id") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportArgumentType] # upstream helper takes and returns a bare dict; caller narrows to str + @staticmethod def _sanitize_proxy_server_request(proxy_server_request: Mapping[str, object] | str | None) -> object: """Allowlist only routing fields (``url``, ``method``) when forwarding diff --git a/tests/unit/integrations/test_rubrik.py b/tests/unit/integrations/test_rubrik.py index f8aec70a2f7..cc9e03ba4b9 100644 --- a/tests/unit/integrations/test_rubrik.py +++ b/tests/unit/integrations/test_rubrik.py @@ -5,6 +5,7 @@ Covers initialization, apply_guardrail (prompt moderation + response/tool blocking), batch logging, and Anthropic format handling. """ +import json import os from typing import Any, Dict from unittest.mock import AsyncMock, Mock, patch @@ -534,6 +535,22 @@ def _mock_service_response(response_json): return mock_client +def _capturing_service(response_json): + """Like ``_mock_service_response`` but also records each posted JSON body.""" + captured: list = [] + + async def mock_post(*_args, **kwargs): + captured.append(kwargs.get("json")) + mock_resp = Mock() + mock_resp.json.return_value = response_json + mock_resp.raise_for_status = Mock() + return mock_resp + + mock_client = AsyncMock() + mock_client.post = mock_post + return mock_client, captured + + def _echo_service(): """Create a mock tool blocking client that echoes the payload back.""" @@ -1230,6 +1247,35 @@ class TestPromptModeration: ) assert result is inputs + async def test_text_completion_prompt_is_sent_as_user_message(self, handler): + """/v1/completions requests carry the prompt as ``texts`` with no + structured_messages; the webhook must still receive it.""" + handler.moderation_client, captured = _capturing_service({}) + + await handler.apply_guardrail( + inputs={"texts": ["write a poem", "", "about cats"]}, + request_data={}, + input_type="request", + ) + + assert list(captured[0]["messages"]) == [ + {"role": "user", "content": "write a poem\nabout cats"} + ] + + async def test_structured_messages_take_precedence_over_texts(self, handler): + handler.moderation_client, captured = _capturing_service({}) + + await handler.apply_guardrail( + inputs={ + "texts": ["ignored"], + "structured_messages": [{"role": "user", "content": "hello"}], + }, + request_data={}, + input_type="request", + ) + + assert list(captured[0]["messages"]) == [{"role": "user", "content": "hello"}] + async def test_prompt_moderation_stashes_logging_obj_on_block(self, handler): """On a prompt block, _stash_block_context must set the blocked flag.""" inputs = { @@ -1378,7 +1424,7 @@ class TestBuildPromptModerationPayload: "structured_messages": [{"role": "user", "content": "hi"}], "tools": [{"type": "function", "function": {"name": "fn"}}], } - payload = RubrikLogger._build_prompt_moderation_payload(inputs, {}) + payload = RubrikLogger._build_prompt_moderation_payload(inputs, inputs["structured_messages"], {}) assert payload["tools"] == [{"type": "function", "function": {"name": "fn"}}] def test_payload_includes_user_when_present(self): @@ -1386,7 +1432,7 @@ class TestBuildPromptModerationPayload: "structured_messages": [{"role": "user", "content": "hi"}], } request_data = {"user": "alice"} - payload = RubrikLogger._build_prompt_moderation_payload(inputs, request_data) + payload = RubrikLogger._build_prompt_moderation_payload(inputs, inputs["structured_messages"], request_data) assert payload["user"] == "alice" def test_payload_uses_explicit_correlation_key(self): @@ -1395,22 +1441,145 @@ class TestBuildPromptModerationPayload: "correlation_key": "corr-123", "litellm_call_id": "litellm-456", } - payload = RubrikLogger._build_prompt_moderation_payload(inputs, request_data) + payload = RubrikLogger._build_prompt_moderation_payload(inputs, inputs["structured_messages"], request_data) assert payload["correlation_key"] == "corr-123" def test_payload_falls_back_to_litellm_call_id(self): """When correlation_key is absent, litellm_call_id is used.""" inputs = {"structured_messages": [{"role": "user", "content": "hi"}]} request_data = {"litellm_call_id": "litellm-789"} - payload = RubrikLogger._build_prompt_moderation_payload(inputs, request_data) + payload = RubrikLogger._build_prompt_moderation_payload(inputs, inputs["structured_messages"], request_data) assert payload["correlation_key"] == "litellm-789" def test_payload_omits_optional_fields_when_absent(self): inputs = {"structured_messages": [{"role": "user", "content": "hi"}]} - payload = RubrikLogger._build_prompt_moderation_payload(inputs, {}) + payload = RubrikLogger._build_prompt_moderation_payload(inputs, inputs["structured_messages"], {}) assert "tools" not in payload assert "user" not in payload assert "correlation_key" not in payload + assert "metadata" not in payload + + +# -- rac_agent_id forwarding -------------------------------------------------- + + +def _proxy_metadata( + key_meta: Dict[str, Any] | None = None, + team_meta: Dict[str, Any] | None = None, + **caller_fields: Any, +) -> Dict[str, Any]: + """Request metadata as the proxy hands it to guardrails: the authenticated + key/team metadata under ``user_api_key_*``, next to fields the caller sent""" + return { + **caller_fields, + "headers": {"authorization": "Bearer sk-secret"}, + "litellm_parent_otel_span": object(), + "user_api_key_metadata": key_meta or {}, + "user_api_key_team_metadata": team_meta or {}, + } + + +def _prompt_and_response_metadata(request_data: Dict[str, Any]): + inputs = {"structured_messages": [{"role": "user", "content": "hi"}]} + prompt_payload = RubrikLogger._build_prompt_moderation_payload( + inputs, inputs["structured_messages"], request_data + ) + response_request = RubrikLogger._extract_request_data( + {"model": "gpt-4"}, request_data + ) + return prompt_payload.get("metadata"), response_request.get("metadata") + + +class TestRacAgentIdForwarding: + def test_forwards_rac_agent_id_from_key_metadata_only(self): + request_data = {"metadata": _proxy_metadata(key_meta={"rac_agent_id": "agent-42", "other": 1})} + assert _prompt_and_response_metadata(request_data) == ( + {"rac_agent_id": "agent-42"}, + {"rac_agent_id": "agent-42"}, + ) + + def test_forwards_rac_agent_id_from_team_metadata(self): + request_data = {"metadata": _proxy_metadata(team_meta={"rac_agent_id": "team-agent"})} + assert _prompt_and_response_metadata(request_data) == ( + {"rac_agent_id": "team-agent"}, + {"rac_agent_id": "team-agent"}, + ) + + def test_key_metadata_overrides_team_metadata(self): + request_data = { + "metadata": _proxy_metadata( + key_meta={"rac_agent_id": "key-agent"}, + team_meta={"rac_agent_id": "team-agent"}, + ) + } + assert _prompt_and_response_metadata(request_data)[0] == {"rac_agent_id": "key-agent"} + + def test_admin_metadata_under_litellm_metadata(self): + """/v1/messages and /v1/responses put proxy metadata under litellm_metadata""" + request_data = {"litellm_metadata": _proxy_metadata(key_meta={"rac_agent_id": "agent-42"})} + assert _prompt_and_response_metadata(request_data)[0] == {"rac_agent_id": "agent-42"} + + @pytest.mark.parametrize("container", ["metadata", "litellm_metadata"]) + def test_caller_supplied_rac_agent_id_is_ignored(self, container): + """A caller must not choose the policy scope: the request-body value is + never forwarded, even when the key has no agent configured""" + request_data = {container: _proxy_metadata(rac_agent_id="exempt-agent")} + assert _prompt_and_response_metadata(request_data) == (None, None) + + def test_caller_cannot_override_the_keys_agent(self): + request_data = { + "metadata": _proxy_metadata( + key_meta={"rac_agent_id": "assigned-agent"}, + rac_agent_id="exempt-agent", + ), + "litellm_metadata": {"rac_agent_id": "exempt-agent"}, + } + assert _prompt_and_response_metadata(request_data) == ( + {"rac_agent_id": "assigned-agent"}, + {"rac_agent_id": "assigned-agent"}, + ) + + @pytest.mark.parametrize( + "request_data", + [ + {}, + {"metadata": None}, + {"metadata": "not-a-dict"}, + {"metadata": _proxy_metadata()}, + {"metadata": _proxy_metadata(key_meta={"rac_agent_id": ""})}, + {"metadata": _proxy_metadata(key_meta={"rac_agent_id": {"nested": "object"}})}, + {"metadata": _proxy_metadata(key_meta={"rac_agent_id": 42})}, + ], + ) + def test_metadata_omitted_without_a_valid_admin_rac_agent_id(self, request_data): + assert _prompt_and_response_metadata(request_data) == (None, None) + + @pytest.mark.asyncio + async def test_both_webhooks_receive_rac_agent_id_and_payloads_serialize( + self, handler + ): + request_data = {"metadata": _proxy_metadata(key_meta={"rac_agent_id": "agent-42"})} + handler.moderation_client, prompt_captured = _capturing_service({}) + await handler.apply_guardrail( + inputs={"structured_messages": [{"role": "user", "content": "hi"}]}, + request_data=request_data, + input_type="request", + ) + handler.moderation_client, response_captured = _capturing_service( + {"choices": [{"message": {"role": "assistant", "content": "hello"}}]} + ) + await handler.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data=request_data, + input_type="response", + ) + + assert prompt_captured[0]["metadata"] == {"rac_agent_id": "agent-42"} + assert response_captured[0]["request"]["metadata"] == { + "rac_agent_id": "agent-42" + } + json.dumps(prompt_captured[0]) + json.dumps(response_captured[0]) # -- _extract_request_data tools preference ----------------------------------- From 4957007b0b9a88b95a3cebd791bd870fdeed4f16 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Wed, 11 Nov 2026 08:31:26 +0800 Subject: [PATCH 27/86] fix(model-prices): use current Hy4 promotional rates (#51072) --- ...odel_prices_and_context_window_backup.json | 20 +++++++++---------- model_prices_and_context_window.json | 20 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 83ab91f2bc0..6ffc20e9e71 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -64701,15 +64701,15 @@ "supports_vision": false }, "tencent/hy4": { - "cache_read_input_token_cost": 4.2e-08, - "comment": "TokenHub bills Hy4 at 50% of list until 2026-12-31 23:59:59 Beijing time; these are the list rates.", - "input_cost_per_token": 8.34e-07, - "input_cost_per_token_cache_hit": 4.2e-08, + "cache_read_input_token_cost": 2.1e-08, + "comment": "These are the 50% promotional rates. Update on 2026-12-31 when the promotion ends at 23:59:59 Beijing time. List rates per 1M tokens are $0.834 input, $2.501 output and $0.042 cached input.", + "input_cost_per_token": 4.17e-07, + "input_cost_per_token_cache_hit": 2.1e-08, "litellm_provider": "tencent", "max_input_tokens": 960000, "max_output_tokens": 64000, "mode": "chat", - "output_cost_per_token": 2.501e-06, + "output_cost_per_token": 1.2505e-06, "source": "https://www.tencentcloud.com/document/product/1300/78937", "supports_function_calling": true, "supports_prompt_caching": true, @@ -64718,15 +64718,15 @@ "supports_vision": true }, "tencent/hy4-preview": { - "cache_read_input_token_cost": 4.2e-08, - "comment": "TokenHub bills Hy4 preview at 50% of list until 2026-12-31 23:59:59 Beijing time; these are the list rates.", - "input_cost_per_token": 8.34e-07, - "input_cost_per_token_cache_hit": 4.2e-08, + "cache_read_input_token_cost": 2.1e-08, + "comment": "These are the 50% promotional rates. Update on 2026-12-31 when the promotion ends at 23:59:59 Beijing time. List rates per 1M tokens are $0.834 input, $2.501 output and $0.042 cached input.", + "input_cost_per_token": 4.17e-07, + "input_cost_per_token_cache_hit": 2.1e-08, "litellm_provider": "tencent", "max_input_tokens": 960000, "max_output_tokens": 64000, "mode": "chat", - "output_cost_per_token": 2.501e-06, + "output_cost_per_token": 1.2505e-06, "source": "https://www.tencentcloud.com/document/product/1300/78937", "supports_function_calling": true, "supports_prompt_caching": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 83ab91f2bc0..6ffc20e9e71 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -64701,15 +64701,15 @@ "supports_vision": false }, "tencent/hy4": { - "cache_read_input_token_cost": 4.2e-08, - "comment": "TokenHub bills Hy4 at 50% of list until 2026-12-31 23:59:59 Beijing time; these are the list rates.", - "input_cost_per_token": 8.34e-07, - "input_cost_per_token_cache_hit": 4.2e-08, + "cache_read_input_token_cost": 2.1e-08, + "comment": "These are the 50% promotional rates. Update on 2026-12-31 when the promotion ends at 23:59:59 Beijing time. List rates per 1M tokens are $0.834 input, $2.501 output and $0.042 cached input.", + "input_cost_per_token": 4.17e-07, + "input_cost_per_token_cache_hit": 2.1e-08, "litellm_provider": "tencent", "max_input_tokens": 960000, "max_output_tokens": 64000, "mode": "chat", - "output_cost_per_token": 2.501e-06, + "output_cost_per_token": 1.2505e-06, "source": "https://www.tencentcloud.com/document/product/1300/78937", "supports_function_calling": true, "supports_prompt_caching": true, @@ -64718,15 +64718,15 @@ "supports_vision": true }, "tencent/hy4-preview": { - "cache_read_input_token_cost": 4.2e-08, - "comment": "TokenHub bills Hy4 preview at 50% of list until 2026-12-31 23:59:59 Beijing time; these are the list rates.", - "input_cost_per_token": 8.34e-07, - "input_cost_per_token_cache_hit": 4.2e-08, + "cache_read_input_token_cost": 2.1e-08, + "comment": "These are the 50% promotional rates. Update on 2026-12-31 when the promotion ends at 23:59:59 Beijing time. List rates per 1M tokens are $0.834 input, $2.501 output and $0.042 cached input.", + "input_cost_per_token": 4.17e-07, + "input_cost_per_token_cache_hit": 2.1e-08, "litellm_provider": "tencent", "max_input_tokens": 960000, "max_output_tokens": 64000, "mode": "chat", - "output_cost_per_token": 2.501e-06, + "output_cost_per_token": 1.2505e-06, "source": "https://www.tencentcloud.com/document/product/1300/78937", "supports_function_calling": true, "supports_prompt_caching": true, From 285ddf82e1825438f7d87d96dfac793d73a6e918 Mon Sep 17 00:00:00 2001 From: Nurlan Mammadli <116812760+nurlanmnn@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:32:49 -0500 Subject: [PATCH 28/86] fix(proxy): allow management_routes keys to call PATCH /model/{model_id}/update (#50979) The PATCH model update endpoint was missing from the management_routes group, so virtual keys restricted to management_routes got a 403 on it while the legacy POST /model/update worked. Fixes #50918. Co-authored-by: Claude Opus 5.5 --- litellm/proxy/_types.py | 1 + tests/unit/proxy/auth/test_route_checks.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 736c09277f1..0d40faf55fb 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -713,6 +713,7 @@ class LiteLLMRoutes(enum.Enum): # model "/model/new", "/model/update", + "/model/{model_id}/update", "/model/delete", "/model/info", "/model/{model_id}/kubernetes_pods", diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index c1011310af3..d36e9558c86 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -387,6 +387,25 @@ def test_virtual_key_allowed_routes_with_litellm_routes_member_name_allowed(grou assert result is True +@pytest.mark.parametrize( + "route,method", + [ + ("/model/update", "POST"), + ("/model/123e4567-e89b-12d3-a456-426614174000/update", "PATCH"), + ], +) +def test_virtual_key_management_routes_allows_model_update(route: str, method: str) -> None: + valid_token: Final = UserAPIKeyAuth(user_id="test_user", allowed_routes=["management_routes"]) + + result: Final = RouteChecks.is_virtual_key_allowed_to_call_route( + route=route, + valid_token=valid_token, + request=_mock_request(method), + ) + + assert result is True + + def test_virtual_key_mcp_routes_allows_v1_mcp_server(): """Regression test for #20325: allow virtual keys to list MCP servers.""" From 7894ec8434424361217d8e5dad79edf8a3a6d7d6 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:35:13 -0800 Subject: [PATCH 29/86] fix(proxy): drop the beta label from the /v1/messages swagger tags (#51046) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/anthropic_endpoints/endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index 7e990e3c71b..1688992f6fa 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -128,7 +128,7 @@ def _strip_total_tokens_from_anthropic_response(response: object) -> None: @router.post( "/v1/messages", - tags=["[beta] Anthropic `/v1/messages`"], + tags=["Anthropic `/v1/messages`"], dependencies=[Depends(user_api_key_auth)], ) async def anthropic_response( @@ -319,7 +319,7 @@ def _provider_context_management(response: TokenCountResponse) -> Mapping[str, o @router.post( "/v1/messages/count_tokens", - tags=["[beta] Anthropic Messages Token Counting"], + tags=["Anthropic Messages Token Counting"], dependencies=[Depends(user_api_key_auth)], ) async def count_tokens( From e2625d0509c8e42253849a53bbf1a831dfcaf3bd Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 10 Nov 2026 16:36:10 -0800 Subject: [PATCH 30/86] fix(router): re-check and reserve per-model rate limits on fallback targets (#50994) * fix(router): re-check and reserve per-model rate limits on fallback targets A provider error fallback handed the caller the fallback model group without its per-model TPM/RPM limits ever being checked. The router gets a fallback_rate_limit_check hook next to fallback_budget_check, the proxy wires it to the v3 limiter, which reserves the target model's per-model descriptors before each fallback hop and skips targets that are over their limit. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): dedupe a repeated fallback target before the reserving rate limit check Both walks now consult the attempted set before the rate limit check, so a chain like ["B", "B"] reserves B once. The deprecated settings parity test also learns the new setting. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(router): a bidirectional fallback chain never re-reserves the requested model group Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(router): match the surfaced failure in the fallback reservation tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(rate-limit): keep the admission stash untouched while building fallback descriptors Building the fallback target's descriptors could rewrite the request stash's PTU ceiling, which post-call settlement reads for the model the caller named. The fallback check now hides the stash while it builds, like the frame check. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 1 + .../routing_common_utils.py | 14 ++- litellm/proxy/auth/fallback_rate_limit.py | 48 ++++++++ .../common_utils/proxy_rate_limit_error.py | 1 + .../hooks/parallel_request_limiter_v3.py | 22 ++++ litellm/proxy/proxy_server.py | 7 ++ litellm/router.py | 3 + litellm/router_api.py | 10 +- litellm/router_deprecated.py | 1 + litellm/router_rust/core.py | 10 ++ .../router_utils/fallback_event_handlers.py | 59 +++++++-- litellm/types/router.py | 14 +++ .../routing/test_fallback_rate_limit_check.py | 77 ++++++++++++ .../proxy/auth/test_fallback_rate_limit.py | 77 ++++++++++++ .../test_fallback_event_handlers.py | 114 +++++++++++++++++- .../core/test_deprecated_members.py | 1 + 16 files changed, 445 insertions(+), 14 deletions(-) create mode 100644 litellm/proxy/auth/fallback_rate_limit.py create mode 100644 tests/integration/routing/test_fallback_rate_limit_check.py create mode 100644 tests/unit/proxy/auth/test_fallback_rate_limit.py diff --git a/litellm/constants.py b/litellm/constants.py index b29c22810e7..25eb5d9b854 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -89,6 +89,7 @@ ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG: Final[frozenset[str]] = frozenset( "ignore_invalid_deployments", "fallback_access_check", "fallback_budget_check", + "fallback_rate_limit_check", "auto_router_capability_limit", } ) diff --git a/litellm/litellm_core_utils/routing_common_utils.py b/litellm/litellm_core_utils/routing_common_utils.py index 89d6729766f..154c3942edc 100644 --- a/litellm/litellm_core_utils/routing_common_utils.py +++ b/litellm/litellm_core_utils/routing_common_utils.py @@ -299,9 +299,13 @@ def format_fallback_outcome_message( over_budget_targets: Final = tuple( dict.fromkeys(target.target for target in skipped if target.reason is FallbackSkipReason.OVER_BUDGET) ) + rate_limited_targets: Final = tuple( + dict.fromkeys(target.target for target in skipped if target.reason is FallbackSkipReason.RATE_LIMITED) + ) attempted_text: Final = ", ".join(str(mask_sensitive_structure(target)) for target in attempted_targets) not_authorized_text: Final = ", ".join(str(mask_sensitive_structure(target)) for target in not_authorized_targets) over_budget_text: Final = ", ".join(str(mask_sensitive_structure(target)) for target in over_budget_targets) + rate_limited_text: Final = ", ".join(str(mask_sensitive_structure(target)) for target in rate_limited_targets) attempted_failure: Final = ( (f"Fallback to {attempted_text} also failed: {fallback_failure_detail}",) if fallback_failure_detail and attempted_targets @@ -323,7 +327,15 @@ def format_fallback_outcome_message( if over_budget_targets else () ) - sentences: Final = attempted_failure + not_authorized_sentence + over_budget_sentence + rate_limited_sentence: Final = ( + ( + f"Fallback to {rate_limited_text} was skipped because the caller is over its per-model rate limit for " + f"{'it' if len(rate_limited_targets) == 1 else 'them'}.", + ) + if rate_limited_targets + else () + ) + sentences: Final = attempted_failure + not_authorized_sentence + over_budget_sentence + rate_limited_sentence if sentences: return f"{lead} {' '.join(sentences)}" diff --git a/litellm/proxy/auth/fallback_rate_limit.py b/litellm/proxy/auth/fallback_rate_limit.py new file mode 100644 index 00000000000..1d4a33b2994 --- /dev/null +++ b/litellm/proxy/auth/fallback_rate_limit.py @@ -0,0 +1,48 @@ +""" +Re-check per-model rate limits before the router takes a fallback. + +The v3 limiter admits a request once, against the model group the caller asked for. When that +group fails at the provider and the router moves to a fallback group, nothing re-checked the +caller's per-model limits on the fallback, so a provider outage on one model removed the cap on +the other for every caller at once. `RouterFallbackRateLimitCheck` runs the limiter's +model-scoped descriptors for the fallback target and reserves capacity on them, so the router +skips a target the caller has exhausted. +""" + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Final + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.auth.fallback_budget import ( + _user_api_key_auth_from_request, # pyright: ignore[reportPrivateUsage] # shared auth lookup for fallback checks +) +from litellm.proxy.hooks.parallel_request_limiter_v3 import ( + PROXY_MaxParallelRequestsHandler_v3, # pyright: ignore[reportPrivateUsage] # the proxy's limiter type +) +from litellm.router_api import RoutingContext + + +@dataclass(frozen=True, slots=True) +class RouterFallbackRateLimitCheck: + """ + `FallbackRateLimitCheck` for the proxy's router: a fallback target is attempted only when the + caller still has per-model rate limit capacity on it, and the attempt reserves that capacity. + Requests that carry no key (for example internal health checks) and proxies running without + the v3 limiter are not restricted. + """ + + limiter: Callable[[], object] + + async def __call__(self, *, model: str, request_kwargs: Mapping[str, object], ctx: RoutingContext) -> bool: + limiter: Final = self.limiter() + if not isinstance(limiter, PROXY_MaxParallelRequestsHandler_v3): + return True + valid_token: Final = _user_api_key_auth_from_request(request_kwargs) + if valid_token is None: + return True + try: + return await limiter.async_fallback_target_allowed(valid_token, model, request_kwargs) + except Exception as e: # noqa: BLE001 # fail closed: a limiter failure must not hand out unchecked capacity + verbose_proxy_logger.warning("Skipping fallback to model=%s: rate limit check failed: %s", model, e) + return False diff --git a/litellm/proxy/common_utils/proxy_rate_limit_error.py b/litellm/proxy/common_utils/proxy_rate_limit_error.py index 02da6f706d1..28d28a5c9bd 100644 --- a/litellm/proxy/common_utils/proxy_rate_limit_error.py +++ b/litellm/proxy/common_utils/proxy_rate_limit_error.py @@ -49,6 +49,7 @@ PER_MODEL_RATE_LIMIT_DESCRIPTOR_KEYS: Final = frozenset( { "model_per_key", "model_per_team", + "model_per_end_user", "model_per_organization", "model_per_project", "model_per_project_itpm", diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index ae9fa548cc9..a3227bca235 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -79,6 +79,7 @@ from litellm.proxy.auth.budget_throttle import throttled_limit from litellm.proxy.common_utils.callback_utils import get_model_group_from_litellm_kwargs from litellm.proxy.common_utils.http_parsing_utils import get_tags_from_request_body from litellm.proxy.common_utils.proxy_rate_limit_error import ( + PER_MODEL_RATE_LIMIT_DESCRIPTOR_KEYS, ProxyRateLimitError, map_v3_rate_limit_type, ) @@ -3777,6 +3778,27 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): if response["overall_code"] == "OK": await self._release_parallel_request_slots(acquisition, user_api_key_dict.parent_otel_span) + async def async_fallback_target_allowed( + self, + user_api_key_dict: UserAPIKeyAuth, + model: str, + request_data: Mapping[str, object], + ) -> bool: + """Reserve the caller's per-model limits on a router fallback target. False when any is exhausted.""" + data: Final = MappingProxyType({**request_data, "model": model}) + hidden_stash: Final = _request_stash.set(None) + try: + built: Final = await self._build_request_rate_limit_descriptors(user_api_key_dict, data, None) + finally: + _request_stash.reset(hidden_stash) + descriptors: Final = [ + descriptor for descriptor in built if descriptor["key"] in PER_MODEL_RATE_LIMIT_DESCRIPTOR_KEYS + ] + if not descriptors: + return True + response: Final = await self.should_rate_limit(descriptors, parent_otel_span=user_api_key_dict.parent_otel_span) + return response["overall_code"] == "OK" + @asynccontextmanager async def request_capacity( self, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index be90b028cf4..888276f7c31 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -454,6 +454,7 @@ from litellm.proxy.auth.auth_utils import ( ) from litellm.proxy.auth.fallback_budget import RouterFallbackBudgetCheck, router_fallback_budget_check from litellm.proxy.auth.fallback_model_access import router_fallback_access_check +from litellm.proxy.auth.fallback_rate_limit import RouterFallbackRateLimitCheck from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.legacy_empty_models_check import warn_about_legacy_empty_models from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY, LicenseCheck @@ -7656,6 +7657,9 @@ class ProxyConfig: is_enforced=router_fallback_budget_check.is_enforced, team_member_model_budget_limiter=model_max_budget_limiter, ), + fallback_rate_limit_check=RouterFallbackRateLimitCheck( + limiter=lambda: proxy_logging_obj.get_proxy_hook("parallel_request_limiter"), + ), auto_router_capability_limit=_license_check.auto_router_capability_limit, ) ) @@ -8162,6 +8166,9 @@ class ProxyConfig: is_enforced=router_fallback_budget_check.is_enforced, team_member_model_budget_limiter=model_max_budget_limiter, ), + fallback_rate_limit_check=RouterFallbackRateLimitCheck( + limiter=lambda: proxy_logging_obj.get_proxy_hook("parallel_request_limiter"), + ), auto_router_capability_limit=_license_check.auto_router_capability_limit, ) ) diff --git a/litellm/router.py b/litellm/router.py index 0d46326b904..98b016f590f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -34,6 +34,7 @@ from litellm.types.router import ( DeploymentTypedDict, FallbackAccessCheck, FallbackBudgetCheck, + FallbackRateLimitCheck, GuardrailTypedDict, LiteLLM_Params, ModelGroupInfo, @@ -120,6 +121,7 @@ def _router_init( enable_mid_stream_fallback_continuation: bool = False, fallback_access_check: FallbackAccessCheck | None = None, fallback_budget_check: FallbackBudgetCheck | None = None, + fallback_rate_limit_check: FallbackRateLimitCheck | None = None, auto_router_capability_limit: AutoRouterCapabilityLimit | None = None, session_affinity_repin_on_failover: bool = False, _host: PythonHost = python_host, @@ -166,6 +168,7 @@ def _router_init( enable_mid_stream_fallback_continuation (bool): When True, a chat-completions stream that breaks after plain assistant text has been delivered continues on a fallback deployment via assistant prefill instead of surfacing the error. Only deployments whose model supports assistant prefill are eligible, so the partial text is continued, not regenerated; if none is, the original error is surfaced. Streams that emitted tool calls, thinking blocks, audio/images, or constrained (JSON / forced tool_choice) output are never continued. Async-only. Defaults to False. fallback_access_check (Optional[FallbackAccessCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects is skipped. Defaults to None (every configured fallback is attempted). fallback_budget_check (Optional[FallbackBudgetCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects as over budget is skipped. Defaults to None (budget is not re-checked on fallback). + fallback_rate_limit_check (Optional[FallbackRateLimitCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects as over its per-model rate limit is skipped. Defaults to None (rate limits are not re-checked on fallback). Returns: Router: An instance of the litellm.Router class. diff --git a/litellm/router_api.py b/litellm/router_api.py index c85a764151f..a75397ea43d 100644 --- a/litellm/router_api.py +++ b/litellm/router_api.py @@ -80,6 +80,7 @@ if TYPE_CHECKING: DeploymentTypedDict, FallbackAccessCheck, FallbackBudgetCheck, + FallbackRateLimitCheck, GuardrailTypedDict, ModelGroupInfo, PreRoutingHookResponse, @@ -273,6 +274,7 @@ ROUTER_INIT_ARGS: Final[tuple[str, ...]] = ( "enable_mid_stream_fallback_continuation", "fallback_access_check", "fallback_budget_check", + "fallback_rate_limit_check", "auto_router_capability_limit", "session_affinity_repin_on_failover", ) @@ -1204,6 +1206,7 @@ SETTING_FIELDS: Final[tuple[str, ...]] = ( "provider_default_deployment_ids", "fallback_access_check", "fallback_budget_check", + "fallback_rate_limit_check", "kubernetes_pod_discovery", "search_tools", "guardrail_list", @@ -1264,6 +1267,7 @@ class RouterSettings: provider_default_deployment_ids: list[str] = None # pyright: ignore[reportAssignmentType] # unset only when built directly (see above) # mutable-ok: the router's live list fallback_access_check: "FallbackAccessCheck | None" = None fallback_budget_check: "FallbackBudgetCheck | None" = None + fallback_rate_limit_check: "FallbackRateLimitCheck | None" = None kubernetes_pod_discovery: "KubernetesPodDiscovery" = None # pyright: ignore[reportAssignmentType] # unset only when built directly (see above) search_tools: "list[SearchToolTypedDict]" = None # pyright: ignore[reportAssignmentType] # unset only when built directly (see above) # mutable-ok: the router's live list guardrail_list: "list[GuardrailTypedDict]" = None # pyright: ignore[reportAssignmentType] # unset only when built directly (see above) # mutable-ok: the router's live list @@ -1522,7 +1526,7 @@ def _takes_legacy_llm_router(check: object) -> bool: async def _run_fallback_check( - check: "FallbackAccessCheck | FallbackBudgetCheck", + check: "FallbackAccessCheck | FallbackBudgetCheck | FallbackRateLimitCheck", ctx: "RoutingContext", *, model: str, @@ -1666,6 +1670,10 @@ class RoutingContext: check: Final = self._router.settings().fallback_budget_check return check is None or await _run_fallback_check(check, self, model=model, request_kwargs=request_kwargs) + async def fallback_rate_limit_allowed(self, *, model: str, request_kwargs: Mapping[str, object]) -> bool: + check: Final = self._router.settings().fallback_rate_limit_check + return check is None or await _run_fallback_check(check, self, model=model, request_kwargs=request_kwargs) + def pre_call_checks( self, model: str, diff --git a/litellm/router_deprecated.py b/litellm/router_deprecated.py index 618ac4321d8..2cf111da2a5 100644 --- a/litellm/router_deprecated.py +++ b/litellm/router_deprecated.py @@ -345,6 +345,7 @@ class DeprecatedRouterMembers: ) fallback_access_check = _setting("fallback_access_check", lambda r: r.settings().fallback_access_check) fallback_budget_check = _setting("fallback_budget_check", lambda r: r.settings().fallback_budget_check) + fallback_rate_limit_check = _setting("fallback_rate_limit_check", lambda r: r.settings().fallback_rate_limit_check) # These three are declared with the attribute type Router gave them rather than the descriptor's: # protocols in router_utils and the proxy match Router on them, and protocol matching does not # see through a descriptor. Reads and writes still go through _setting at runtime. diff --git a/litellm/router_rust/core.py b/litellm/router_rust/core.py index 682aa557007..aef4db05ca9 100644 --- a/litellm/router_rust/core.py +++ b/litellm/router_rust/core.py @@ -330,6 +330,7 @@ _SERVED_ARGUMENTS: Final = frozenset( "default_litellm_params", "fallback_access_check", "fallback_budget_check", + "fallback_rate_limit_check", "enable_mid_stream_fallback_continuation", "redis_url", "redis_host", @@ -418,6 +419,7 @@ _DYNAMIC_SETTINGS: Final = frozenset( "default_litellm_params", "fallback_access_check", "fallback_budget_check", + "fallback_rate_limit_check", "routing_strategy_args", "enable_tag_filtering", "tag_filtering_match_any", @@ -664,6 +666,7 @@ class Resolved: default_litellm_params: Mapping[str, object] fallback_access_check: object fallback_budget_check: object + fallback_rate_limit_check: object enable_mid_stream_fallback_continuation: bool = False routing_groups: "tuple[RoutingGroup, ...]" = () enable_tag_filtering: bool = False @@ -779,6 +782,7 @@ class Resolved: default_litellm_params=MappingProxyType(dict(as_str_mapping(get("default_litellm_params")) or {})), fallback_access_check=get("fallback_access_check"), fallback_budget_check=get("fallback_budget_check"), + fallback_rate_limit_check=get("fallback_rate_limit_check"), enable_mid_stream_fallback_continuation=continuation_flag(get("enable_mid_stream_fallback_continuation")), enable_tag_filtering=bool(get("enable_tag_filtering")), tag_filtering_match_any=_true_unless_given(get("tag_filtering_match_any")), @@ -1911,6 +1915,7 @@ class RustCore( tag_routing_prefix=r.read("tag_routing_prefix"), # pyright: ignore[reportArgumentType] # the value as given, as the Python core stores it fallback_access_check=r.fallback_access_check, # pyright: ignore[reportArgumentType] # the check as passed fallback_budget_check=r.fallback_budget_check, # pyright: ignore[reportArgumentType] # the check as passed + fallback_rate_limit_check=r.fallback_rate_limit_check, # pyright: ignore[reportArgumentType] # the check as passed cache_responses=self._endpoints.cache_responses, search_tools=self._endpoints.search_tools, # pyright: ignore[reportArgumentType] # the configured entries, as passed guardrail_list=self._endpoints.guardrail_list, # pyright: ignore[reportArgumentType] # same @@ -5414,6 +5419,11 @@ class _Driver: if not await ctx.fallback_budget_allowed(model=target, request_kwargs=request): self._walk.targets().record_skip(target, FallbackSkipReason.OVER_BUDGET) return False + if target not in self._walk.targets() and not await ctx.fallback_rate_limit_allowed( + model=target, request_kwargs=request + ): + self._walk.targets().record_skip(target, FallbackSkipReason.RATE_LIMITED) + return False except Exception as error: # noqa: BLE001 # handed to the Rust call loop, which ends the chain self._level_errors[self._level + 1] = error return {**attempt_failure(error), "raised": True} diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index 068782f7a08..88687269d4b 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -753,6 +753,47 @@ async def _is_fallback_target_within_budget( return False +async def _is_fallback_target_admitted( + ctx: "RoutingContext", + fallback_entry: str | Mapping[str, object], + original_model_group: str, + kwargs: Mapping[str, object], + *, + attempted: AttemptedFallbackTargets, +) -> bool: + return ( + await _is_fallback_target_authorized(ctx, fallback_entry, original_model_group, kwargs, attempted=attempted) + and await _is_fallback_target_within_budget( + ctx, fallback_entry, original_model_group, kwargs, attempted=attempted + ) + and await _is_fallback_target_within_rate_limit( + ctx, fallback_entry, original_model_group, kwargs, attempted=attempted + ) + ) + + +async def _is_fallback_target_within_rate_limit( + ctx: "RoutingContext", + fallback_entry: str | Mapping[str, object], + original_model_group: str, + kwargs: Mapping[str, object], + *, + attempted: AttemptedFallbackTargets, +) -> bool: + rate_limit_check: Final = ctx.settings().fallback_rate_limit_check + target: Final = _get_fallback_target_model_group(fallback_entry) + if rate_limit_check is None or target is None or target == original_model_group: + return True + if await ctx.fallback_rate_limit_allowed(model=target, request_kwargs=kwargs): + return True + verbose_router_logger.info( + "Skipping fallback to model_group = %s: caller is over its per-model rate limit", + mask_sensitive_structure(fallback_entry), + ) + attempted.record_skip(target, FallbackSkipReason.RATE_LIMITED) + return False + + def references_provider_scoped_resource(kwargs: Mapping[str, object]) -> bool: """ True when a file, batch, or fine-tuning job operation names an id that only exists @@ -863,18 +904,16 @@ async def run_async_fallback( original_model_group, ) continue - if not await _is_fallback_target_authorized(ctx, mg, original_model_group, kwargs, attempted=attempted): - continue - if not await _is_fallback_target_within_budget(ctx, mg, original_model_group, kwargs, attempted=attempted): - continue attempt_key = fallback_attempt_key(mg) + if attempt_key is not None and attempt_key in attempted: + verbose_router_logger.info( + "Skipping fallback to model_group = %s, already attempted for this request", + mask_sensitive_structure(mg), + ) + continue + if not await _is_fallback_target_admitted(ctx, mg, original_model_group, kwargs, attempted=attempted): + continue if attempt_key is not None: - if attempt_key in attempted: - verbose_router_logger.info( - "Skipping fallback to model_group = %s, already attempted for this request", - mask_sensitive_structure(mg), - ) - continue attempted.record(attempt_key) try: # LOGGING diff --git a/litellm/types/router.py b/litellm/types/router.py index 6f5652db771..bc715759c45 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -89,6 +89,7 @@ class SilentExperimentFlag(TypedDict, total=False): class FallbackSkipReason(str, enum.Enum): NOT_AUTHORIZED = "not_authorized" OVER_BUDGET = "over_budget" + RATE_LIMITED = "rate_limited" @dataclass(frozen=True, slots=True) @@ -1352,6 +1353,19 @@ class FallbackBudgetCheck(Protocol): async def __call__(self, *, model: str, request_kwargs: Mapping[str, object], ctx: "RouterContext") -> bool: ... +class FallbackRateLimitCheck(Protocol): + """ + Decides whether the caller behind `request_kwargs` still has per-model rate limit capacity on fallback `model`. + + The limiter admits a request once, against the *requested* model group, so a provider failure would hand + the caller a fallback group whose per-model limits were never checked. The router runs this before every + cross-model-group fallback attempt, skips rejected targets, and an implementation reserves capacity when + it admits so concurrent failovers cannot all pass. + """ + + async def __call__(self, *, model: str, request_kwargs: Mapping[str, object], ctx: "RouterContext") -> bool: ... + + class AutoRouterCapabilityLimit(Protocol): """ Resolves how many complexity routers may claim each licensed capability right now; None means unlimited. diff --git a/tests/integration/routing/test_fallback_rate_limit_check.py b/tests/integration/routing/test_fallback_rate_limit_check.py new file mode 100644 index 00000000000..e7db24f5464 --- /dev/null +++ b/tests/integration/routing/test_fallback_rate_limit_check.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from contextlib import ExitStack +from dataclasses import dataclass +from typing import Final + +import pytest +from integration._support.client import Gateway, gateway_from_environment +from integration._support.process import graceful_stop_seconds, owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from integration.routing.test_payment_required_mapping import is_model_info_probe, model_list_reply +from integration.routing.test_per_model_rate_limit_fallbacks import ( + assert_served, + chat, + error_message, + healthy_reply, + model_entry, + proxy_config, +) + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + + +@dataclass(frozen=True, slots=True) +class Rig: + gateway: Gateway + primary: str + fallback: str + fallback_wire: Wire + + +def provider_error_reply(request: Request) -> Reply: + if is_model_info_probe(request): + return model_list_reply() + return Reply(status=500, body=b'{"error": {"message": "scripted provider outage", "type": "server_error"}}') + + +def served_requests(wire: Wire) -> int: + return sum(1 for request in wire.drain() if not is_model_info_probe(request)) + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + suffix: Final = uuid.uuid4().hex[:10] + primary: Final = f"fallback-check-primary-{suffix}" + fallback: Final = f"fallback-check-fallback-{suffix}" + directory: Final = tmp_path_factory.mktemp("fallback-rate-limit-check") + with ExitStack() as stack: + rig_gateway: Final = stack.enter_context(gateway_from_environment()) + primary_wire: Final = stack.enter_context(wire_server(provider_error_reply)) + fallback_wire: Final = stack.enter_context(wire_server(healthy_reply)) + config: Final = proxy_config( + directory, + model_list=(model_entry(primary, primary_wire), model_entry(fallback, fallback_wire)), + fallbacks=({primary: [fallback]},), + general_settings={}, + ) + owned: Final = stack.enter_context(owned_proxy_process(rig_gateway, directory, {}, config=config, workers=2)) + yield Rig(owned.gateway, primary, fallback, fallback_wire) + + +def test_provider_error_fallback_honors_the_keys_per_model_rpm_limit_on_the_fallback_model(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + key: Final = scenario.key(model_rpm_limit={rig.fallback: 1}) + served_requests(rig.fallback_wire) + + first: Final = chat(rig.gateway, key, rig.primary, f"pmrl-{uuid.uuid4().hex}") + assert_served(first, rig.fallback, "served pmrl-") + + second: Final = chat(rig.gateway, key, rig.primary, f"pmrl-{uuid.uuid4().hex}") + assert second.status_code == 500, (second.status_code, second.text) + assert f"Fallback to {rig.fallback} was skipped because the caller is over its per-model rate limit" in ( + error_message(second.text) + ), second.text + assert served_requests(rig.fallback_wire) == 1 diff --git a/tests/unit/proxy/auth/test_fallback_rate_limit.py b/tests/unit/proxy/auth/test_fallback_rate_limit.py new file mode 100644 index 00000000000..b1173a758cd --- /dev/null +++ b/tests/unit/proxy/auth/test_fallback_rate_limit.py @@ -0,0 +1,77 @@ +from typing import Final + +import pytest + +from litellm import Router +from litellm.caching.dual_cache import DualCache +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.fallback_rate_limit import RouterFallbackRateLimitCheck +from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3 +from litellm.proxy.utils import InternalUsageCache +from litellm.router_api import RoutingContext + +PRIMARY: Final = {"model_name": "primary", "litellm_params": {"model": "openai/gpt-4o", "api_key": "k"}} +FALLBACK: Final = {"model_name": "fallback", "litellm_params": {"model": "openai/gpt-4o", "api_key": "k"}} + + +def _router() -> Router: + return Router(model_list=[PRIMARY, FALLBACK], fallbacks=[{"primary": ["fallback"]}]) + + +def _limiter() -> PROXY_MaxParallelRequestsHandler_v3: + return PROXY_MaxParallelRequestsHandler_v3(internal_usage_cache=InternalUsageCache(DualCache())) + + +def _request(token: UserAPIKeyAuth) -> dict[str, object]: + return { + "model": "primary", + "messages": [{"role": "user", "content": "hi"}], + "metadata": {"user_api_key_auth": token}, + } + + +@pytest.mark.asyncio +async def test_fallback_target_is_admitted_until_the_keys_per_model_rpm_limit_is_spent() -> None: + limiter: Final = _limiter() + check: Final = RouterFallbackRateLimitCheck(limiter=lambda: limiter) + token: Final = UserAPIKeyAuth(api_key="hashed", token="hashed", metadata={"model_rpm_limit": {"fallback": 2}}) + ctx: Final = RoutingContext(_router()) + verdicts: Final = tuple([await check(model="fallback", request_kwargs=_request(token), ctx=ctx) for _ in range(3)]) + assert verdicts == (True, True, False) + + +@pytest.mark.asyncio +async def test_fallback_target_without_a_per_model_limit_is_always_admitted() -> None: + limiter: Final = _limiter() + check: Final = RouterFallbackRateLimitCheck(limiter=lambda: limiter) + token: Final = UserAPIKeyAuth(api_key="hashed", token="hashed", metadata={"model_rpm_limit": {"other": 1}}) + ctx: Final = RoutingContext(_router()) + verdicts: Final = tuple([await check(model="fallback", request_kwargs=_request(token), ctx=ctx) for _ in range(3)]) + assert verdicts == (True, True, True) + + +@pytest.mark.asyncio +async def test_request_without_a_key_is_unrestricted() -> None: + check: Final = RouterFallbackRateLimitCheck(limiter=_limiter) + assert await check(model="fallback", request_kwargs={"model": "primary"}, ctx=RoutingContext(_router())) is True + + +@pytest.mark.asyncio +async def test_proxy_without_the_v3_limiter_is_unrestricted() -> None: + check: Final = RouterFallbackRateLimitCheck(limiter=lambda: None) + token: Final = UserAPIKeyAuth(api_key="hashed", token="hashed", metadata={"model_rpm_limit": {"fallback": 0}}) + assert await check(model="fallback", request_kwargs=_request(token), ctx=RoutingContext(_router())) is True + + +@pytest.mark.asyncio +async def test_a_failing_limiter_fails_closed() -> None: + class BrokenLimiter(PROXY_MaxParallelRequestsHandler_v3): + async def async_fallback_target_allowed( + self, user_api_key_dict: object, model: str, request_data: object + ) -> bool: + raise RuntimeError("redis down") + + limiter: Final = BrokenLimiter(internal_usage_cache=InternalUsageCache(DualCache())) + check: Final = RouterFallbackRateLimitCheck(limiter=lambda: limiter) + token: Final = UserAPIKeyAuth(api_key="hashed", token="hashed", metadata={"model_rpm_limit": {"fallback": 5}}) + assert await check(model="fallback", request_kwargs=_request(token), ctx=RoutingContext(_router())) is False diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index 6263292fb40..9c034b77fe7 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -5,7 +5,7 @@ import time from collections.abc import AsyncIterator, Mapping from datetime import datetime, timedelta from types import MappingProxyType -from typing import Any, Final, Literal, NoReturn +from typing import Any, Final, NoReturn from unittest.mock import MagicMock, patch import httpx @@ -20,7 +20,6 @@ from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper from litellm.router_api import RouterSettings, RoutingContext from litellm.router_utils.cooldown_handlers import async_get_cooldown_deployments, mark_advisor_orchestration_failure -from litellm.types.utils import Choices, Message, ModelResponse, StandardLoggingPayload, Usage from litellm.router_utils.fallback_event_handlers import ( MID_STREAM_FALLBACK_CONTROLS_KEY, PRE_ROUTING_SELECTED_MODEL_KEY, @@ -45,6 +44,7 @@ from litellm.router_utils.fallback_event_handlers import ( run_async_fallback, ) from litellm.types.router import FallbackSkipReason, SkippedFallbackTarget +from litellm.types.utils import Choices, Message, ModelResponse, StandardLoggingPayload, Usage from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome @@ -2501,3 +2501,113 @@ def test_is_bad_request_without_fallback_only_stops_on_a_real_400_when_the_flag_ ) -> None: assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=False) is stops_fallbacks assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=True) is False + + +@pytest.mark.asyncio +async def test_run_async_fallback_skips_a_target_the_rate_limit_check_rejects() -> None: + checked: Final = [] # mutable-ok: records the targets the check saw + + async def refuse(*, model: str, request_kwargs: Mapping[str, object], ctx: object) -> bool: + checked.append(model) + return False + + router: Final = Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "api_base": "http://127.0.0.1:9"}, + }, + { + "model_name": "fallback", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "mock_response": "served"}, + }, + ], + fallbacks=[{"primary": ["fallback"]}], + num_retries=0, + fallback_rate_limit_check=refuse, + ) + with pytest.raises( + Exception, match="Fallback to fallback was skipped because the caller is over its per-model rate limit" + ): + await router.acompletion(model="primary", messages=[{"role": "user", "content": "hi"}]) + assert checked == ["fallback"] + + +@pytest.mark.asyncio +async def test_run_async_fallback_serves_a_target_the_rate_limit_check_admits() -> None: + async def admit(*, model: str, request_kwargs: Mapping[str, object], ctx: object) -> bool: + return True + + router: Final = Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "api_base": "http://127.0.0.1:9"}, + }, + { + "model_name": "fallback", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "mock_response": "served"}, + }, + ], + fallbacks=[{"primary": ["fallback"]}], + num_retries=0, + fallback_rate_limit_check=admit, + ) + response: Final = await router.acompletion(model="primary", messages=[{"role": "user", "content": "hi"}]) + assert response.choices[0].message.content == "served" + + +@pytest.mark.asyncio +async def test_run_async_fallback_reserves_a_repeated_target_once() -> None: + checked: Final = [] # mutable-ok: records the targets the check saw + + async def admit(*, model: str, request_kwargs: Mapping[str, object], ctx: object) -> bool: + checked.append(model) + return True + + router: Final = Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "api_base": "http://127.0.0.1:9"}, + }, + { + "model_name": "fallback", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "api_base": "http://127.0.0.1:9"}, + }, + ], + fallbacks=[{"primary": ["fallback", "fallback"]}], + num_retries=0, + fallback_rate_limit_check=admit, + ) + with pytest.raises(Exception, match="model group 'primary' failed"): + await router.acompletion(model="primary", messages=[{"role": "user", "content": "hi"}]) + assert checked == ["fallback"] + + +@pytest.mark.asyncio +async def test_run_async_fallback_never_re_reserves_the_requested_model_group() -> None: + checked: Final = [] # mutable-ok: records the targets the check saw + + async def admit(*, model: str, request_kwargs: Mapping[str, object], ctx: object) -> bool: + checked.append(model) + return True + + router: Final = Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "api_base": "http://127.0.0.1:9"}, + }, + { + "model_name": "fallback", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "k", "api_base": "http://127.0.0.1:9"}, + }, + ], + fallbacks=[{"primary": ["fallback"]}, {"fallback": ["primary"]}], + num_retries=0, + fallback_rate_limit_check=admit, + ) + with pytest.raises(Exception, match="model group 'primary' failed"): + await router.acompletion(model="primary", messages=[{"role": "user", "content": "hi"}]) + assert checked == ["fallback"] diff --git a/tests/unit/test_router/core/test_deprecated_members.py b/tests/unit/test_router/core/test_deprecated_members.py index 01cc6b3f6d9..7d5f634cda5 100644 --- a/tests/unit/test_router/core/test_deprecated_members.py +++ b/tests/unit/test_router/core/test_deprecated_members.py @@ -103,6 +103,7 @@ _CONFIGURED_SETTINGS: Final[dict[str, object]] = { # mutable-ok: expected value "provider_default_deployment_ids": ["w"], "fallback_access_check": None, "fallback_budget_check": None, + "fallback_rate_limit_check": None, "search_tools": [{"search_tool_name": "s", "litellm_params": {"search_provider": "tavily"}}], "guardrail_list": [{"guardrail_name": "g", "litellm_params": {"guardrail": "x"}}], "cache_responses": True, From 2605aa1c841bdd820febb3d357a8db204090c035 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:39:17 +0000 Subject: [PATCH 31/86] chore(cost-map): add 61 fal_ai image and video rows priced from the fal.ai model pages (second fal-ai sync) (#51077) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 971 ++++++++++++++++++ model_prices_and_context_window.json | 971 ++++++++++++++++++ tests/unit/test_utils.py | 1 + 3 files changed, 1943 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 6ffc20e9e71..68b77e3b785 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -97061,5 +97061,976 @@ "supported_output_modalities": [ "video" ] + }, + "fal_ai/blackforestlabs/flux-3/edit-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"the page states only the 1K (1 MP) price of $0.048 and the pricing API bills in megapixels at $0.048, so the per-pixel rate is $0.048 per megapixel and the per-image price is the default 1k output; 768sq, 1.5k, 2k and 4k outputs are priced by the per-pixel rate"}, + "mode": "image_generation", + "output_cost_per_image": 0.048, + "output_cost_per_pixel": 4.57763671875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/edit-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; the pricing API reports a $0.085 per second base price that the page does not state"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_4k": 0.47, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/klein/edit-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per output image by resolution; output_cost_per_image is the 1K rate (image_size defaults to the input size, 1K is the page example); 768 x 768 is $0.008 and 1.5K is $0.011 per image, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_image_1K": 0.009, + "output_cost_per_image_2K": 0.014, + "output_cost_per_image_4K": 0.036, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/edit-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/klein/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per output image by resolution; output_cost_per_image is the 1K rate (default image_size square_hd); 768 x 768 is $0.008 and 1.5K is $0.011 per image, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_image_1K": 0.009, + "output_cost_per_image_2K": 0.014, + "output_cost_per_image_4K": 0.036, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"the page states only the 1K (1 MP) price of $0.048 and the pricing API bills in megapixels at $0.048, so the per-pixel rate is $0.048 per megapixel and the per-image price is the default 1k output; 768sq, 1.5k, 2k and 4k outputs are priced by the per-pixel rate"}, + "mode": "image_generation", + "output_cost_per_image": 0.048, + "output_cost_per_pixel": 4.57763671875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; the pricing API reports a $0.085 per second base price that the page does not state"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_4k": 0.47, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-video-upscale": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution in the default creative mode (creativity=1); output_cost_per_second is the highest tier (4K) because the output resolution depends on the source and upscale_factor; precise mode (creativity=0) is $0.084 (1080p), $0.15 (2K) and $0.33 (4K) per second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.474, + "output_cost_per_second_1080p": 0.12, + "output_cost_per_second_2k": 0.21, + "output_cost_per_second_4k": 0.474, + "source": "https://fal.ai/models/blackforestlabs/flux-video-upscale", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/fibo-2/generate": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image at the default 1MP output; the 4MP output is $0.06 per image and has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-2/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bytedance/seedance-2.0/fast/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video at the default resolution 720p, the only per-second rate the page states; fal bills tokens at $0.0112 per 1000 tokens, counts input video duration, and multiplies by 0.6 with video inputs"}, + "mode": "video_generation", + "output_cost_per_second": 0.2419, + "output_cost_per_second_720p": 0.2419, + "source": "https://fal.ai/models/bytedance/seedance-2.0/fast/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/us/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; 1080p and 4k are not priced on the page; fal bills tokens at $0.0168 per 1000 tokens, counts input video duration, and multiplies by 0.6 with video inputs"}, + "mode": "video_generation", + "output_cost_per_second": 0.37, + "output_cost_per_second_480p": 0.1731, + "output_cost_per_second_720p": 0.37, + "source": "https://fal.ai/models/bytedance/seedance-2.0/us/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; with video references fal bills input and output duration and multiplies by 0.6"}, + "mode": "video_generation", + "output_cost_per_second": 0.473, + "output_cost_per_second_1080p": 1.164, + "output_cost_per_second_480p": 0.2205, + "output_cost_per_second_720p": 0.473, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bria/background/remove": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image without a prompt (prompt is optional); $0.03 per image when a prompt is given has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.018, + "source": "https://fal.ai/models/fal-ai/bria/background/remove", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bytedance-upscaler/upscale/video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by target_resolution at 30fps in normal mode; output_cost_per_second is the default target_resolution 1080p rate; 6k $0.0574 and 8k $0.1148 per second have no catalog field; 30-60fps doubles, over 60fps quadruples and pro mode multiplies by 10, none of which is modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.0072, + "output_cost_per_second_1080p": 0.0072, + "output_cost_per_second_2k": 0.0144, + "output_cost_per_second_4k": 0.0288, + "source": "https://fal.ai/models/fal-ai/bytedance-upscaler/upscale/video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/emu-3.5-image/edit-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default 720p resolution; 480p is $0.15 per image and has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/emu-3.5-image/edit-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/emu-3.5-image/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default 720p resolution; 480p is $0.15 per image and has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/emu-3.5-image/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/standard/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.042, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/standard/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768P rate; 512P is roughly $0.017 per second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.045, + "output_cost_per_second_768p": 0.045, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/image-01": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/minimax/image-01", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/object-removal": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default model best_quality; low_quality $0.006, medium_quality $0.012 and high_quality $0.018 per image have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/fal-ai/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/object-removal/bbox": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default model best_quality; low_quality $0.006, medium_quality $0.012 and high_quality $0.018 per image have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/fal-ai/object-removal/bbox", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/object-removal/mask": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default model best_quality; low_quality $0.006, medium_quality $0.012 and high_quality $0.018 per image have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/fal-ai/object-removal/mask", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/v3/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default realistic_image style; vector styles are $0.08 per image and have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/recraft/v3/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/scail-2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/scail-2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-apps/long-reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution, converted from the page rate per 2 seconds ($0.08 per 2s at 720p, $0.04 per 2s at 480p); output_cost_per_second is the 720p rate because the default resolution is auto; 580p ($0.06 per 2s) has no catalog field, 240p and 360p are not priced on the page; seconds are rounded up to a multiple of 2"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_720p": 0.04, + "source": "https://fal.ai/models/fal-ai/wan-vace-apps/long-reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-14b/animate/move": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per video second of output by resolution, where a video second is 16 frames; output_cost_per_second is the default resolution 480p rate; 580p is $0.06 per video second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-14b/animate/move", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-14b/animate/replace": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per video second of output by resolution, where a video second is 16 frames; output_cost_per_second is the default resolution 480p rate; 580p is $0.06 per video second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-14b/animate/replace", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/edit": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/extend": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/extend", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/image-to-video": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/reference-to-video": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/text-to-video": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/virtual-try-on": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/google/virtual-try-on", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/ideogram/v4/tiling": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per megapixel at the default rendering_speed BALANCED; TURBO is $0.03 and QUALITY is $0.10 per megapixel, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.06, + "output_cost_per_pixel": 5.7220458984375e-08, + "source": "https://fal.ai/models/ideogram/v4/tiling", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/ideogram/v4/tiling/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per megapixel at the default rendering_speed BALANCED; TURBO is $0.03375 and QUALITY is $0.1125 per megapixel, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.0675, + "output_cost_per_pixel": 6.437301635742188e-08, + "source": "https://fal.ai/models/ideogram/v4/tiling/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/kling/v4/flash/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video at 720p (the only resolution) with image references; with video references fal bills $0.063 per second on the output duration plus the greater of the input and output durations, which has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/flash/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution with image references; output_cost_per_second is the default resolution 1080p rate; with video references or video elements fal bills $0.094 (720p) or $0.141 (1080p) per second on the input plus output length, which has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.235, + "output_cost_per_second_1080p": 0.235, + "output_cost_per_second_720p": 0.157, + "source": "https://fal.ai/models/kling/v4/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per started source second by resolution; output_cost_per_second is the default resolution 540p rate ($0.06), which has no tier field"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.36, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of requested output duration by resolution; output_cost_per_second is the default resolution 768P rate; the page states no promotion for this endpoint; reference tokens above the 4,096 included per request are $0.02 per 1,000 tokens and are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/16bit-pixel": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/16bit-pixel", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/hand-drawn": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/hand-drawn", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/low-poly": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/low-poly", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/retro-toon-70s": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/retro-toon-70s", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/vhs": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/vhs", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/mirelo-ai/sfx2.0/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated audio per sample; promotional 33% off rate through 2026-12-13 per the page; the standard rate of $0.015 per second applies from 2026-12-14 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10); mode and endpoint follow fal_ai/sonilo/v1.1/video-to-video-sound-effects"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/mirelo-ai/sfx2.0/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/nvidia/cosmos-3-super/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image; the optional $0.02 per request prompt expansion surcharge is not modeled, and agentic generation bills every candidate image"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/nvidia/cosmos-3-super/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/pro/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/recraft/v4/style/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/pro/text-to-vector": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.12, + "source": "https://fal.ai/models/recraft/v4/style/pro/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/recraft/v4/style/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/text-to-vector": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/recraft/v4/style/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/vast/eden-live-14b-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of video generated in a live session; the 10 billable second ($0.10) minimum per session is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/vast/eden-live-14b-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/vast/eden-live-5b-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of video generated in a live session; the 10 billable second ($0.035) minimum per session is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.0035, + "source": "https://fal.ai/models/vast/eden-live-5b-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-image/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.022, + "source": "https://fal.ai/models/xai/grok-imagine-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/xai/grok-imagine-video/edit-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the 720p rate because the default resolution is auto; the $0.01 per second of input video charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/edit-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the 720p rate because the endpoint has no resolution field; the $0.01 per second of input video charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; the $0.002 per request image input charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 480p rate; the $0.002 per request image input charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; the $0.01 per reference image charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.25, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 480p rate; the $0.01 per reference image charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 6ffc20e9e71..68b77e3b785 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -97061,5 +97061,976 @@ "supported_output_modalities": [ "video" ] + }, + "fal_ai/blackforestlabs/flux-3/edit-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"the page states only the 1K (1 MP) price of $0.048 and the pricing API bills in megapixels at $0.048, so the per-pixel rate is $0.048 per megapixel and the per-image price is the default 1k output; 768sq, 1.5k, 2k and 4k outputs are priced by the per-pixel rate"}, + "mode": "image_generation", + "output_cost_per_image": 0.048, + "output_cost_per_pixel": 4.57763671875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/edit-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; the pricing API reports a $0.085 per second base price that the page does not state"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_4k": 0.47, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-3/klein/edit-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per output image by resolution; output_cost_per_image is the 1K rate (image_size defaults to the input size, 1K is the page example); 768 x 768 is $0.008 and 1.5K is $0.011 per image, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_image_1K": 0.009, + "output_cost_per_image_2K": 0.014, + "output_cost_per_image_4K": 0.036, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/edit-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/blackforestlabs/flux-3/klein/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per output image by resolution; output_cost_per_image is the 1K rate (default image_size square_hd); 768 x 768 is $0.008 and 1.5K is $0.011 per image, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.009, + "output_cost_per_image_1K": 0.009, + "output_cost_per_image_2K": 0.014, + "output_cost_per_image_4K": 0.036, + "source": "https://fal.ai/models/blackforestlabs/flux-3/klein/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"the page states only the 1K (1 MP) price of $0.048 and the pricing API bills in megapixels at $0.048, so the per-pixel rate is $0.048 per megapixel and the per-image price is the default 1k output; 768sq, 1.5k, 2k and 4k outputs are priced by the per-pixel rate"}, + "mode": "image_generation", + "output_cost_per_image": 0.048, + "output_cost_per_pixel": 4.57763671875e-08, + "source": "https://fal.ai/models/blackforestlabs/flux-3/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/blackforestlabs/flux-3/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; the pricing API reports a $0.085 per second base price that the page does not state"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_2k": 0.32, + "output_cost_per_second_4k": 0.47, + "output_cost_per_second_720p": 0.17, + "source": "https://fal.ai/models/blackforestlabs/flux-3/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/blackforestlabs/flux-video-upscale": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution in the default creative mode (creativity=1); output_cost_per_second is the highest tier (4K) because the output resolution depends on the source and upscale_factor; precise mode (creativity=0) is $0.084 (1080p), $0.15 (2K) and $0.33 (4K) per second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.474, + "output_cost_per_second_1080p": 0.12, + "output_cost_per_second_2k": 0.21, + "output_cost_per_second_4k": 0.474, + "source": "https://fal.ai/models/blackforestlabs/flux-video-upscale", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bria/fibo-2/generate": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image at the default 1MP output; the 4MP output is $0.06 per image and has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/bria/fibo-2/generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/bytedance/seedance-2.0/fast/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video at the default resolution 720p, the only per-second rate the page states; fal bills tokens at $0.0112 per 1000 tokens, counts input video duration, and multiplies by 0.6 with video inputs"}, + "mode": "video_generation", + "output_cost_per_second": 0.2419, + "output_cost_per_second_720p": 0.2419, + "source": "https://fal.ai/models/bytedance/seedance-2.0/fast/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.0/us/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; 1080p and 4k are not priced on the page; fal bills tokens at $0.0168 per 1000 tokens, counts input video duration, and multiplies by 0.6 with video inputs"}, + "mode": "video_generation", + "output_cost_per_second": 0.37, + "output_cost_per_second_480p": 0.1731, + "output_cost_per_second_720p": 0.37, + "source": "https://fal.ai/models/bytedance/seedance-2.0/us/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/bytedance/seedance-2.5/us/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 720p rate; with video references fal bills input and output duration and multiplies by 0.6"}, + "mode": "video_generation", + "output_cost_per_second": 0.473, + "output_cost_per_second_1080p": 1.164, + "output_cost_per_second_480p": 0.2205, + "output_cost_per_second_720p": 0.473, + "source": "https://fal.ai/models/bytedance/seedance-2.5/us/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/bria/background/remove": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image without a prompt (prompt is optional); $0.03 per image when a prompt is given has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.018, + "source": "https://fal.ai/models/fal-ai/bria/background/remove", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/bytedance-upscaler/upscale/video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by target_resolution at 30fps in normal mode; output_cost_per_second is the default target_resolution 1080p rate; 6k $0.0574 and 8k $0.1148 per second have no catalog field; 30-60fps doubles, over 60fps quadruples and pro mode multiplies by 10, none of which is modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.0072, + "output_cost_per_second_1080p": 0.0072, + "output_cost_per_second_2k": 0.0144, + "output_cost_per_second_4k": 0.0288, + "source": "https://fal.ai/models/fal-ai/bytedance-upscaler/upscale/video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/emu-3.5-image/edit-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default 720p resolution; 480p is $0.15 per image and has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/emu-3.5-image/edit-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/emu-3.5-image/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default 720p resolution; 480p is $0.15 per image and has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://fal.ai/models/fal-ai/emu-3.5-image/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/standard/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.042, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-02/standard/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution; output_cost_per_second is the default resolution 768P rate; 512P is roughly $0.017 per second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.045, + "output_cost_per_second_768p": 0.045, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-02/standard/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/image-01": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://fal.ai/models/fal-ai/minimax/image-01", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/object-removal": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default model best_quality; low_quality $0.006, medium_quality $0.012 and high_quality $0.018 per image have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/fal-ai/object-removal", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/object-removal/bbox": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default model best_quality; low_quality $0.006, medium_quality $0.012 and high_quality $0.018 per image have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/fal-ai/object-removal/bbox", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/object-removal/mask": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default model best_quality; low_quality $0.006, medium_quality $0.012 and high_quality $0.018 per image have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.024, + "source": "https://fal.ai/models/fal-ai/object-removal/mask", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/recraft/v3/image-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default realistic_image style; vector styles are $0.08 per image and have no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/fal-ai/recraft/v3/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/scail-2": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.2, + "source": "https://fal.ai/models/fal-ai/scail-2", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-vace-apps/long-reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution, converted from the page rate per 2 seconds ($0.08 per 2s at 720p, $0.04 per 2s at 480p); output_cost_per_second is the 720p rate because the default resolution is auto; 580p ($0.06 per 2s) has no catalog field, 240p and 360p are not priced on the page; seconds are rounded up to a multiple of 2"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_720p": 0.04, + "source": "https://fal.ai/models/fal-ai/wan-vace-apps/long-reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-14b/animate/move": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per video second of output by resolution, where a video second is 16 frames; output_cost_per_second is the default resolution 480p rate; 580p is $0.06 per video second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-14b/animate/move", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-14b/animate/replace": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per video second of output by resolution, where a video second is 16 frames; output_cost_per_second is the default resolution 480p rate; 580p is $0.06 per video second and has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_480p": 0.04, + "output_cost_per_second_720p": 0.08, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-14b/animate/replace", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/edit": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/edit", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/extend": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/extend", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/image-to-video": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/reference-to-video": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/gemini-omni-flash/v1.2/text-to-video": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; 360p is $0.0273 per second and has no catalog field; input tokens (text, image, video) are billed at $1.50 per million"}, + "mode": "video_generation", + "output_cost_per_second": 0.0819, + "output_cost_per_second_1080p": 0.1229, + "output_cost_per_second_4k": 0.2457, + "output_cost_per_second_720p": 0.0819, + "source": "https://fal.ai/models/google/gemini-omni-flash/v1.2/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/google/virtual-try-on": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://fal.ai/models/google/virtual-try-on", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/ideogram/v4/tiling": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per megapixel at the default rendering_speed BALANCED; TURBO is $0.03 and QUALITY is $0.10 per megapixel, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.06, + "output_cost_per_pixel": 5.7220458984375e-08, + "source": "https://fal.ai/models/ideogram/v4/tiling", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/ideogram/v4/tiling/lora": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per megapixel at the default rendering_speed BALANCED; TURBO is $0.03375 and QUALITY is $0.1125 per megapixel, no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.0675, + "output_cost_per_pixel": 6.437301635742188e-08, + "source": "https://fal.ai/models/ideogram/v4/tiling/lora", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/kling/v4/flash/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video at 720p (the only resolution) with image references; with video references fal bills $0.063 per second on the output duration plus the greater of the input and output durations, which has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "output_cost_per_second_720p": 0.094, + "source": "https://fal.ai/models/kling/v4/flash/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/kling/v4/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution with image references; output_cost_per_second is the default resolution 1080p rate; with video references or video elements fal bills $0.094 (720p) or $0.141 (1080p) per second on the input plus output length, which has no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.235, + "output_cost_per_second_1080p": 0.235, + "output_cost_per_second_720p": 0.157, + "source": "https://fal.ai/models/kling/v4/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/reframe": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per started source second by resolution; output_cost_per_second is the default resolution 540p rate ($0.06), which has no tier field"}, + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.36, + "output_cost_per_second_720p": 0.12, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/reframe", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of requested output duration by resolution; output_cost_per_second is the default resolution 768P rate; the page states no promotion for this endpoint; reference tokens above the 4,096 included per request are $0.02 per 1,000 tokens and are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.16, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/16bit-pixel": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/16bit-pixel", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/hand-drawn": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/hand-drawn", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/low-poly": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/low-poly", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/retro-toon-70s": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/retro-toon-70s", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/minimax/h3-max/styles/vhs": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_768p": 0.08, + "source": "https://fal.ai/models/minimax/h3-max/styles/vhs", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/mirelo-ai/sfx2.0/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated audio per sample; promotional 33% off rate through 2026-12-13 per the page; the standard rate of $0.015 per second applies from 2026-12-14 (ruling fal-ai-promotional-rates-apply-until-promotion-ends, 2026-11-10); mode and endpoint follow fal_ai/sonilo/v1.1/video-to-video-sound-effects"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/mirelo-ai/sfx2.0/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/nvidia/cosmos-3-super/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image; the optional $0.02 per request prompt expansion surcharge is not modeled, and agentic generation bills every candidate image"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://fal.ai/models/nvidia/cosmos-3-super/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/pro/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://fal.ai/models/recraft/v4/style/pro/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/pro/text-to-vector": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.12, + "source": "https://fal.ai/models/recraft/v4/style/pro/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/text-to-image": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://fal.ai/models/recraft/v4/style/text-to-image", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/recraft/v4/style/text-to-vector": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per generated image when a style_id is provided; the one-time $0.005 style creation surcharge when no style_id is given is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://fal.ai/models/recraft/v4/style/text-to-vector", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/vast/eden-live-14b-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of video generated in a live session; the 10 billable second ($0.10) minimum per session is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.01, + "source": "https://fal.ai/models/vast/eden-live-14b-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/vast/eden-live-5b-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of video generated in a live session; the 10 billable second ($0.035) minimum per session is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.0035, + "source": "https://fal.ai/models/vast/eden-live-5b-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-image/edit": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.022, + "source": "https://fal.ai/models/xai/grok-imagine-image/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/xai/grok-imagine-video/edit-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the 720p rate because the default resolution is auto; the $0.01 per second of input video charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/edit-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/extend-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the 720p rate because the endpoint has no resolution field; the $0.01 per second of input video charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/extend-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; the $0.002 per request image input charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 480p rate; the $0.002 per request image input charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://fal.ai/models/xai/grok-imagine-video/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 720p rate; the $0.01 per reference image charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.25, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/xai/grok-imagine-video/v1.5/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video by resolution; output_cost_per_second is the default resolution 480p rate; the $0.01 per reference image charge is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.14, + "source": "https://fal.ai/models/xai/grok-imagine-video/v1.5/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] } } diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 8ff7f8bd251..8d8c123ef49 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1455,6 +1455,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "fal_ai/bytedance/seedance-2.0/reference-to-video", "fal_ai/bytedance/seedance-2.5/us/draft/complete", # 1080p is $1.164/second per fal.ai "fal_ai/bytedance/seedance-2.5/us/image-to-video", # 1080p is $1.164/second per fal.ai + "fal_ai/bytedance/seedance-2.5/us/reference-to-video", # 1080p is $1.164/second per fal.ai "fal_ai/bytedance/seedance-2.5/us/text-to-video", # 1080p is $1.164/second per fal.ai ] From 076ded32be1b000070f07c11b5cbdb4463d6b96f Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Tue, 10 Nov 2026 17:07:11 -0800 Subject: [PATCH 32/86] feat(auto_router): report a session's whole spend and name why savings stay unestimated (#51066) * feat(auto_router): report a session's whole spend on /auto_router/session Claude Code sends its background calls straight to Haiku under the same session id, so those requests never reached the auto-router session rollup and the status line under-reported the session. Every billed spend row in a caller-supplied session now increments a key-scoped Redis counter whose one-hour expiry each write resets, and the endpoint reports it as total_spend, with spend unchanged. The increment runs where the spend row is accepted, so it counts each row exactly as often as the key's spend counters do. It reads as no total without a shared Redis * fix(auto_router): name the unsupported provider when a turn routes off Anthropic A turn routed to a non-Anthropic tier with no native wire response, such as a Cloudflare DeepSeek model, failed strict native validation and was logged as observation_unavailable, which reads like a broken capture. It now reports unsupported_selected_provider. Turns with a native wire response keep their existing reasons, and no estimate is added, so savings for these turns stay unknown * docs(auto_router): explain when session savings stay unestimated saved_spend on /auto_router/session now says a turn inside the longest prompt-cache TTL of the session's first observed turn, or of its last incomplete observation, is not estimated, so a null there is expected for that window * fix(auto_router): say why a system-only cache prefix has no savings estimate Claude Code puts its cache breakpoint on the system prompt, so the baseline prefix count sends Anthropic a body with no messages, which it rejects, and every turn read as token_count_unavailable. A count that fails only on system or tools prefixes now reports uncountable_system_or_tools_prefix. Gateways that accept the empty body still count it. A live check against claude-opus-5-5 showed a differential count with a placeholder message is off by -2 tokens for system-only and +68 for tools, so these turns stay unestimated and the savings gap for Claude Code is not fixed here * fix(auto_router): declare the session total's Redis key family and test its expiry The session total read and wrote Redis without a service target, so its calls showed up as bare redis spans. Both now run under session_spend_totals. The key no longer hashes the api key, which is already a hashed token, and only digests the session id. A new test drives Redis expiry on a test clock to show an active session keeps its total and an idle one restarts * docs(auto_router): note which turns are still estimated inside the history window Turns that took the identical baseline path, and turns whose whole cached prefix the session had already written, are estimated even inside the first cache TTL window, so saved_spend now says so --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- litellm/constants.py | 1 + .../llms/anthropic/prompt_cache_prediction.py | 6 +- litellm/proxy/db/db_spend_update_writer.py | 17 +++ .../proxy/hooks/autorouter_baseline_cache.py | 12 ++ .../auto_router_endpoints.py | 8 +- .../spend_tracking/session_spend_total.py | 59 ++++++++ .../spend_tracking/spend_tracking_utils.py | 25 +++- litellm/proxy/utils.py | 4 +- .../auto_router_endpoints.py | 14 +- .../test_anthropic_prompt_cache_prediction.py | 18 ++- .../proxy/db/test_db_spend_update_writer.py | 126 +++++++++++++++++- .../hooks/test_autorouter_baseline_cache.py | 52 ++++++++ .../test_auto_router_endpoints.py | 37 +++++ .../test_session_spend_total.py | 90 +++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 7 +- 15 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 litellm/proxy/spend_tracking/session_spend_total.py create mode 100644 tests/unit/proxy/spend_tracking/test_session_spend_total.py diff --git a/litellm/constants.py b/litellm/constants.py index 25eb5d9b854..931ac4f2b81 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1713,6 +1713,7 @@ RESPONSES_WEBSOCKET_CALL_ID_METADATA_KEY: Final = "responses_websocket_call_id" BILLED_RETRY_ATTEMPT_METADATA_KEY: Final = "billed_retry_attempt" MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: Final = "_managed_background_response" SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated" +SESSION_SPEND_TOTAL_TTL_SECONDS: Final = 3600 SESSION_ID_OMITTED_METADATA_KEY: Final = "litellm_session_id_omitted" CALLER_SESSION_ID_METADATA_KEY: Final = "litellm_caller_session_id" COMPLEXITY_ROUTER_CHILD_SESSION_ID_METADATA_KEY: Final = "_complexity_router_child_session_id" diff --git a/litellm/llms/anthropic/prompt_cache_prediction.py b/litellm/llms/anthropic/prompt_cache_prediction.py index 28896c99eaf..6429b3b402c 100644 --- a/litellm/llms/anthropic/prompt_cache_prediction.py +++ b/litellm/llms/anthropic/prompt_cache_prediction.py @@ -208,6 +208,7 @@ class UnsupportedCachePlan: "invalid_cache_ttl_order", "unsupported_thinking_cache_semantics", "token_count_unavailable", + "uncountable_system_or_tools_prefix", "inconsistent_prefix_token_count", ] @@ -583,7 +584,10 @@ async def count_cache_plan( counts: Final = tuple( await asyncio.gather(*(token_counter(model, api_key, marker.prefix_body) for marker in plan.breakpoints)) ) - if any(value is None for value in counts): + failed: Final = tuple(marker for marker, value in zip(plan.breakpoints, counts, strict=True) if value is None) + if failed and all(not marker.prefix_body.get("messages") for marker in failed): + return UnsupportedCachePlan("uncountable_system_or_tools_prefix") + if failed: return UnsupportedCachePlan("token_count_unavailable") known: Final = tuple(value for value in counts if value is not None) if any(value < 0 for value in (total, *known)) or any( diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index fc280dd4f73..fd47b2fbb6b 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -92,6 +92,7 @@ from litellm.proxy.spend_tracking.savings import ( extract_cache_read_tokens, marks_gateway_injection, ) +from litellm.proxy.spend_tracking.session_spend_total import SessionSpendTotals from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error from litellm.repositories.prisma_protocols import BatchTable from litellm.responses.streaming_iterator import spawn_logging_task @@ -685,8 +686,10 @@ class DBSpendUpdateWriter: self, redis_cache: RedisCache | None = None, accounting_clock: Callable[[], float] = time.monotonic, + session_spend_totals: SessionSpendTotals | None = None, ): self.redis_cache = redis_cache + self.session_spend_totals: Final = session_spend_totals self.accounting_storage_checks = Cadence(accounting_clock) self.accounting_delay_alerts = Cadence(accounting_clock) self.redis_update_buffer = RedisUpdateBuffer(redis_cache=self.redis_cache) @@ -819,6 +822,7 @@ class DBSpendUpdateWriter: prisma_client=prisma_client, spend_logs_kept=keep_spend_logs, ) + await self._record_session_spend(hashed_token, billable_cost or 0.0, request_fields) if keep_spend_logs: await self._enqueue_tool_usage_transaction( payload=payload, @@ -874,6 +878,19 @@ class DBSpendUpdateWriter: ) return True + async def _record_session_spend( + self, api_key: str | None, spend: float, request_fields: Mapping[str, object] + ) -> None: + from litellm.proxy.spend_tracking.spend_tracking_utils import caller_session_for_accounting + + totals: Final = self.session_spend_totals + if totals is None or not api_key or spend <= 0: + return + session_id: Final = caller_session_for_accounting(request_fields) + if session_id is None: + return + await totals.record(api_key=api_key, session_id=session_id, spend=spend) + async def _record_spend_log( self, payload: SpendLogsPayload, diff --git a/litellm/proxy/hooks/autorouter_baseline_cache.py b/litellm/proxy/hooks/autorouter_baseline_cache.py index b7dbebbe8d8..8e445589942 100644 --- a/litellm/proxy/hooks/autorouter_baseline_cache.py +++ b/litellm/proxy/hooks/autorouter_baseline_cache.py @@ -312,6 +312,18 @@ async def _capture_native( ) -> CapturedBaselineObservation: capture: Final = context.capture original: Final = capture.observation + details: Final = logging_obj.model_call_details + if details.get("custom_llm_provider") != "anthropic" and not isinstance( + details.get("httpx_response"), httpx.Response + ): + return capture.with_observation( + original.model_copy( + update={ + "available_at": max(original.started_at, context.collector.clock()), + "reason": context.invalidated or "unsupported_selected_provider", + } + ) + ) event: Final = _WireEvent.model_validate(logging_obj.model_call_details) wire: Final = event.httpx_response.request usage: Final = _ResponseUsage.model_validate(response_obj).usage diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py index 1e43a8375a6..ca10487a8a3 100644 --- a/litellm/proxy/management_endpoints/auto_router_endpoints.py +++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py @@ -1052,7 +1052,7 @@ async def get_auto_router_session( fills a moment after each turn; a session with no flushed auto-routed turn yet is a 404. The id is bounded the way the writer bounded it, so an oversized client id still finds its row. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) @@ -1063,6 +1063,11 @@ async def get_auto_router_session( raise HTTPException( status_code=404, detail=f"No auto-routed turns recorded for session {session_id!r} under this key" ) + total: Final = ( + await proxy_logging_obj.session_spend_totals.read(user_api_key_dict.api_key, session_id) + if user_api_key_dict.api_key + else None + ) saved_spend, baseline_spend = _savings_cohort(row.turns, row.savings_estimated_turns, row.spend, row.saved_spend) _, estimated_baseline_spend = _savings_cohort( row.turns, row.savings_estimated_turns, row.savings_estimated_actual_spend, row.savings_estimated_saved_spend @@ -1074,6 +1079,7 @@ async def get_auto_router_session( turns=row.turns, last_model=row.last_model, spend=row.spend, + total_spend=total, savings_estimated_turns=row.savings_estimated_turns, savings_estimated_actual_spend=row.savings_estimated_actual_spend, saved_spend=saved_spend, diff --git a/litellm/proxy/spend_tracking/session_spend_total.py b/litellm/proxy/spend_tracking/session_spend_total.py new file mode 100644 index 00000000000..af13a8940a7 --- /dev/null +++ b/litellm/proxy/spend_tracking/session_spend_total.py @@ -0,0 +1,59 @@ +import hashlib +import logging +from collections.abc import Callable +from typing import Final, Protocol + +from pydantic import TypeAdapter + +from litellm._internal_context import with_service_target +from litellm._logging import verbose_proxy_logger +from litellm.caching.redis_cache import log_redis_failure +from litellm.constants import SESSION_SPEND_TOTAL_TTL_SECONDS + +_STORED_SPEND: Final[TypeAdapter[float | str | bytes | None]] = TypeAdapter(float | str | bytes | None) + + +class SharedCounterStore(Protocol): + async def async_increment( + self, key: str, value: float, *, ttl: int | None = None, refresh_ttl: bool = False + ) -> float: ... + + async def async_get_cache(self, key: str) -> object: ... + + +def _key(api_key: str, session_id: str) -> str: + session_digest: Final = hashlib.sha256(session_id.encode("utf-8", errors="surrogatepass")).hexdigest() + return f"session_spend_total:{api_key}:{session_digest}" + + +class SessionSpendTotals: + """Spend billed to one key's caller-supplied session, kept in the shared Redis on a TTL that every write slides.""" + + def __init__(self, shared_store: Callable[[], SharedCounterStore | None]) -> None: + self.shared_store: Final = shared_store + + @with_service_target("session_spend_totals") + async def record(self, api_key: str, session_id: str, spend: float) -> None: + store: Final = self.shared_store() + if store is None: + return + try: + await store.async_increment( + _key(api_key, session_id), spend, ttl=SESSION_SPEND_TOTAL_TTL_SECONDS, refresh_ttl=True + ) + except Exception as record_error: # noqa: BLE001 # a lost session total must never fail the spend write + log_redis_failure(verbose_proxy_logger, logging.WARNING, "Session spend total write failed", record_error) + + @with_service_target("session_spend_totals") + async def read(self, api_key: str, session_id: str) -> float | None: + store: Final = self.shared_store() + if store is None: + return None + try: + stored: Final = _STORED_SPEND.validate_python(await store.async_get_cache(_key(api_key, session_id))) + except Exception as read_error: # noqa: BLE001 # an unreadable total is reported as no total + log_redis_failure(verbose_proxy_logger, logging.WARNING, "Session spend total read failed", read_error) + return None + if stored is None: + return None + return float(stored.decode() if isinstance(stored, bytes) else stored) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 15b2d18eeae..f431763c049 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -37,7 +37,7 @@ from litellm.constants import ( MAX_STRING_LENGTH_PROMPT_IN_DB as DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB, ) from litellm.integrations.otel.model.metadata import caller_session_id -from litellm.integrations.otel.model.trace_controls import TraceControls +from litellm.integrations.otel.model.trace_controls import TraceControls, caller_trace_controls from litellm.litellm_core_utils.batch_utils import ( BATCH_COST_REQUEST_ID_SUFFIX, charged_poll_cost, @@ -1050,12 +1050,31 @@ def _get_batch_trace_session_id(call_type: str | None, request_id: str | None) - return request_id.removesuffix(BATCH_COST_REQUEST_ID_SUFFIX) -def _caller_session_for_spend_log(kwargs: Mapping[str, object], metadata: Mapping[str, object] | None) -> str | None: +def caller_session_for_accounting(kwargs: Mapping[str, object]) -> str | None: + params: Final = as_str_mapping(kwargs.get("litellm_params")) or MappingProxyType({}) + preserved: Final = next( + ( + session + for key in ("litellm_metadata", "metadata") + if isinstance(session := (as_str_mapping(params.get(key)) or {}).get(CALLER_SESSION_ID_METADATA_KEY), str) + ), + None, + ) + return _caller_session_for_spend_log( + kwargs, + MappingProxyType({CALLER_SESSION_ID_METADATA_KEY: preserved}) if preserved else None, + trace=caller_trace_controls(kwargs), + ) + + +def _caller_session_for_spend_log( + kwargs: Mapping[str, object], metadata: Mapping[str, object] | None, trace: TraceControls | None = None +) -> str | None: beside_trace_header: Final = metadata.get(CALLER_SESSION_ID_METADATA_KEY) if metadata else None caller_session: Final = ( beside_trace_header if isinstance(beside_trace_header, str) and beside_trace_header - else caller_session_id(kwargs, TraceControls()) + else caller_session_id(kwargs, trace or TraceControls()) ) return caller_session if caller_session and "\x00" not in caller_session else None diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index f78e68220b1..4468a724d2e 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -285,6 +285,7 @@ from litellm.proxy.policy_engine.pipeline_executor import ( ) from litellm.proxy.policy_engine.policy_registry import get_policy_registry from litellm.proxy.policy_engine.policy_resolver import PolicyResolver +from litellm.proxy.spend_tracking.session_spend_total import SessionSpendTotals from litellm.repositories.budget_repository import BudgetRepository from litellm.repositories.config_repository import ConfigRepository from litellm.repositories.spend_log_insert import RejectedSpendLogRow @@ -1983,7 +1984,8 @@ class ProxyLogging: ) self.premium_user = premium_user self.service_logging_obj = ServiceLogging() - self.db_spend_update_writer = DBSpendUpdateWriter() + self.session_spend_totals: Final = SessionSpendTotals(lambda: self.internal_usage_cache.dual_cache.redis_cache) + self.db_spend_update_writer = DBSpendUpdateWriter(session_spend_totals=self.session_spend_totals) self.proxy_hook_mapping: dict[str, CustomLogger] = {} # Guard flags to prevent duplicate background tasks diff --git a/litellm/types/management_endpoints/auto_router_endpoints.py b/litellm/types/management_endpoints/auto_router_endpoints.py index dfcac3b6bde..e244aba3e3c 100644 --- a/litellm/types/management_endpoints/auto_router_endpoints.py +++ b/litellm/types/management_endpoints/auto_router_endpoints.py @@ -176,12 +176,24 @@ class AutoRouterSessionResponse(LiteLLMBaseModel): turns: int = Field(description="Auto-routed turns the rollup has recorded for this session so far") last_model: str = Field(description="The deployment model the most recent turn was routed to") spend: float = Field(description="What the session's routed traffic actually cost, classifier calls included") + total_spend: float | None = Field( + default=None, + description="Spend billed to this key under the session id, routed or not, from a shared Redis counter " + "whose one-hour expiry every billed request resets. None without a shared Redis or once the counter " + "expired. A best-effort figure, not a lifetime total: a session idle past the expiry restarts from zero, " + "and a failed Redis write, eviction or flush drops spend without notice", + ) savings_estimated_turns: int = Field(description="Requests whose savings estimate recorded its baseline cost") savings_estimated_actual_spend: float = Field( description="Actual spend, including classifier cost, for requests whose estimate recorded its baseline cost" ) saved_spend: float | None = Field( - description="Recorded historical savings plus newer estimates, net of classifier cost" + description="Recorded historical savings plus newer estimates, net of classifier cost. None until a turn is " + "estimated. A turn that starts within the longest prompt-cache TTL its breakpoints use (5 minutes, or 1 hour " + "with a 1h breakpoint) of the session's first observed turn, or of the last turn whose observation was " + "incomplete, is not estimated, because the baseline's cache state is unknown then. Two kinds of turn are " + "still estimated inside that window: turns that took the identical baseline path, and turns whose whole " + "cached prefix the session had already written" ) baseline_spend: float | None = Field(description="Estimated single-model cost: spend plus recorded savings") savings_estimated_baseline_spend: float | None = Field( diff --git a/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py b/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py index 89ea412af0c..6a946c2e75c 100644 --- a/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py +++ b/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py @@ -515,10 +515,15 @@ async def test_cache_plan_count_conserves_total_and_rejects_unknown( @pytest.mark.asyncio @pytest.mark.parametrize("section", ["system", "tools"]) -@pytest.mark.parametrize("rejects_prefix", (False, True)) +@pytest.mark.parametrize( + ("rejected", "reason"), + [(None, None), (1, "uncountable_system_or_tools_prefix"), (2, "token_count_unavailable")], + ids=["counted", "system-or-tools-prefix-rejected", "message-prefix-rejected"], +) async def test_native_count_preserves_settings_and_requires_every_prefix( section: str, - rejects_prefix: bool, + rejected: int | None, + reason: str | None, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) @@ -572,9 +577,9 @@ async def test_native_count_preserves_settings_and_requires_every_prefix( endpoint: Final = "https://gateway.example/v1/messages/count_tokens" routes: Final = tuple( upstream.post(endpoint, json={**body, "model": _MODEL}).respond( - 400 if rejects_prefix and index == 1 else 200, + 400 if index == rejected else 200, json={"detail": {"error": "messages parameter is required"}} - if rejects_prefix and index == 1 + if index == rejected else {"input_tokens": tokens}, ) for index, (body, tokens) in enumerate( @@ -588,8 +593,9 @@ async def test_native_count_preserves_settings_and_requires_every_prefix( unexpected: Final = upstream.post(endpoint).respond(200, json={"input_tokens": 1}) result: Final = await count_cache_plan(target.model, target.api_key, plan, count) - if rejects_prefix: - assert result == UnsupportedCachePlan("token_count_unavailable") + if reason is not None: + assert isinstance(result, UnsupportedCachePlan) + assert result.reason == reason else: assert isinstance(result, CountedPromptCachePlan) assert result.total_tokens == 6000 diff --git a/tests/unit/proxy/db/test_db_spend_update_writer.py b/tests/unit/proxy/db/test_db_spend_update_writer.py index e2debe05c03..8bf7cd36a8c 100644 --- a/tests/unit/proxy/db/test_db_spend_update_writer.py +++ b/tests/unit/proxy/db/test_db_spend_update_writer.py @@ -5,7 +5,7 @@ import itertools import json import logging import re -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping from contextlib import AbstractAsyncContextManager, asynccontextmanager, contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path @@ -39,6 +39,7 @@ from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import ( build_window_spend_transaction, ) from litellm.proxy.db.rollup_lock_timeout import ROLLUP_LOCK_TIMEOUT_SQL +from litellm.proxy.spend_tracking.session_spend_total import SessionSpendTotals from litellm.responses.streaming_iterator import drain_logging_tasks from litellm.types.caching import RedisPipelineLpopOperation, RedisPipelineRpushOperation from tests.unit.proxy.db.fake_prisma_engine import engine_call @@ -5887,3 +5888,126 @@ async def test_request_error_counts_survive_the_ledger_path_switch(monkeypatch: assert accumulator.drain() == {RequestErrorKey( date="2026-11-02", api_key=prisma.spend_log_transactions[0]["api_key"], team_id="", user_id="user", model_group="ledger-test", status_code=503, ): 1} + + +class _SessionTotalsRedis: + def __init__(self) -> None: + self.values: dict[str, float] = {} # mutable-ok: stands in for Redis state + + async def async_increment(self, key: str, value: float, ttl: int | None = None, refresh_ttl: bool = False) -> float: + self.values[key] = self.values.get(key, 0.0) + value + return self.values[key] + + async def async_get_cache(self, key: str) -> object: + return self.values.get(key) + + +def _session_writer() -> tuple[DBSpendUpdateWriter, SessionSpendTotals]: + redis: Final = _SessionTotalsRedis() + totals: Final = SessionSpendTotals(lambda: redis) + writer: Final = DBSpendUpdateWriter(session_spend_totals=totals) + writer._batch_database_updates = AsyncMock() # pyright: ignore[reportPrivateUsage] # the batch rollup needs a database this test does not stand up + return writer, totals + + +async def _record_spend_row(writer: DBSpendUpdateWriter, spend: float, litellm_params: Mapping[str, object]) -> None: + payload: Final[dict[str, object]] = { + **_minimal_spend_payload(), + "request_id": "req-1", + "call_type": "anthropic_messages", + "api_key": "hashed-key", + "spend": spend, + "status": "success", + } + with ( + patch( # test-quality-ok: update_database reads this proxy_server global at call time, no seam + "litellm.proxy.proxy_server.disable_spend_logs", False + ), + patch( # test-quality-ok: update_database reads this proxy_server global at call time, no seam + "litellm.proxy.proxy_server.prisma_client", _tool_usage_prisma() + ), + patch( # test-quality-ok: update_database reads this proxy_server global at call time, no seam + "litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget" + ), + patch( # test-quality-ok: update_database imports the payload builder inside its body, no seam + "litellm.proxy.spend_tracking.spend_tracking_utils.get_logging_payload", + return_value=payload, + ), + ): + await writer.update_database( + token="hashed-key", + user_id="test-user", + end_user_id=None, + team_id=None, + org_id=None, + kwargs={"model": "claude-haiku-5-5", "litellm_params": dict(litellm_params)}, + completion_response=None, + start_time=datetime(2026, 11, 10, 10, 0, tzinfo=timezone.utc), + end_time=datetime(2026, 11, 10, 10, 0, 1, tzinfo=timezone.utc), + response_cost=spend, + ) + + +_CALLER_SESSION: Final[Mapping[str, object]] = { + "litellm_session_id": "cc-session-1", + "metadata": {"session_id": "cc-session-1"}, +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "litellm_params", + [ + pytest.param(_CALLER_SESSION, id="direct-call"), + pytest.param( + { + "litellm_session_id": "cc-session-1", + "metadata": {"session_id": "cc-session-1", "trace_id": "cc-session-1"}, + }, + id="claude-code-session-header", + ), + pytest.param( + { + **_CALLER_SESSION, + "metadata": {"session_id": "cc-session-1", "internal_call_origin": "autorouter_classifier"}, + }, + id="classifier-sub-call", + ), + ], +) +async def test_a_billed_row_in_a_caller_session_adds_its_spend_to_that_sessions_total( + litellm_params: Mapping[str, object], +): + writer, totals = _session_writer() + + await _record_spend_row(writer, 0.25, litellm_params) + + assert await totals.read("hashed-key", "cc-session-1") == 0.25 + assert await totals.read("another-key", "cc-session-1") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("litellm_params", "spend"), + [ + pytest.param( + { + "litellm_session_id": "minted-1", + "metadata": {"session_id": "minted-1", "litellm_session_id_generated": True}, + }, + 0.25, + id="generated-session", + ), + pytest.param({"metadata": {}}, 0.25, id="no-session"), + pytest.param(_CALLER_SESSION, 0.0, id="free-row"), + ], +) +async def test_a_row_without_a_caller_session_or_cost_adds_nothing( + litellm_params: Mapping[str, object], spend: float +): + writer, totals = _session_writer() + + await _record_spend_row(writer, spend, litellm_params) + + assert await totals.read("hashed-key", "cc-session-1") is None + assert await totals.read("hashed-key", "minted-1") is None diff --git a/tests/unit/proxy/hooks/test_autorouter_baseline_cache.py b/tests/unit/proxy/hooks/test_autorouter_baseline_cache.py index 1fe2cb2ca68..12aa850afb6 100644 --- a/tests/unit/proxy/hooks/test_autorouter_baseline_cache.py +++ b/tests/unit/proxy/hooks/test_autorouter_baseline_cache.py @@ -1098,3 +1098,55 @@ async def test_tier_pins_never_enter_the_caller_snapshot_on_later_routing_passes assert context is not None and context.baseline_body is not None assert context.baseline_body.get("max_tokens") == 16 assert "output_config" not in context.baseline_body and "thinking" not in context.baseline_body + + +_NON_NATIVE_MODELS: Final = _MESSAGES.validate_json("""[ + {"model_name":"test-router","litellm_params":{"model":"auto_router/complexity_router", + "complexity_router_config":{"tiers":{"SIMPLE":"flash","MEDIUM":"flash","COMPLEX":"flash", + "REASONING":"opus"},"session_affinity":false}}}, + {"model_name":"flash","litellm_params":{"model":"cloudflare/@cf/deepseek-ai/deepseek-v4.1-flash","api_key":"test-selected", + "api_base":"https://chat.example.test/v1"},"model_info":{"id":"selected"}}, + {"model_name":"opus","litellm_params":{"model":"anthropic/claude-opus-5","api_key":"test-selected"}, + "model_info":{"id":"baseline"}}]""") + + +def _chat_upstream(request: httpx.Request) -> httpx.Response: + stream: Final = _JSON_OBJECT.validate_json(request.content).get("stream") is True + chunk: Final = { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 1, + "model": "deepseek-v4.1-flash", + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 12, "completion_tokens": 2, "total_tokens": 14}, + } + completion: Final = { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "deepseek-v4.1-flash", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 12, "completion_tokens": 2, "total_tokens": 14}, + } + content: Final = ( + f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode() if stream else json.dumps(completion).encode() + ) + return httpx.Response( + 200, + content=content, + request=request, + headers=MappingProxyType({"content-type": "text/event-stream" if stream else "application/json"}), + ) + + +@pytest.mark.parametrize("stream", (False, True)) +async def test_a_turn_routed_to_a_non_anthropic_tier_says_its_provider_is_unsupported( + monkeypatch: pytest.MonkeyPatch, stream: bool +) -> None: + rig: Final = _Rig(monkeypatch, models=_NON_NATIVE_MODELS) + with respx.mock() as transport: + transport.route(host="chat.example.test").mock(side_effect=_chat_upstream) + await _call(rig.router, rig.logging(stream)) + captured: Final = _observation(await rig.capture.payload()) + assert captured.observation.outcome == "uncertain" + assert captured.observation.reason == "unsupported_selected_provider" diff --git a/tests/unit/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/unit/proxy/management_endpoints/test_auto_router_endpoints.py index 59e12a97899..ba4b3b8cf4e 100644 --- a/tests/unit/proxy/management_endpoints/test_auto_router_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_auto_router_endpoints.py @@ -1339,6 +1339,7 @@ class TestAutoRouterSession: "turns": turns, "last_model": "anthropic/claude-sonnet-5", "spend": spend, + "total_spend": None, "saved_spend": 0.24, "savings_estimated_turns": 3 if estimated else 0, "savings_estimated_actual_spend": 0.14 if estimated else 0.0, @@ -1350,6 +1351,42 @@ class TestAutoRouterSession: "baseline_models": {"anthropic/claude-opus-5": 3}, } + @pytest.mark.asyncio + @pytest.mark.parametrize(("recorded", "expected"), [((0.5, 0.25), 0.75), ((), None)], ids=["counted", "none"]) + async def test_the_session_reports_the_spend_billed_to_its_key_under_the_session( + self, monkeypatch: pytest.MonkeyPatch, recorded: tuple[float, ...], expected: float | None + ) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.management_endpoints.auto_router_endpoints import get_auto_router_session + from litellm.proxy.spend_tracking.session_spend_total import SessionSpendTotals + + class _Redis: + def __init__(self) -> None: + self.values: dict[str, float] = {} # mutable-ok: stands in for Redis state + + async def async_increment( + self, key: str, value: float, ttl: int | None = None, refresh_ttl: bool = False + ) -> float: + self.values[key] = self.values.get(key, 0.0) + value + return self.values[key] + + async def async_get_cache(self, key: str) -> object: + return self.values.get(key) + + redis: Final = _Redis() + totals: Final = SessionSpendTotals(lambda: redis) + caller = UserAPIKeyAuth(api_key="sk-caller") + for spend in recorded: + await totals.record(caller.api_key or "", "sess-1", spend) + await totals.record(UserAPIKeyAuth(api_key="sk-other").api_key or "", "sess-1", 9.0) + monkeypatch.setattr(proxy_server.proxy_logging_obj, "session_spend_totals", totals) + self._rig(monkeypatch, [{**self.ROW, "api_key": caller.api_key, "session_id": "sess-1"}]) + + response = await get_auto_router_session(user_api_key_dict=caller, session_id="sess-1") + + assert response.total_spend == expected + assert response.spend == self.ROW["spend"] + @pytest.mark.asyncio async def test_another_keys_session_is_a_404_even_for_an_admin(self, monkeypatch: pytest.MonkeyPatch): # The scope is the caller's own key hash, exactly what the spend writer keyed the row under; diff --git a/tests/unit/proxy/spend_tracking/test_session_spend_total.py b/tests/unit/proxy/spend_tracking/test_session_spend_total.py new file mode 100644 index 00000000000..de208b7947f --- /dev/null +++ b/tests/unit/proxy/spend_tracking/test_session_spend_total.py @@ -0,0 +1,90 @@ +from typing import Final + +import pytest + +from litellm.proxy.spend_tracking.session_spend_total import SessionSpendTotals + + +class _Redis: + """The two RedisCache calls the session total makes, over a dict with Redis expiry on a test clock.""" + + def __init__(self, failing: bool = False) -> None: + self.failing = failing + self.now = 0.0 + self.values: dict[str, tuple[float, float]] = {} # mutable-ok: stands in for Redis state + + def _live(self, key: str) -> tuple[float, float] | None: + entry: Final = self.values.get(key) + return entry if entry is not None and entry[1] > self.now else None + + async def async_increment( + self, key: str, value: float, *, ttl: int | None = None, refresh_ttl: bool = False + ) -> float: + if self.failing: + raise ConnectionError("Redis dropped the write") + live: Final = self._live(key) + expires_at: Final = self.now + (ttl or 0) if live is None or refresh_ttl else live[1] + total: Final = (live[0] if live is not None else 0.0) + value + self.values[key] = (total, expires_at) + return total + + async def async_get_cache(self, key: str) -> str | None: + if self.failing: + raise ConnectionError("Redis dropped the read") + live: Final = self._live(key) + return str(live[0]) if live is not None else None + + +def _totals(redis: _Redis) -> SessionSpendTotals: + return SessionSpendTotals(lambda: redis) + + +@pytest.mark.asyncio +async def test_a_session_total_sums_its_own_keys_rows_only() -> None: + totals: Final = _totals(_Redis()) + + await totals.record("key-a", "session-1", 0.5) + await totals.record("key-a", "session-1", 0.25) + await totals.record("key-b", "session-1", 4.0) + await totals.record("key-a", "session-2", 8.0) + + assert await totals.read("key-a", "session-1") == 0.75 + assert await totals.read("key-b", "session-1") == 4.0 + assert await totals.read("key-a", "session-3") is None + + +@pytest.mark.asyncio +async def test_a_redis_outage_never_fails_the_spend_write_and_reads_as_no_total() -> None: + totals: Final = _totals(_Redis(failing=True)) + + await totals.record("key", "session", 0.5) + + assert await totals.read("key", "session") is None + + +@pytest.mark.asyncio +async def test_without_a_shared_redis_there_is_no_total() -> None: + totals: Final = SessionSpendTotals(lambda: None) + + await totals.record("key", "session", 0.5) + + assert await totals.read("key", "session") is None + + +@pytest.mark.asyncio +async def test_an_active_session_keeps_its_total_and_an_idle_one_restarts() -> None: + redis: Final = _Redis() + totals: Final = _totals(redis) + + await totals.record("key", "session", 0.5) + redis.now = 3000.0 + await totals.record("key", "session", 0.25) + redis.now = 6000.0 + active: Final = await totals.read("key", "session") + redis.now = 6000.0 + 3601.0 + idle: Final = await totals.read("key", "session") + await totals.record("key", "session", 0.125) + + assert active == 0.75 + assert idle is None + assert await totals.read("key", "session") == 0.125 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bd5e87bcbdd..4f946a4be89 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27922,7 +27922,7 @@ export interface components { router_type: string; /** * Saved Spend - * @description Recorded historical savings plus newer estimates, net of classifier cost + * @description Recorded historical savings plus newer estimates, net of classifier cost. None until a turn is estimated. A turn that starts within the longest prompt-cache TTL its breakpoints use (5 minutes, or 1 hour with a 1h breakpoint) of the session's first observed turn, or of the last turn whose observation was incomplete, is not estimated, because the baseline's cache state is unknown then. Two kinds of turn are still estimated inside that window: turns that took the identical baseline path, and turns whose whole cached prefix the session had already written */ saved_spend: number | null; /** @@ -27947,6 +27947,11 @@ export interface components { * @description What the session's routed traffic actually cost, classifier calls included */ spend: number; + /** + * Total Spend + * @description Spend billed to this key under the session id, routed or not, from a shared Redis counter whose one-hour expiry every billed request resets. None without a shared Redis or once the counter expired. A best-effort figure, not a lifetime total: a session idle past the expiry restarts from zero, and a failed Redis write, eviction or flush drops spend without notice + */ + total_spend?: number | null; /** * Turns * @description Auto-routed turns the rollup has recorded for this session so far From 665f56d64a1e9ad5ccfccb30e1e82b5520d93701 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:07:16 +0000 Subject: [PATCH 33/86] chore(pricing): sync replicate model prices from replicate.com model pages (#51067) * chore(pricing): sync replicate model prices from replicate.com model pages * chore(pricing): sync replicate model prices from replicate.com model pages * chore(pricing): sync replicate model prices from replicate.com model pages * chore(pricing): sync replicate model prices from replicate.com model pages * chore(pricing): regenerate provider endpoint support for new replicate modes --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 1951 ++++++++++++++++- .../provider_endpoints_support_backup.json | 10 +- model_prices_and_context_window.json | 1951 ++++++++++++++++- provider_endpoints_support.json | 10 +- 4 files changed, 3906 insertions(+), 16 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 68b77e3b785..0ee57858b17 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -48622,6 +48622,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, + "source": "https://replicate.com/meta/llama-2-13b", "supports_tool_choice": true }, "replicate/meta/llama-2-13b-chat": { @@ -48631,6 +48632,8 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, + "source": "https://replicate.com/meta/llama-2-13b-chat", + "supports_system_messages": true, "supports_tool_choice": true }, "replicate/meta/llama-2-70b": { @@ -48640,6 +48643,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/llama-2-70b", "supports_tool_choice": true }, "replicate/meta/llama-2-70b-chat": { @@ -48649,6 +48653,8 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/llama-2-70b-chat", + "supports_system_messages": true, "supports_tool_choice": true }, "replicate/meta/llama-2-7b": { @@ -48658,6 +48664,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/llama-2-7b", "supports_tool_choice": true }, "replicate/meta/llama-2-7b-chat": { @@ -48667,6 +48674,8 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/llama-2-7b-chat", + "supports_system_messages": true, "supports_tool_choice": true }, "replicate/meta/llama-3-70b": { @@ -48721,6 +48730,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/mistralai/mistral-7b-v0.1", "supports_tool_choice": true }, "replicate/mistralai/mixtral-8x7b-instruct-v0.1": { @@ -48737,8 +48747,10 @@ "output_cost_per_token": 1e-05, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-5", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_reasoning": true, "supports_vision": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -48749,6 +48761,7 @@ "output_cost_per_token": 3.6e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-oss-20b", "supports_function_calling": true, "supports_system_messages": true }, @@ -48764,13 +48777,15 @@ "supports_tool_choice": true, "supports_response_schema": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://replicate.com/anthropic/claude-4.5-haiku" }, "replicate/ibm-granite/granite-3.3-8b-instruct": { "input_cost_per_token": 3e-08, "output_cost_per_token": 2.5e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/ibm-granite/granite-3.3-8b-instruct", "supports_function_calling": true, "supports_system_messages": true }, @@ -48779,6 +48794,7 @@ "output_cost_per_token": 1e-05, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4o", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48820,6 +48836,7 @@ "output_cost_per_token": 6e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4o-mini", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48832,6 +48849,7 @@ "output_cost_per_token": 1.06e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/qwen/qwen3-235b-a22b-instruct-2507", "supports_function_calling": true, "supports_system_messages": true }, @@ -48856,6 +48874,7 @@ "mode": "chat", "max_input_tokens": 65536, "max_output_tokens": 8192, + "source": "https://replicate.com/deepseek-ai/deepseek-v3", "supports_function_calling": true, "supports_system_messages": true }, @@ -48911,7 +48930,9 @@ "supports_response_schema": false, "input_cost_per_token_above_200k_tokens": 4e-06, "output_cost_per_token_above_200k_tokens": 1.8e-05, + "source": "https://replicate.com/google/gemini-3-pro", "supports_audio_input": true, + "supports_reasoning": true, "supports_video_input": true }, "replicate/anthropic/claude-4.5-sonnet": { @@ -48926,13 +48947,15 @@ "supports_tool_choice": true, "supports_response_schema": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://replicate.com/anthropic/claude-4.5-sonnet" }, "replicate/openai/gpt-4.1": { "input_cost_per_token": 2e-06, "output_cost_per_token": 8e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4.1", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48953,6 +48976,7 @@ "output_cost_per_token": 1.6e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4.1-mini", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48965,16 +48989,21 @@ "output_cost_per_token": 4e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-5-nano", "supports_function_calling": true, - "supports_system_messages": true + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true }, "replicate/openai/gpt-5-mini": { "input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-5-mini", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_reasoning": true, "supports_vision": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -48985,6 +49014,7 @@ "output_cost_per_token": 2.5e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/google/gemini-2.5-flash", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": true, @@ -49000,6 +49030,7 @@ "output_cost_per_token": 7.2e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-oss-120b", "supports_function_calling": true, "supports_system_messages": true }, @@ -49010,6 +49041,7 @@ "mode": "chat", "max_input_tokens": 163840, "max_output_tokens": 163840, + "source": "https://replicate.com/deepseek-ai/deepseek-v3.1", "supports_function_calling": true, "supports_reasoning": true, "supports_system_messages": true @@ -49030,6 +49062,7 @@ "mode": "chat", "max_input_tokens": 65536, "max_output_tokens": 8192, + "source": "https://replicate.com/deepseek-ai/deepseek-r1", "supports_reasoning": true, "supports_system_messages": true }, @@ -97062,6 +97095,1918 @@ "video" ] }, + "replicate/alibaba/happyhorse-1.0": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://replicate.com/alibaba/happyhorse-1.0" + }, + "replicate/alibaba/happyhorse-1.1": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://replicate.com/alibaba/happyhorse-1.1" + }, + "replicate/alibaba/qwen-image-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/alibaba/qwen-image-3" + }, + "replicate/alibaba/qwen-image-3-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/alibaba/qwen-image-3-pro" + }, + "replicate/alibaba/wan-3": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_720p": 0.07, + "source": "https://replicate.com/alibaba/wan-3" + }, + "replicate/alibaba/wan-3-prime": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://replicate.com/alibaba/wan-3-prime" + }, + "replicate/anthropic/claude-fable-5": { + "input_cost_per_token": 1e-05, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 5e-05, + "source": "https://replicate.com/anthropic/claude-fable-5", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-fable-5.2": { + "input_cost_per_token": 8e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 4e-05, + "source": "https://replicate.com/anthropic/claude-fable-5.2", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-opus-4.6": { + "input_cost_per_token": 5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://replicate.com/anthropic/claude-opus-4.6", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-opus-4.7": { + "input_cost_per_token": 5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://replicate.com/anthropic/claude-opus-4.7", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-opus-5.5": { + "input_cost_per_token": 4e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2e-05, + "source": "https://replicate.com/anthropic/claude-opus-5.5", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-sonnet-4.6": { + "input_cost_per_token": 3e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://replicate.com/anthropic/claude-sonnet-4.6", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-sonnet-5": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/anthropic/claude-sonnet-5", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/black-forest-labs/flux-1.1-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/black-forest-labs/flux-1.1-pro" + }, + "replicate/black-forest-labs/flux-1.1-pro-ultra": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/black-forest-labs/flux-1.1-pro-ultra" + }, + "replicate/black-forest-labs/flux-1.1-pro-ultra-finetuned": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://replicate.com/black-forest-labs/flux-1.1-pro-ultra-finetuned" + }, + "replicate/black-forest-labs/flux-2-flex": { + "input_cost_per_pixel": 6e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 6e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-flex" + }, + "replicate/black-forest-labs/flux-2-klein-4b": { + "input_cost_per_pixel": 1e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1e-09, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-4b" + }, + "replicate/black-forest-labs/flux-2-klein-4b-base": { + "input_cost_per_pixel": 9e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 9e-09, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-4b-base" + }, + "replicate/black-forest-labs/flux-2-klein-4b-base-lora": { + "input_cost_per_pixel": 9e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1.9e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-4b-base-lora" + }, + "replicate/black-forest-labs/flux-2-klein-9b": { + "input_cost_per_pixel": 2e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1.5e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-9b" + }, + "replicate/black-forest-labs/flux-2-klein-9b-base": { + "input_cost_per_pixel": 1.1e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1.1e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-9b-base" + }, + "replicate/black-forest-labs/flux-2-klein-9b-base-lora": { + "input_cost_per_pixel": 1.1e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 2.1e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-9b-base-lora" + }, + "replicate/black-forest-labs/flux-canny-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-canny-dev" + }, + "replicate/black-forest-labs/flux-canny-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/black-forest-labs/flux-canny-pro" + }, + "replicate/black-forest-labs/flux-depth-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-depth-dev" + }, + "replicate/black-forest-labs/flux-depth-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/black-forest-labs/flux-depth-pro" + }, + "replicate/black-forest-labs/flux-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-dev" + }, + "replicate/black-forest-labs/flux-dev-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.032, + "source": "https://replicate.com/black-forest-labs/flux-dev-lora" + }, + "replicate/black-forest-labs/flux-fill-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/black-forest-labs/flux-fill-dev" + }, + "replicate/black-forest-labs/flux-fill-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/black-forest-labs/flux-fill-pro" + }, + "replicate/black-forest-labs/flux-kontext-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-kontext-dev" + }, + "replicate/black-forest-labs/flux-kontext-dev-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.032, + "source": "https://replicate.com/black-forest-labs/flux-kontext-dev-lora" + }, + "replicate/black-forest-labs/flux-kontext-max": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/black-forest-labs/flux-kontext-max" + }, + "replicate/black-forest-labs/flux-kontext-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/black-forest-labs/flux-kontext-pro" + }, + "replicate/black-forest-labs/flux-krea-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-krea-dev" + }, + "replicate/black-forest-labs/flux-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.055, + "source": "https://replicate.com/black-forest-labs/flux-pro" + }, + "replicate/black-forest-labs/flux-pro-finetuned": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/black-forest-labs/flux-pro-finetuned" + }, + "replicate/black-forest-labs/flux-redux-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-redux-dev" + }, + "replicate/black-forest-labs/flux-redux-schnell": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.003, + "source": "https://replicate.com/black-forest-labs/flux-redux-schnell" + }, + "replicate/black-forest-labs/flux-schnell": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.003, + "source": "https://replicate.com/black-forest-labs/flux-schnell" + }, + "replicate/black-forest-labs/flux-schnell-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/black-forest-labs/flux-schnell-lora" + }, + "replicate/bria/eraser": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/eraser" + }, + "replicate/bria/expand-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/expand-image" + }, + "replicate/bria/fibo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/fibo" + }, + "replicate/bria/fibo-edit": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/fibo-edit" + }, + "replicate/bria/generate-background": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/generate-background" + }, + "replicate/bria/genfill": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/genfill" + }, + "replicate/bria/image-3.2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/image-3.2" + }, + "replicate/bria/increase-resolution": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/increase-resolution" + }, + "replicate/bria/product-cutout": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/product-cutout" + }, + "replicate/bria/product-packshot": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/product-packshot" + }, + "replicate/bria/product-shadow": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/product-shadow" + }, + "replicate/bria/remove-background": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.018, + "source": "https://replicate.com/bria/remove-background" + }, + "replicate/bria/video-erase-object": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bria/video-erase-object" + }, + "replicate/bria/video-increase-resolution": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bria/video-increase-resolution" + }, + "replicate/bria/video-remove-background": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bria/video-remove-background" + }, + "replicate/bytedance/dreamactor-m2.0": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bytedance/dreamactor-m2.0" + }, + "replicate/bytedance/dreamina-3.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/bytedance/dreamina-3.1" + }, + "replicate/bytedance/omni-human": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://replicate.com/bytedance/omni-human" + }, + "replicate/bytedance/omni-human-1.5": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.16, + "source": "https://replicate.com/bytedance/omni-human-1.5" + }, + "replicate/bytedance/seedance-1-lite": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.036, + "output_cost_per_second_1080p": 0.072, + "output_cost_per_second_480p": 0.018, + "output_cost_per_second_720p": 0.036, + "source": "https://replicate.com/bytedance/seedance-1-lite" + }, + "replicate/bytedance/seedance-1-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.03, + "output_cost_per_second_720p": 0.06, + "source": "https://replicate.com/bytedance/seedance-1-pro" + }, + "replicate/bytedance/seedance-1-pro-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_480p": 0.015, + "output_cost_per_second_720p": 0.025, + "source": "https://replicate.com/bytedance/seedance-1-pro-fast" + }, + "replicate/bytedance/seedream-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/bytedance/seedream-3" + }, + "replicate/bytedance/seedream-4": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/bytedance/seedream-4" + }, + "replicate/bytedance/seedream-4.5": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bytedance/seedream-4.5" + }, + "replicate/bytedance/seedream-5-lite": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/bytedance/seedream-5-lite" + }, + "replicate/datalab-to/ocr": { + "litellm_provider": "replicate", + "mode": "ocr", + "ocr_cost_per_page": 0.002, + "source": "https://replicate.com/datalab-to/ocr" + }, + "replicate/decart/lucy-edit-2": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://replicate.com/decart/lucy-edit-2" + }, + "replicate/easel/ai-avatars": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/easel/ai-avatars" + }, + "replicate/elevenlabs/dubbing": { + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_second": 0.0366667, + "source": "https://replicate.com/elevenlabs/dubbing" + }, + "replicate/elevenlabs/flash-v2.5": { + "input_cost_per_character": 5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/flash-v2.5" + }, + "replicate/elevenlabs/turbo-v2.5": { + "input_cost_per_character": 5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/turbo-v2.5" + }, + "replicate/elevenlabs/v2-multilingual": { + "input_cost_per_character": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/v2-multilingual" + }, + "replicate/elevenlabs/v3": { + "input_cost_per_character": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/v3" + }, + "replicate/flux-kontext-apps/cartoonify": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/cartoonify" + }, + "replicate/flux-kontext-apps/change-haircut": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/change-haircut" + }, + "replicate/flux-kontext-apps/depth-of-field": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/depth-of-field" + }, + "replicate/flux-kontext-apps/face-to-many-kontext": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/face-to-many-kontext" + }, + "replicate/flux-kontext-apps/filters": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/filters" + }, + "replicate/flux-kontext-apps/iconic-locations": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/iconic-locations" + }, + "replicate/flux-kontext-apps/impossible-scenarios": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/impossible-scenarios" + }, + "replicate/flux-kontext-apps/multi-image-kontext-max": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/flux-kontext-apps/multi-image-kontext-max" + }, + "replicate/flux-kontext-apps/multi-image-kontext-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/multi-image-kontext-pro" + }, + "replicate/flux-kontext-apps/multi-image-list": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/flux-kontext-apps/multi-image-list" + }, + "replicate/flux-kontext-apps/portrait-series": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/portrait-series" + }, + "replicate/flux-kontext-apps/professional-headshot": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/professional-headshot" + }, + "replicate/flux-kontext-apps/restore-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/restore-image" + }, + "replicate/flux-kontext-apps/restyle-video-frame": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/restyle-video-frame" + }, + "replicate/flux-kontext-apps/text-removal": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/text-removal" + }, + "replicate/google/gemini-2.5-flash-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.039, + "source": "https://replicate.com/google/gemini-2.5-flash-image" + }, + "replicate/google/gemini-3-flash": { + "input_cost_per_token": 5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://replicate.com/google/gemini-3-flash", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/google/gemini-3.1-flash-tts": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_token": 4e-05, + "source": "https://replicate.com/google/gemini-3.1-flash-tts" + }, + "replicate/google/gemini-3.1-pro": { + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "source": "https://replicate.com/google/gemini-3.1-pro", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/google/gemini-3.5-flash": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 9e-06, + "source": "https://replicate.com/google/gemini-3.5-flash", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/google/gemini-3.8-flash-lite-tts": { + "input_cost_per_token": 5e-07, + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_token": 6e-06, + "source": "https://replicate.com/google/gemini-3.8-flash-lite-tts" + }, + "replicate/google/gemini-3.8-flash-tts": { + "input_cost_per_token": 5e-07, + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_token": 9e-06, + "source": "https://replicate.com/google/gemini-3.8-flash-tts" + }, + "replicate/google/nano-banana": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.039, + "source": "https://replicate.com/google/nano-banana" + }, + "replicate/google/nano-banana-2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.067, + "output_cost_per_image_1K": 0.067, + "output_cost_per_image_2K": 0.101, + "output_cost_per_image_4K": 0.151, + "source": "https://replicate.com/google/nano-banana-2" + }, + "replicate/google/nano-banana-2-lite": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.034, + "source": "https://replicate.com/google/nano-banana-2-lite" + }, + "replicate/google/nano-banana-2.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_1K": 0.0336, + "output_cost_per_image_2K": 0.0504, + "output_cost_per_image_4K": 0.1134, + "source": "https://replicate.com/google/nano-banana-2.1" + }, + "replicate/heygen/avatar-iv": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://replicate.com/heygen/avatar-iv" + }, + "replicate/heygen/avatar-v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://replicate.com/heygen/avatar-v" + }, + "replicate/heygen/lipsync-precision": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.0667, + "source": "https://replicate.com/heygen/lipsync-precision" + }, + "replicate/heygen/lipsync-speed": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://replicate.com/heygen/lipsync-speed" + }, + "replicate/heygen/video-agent": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://replicate.com/heygen/video-agent" + }, + "replicate/ibm-granite/granite-20b-code-instruct-8k": { + "input_cost_per_token": 1e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://replicate.com/ibm-granite/granite-20b-code-instruct-8k", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.0-2b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.0-2b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.0-8b-instruct": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.0-8b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.1-2b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.1-2b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.1-8b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.1-8b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.2-8b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.2-8b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-4.0-h-small": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-4.0-h-small", + "supports_function_calling": true, + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-4.1-8b": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-4.1-8b", + "supports_function_calling": true, + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-4.2-8b": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-4.2-8b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-8b-code-instruct-128k": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-8b-code-instruct-128k", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-speech-3.3-8b": { + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 4.9e-06, + "source": "https://replicate.com/ibm-granite/granite-speech-3.3-8b" + }, + "replicate/ibm-granite/granite-speech-4.1-2b": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-speech-4.1-2b" + }, + "replicate/ibm-granite/granite-vision-3.3-2b": { + "input_cost_per_token": 0.0, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-vision-3.3-2b", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/ibm-granite/granite-vision-4.1-4b": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-vision-4.1-4b", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/ideogram-ai/ideogram-v2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/ideogram-ai/ideogram-v2" + }, + "replicate/ideogram-ai/ideogram-v2-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/ideogram-ai/ideogram-v2-turbo" + }, + "replicate/ideogram-ai/ideogram-v2a": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/ideogram-ai/ideogram-v2a" + }, + "replicate/ideogram-ai/ideogram-v2a-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/ideogram-ai/ideogram-v2a-turbo" + }, + "replicate/ideogram-ai/ideogram-v3-balanced": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/ideogram-ai/ideogram-v3-balanced" + }, + "replicate/ideogram-ai/ideogram-v3-quality": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.09, + "source": "https://replicate.com/ideogram-ai/ideogram-v3-quality" + }, + "replicate/ideogram-ai/ideogram-v3-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/ideogram-ai/ideogram-v3-turbo" + }, + "replicate/ideogram-ai/ideogram-v4-balanced": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/ideogram-ai/ideogram-v4-balanced" + }, + "replicate/ideogram-ai/ideogram-v4-quality": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://replicate.com/ideogram-ai/ideogram-v4-quality" + }, + "replicate/ideogram-ai/layerize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.09, + "source": "https://replicate.com/ideogram-ai/layerize" + }, + "replicate/intelligent-utilities/html-to-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://replicate.com/intelligent-utilities/html-to-image" + }, + "replicate/inworld/realtime-tts-1.5-max": { + "input_cost_per_character": 3.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/inworld/realtime-tts-1.5-max" + }, + "replicate/inworld/realtime-tts-1.5-mini": { + "input_cost_per_character": 1.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/inworld/realtime-tts-1.5-mini" + }, + "replicate/inworld/realtime-tts-2": { + "input_cost_per_character": 2.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/inworld/realtime-tts-2" + }, + "replicate/kwaivgi/kling-lip-sync": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.014, + "source": "https://replicate.com/kwaivgi/kling-lip-sync" + }, + "replicate/kwaivgi/kling-v2.1-master": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.28, + "source": "https://replicate.com/kwaivgi/kling-v2.1-master" + }, + "replicate/kwaivgi/kling-v2.5-turbo-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://replicate.com/kwaivgi/kling-v2.5-turbo-pro" + }, + "replicate/lightricks/audio-to-video": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/lightricks/audio-to-video" + }, + "replicate/lightricks/ltx-2-distilled": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://replicate.com/lightricks/ltx-2-distilled" + }, + "replicate/lightricks/ltx-2-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_1080p": 0.04, + "output_cost_per_second_2k": 0.08, + "output_cost_per_second_4k": 0.16, + "source": "https://replicate.com/lightricks/ltx-2-fast" + }, + "replicate/lightricks/ltx-2-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_4k": 0.24, + "source": "https://replicate.com/lightricks/ltx-2-pro" + }, + "replicate/lightricks/ltx-2-retake": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/lightricks/ltx-2-retake" + }, + "replicate/lightricks/ltx-2.3-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_4k": 0.24, + "source": "https://replicate.com/lightricks/ltx-2.3-fast" + }, + "replicate/lightricks/ltx-2.3-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.08, + "output_cost_per_second_2k": 0.16, + "output_cost_per_second_4k": 0.32, + "source": "https://replicate.com/lightricks/ltx-2.3-pro" + }, + "replicate/lightricks/ltx-2.5-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_4k": 0.24, + "output_cost_per_second_720p": 0.03, + "source": "https://replicate.com/lightricks/ltx-2.5-fast" + }, + "replicate/luma/photon": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/luma/photon" + }, + "replicate/luma/photon-flash": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/luma/photon-flash" + }, + "replicate/luma/reframe-video": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "source": "https://replicate.com/luma/reframe-video" + }, + "replicate/meta/llama-4-maverick-instruct": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 9.5e-07, + "source": "https://replicate.com/meta/llama-4-maverick-instruct", + "supports_system_messages": true + }, + "replicate/meta/llama-4-scout-instruct": { + "input_cost_per_token": 1.7e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 6.5e-07, + "source": "https://replicate.com/meta/llama-4-scout-instruct", + "supports_system_messages": true + }, + "replicate/meta/llama-guard-4-12b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://replicate.com/meta/llama-guard-4-12b", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/meta/meta-llama-3-70b": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/meta-llama-3-70b" + }, + "replicate/meta/meta-llama-3-70b-instruct": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/meta-llama-3-70b-instruct", + "supports_system_messages": true + }, + "replicate/meta/meta-llama-3-8b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/meta-llama-3-8b" + }, + "replicate/meta/meta-llama-3-8b-instruct": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/meta-llama-3-8b-instruct", + "supports_system_messages": true + }, + "replicate/minimax/h3": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_2k": 0.13, + "output_cost_per_second_768p": 0.08, + "source": "https://replicate.com/minimax/h3" + }, + "replicate/minimax/h3-highspeed": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.03, + "source": "https://replicate.com/minimax/h3-highspeed" + }, + "replicate/minimax/h3-lite": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_480p": 0.0125, + "output_cost_per_second_768p": 0.02, + "source": "https://replicate.com/minimax/h3-lite" + }, + "replicate/minimax/image-01": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/minimax/image-01" + }, + "replicate/minimax/speech-02-hd": { + "input_cost_per_token": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-02-hd" + }, + "replicate/minimax/speech-02-turbo": { + "input_cost_per_token": 6e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-02-turbo" + }, + "replicate/minimax/speech-2.6-hd": { + "input_cost_per_token": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.6-hd" + }, + "replicate/minimax/speech-2.6-turbo": { + "input_cost_per_token": 6e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.6-turbo" + }, + "replicate/minimax/speech-2.8-hd": { + "input_cost_per_token": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.8-hd" + }, + "replicate/minimax/speech-2.8-turbo": { + "input_cost_per_token": 6e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.8-turbo" + }, + "replicate/moonshotai/kimi-k2-thinking": { + "input_cost_per_token": 6.6e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.7e-06, + "source": "https://replicate.com/moonshotai/kimi-k2-thinking" + }, + "replicate/moonshotai/kimi-k2.5": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://replicate.com/moonshotai/kimi-k2.5", + "supports_vision": true + }, + "replicate/moonshotai/kimi-k2.6": { + "input_cost_per_token": 9.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://replicate.com/moonshotai/kimi-k2.6", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/nightmareai/real-esrgan": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.002, + "source": "https://replicate.com/nightmareai/real-esrgan" + }, + "replicate/openai/gpt-4o-mini-transcribe": { + "input_cost_per_token": 3e-06, + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 5e-06, + "source": "https://replicate.com/openai/gpt-4o-mini-transcribe" + }, + "replicate/openai/gpt-4o-transcribe": { + "input_cost_per_token": 6e-06, + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/openai/gpt-4o-transcribe" + }, + "replicate/openai/gpt-5-pro": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 0.00012, + "source": "https://replicate.com/openai/gpt-5-pro", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.1": { + "input_cost_per_token": 1.25e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/openai/gpt-5.1", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.2": { + "input_cost_per_token": 1.75e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.4e-05, + "source": "https://replicate.com/openai/gpt-5.2", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.4": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://replicate.com/openai/gpt-5.4", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.6-luna": { + "input_cost_per_token": 1e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://replicate.com/openai/gpt-5.6-luna", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.6-sol": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/openai/gpt-5.6-sol", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.6-terra": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://replicate.com/openai/gpt-5.6-terra", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/perceptron-ai-inc/isaac-0.1": { + "input_cost_per_token": 2.6e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.17e-06, + "source": "https://replicate.com/perceptron-ai-inc/isaac-0.1", + "supports_vision": true + }, + "replicate/philz1337x/clarity-pro-upscaler": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 3e-08, + "source": "https://replicate.com/philz1337x/clarity-pro-upscaler" + }, + "replicate/pixverse/lipsync": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://replicate.com/pixverse/lipsync" + }, + "replicate/playht/play-dialog": { + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_second": 0.001, + "source": "https://replicate.com/playht/play-dialog" + }, + "replicate/prunaai/flux-fast": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/flux-fast" + }, + "replicate/prunaai/flux-kontext-fast": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/prunaai/flux-kontext-fast" + }, + "replicate/prunaai/hidream-l1-fast": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/hidream-l1-fast" + }, + "replicate/prunaai/p-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/p-image" + }, + "replicate/prunaai/p-image-edit": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/prunaai/p-image-edit" + }, + "replicate/prunaai/p-image-edit-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/prunaai/p-image-edit-lora" + }, + "replicate/prunaai/p-image-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/p-image-lora" + }, + "replicate/prunaai/p-video-2-pro-avatar": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.08, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_4k": 0.12, + "output_cost_per_second_768p": 0.04, + "source": "https://replicate.com/prunaai/p-video-2-pro-avatar" + }, + "replicate/prunaai/p-video-2-pro-replace": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.07, + "output_cost_per_second_480p": 0.025, + "output_cost_per_second_4k": 0.09, + "output_cost_per_second_768p": 0.05, + "source": "https://replicate.com/prunaai/p-video-2-pro-replace" + }, + "replicate/prunaai/p-video-animate": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_720p": 0.03, + "source": "https://replicate.com/prunaai/p-video-animate" + }, + "replicate/prunaai/p-video-avatar": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "output_cost_per_second_1080p": 0.045, + "output_cost_per_second_720p": 0.025, + "source": "https://replicate.com/prunaai/p-video-avatar" + }, + "replicate/prunaai/p-video-replace": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_720p": 0.03, + "source": "https://replicate.com/prunaai/p-video-replace" + }, + "replicate/prunaai/wan-2.2-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/prunaai/wan-2.2-image" + }, + "replicate/quiverai/arrow-1.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.2, + "source": "https://replicate.com/quiverai/arrow-1.1" + }, + "replicate/quiverai/arrow-1.1-max": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/quiverai/arrow-1.1-max" + }, + "replicate/quiverai/arrow-1.1-max-vectorize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.2, + "source": "https://replicate.com/quiverai/arrow-1.1-max-vectorize" + }, + "replicate/quiverai/arrow-1.1-vectorize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://replicate.com/quiverai/arrow-1.1-vectorize" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-fusion": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-fusion" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-next-scene": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-next-scene" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-photo-to-anime": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-photo-to-anime" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-relight": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-relight" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-skin": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-skin" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-upscale": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-upscale" + }, + "replicate/qwen/qwen-edit-multiangle": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-edit-multiangle" + }, + "replicate/qwen/qwen-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/qwen/qwen-image" + }, + "replicate/qwen/qwen-image-2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/qwen/qwen-image-2" + }, + "replicate/qwen/qwen-image-2-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://replicate.com/qwen/qwen-image-2-pro" + }, + "replicate/qwen/qwen-image-2512": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/qwen/qwen-image-2512" + }, + "replicate/qwen/qwen-image-edit": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit" + }, + "replicate/qwen/qwen-image-edit-2511": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit-2511" + }, + "replicate/qwen/qwen-image-edit-plus": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit-plus" + }, + "replicate/qwen/qwen-image-edit-plus-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit-plus-lora" + }, + "replicate/qwen/qwen3-7-plus": { + "input_cost_per_token": 2.76e-07, + "input_cost_per_token_above_256k_tokens": 8.26e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.101e-06, + "output_cost_per_token_above_256k_tokens": 3.301e-06, + "source": "https://replicate.com/qwen/qwen3-7-plus", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/qwen/qwen3-tts": { + "input_cost_per_character": 2e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/qwen/qwen3-tts" + }, + "replicate/recraft-ai/recraft-20b": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.022, + "source": "https://replicate.com/recraft-ai/recraft-20b" + }, + "replicate/recraft-ai/recraft-20b-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.044, + "source": "https://replicate.com/recraft-ai/recraft-20b-svg" + }, + "replicate/recraft-ai/recraft-creative-upscale": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://replicate.com/recraft-ai/recraft-creative-upscale" + }, + "replicate/recraft-ai/recraft-crisp-upscale": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.006, + "source": "https://replicate.com/recraft-ai/recraft-crisp-upscale" + }, + "replicate/recraft-ai/recraft-remove-background": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/recraft-ai/recraft-remove-background" + }, + "replicate/recraft-ai/recraft-v3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v3" + }, + "replicate/recraft-ai/recraft-v3-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/recraft-ai/recraft-v3-svg" + }, + "replicate/recraft-ai/recraft-v4": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4" + }, + "replicate/recraft-ai/recraft-v4-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4-pro" + }, + "replicate/recraft-ai/recraft-v4-pro-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://replicate.com/recraft-ai/recraft-v4-pro-svg" + }, + "replicate/recraft-ai/recraft-v4-styles-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://replicate.com/recraft-ai/recraft-v4-styles-pro" + }, + "replicate/recraft-ai/recraft-v4-styles-pro-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.12, + "source": "https://replicate.com/recraft-ai/recraft-v4-styles-pro-svg" + }, + "replicate/recraft-ai/recraft-v4-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/recraft-ai/recraft-v4-svg" + }, + "replicate/recraft-ai/recraft-v4.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4.1" + }, + "replicate/recraft-ai/recraft-v4.1-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-pro" + }, + "replicate/recraft-ai/recraft-v4.1-pro-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-pro-svg" + }, + "replicate/recraft-ai/recraft-v4.1-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-svg" + }, + "replicate/recraft-ai/recraft-v4.1-utility": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-utility" + }, + "replicate/recraft-ai/recraft-v4.1-utility-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-utility-pro" + }, + "replicate/recraft-ai/recraft-vectorize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/recraft-ai/recraft-vectorize" + }, + "replicate/resemble-ai/chatterbox": { + "input_cost_per_character": 2.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/resemble-ai/chatterbox" + }, + "replicate/resemble-ai/chatterbox-pro": { + "input_cost_per_character": 4e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/resemble-ai/chatterbox-pro" + }, + "replicate/resemble-ai/chatterbox-turbo": { + "input_cost_per_character": 2.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/resemble-ai/chatterbox-turbo" + }, + "replicate/retro-diffusion/rd-animation": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://replicate.com/retro-diffusion/rd-animation" + }, + "replicate/runwayml/aleph-2": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.336, + "source": "https://replicate.com/runwayml/aleph-2" + }, + "replicate/runwayml/gen-4.5": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.12, + "source": "https://replicate.com/runwayml/gen-4.5" + }, + "replicate/runwayml/gen4-image-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/runwayml/gen4-image-turbo" + }, + "replicate/runwayml/gen4-turbo": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/runwayml/gen4-turbo" + }, + "replicate/snowflake/snowflake-arctic-instruct": { + "input_cost_per_token": 2e-05, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2e-05, + "source": "https://replicate.com/snowflake/snowflake-arctic-instruct", + "supports_system_messages": true + }, + "replicate/stability-ai/stable-diffusion-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/stability-ai/stable-diffusion-3" + }, + "replicate/stability-ai/stable-diffusion-3.5-large": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.065, + "source": "https://replicate.com/stability-ai/stable-diffusion-3.5-large" + }, + "replicate/stability-ai/stable-diffusion-3.5-large-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/stability-ai/stable-diffusion-3.5-large-turbo" + }, + "replicate/stability-ai/stable-diffusion-3.5-medium": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/stability-ai/stable-diffusion-3.5-medium" + }, + "replicate/sync/lipsync-2": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/sync/lipsync-2" + }, + "replicate/sync/lipsync-2-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.08325, + "source": "https://replicate.com/sync/lipsync-2-pro" + }, + "replicate/sync/react-1": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.167, + "source": "https://replicate.com/sync/react-1" + }, + "replicate/tencent/hunyuan-image-2.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/tencent/hunyuan-image-2.1" + }, + "replicate/tencent/hunyuan-image-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/tencent/hunyuan-image-3" + }, + "replicate/veed/fabric-1.0": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.15, + "source": "https://replicate.com/veed/fabric-1.0" + }, + "replicate/wan-video/wan-2.2-s2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://replicate.com/wan-video/wan-2.2-s2v" + }, + "replicate/wan-video/wan-2.5-i2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.5-i2v" + }, + "replicate/wan-video/wan-2.5-i2v-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.068, + "output_cost_per_second_1080p": 0.102, + "output_cost_per_second_720p": 0.068, + "source": "https://replicate.com/wan-video/wan-2.5-i2v-fast" + }, + "replicate/wan-video/wan-2.5-t2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.5-t2v" + }, + "replicate/wan-video/wan-2.5-t2v-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.068, + "output_cost_per_second_1080p": 0.102, + "output_cost_per_second_720p": 0.068, + "source": "https://replicate.com/wan-video/wan-2.5-t2v-fast" + }, + "replicate/wan-video/wan-2.6-i2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.6-i2v" + }, + "replicate/wan-video/wan-2.6-t2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.6-t2v" + }, + "replicate/wan-video/wan-2.7-i2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-i2v" + }, + "replicate/wan-video/wan-2.7-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/wan-video/wan-2.7-image" + }, + "replicate/wan-video/wan-2.7-image-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/wan-video/wan-2.7-image-pro" + }, + "replicate/wan-video/wan-2.7-r2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-r2v" + }, + "replicate/wan-video/wan-2.7-t2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-t2v" + }, + "replicate/wan-video/wan-2.7-videoedit": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-videoedit" + }, + "replicate/wan-video/wan2.6-i2v-flash": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.075, + "output_cost_per_second_720p": 0.05, + "source": "https://replicate.com/wan-video/wan2.6-i2v-flash" + }, + "replicate/wavespeedai/qwen-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/wavespeedai/qwen-image" + }, + "replicate/wavespeedai/wan-2.1-i2v-480p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.09, + "source": "https://replicate.com/wavespeedai/wan-2.1-i2v-480p" + }, + "replicate/wavespeedai/wan-2.1-i2v-720p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.25, + "source": "https://replicate.com/wavespeedai/wan-2.1-i2v-720p" + }, + "replicate/wavespeedai/wan-2.1-t2v-480p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://replicate.com/wavespeedai/wan-2.1-t2v-480p" + }, + "replicate/wavespeedai/wan-2.1-t2v-720p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.24, + "source": "https://replicate.com/wavespeedai/wan-2.1-t2v-720p" + }, + "replicate/xai/grok-imagine-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/xai/grok-imagine-image" + }, + "replicate/xai/grok-imagine-image-quality": { + "input_cost_per_pixel": 1e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "output_cost_per_image_1K": 0.04, + "output_cost_per_image_2K": 0.06, + "source": "https://replicate.com/xai/grok-imagine-image-quality" + }, + "replicate/xai/grok-imagine-r2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/xai/grok-imagine-r2v" + }, + "replicate/xai/grok-imagine-video": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://replicate.com/xai/grok-imagine-video" + }, + "replicate/xai/grok-imagine-video-1.5": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://replicate.com/xai/grok-imagine-video-1.5" + }, + "replicate/xai/grok-imagine-video-extension": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/xai/grok-imagine-video-extension" + }, + "replicate/xai/grok-text-to-speech": { + "input_cost_per_character": 1.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/xai/grok-text-to-speech" + }, "fal_ai/blackforestlabs/flux-3/edit-image": { "litellm_provider": "fal_ai", "metadata": {"notes":"the page states only the 1K (1 MP) price of $0.048 and the pricing API bills in megapixels at $0.048, so the per-pixel rate is $0.048 per megapixel and the per-image price is the default 1k output; 768sq, 1.5k, 2k and 4k outputs are priced by the per-pixel rate"}, diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 3431cc97969..b35037ee651 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -3508,16 +3508,16 @@ "responses": true, "text_completion": true, "embeddings": false, - "image_generations": false, + "image_generations": true, "image_edits": false, - "audio_transcriptions": false, - "audio_speech": false, + "audio_transcriptions": true, + "audio_speech": true, "rerank": false, - "ocr": false, + "ocr": true, "search": false, "moderations": false, "realtime": false, - "video_generations": false, + "video_generations": true, "batches": false, "messages": true, "interactions": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 68b77e3b785..0ee57858b17 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -48622,6 +48622,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, + "source": "https://replicate.com/meta/llama-2-13b", "supports_tool_choice": true }, "replicate/meta/llama-2-13b-chat": { @@ -48631,6 +48632,8 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, + "source": "https://replicate.com/meta/llama-2-13b-chat", + "supports_system_messages": true, "supports_tool_choice": true }, "replicate/meta/llama-2-70b": { @@ -48640,6 +48643,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/llama-2-70b", "supports_tool_choice": true }, "replicate/meta/llama-2-70b-chat": { @@ -48649,6 +48653,8 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/llama-2-70b-chat", + "supports_system_messages": true, "supports_tool_choice": true }, "replicate/meta/llama-2-7b": { @@ -48658,6 +48664,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/llama-2-7b", "supports_tool_choice": true }, "replicate/meta/llama-2-7b-chat": { @@ -48667,6 +48674,8 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/llama-2-7b-chat", + "supports_system_messages": true, "supports_tool_choice": true }, "replicate/meta/llama-3-70b": { @@ -48721,6 +48730,7 @@ "max_output_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/mistralai/mistral-7b-v0.1", "supports_tool_choice": true }, "replicate/mistralai/mixtral-8x7b-instruct-v0.1": { @@ -48737,8 +48747,10 @@ "output_cost_per_token": 1e-05, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-5", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_reasoning": true, "supports_vision": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -48749,6 +48761,7 @@ "output_cost_per_token": 3.6e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-oss-20b", "supports_function_calling": true, "supports_system_messages": true }, @@ -48764,13 +48777,15 @@ "supports_tool_choice": true, "supports_response_schema": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "source": "https://replicate.com/anthropic/claude-4.5-haiku" }, "replicate/ibm-granite/granite-3.3-8b-instruct": { "input_cost_per_token": 3e-08, "output_cost_per_token": 2.5e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/ibm-granite/granite-3.3-8b-instruct", "supports_function_calling": true, "supports_system_messages": true }, @@ -48779,6 +48794,7 @@ "output_cost_per_token": 1e-05, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4o", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48820,6 +48836,7 @@ "output_cost_per_token": 6e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4o-mini", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48832,6 +48849,7 @@ "output_cost_per_token": 1.06e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/qwen/qwen3-235b-a22b-instruct-2507", "supports_function_calling": true, "supports_system_messages": true }, @@ -48856,6 +48874,7 @@ "mode": "chat", "max_input_tokens": 65536, "max_output_tokens": 8192, + "source": "https://replicate.com/deepseek-ai/deepseek-v3", "supports_function_calling": true, "supports_system_messages": true }, @@ -48911,7 +48930,9 @@ "supports_response_schema": false, "input_cost_per_token_above_200k_tokens": 4e-06, "output_cost_per_token_above_200k_tokens": 1.8e-05, + "source": "https://replicate.com/google/gemini-3-pro", "supports_audio_input": true, + "supports_reasoning": true, "supports_video_input": true }, "replicate/anthropic/claude-4.5-sonnet": { @@ -48926,13 +48947,15 @@ "supports_tool_choice": true, "supports_response_schema": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://replicate.com/anthropic/claude-4.5-sonnet" }, "replicate/openai/gpt-4.1": { "input_cost_per_token": 2e-06, "output_cost_per_token": 8e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4.1", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48953,6 +48976,7 @@ "output_cost_per_token": 1.6e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-4.1-mini", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true, @@ -48965,16 +48989,21 @@ "output_cost_per_token": 4e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-5-nano", "supports_function_calling": true, - "supports_system_messages": true + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true }, "replicate/openai/gpt-5-mini": { "input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-5-mini", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_reasoning": true, "supports_vision": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -48985,6 +49014,7 @@ "output_cost_per_token": 2.5e-06, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/google/gemini-2.5-flash", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": true, @@ -49000,6 +49030,7 @@ "output_cost_per_token": 7.2e-07, "litellm_provider": "replicate", "mode": "chat", + "source": "https://replicate.com/openai/gpt-oss-120b", "supports_function_calling": true, "supports_system_messages": true }, @@ -49010,6 +49041,7 @@ "mode": "chat", "max_input_tokens": 163840, "max_output_tokens": 163840, + "source": "https://replicate.com/deepseek-ai/deepseek-v3.1", "supports_function_calling": true, "supports_reasoning": true, "supports_system_messages": true @@ -49030,6 +49062,7 @@ "mode": "chat", "max_input_tokens": 65536, "max_output_tokens": 8192, + "source": "https://replicate.com/deepseek-ai/deepseek-r1", "supports_reasoning": true, "supports_system_messages": true }, @@ -97062,6 +97095,1918 @@ "video" ] }, + "replicate/alibaba/happyhorse-1.0": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_720p": 0.14, + "source": "https://replicate.com/alibaba/happyhorse-1.0" + }, + "replicate/alibaba/happyhorse-1.1": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.18, + "output_cost_per_second_1080p": 0.18, + "output_cost_per_second_720p": 0.14, + "source": "https://replicate.com/alibaba/happyhorse-1.1" + }, + "replicate/alibaba/qwen-image-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/alibaba/qwen-image-3" + }, + "replicate/alibaba/qwen-image-3-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/alibaba/qwen-image-3-pro" + }, + "replicate/alibaba/wan-3": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.035, + "output_cost_per_second_720p": 0.07, + "source": "https://replicate.com/alibaba/wan-3" + }, + "replicate/alibaba/wan-3-prime": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.28, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "output_cost_per_second_720p": 0.14, + "source": "https://replicate.com/alibaba/wan-3-prime" + }, + "replicate/anthropic/claude-fable-5": { + "input_cost_per_token": 1e-05, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 5e-05, + "source": "https://replicate.com/anthropic/claude-fable-5", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-fable-5.2": { + "input_cost_per_token": 8e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 4e-05, + "source": "https://replicate.com/anthropic/claude-fable-5.2", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-opus-4.6": { + "input_cost_per_token": 5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://replicate.com/anthropic/claude-opus-4.6", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-opus-4.7": { + "input_cost_per_token": 5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://replicate.com/anthropic/claude-opus-4.7", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-opus-5.5": { + "input_cost_per_token": 4e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2e-05, + "source": "https://replicate.com/anthropic/claude-opus-5.5", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-sonnet-4.6": { + "input_cost_per_token": 3e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://replicate.com/anthropic/claude-sonnet-4.6", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/anthropic/claude-sonnet-5": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/anthropic/claude-sonnet-5", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/black-forest-labs/flux-1.1-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/black-forest-labs/flux-1.1-pro" + }, + "replicate/black-forest-labs/flux-1.1-pro-ultra": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/black-forest-labs/flux-1.1-pro-ultra" + }, + "replicate/black-forest-labs/flux-1.1-pro-ultra-finetuned": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://replicate.com/black-forest-labs/flux-1.1-pro-ultra-finetuned" + }, + "replicate/black-forest-labs/flux-2-flex": { + "input_cost_per_pixel": 6e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 6e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-flex" + }, + "replicate/black-forest-labs/flux-2-klein-4b": { + "input_cost_per_pixel": 1e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1e-09, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-4b" + }, + "replicate/black-forest-labs/flux-2-klein-4b-base": { + "input_cost_per_pixel": 9e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 9e-09, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-4b-base" + }, + "replicate/black-forest-labs/flux-2-klein-4b-base-lora": { + "input_cost_per_pixel": 9e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1.9e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-4b-base-lora" + }, + "replicate/black-forest-labs/flux-2-klein-9b": { + "input_cost_per_pixel": 2e-09, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1.5e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-9b" + }, + "replicate/black-forest-labs/flux-2-klein-9b-base": { + "input_cost_per_pixel": 1.1e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 1.1e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-9b-base" + }, + "replicate/black-forest-labs/flux-2-klein-9b-base-lora": { + "input_cost_per_pixel": 1.1e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 2.1e-08, + "source": "https://replicate.com/black-forest-labs/flux-2-klein-9b-base-lora" + }, + "replicate/black-forest-labs/flux-canny-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-canny-dev" + }, + "replicate/black-forest-labs/flux-canny-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/black-forest-labs/flux-canny-pro" + }, + "replicate/black-forest-labs/flux-depth-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-depth-dev" + }, + "replicate/black-forest-labs/flux-depth-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/black-forest-labs/flux-depth-pro" + }, + "replicate/black-forest-labs/flux-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-dev" + }, + "replicate/black-forest-labs/flux-dev-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.032, + "source": "https://replicate.com/black-forest-labs/flux-dev-lora" + }, + "replicate/black-forest-labs/flux-fill-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/black-forest-labs/flux-fill-dev" + }, + "replicate/black-forest-labs/flux-fill-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/black-forest-labs/flux-fill-pro" + }, + "replicate/black-forest-labs/flux-kontext-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-kontext-dev" + }, + "replicate/black-forest-labs/flux-kontext-dev-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.032, + "source": "https://replicate.com/black-forest-labs/flux-kontext-dev-lora" + }, + "replicate/black-forest-labs/flux-kontext-max": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/black-forest-labs/flux-kontext-max" + }, + "replicate/black-forest-labs/flux-kontext-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/black-forest-labs/flux-kontext-pro" + }, + "replicate/black-forest-labs/flux-krea-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-krea-dev" + }, + "replicate/black-forest-labs/flux-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.055, + "source": "https://replicate.com/black-forest-labs/flux-pro" + }, + "replicate/black-forest-labs/flux-pro-finetuned": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/black-forest-labs/flux-pro-finetuned" + }, + "replicate/black-forest-labs/flux-redux-dev": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/black-forest-labs/flux-redux-dev" + }, + "replicate/black-forest-labs/flux-redux-schnell": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.003, + "source": "https://replicate.com/black-forest-labs/flux-redux-schnell" + }, + "replicate/black-forest-labs/flux-schnell": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.003, + "source": "https://replicate.com/black-forest-labs/flux-schnell" + }, + "replicate/black-forest-labs/flux-schnell-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/black-forest-labs/flux-schnell-lora" + }, + "replicate/bria/eraser": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/eraser" + }, + "replicate/bria/expand-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/expand-image" + }, + "replicate/bria/fibo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/fibo" + }, + "replicate/bria/fibo-edit": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/fibo-edit" + }, + "replicate/bria/generate-background": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/generate-background" + }, + "replicate/bria/genfill": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/genfill" + }, + "replicate/bria/image-3.2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/image-3.2" + }, + "replicate/bria/increase-resolution": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/increase-resolution" + }, + "replicate/bria/product-cutout": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/product-cutout" + }, + "replicate/bria/product-packshot": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/product-packshot" + }, + "replicate/bria/product-shadow": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bria/product-shadow" + }, + "replicate/bria/remove-background": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.018, + "source": "https://replicate.com/bria/remove-background" + }, + "replicate/bria/video-erase-object": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bria/video-erase-object" + }, + "replicate/bria/video-increase-resolution": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bria/video-increase-resolution" + }, + "replicate/bria/video-remove-background": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bria/video-remove-background" + }, + "replicate/bytedance/dreamactor-m2.0": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/bytedance/dreamactor-m2.0" + }, + "replicate/bytedance/dreamina-3.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/bytedance/dreamina-3.1" + }, + "replicate/bytedance/omni-human": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://replicate.com/bytedance/omni-human" + }, + "replicate/bytedance/omni-human-1.5": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.16, + "source": "https://replicate.com/bytedance/omni-human-1.5" + }, + "replicate/bytedance/seedance-1-lite": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.036, + "output_cost_per_second_1080p": 0.072, + "output_cost_per_second_480p": 0.018, + "output_cost_per_second_720p": 0.036, + "source": "https://replicate.com/bytedance/seedance-1-lite" + }, + "replicate/bytedance/seedance-1-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.03, + "output_cost_per_second_720p": 0.06, + "source": "https://replicate.com/bytedance/seedance-1-pro" + }, + "replicate/bytedance/seedance-1-pro-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_480p": 0.015, + "output_cost_per_second_720p": 0.025, + "source": "https://replicate.com/bytedance/seedance-1-pro-fast" + }, + "replicate/bytedance/seedream-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/bytedance/seedream-3" + }, + "replicate/bytedance/seedream-4": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/bytedance/seedream-4" + }, + "replicate/bytedance/seedream-4.5": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/bytedance/seedream-4.5" + }, + "replicate/bytedance/seedream-5-lite": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/bytedance/seedream-5-lite" + }, + "replicate/datalab-to/ocr": { + "litellm_provider": "replicate", + "mode": "ocr", + "ocr_cost_per_page": 0.002, + "source": "https://replicate.com/datalab-to/ocr" + }, + "replicate/decart/lucy-edit-2": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://replicate.com/decart/lucy-edit-2" + }, + "replicate/easel/ai-avatars": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/easel/ai-avatars" + }, + "replicate/elevenlabs/dubbing": { + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_second": 0.0366667, + "source": "https://replicate.com/elevenlabs/dubbing" + }, + "replicate/elevenlabs/flash-v2.5": { + "input_cost_per_character": 5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/flash-v2.5" + }, + "replicate/elevenlabs/turbo-v2.5": { + "input_cost_per_character": 5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/turbo-v2.5" + }, + "replicate/elevenlabs/v2-multilingual": { + "input_cost_per_character": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/v2-multilingual" + }, + "replicate/elevenlabs/v3": { + "input_cost_per_character": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/elevenlabs/v3" + }, + "replicate/flux-kontext-apps/cartoonify": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/cartoonify" + }, + "replicate/flux-kontext-apps/change-haircut": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/change-haircut" + }, + "replicate/flux-kontext-apps/depth-of-field": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/depth-of-field" + }, + "replicate/flux-kontext-apps/face-to-many-kontext": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/face-to-many-kontext" + }, + "replicate/flux-kontext-apps/filters": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/filters" + }, + "replicate/flux-kontext-apps/iconic-locations": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/iconic-locations" + }, + "replicate/flux-kontext-apps/impossible-scenarios": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/impossible-scenarios" + }, + "replicate/flux-kontext-apps/multi-image-kontext-max": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/flux-kontext-apps/multi-image-kontext-max" + }, + "replicate/flux-kontext-apps/multi-image-kontext-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/multi-image-kontext-pro" + }, + "replicate/flux-kontext-apps/multi-image-list": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/flux-kontext-apps/multi-image-list" + }, + "replicate/flux-kontext-apps/portrait-series": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/portrait-series" + }, + "replicate/flux-kontext-apps/professional-headshot": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/professional-headshot" + }, + "replicate/flux-kontext-apps/restore-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/restore-image" + }, + "replicate/flux-kontext-apps/restyle-video-frame": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/restyle-video-frame" + }, + "replicate/flux-kontext-apps/text-removal": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/flux-kontext-apps/text-removal" + }, + "replicate/google/gemini-2.5-flash-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.039, + "source": "https://replicate.com/google/gemini-2.5-flash-image" + }, + "replicate/google/gemini-3-flash": { + "input_cost_per_token": 5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://replicate.com/google/gemini-3-flash", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/google/gemini-3.1-flash-tts": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_token": 4e-05, + "source": "https://replicate.com/google/gemini-3.1-flash-tts" + }, + "replicate/google/gemini-3.1-pro": { + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "source": "https://replicate.com/google/gemini-3.1-pro", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/google/gemini-3.5-flash": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 9e-06, + "source": "https://replicate.com/google/gemini-3.5-flash", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/google/gemini-3.8-flash-lite-tts": { + "input_cost_per_token": 5e-07, + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_token": 6e-06, + "source": "https://replicate.com/google/gemini-3.8-flash-lite-tts" + }, + "replicate/google/gemini-3.8-flash-tts": { + "input_cost_per_token": 5e-07, + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_token": 9e-06, + "source": "https://replicate.com/google/gemini-3.8-flash-tts" + }, + "replicate/google/nano-banana": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.039, + "source": "https://replicate.com/google/nano-banana" + }, + "replicate/google/nano-banana-2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.067, + "output_cost_per_image_1K": 0.067, + "output_cost_per_image_2K": 0.101, + "output_cost_per_image_4K": 0.151, + "source": "https://replicate.com/google/nano-banana-2" + }, + "replicate/google/nano-banana-2-lite": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.034, + "source": "https://replicate.com/google/nano-banana-2-lite" + }, + "replicate/google/nano-banana-2.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_1K": 0.0336, + "output_cost_per_image_2K": 0.0504, + "output_cost_per_image_4K": 0.1134, + "source": "https://replicate.com/google/nano-banana-2.1" + }, + "replicate/heygen/avatar-iv": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://replicate.com/heygen/avatar-iv" + }, + "replicate/heygen/avatar-v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.075, + "source": "https://replicate.com/heygen/avatar-v" + }, + "replicate/heygen/lipsync-precision": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.0667, + "source": "https://replicate.com/heygen/lipsync-precision" + }, + "replicate/heygen/lipsync-speed": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://replicate.com/heygen/lipsync-speed" + }, + "replicate/heygen/video-agent": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.0333, + "source": "https://replicate.com/heygen/video-agent" + }, + "replicate/ibm-granite/granite-20b-code-instruct-8k": { + "input_cost_per_token": 1e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://replicate.com/ibm-granite/granite-20b-code-instruct-8k", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.0-2b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.0-2b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.0-8b-instruct": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.0-8b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.1-2b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.1-2b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.1-8b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.1-8b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-3.2-8b-instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-3.2-8b-instruct", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-4.0-h-small": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-4.0-h-small", + "supports_function_calling": true, + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-4.1-8b": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-4.1-8b", + "supports_function_calling": true, + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-4.2-8b": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-4.2-8b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-8b-code-instruct-128k": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-8b-code-instruct-128k", + "supports_system_messages": true + }, + "replicate/ibm-granite/granite-speech-3.3-8b": { + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 4.9e-06, + "source": "https://replicate.com/ibm-granite/granite-speech-3.3-8b" + }, + "replicate/ibm-granite/granite-speech-4.1-2b": { + "input_cost_per_token": 3e-08, + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-speech-4.1-2b" + }, + "replicate/ibm-granite/granite-vision-3.3-2b": { + "input_cost_per_token": 0.0, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-vision-3.3-2b", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/ibm-granite/granite-vision-4.1-4b": { + "input_cost_per_token": 6e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/ibm-granite/granite-vision-4.1-4b", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/ideogram-ai/ideogram-v2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/ideogram-ai/ideogram-v2" + }, + "replicate/ideogram-ai/ideogram-v2-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://replicate.com/ideogram-ai/ideogram-v2-turbo" + }, + "replicate/ideogram-ai/ideogram-v2a": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/ideogram-ai/ideogram-v2a" + }, + "replicate/ideogram-ai/ideogram-v2a-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/ideogram-ai/ideogram-v2a-turbo" + }, + "replicate/ideogram-ai/ideogram-v3-balanced": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/ideogram-ai/ideogram-v3-balanced" + }, + "replicate/ideogram-ai/ideogram-v3-quality": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.09, + "source": "https://replicate.com/ideogram-ai/ideogram-v3-quality" + }, + "replicate/ideogram-ai/ideogram-v3-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/ideogram-ai/ideogram-v3-turbo" + }, + "replicate/ideogram-ai/ideogram-v4-balanced": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://replicate.com/ideogram-ai/ideogram-v4-balanced" + }, + "replicate/ideogram-ai/ideogram-v4-quality": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://replicate.com/ideogram-ai/ideogram-v4-quality" + }, + "replicate/ideogram-ai/layerize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.09, + "source": "https://replicate.com/ideogram-ai/layerize" + }, + "replicate/intelligent-utilities/html-to-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.001, + "source": "https://replicate.com/intelligent-utilities/html-to-image" + }, + "replicate/inworld/realtime-tts-1.5-max": { + "input_cost_per_character": 3.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/inworld/realtime-tts-1.5-max" + }, + "replicate/inworld/realtime-tts-1.5-mini": { + "input_cost_per_character": 1.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/inworld/realtime-tts-1.5-mini" + }, + "replicate/inworld/realtime-tts-2": { + "input_cost_per_character": 2.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/inworld/realtime-tts-2" + }, + "replicate/kwaivgi/kling-lip-sync": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.014, + "source": "https://replicate.com/kwaivgi/kling-lip-sync" + }, + "replicate/kwaivgi/kling-v2.1-master": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.28, + "source": "https://replicate.com/kwaivgi/kling-v2.1-master" + }, + "replicate/kwaivgi/kling-v2.5-turbo-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://replicate.com/kwaivgi/kling-v2.5-turbo-pro" + }, + "replicate/lightricks/audio-to-video": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/lightricks/audio-to-video" + }, + "replicate/lightricks/ltx-2-distilled": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://replicate.com/lightricks/ltx-2-distilled" + }, + "replicate/lightricks/ltx-2-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_1080p": 0.04, + "output_cost_per_second_2k": 0.08, + "output_cost_per_second_4k": 0.16, + "source": "https://replicate.com/lightricks/ltx-2-fast" + }, + "replicate/lightricks/ltx-2-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_4k": 0.24, + "source": "https://replicate.com/lightricks/ltx-2-pro" + }, + "replicate/lightricks/ltx-2-retake": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/lightricks/ltx-2-retake" + }, + "replicate/lightricks/ltx-2.3-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_4k": 0.24, + "source": "https://replicate.com/lightricks/ltx-2.3-fast" + }, + "replicate/lightricks/ltx-2.3-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_1080p": 0.08, + "output_cost_per_second_2k": 0.16, + "output_cost_per_second_4k": 0.32, + "source": "https://replicate.com/lightricks/ltx-2.3-pro" + }, + "replicate/lightricks/ltx-2.5-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.12, + "output_cost_per_second_4k": 0.24, + "output_cost_per_second_720p": 0.03, + "source": "https://replicate.com/lightricks/ltx-2.5-fast" + }, + "replicate/luma/photon": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/luma/photon" + }, + "replicate/luma/photon-flash": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/luma/photon-flash" + }, + "replicate/luma/reframe-video": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.06, + "source": "https://replicate.com/luma/reframe-video" + }, + "replicate/meta/llama-4-maverick-instruct": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 9.5e-07, + "source": "https://replicate.com/meta/llama-4-maverick-instruct", + "supports_system_messages": true + }, + "replicate/meta/llama-4-scout-instruct": { + "input_cost_per_token": 1.7e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 6.5e-07, + "source": "https://replicate.com/meta/llama-4-scout-instruct", + "supports_system_messages": true + }, + "replicate/meta/llama-guard-4-12b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://replicate.com/meta/llama-guard-4-12b", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/meta/meta-llama-3-70b": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/meta-llama-3-70b" + }, + "replicate/meta/meta-llama-3-70b-instruct": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "source": "https://replicate.com/meta/meta-llama-3-70b-instruct", + "supports_system_messages": true + }, + "replicate/meta/meta-llama-3-8b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/meta-llama-3-8b" + }, + "replicate/meta/meta-llama-3-8b-instruct": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://replicate.com/meta/meta-llama-3-8b-instruct", + "supports_system_messages": true + }, + "replicate/minimax/h3": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.13, + "output_cost_per_second_2k": 0.13, + "output_cost_per_second_768p": 0.08, + "source": "https://replicate.com/minimax/h3" + }, + "replicate/minimax/h3-highspeed": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.03, + "source": "https://replicate.com/minimax/h3-highspeed" + }, + "replicate/minimax/h3-lite": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "output_cost_per_second_480p": 0.0125, + "output_cost_per_second_768p": 0.02, + "source": "https://replicate.com/minimax/h3-lite" + }, + "replicate/minimax/image-01": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/minimax/image-01" + }, + "replicate/minimax/speech-02-hd": { + "input_cost_per_token": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-02-hd" + }, + "replicate/minimax/speech-02-turbo": { + "input_cost_per_token": 6e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-02-turbo" + }, + "replicate/minimax/speech-2.6-hd": { + "input_cost_per_token": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.6-hd" + }, + "replicate/minimax/speech-2.6-turbo": { + "input_cost_per_token": 6e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.6-turbo" + }, + "replicate/minimax/speech-2.8-hd": { + "input_cost_per_token": 0.0001, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.8-hd" + }, + "replicate/minimax/speech-2.8-turbo": { + "input_cost_per_token": 6e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/minimax/speech-2.8-turbo" + }, + "replicate/moonshotai/kimi-k2-thinking": { + "input_cost_per_token": 6.6e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.7e-06, + "source": "https://replicate.com/moonshotai/kimi-k2-thinking" + }, + "replicate/moonshotai/kimi-k2.5": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://replicate.com/moonshotai/kimi-k2.5", + "supports_vision": true + }, + "replicate/moonshotai/kimi-k2.6": { + "input_cost_per_token": 9.5e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://replicate.com/moonshotai/kimi-k2.6", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/nightmareai/real-esrgan": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.002, + "source": "https://replicate.com/nightmareai/real-esrgan" + }, + "replicate/openai/gpt-4o-mini-transcribe": { + "input_cost_per_token": 3e-06, + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 5e-06, + "source": "https://replicate.com/openai/gpt-4o-mini-transcribe" + }, + "replicate/openai/gpt-4o-transcribe": { + "input_cost_per_token": 6e-06, + "litellm_provider": "replicate", + "mode": "audio_transcription", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/openai/gpt-4o-transcribe" + }, + "replicate/openai/gpt-5-pro": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 0.00012, + "source": "https://replicate.com/openai/gpt-5-pro", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.1": { + "input_cost_per_token": 1.25e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/openai/gpt-5.1", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.2": { + "input_cost_per_token": 1.75e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.4e-05, + "source": "https://replicate.com/openai/gpt-5.2", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.4": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://replicate.com/openai/gpt-5.4", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.6-luna": { + "input_cost_per_token": 1e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://replicate.com/openai/gpt-5.6-luna", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.6-sol": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/openai/gpt-5.6-sol", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/openai/gpt-5.6-terra": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://replicate.com/openai/gpt-5.6-terra", + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/perceptron-ai-inc/isaac-0.1": { + "input_cost_per_token": 2.6e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2.17e-06, + "source": "https://replicate.com/perceptron-ai-inc/isaac-0.1", + "supports_vision": true + }, + "replicate/philz1337x/clarity-pro-upscaler": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_pixel": 3e-08, + "source": "https://replicate.com/philz1337x/clarity-pro-upscaler" + }, + "replicate/pixverse/lipsync": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://replicate.com/pixverse/lipsync" + }, + "replicate/playht/play-dialog": { + "litellm_provider": "replicate", + "mode": "audio_speech", + "output_cost_per_second": 0.001, + "source": "https://replicate.com/playht/play-dialog" + }, + "replicate/prunaai/flux-fast": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/flux-fast" + }, + "replicate/prunaai/flux-kontext-fast": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/prunaai/flux-kontext-fast" + }, + "replicate/prunaai/hidream-l1-fast": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/hidream-l1-fast" + }, + "replicate/prunaai/p-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/p-image" + }, + "replicate/prunaai/p-image-edit": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/prunaai/p-image-edit" + }, + "replicate/prunaai/p-image-edit-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/prunaai/p-image-edit-lora" + }, + "replicate/prunaai/p-image-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.005, + "source": "https://replicate.com/prunaai/p-image-lora" + }, + "replicate/prunaai/p-video-2-pro-avatar": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.04, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.08, + "output_cost_per_second_480p": 0.02, + "output_cost_per_second_4k": 0.12, + "output_cost_per_second_768p": 0.04, + "source": "https://replicate.com/prunaai/p-video-2-pro-avatar" + }, + "replicate/prunaai/p-video-2-pro-replace": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_2k": 0.07, + "output_cost_per_second_480p": 0.025, + "output_cost_per_second_4k": 0.09, + "output_cost_per_second_768p": 0.05, + "source": "https://replicate.com/prunaai/p-video-2-pro-replace" + }, + "replicate/prunaai/p-video-animate": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_720p": 0.03, + "source": "https://replicate.com/prunaai/p-video-animate" + }, + "replicate/prunaai/p-video-avatar": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.025, + "output_cost_per_second_1080p": 0.045, + "output_cost_per_second_720p": 0.025, + "source": "https://replicate.com/prunaai/p-video-avatar" + }, + "replicate/prunaai/p-video-replace": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.06, + "output_cost_per_second_720p": 0.03, + "source": "https://replicate.com/prunaai/p-video-replace" + }, + "replicate/prunaai/wan-2.2-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/prunaai/wan-2.2-image" + }, + "replicate/quiverai/arrow-1.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.2, + "source": "https://replicate.com/quiverai/arrow-1.1" + }, + "replicate/quiverai/arrow-1.1-max": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/quiverai/arrow-1.1-max" + }, + "replicate/quiverai/arrow-1.1-max-vectorize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.2, + "source": "https://replicate.com/quiverai/arrow-1.1-max-vectorize" + }, + "replicate/quiverai/arrow-1.1-vectorize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.15, + "source": "https://replicate.com/quiverai/arrow-1.1-vectorize" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-fusion": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-fusion" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-next-scene": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-next-scene" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-photo-to-anime": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-photo-to-anime" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-relight": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-relight" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-skin": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-skin" + }, + "replicate/qwen-edit-apps/qwen-image-edit-plus-lora-upscale": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen-edit-apps/qwen-image-edit-plus-lora-upscale" + }, + "replicate/qwen/qwen-edit-multiangle": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-edit-multiangle" + }, + "replicate/qwen/qwen-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/qwen/qwen-image" + }, + "replicate/qwen/qwen-image-2": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/qwen/qwen-image-2" + }, + "replicate/qwen/qwen-image-2-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.075, + "source": "https://replicate.com/qwen/qwen-image-2-pro" + }, + "replicate/qwen/qwen-image-2512": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/qwen/qwen-image-2512" + }, + "replicate/qwen/qwen-image-edit": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit" + }, + "replicate/qwen/qwen-image-edit-2511": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit-2511" + }, + "replicate/qwen/qwen-image-edit-plus": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit-plus" + }, + "replicate/qwen/qwen-image-edit-plus-lora": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/qwen/qwen-image-edit-plus-lora" + }, + "replicate/qwen/qwen3-7-plus": { + "input_cost_per_token": 2.76e-07, + "input_cost_per_token_above_256k_tokens": 8.26e-07, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 1.101e-06, + "output_cost_per_token_above_256k_tokens": 3.301e-06, + "source": "https://replicate.com/qwen/qwen3-7-plus", + "supports_system_messages": true, + "supports_vision": true + }, + "replicate/qwen/qwen3-tts": { + "input_cost_per_character": 2e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/qwen/qwen3-tts" + }, + "replicate/recraft-ai/recraft-20b": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.022, + "source": "https://replicate.com/recraft-ai/recraft-20b" + }, + "replicate/recraft-ai/recraft-20b-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.044, + "source": "https://replicate.com/recraft-ai/recraft-20b-svg" + }, + "replicate/recraft-ai/recraft-creative-upscale": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://replicate.com/recraft-ai/recraft-creative-upscale" + }, + "replicate/recraft-ai/recraft-crisp-upscale": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.006, + "source": "https://replicate.com/recraft-ai/recraft-crisp-upscale" + }, + "replicate/recraft-ai/recraft-remove-background": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/recraft-ai/recraft-remove-background" + }, + "replicate/recraft-ai/recraft-v3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v3" + }, + "replicate/recraft-ai/recraft-v3-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/recraft-ai/recraft-v3-svg" + }, + "replicate/recraft-ai/recraft-v4": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4" + }, + "replicate/recraft-ai/recraft-v4-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4-pro" + }, + "replicate/recraft-ai/recraft-v4-pro-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "source": "https://replicate.com/recraft-ai/recraft-v4-pro-svg" + }, + "replicate/recraft-ai/recraft-v4-styles-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.1, + "source": "https://replicate.com/recraft-ai/recraft-v4-styles-pro" + }, + "replicate/recraft-ai/recraft-v4-styles-pro-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.12, + "source": "https://replicate.com/recraft-ai/recraft-v4-styles-pro-svg" + }, + "replicate/recraft-ai/recraft-v4-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/recraft-ai/recraft-v4-svg" + }, + "replicate/recraft-ai/recraft-v4.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4.1" + }, + "replicate/recraft-ai/recraft-v4.1-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-pro" + }, + "replicate/recraft-ai/recraft-v4.1-pro-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-pro-svg" + }, + "replicate/recraft-ai/recraft-v4.1-svg": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-svg" + }, + "replicate/recraft-ai/recraft-v4.1-utility": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-utility" + }, + "replicate/recraft-ai/recraft-v4.1-utility-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.25, + "source": "https://replicate.com/recraft-ai/recraft-v4.1-utility-pro" + }, + "replicate/recraft-ai/recraft-vectorize": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://replicate.com/recraft-ai/recraft-vectorize" + }, + "replicate/resemble-ai/chatterbox": { + "input_cost_per_character": 2.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/resemble-ai/chatterbox" + }, + "replicate/resemble-ai/chatterbox-pro": { + "input_cost_per_character": 4e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/resemble-ai/chatterbox-pro" + }, + "replicate/resemble-ai/chatterbox-turbo": { + "input_cost_per_character": 2.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/resemble-ai/chatterbox-turbo" + }, + "replicate/retro-diffusion/rd-animation": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://replicate.com/retro-diffusion/rd-animation" + }, + "replicate/runwayml/aleph-2": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.336, + "source": "https://replicate.com/runwayml/aleph-2" + }, + "replicate/runwayml/gen-4.5": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.12, + "source": "https://replicate.com/runwayml/gen-4.5" + }, + "replicate/runwayml/gen4-image-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/runwayml/gen4-image-turbo" + }, + "replicate/runwayml/gen4-turbo": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/runwayml/gen4-turbo" + }, + "replicate/snowflake/snowflake-arctic-instruct": { + "input_cost_per_token": 2e-05, + "litellm_provider": "replicate", + "mode": "chat", + "output_cost_per_token": 2e-05, + "source": "https://replicate.com/snowflake/snowflake-arctic-instruct", + "supports_system_messages": true + }, + "replicate/stability-ai/stable-diffusion-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/stability-ai/stable-diffusion-3" + }, + "replicate/stability-ai/stable-diffusion-3.5-large": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.065, + "source": "https://replicate.com/stability-ai/stable-diffusion-3.5-large" + }, + "replicate/stability-ai/stable-diffusion-3.5-large-turbo": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://replicate.com/stability-ai/stable-diffusion-3.5-large-turbo" + }, + "replicate/stability-ai/stable-diffusion-3.5-medium": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://replicate.com/stability-ai/stable-diffusion-3.5-medium" + }, + "replicate/sync/lipsync-2": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/sync/lipsync-2" + }, + "replicate/sync/lipsync-2-pro": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.08325, + "source": "https://replicate.com/sync/lipsync-2-pro" + }, + "replicate/sync/react-1": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.167, + "source": "https://replicate.com/sync/react-1" + }, + "replicate/tencent/hunyuan-image-2.1": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/tencent/hunyuan-image-2.1" + }, + "replicate/tencent/hunyuan-image-3": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.08, + "source": "https://replicate.com/tencent/hunyuan-image-3" + }, + "replicate/veed/fabric-1.0": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_480p": 0.08, + "output_cost_per_second_720p": 0.15, + "source": "https://replicate.com/veed/fabric-1.0" + }, + "replicate/wan-video/wan-2.2-s2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.02, + "source": "https://replicate.com/wan-video/wan-2.2-s2v" + }, + "replicate/wan-video/wan-2.5-i2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.5-i2v" + }, + "replicate/wan-video/wan-2.5-i2v-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.068, + "output_cost_per_second_1080p": 0.102, + "output_cost_per_second_720p": 0.068, + "source": "https://replicate.com/wan-video/wan-2.5-i2v-fast" + }, + "replicate/wan-video/wan-2.5-t2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.5-t2v" + }, + "replicate/wan-video/wan-2.5-t2v-fast": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.068, + "output_cost_per_second_1080p": 0.102, + "output_cost_per_second_720p": 0.068, + "source": "https://replicate.com/wan-video/wan-2.5-t2v-fast" + }, + "replicate/wan-video/wan-2.6-i2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.6-i2v" + }, + "replicate/wan-video/wan-2.6-t2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.6-t2v" + }, + "replicate/wan-video/wan-2.7-i2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_720p": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-i2v" + }, + "replicate/wan-video/wan-2.7-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/wan-video/wan-2.7-image" + }, + "replicate/wan-video/wan-2.7-image-pro": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://replicate.com/wan-video/wan-2.7-image-pro" + }, + "replicate/wan-video/wan-2.7-r2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-r2v" + }, + "replicate/wan-video/wan-2.7-t2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-t2v" + }, + "replicate/wan-video/wan-2.7-videoedit": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://replicate.com/wan-video/wan-2.7-videoedit" + }, + "replicate/wan-video/wan2.6-i2v-flash": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.075, + "output_cost_per_second_720p": 0.05, + "source": "https://replicate.com/wan-video/wan2.6-i2v-flash" + }, + "replicate/wavespeedai/qwen-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://replicate.com/wavespeedai/qwen-image" + }, + "replicate/wavespeedai/wan-2.1-i2v-480p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.09, + "source": "https://replicate.com/wavespeedai/wan-2.1-i2v-480p" + }, + "replicate/wavespeedai/wan-2.1-i2v-720p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.25, + "source": "https://replicate.com/wavespeedai/wan-2.1-i2v-720p" + }, + "replicate/wavespeedai/wan-2.1-t2v-480p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "source": "https://replicate.com/wavespeedai/wan-2.1-t2v-480p" + }, + "replicate/wavespeedai/wan-2.1-t2v-720p": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.24, + "source": "https://replicate.com/wavespeedai/wan-2.1-t2v-720p" + }, + "replicate/xai/grok-imagine-image": { + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://replicate.com/xai/grok-imagine-image" + }, + "replicate/xai/grok-imagine-image-quality": { + "input_cost_per_pixel": 1e-08, + "litellm_provider": "replicate", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "output_cost_per_image_1K": 0.04, + "output_cost_per_image_2K": 0.06, + "source": "https://replicate.com/xai/grok-imagine-image-quality" + }, + "replicate/xai/grok-imagine-r2v": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/xai/grok-imagine-r2v" + }, + "replicate/xai/grok-imagine-video": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_480p": 0.05, + "output_cost_per_second_720p": 0.07, + "source": "https://replicate.com/xai/grok-imagine-video" + }, + "replicate/xai/grok-imagine-video-1.5": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://replicate.com/xai/grok-imagine-video-1.5" + }, + "replicate/xai/grok-imagine-video-extension": { + "litellm_provider": "replicate", + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://replicate.com/xai/grok-imagine-video-extension" + }, + "replicate/xai/grok-text-to-speech": { + "input_cost_per_character": 1.5e-05, + "litellm_provider": "replicate", + "mode": "audio_speech", + "source": "https://replicate.com/xai/grok-text-to-speech" + }, "fal_ai/blackforestlabs/flux-3/edit-image": { "litellm_provider": "fal_ai", "metadata": {"notes":"the page states only the 1K (1 MP) price of $0.048 and the pricing API bills in megapixels at $0.048, so the per-pixel rate is $0.048 per megapixel and the per-image price is the default 1k output; 768sq, 1.5k, 2k and 4k outputs are priced by the per-pixel rate"}, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 3431cc97969..b35037ee651 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -3508,16 +3508,16 @@ "responses": true, "text_completion": true, "embeddings": false, - "image_generations": false, + "image_generations": true, "image_edits": false, - "audio_transcriptions": false, - "audio_speech": false, + "audio_transcriptions": true, + "audio_speech": true, "rerank": false, - "ocr": false, + "ocr": true, "search": false, "moderations": false, "realtime": false, - "video_generations": false, + "video_generations": true, "batches": false, "messages": true, "interactions": true, From 4b498d1bba768bd81e4a434a14ea7ae2870dd01f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:08:48 +0000 Subject: [PATCH 34/86] chore(databricks): add claude-fable-5-2, date glm-5-2 retirement and fix llama-3-1-8b context from the supported-models page (#51076) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 59 ++++++++++++++++++- model_prices_and_context_window.json | 59 ++++++++++++++++++- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 0ee57858b17..092987ce69e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -21878,7 +21878,7 @@ "thinking_always_on": true }, "databricks/claude-fable-latest": { - "alias_of": "databricks/databricks-claude-fable-5-1" + "alias_of": "databricks/databricks-claude-fable-5-2" }, "databricks/databricks-claude-haiku-4-5": { "cache_creation_input_token_cost": 1.24999e-06, @@ -22804,7 +22804,10 @@ "input_cost_per_token_priority": 2.45e-06, "output_cost_per_token_priority": 7.7e-06, "cache_read_input_token_cost_priority": 4.55e-07, - "cache_creation_input_token_cost_priority": 2.45e-06 + "cache_creation_input_token_cost_priority": 2.45e-06, + "deprecation_date": "2027-02-04", + "deprecation_source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "successor_model": "databricks/databricks-glm-5-3" }, "databricks/databricks-glm-5-3": { "cache_creation_input_token_cost": 1.4e-06, @@ -23471,7 +23474,7 @@ "input_cost_per_token": 1.5001e-07, "input_dbu_cost_per_token": 2.1429999999999996e-06, "litellm_provider": "databricks", - "max_input_tokens": 200000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." @@ -99977,5 +99980,55 @@ "supported_output_modalities": [ "video" ] + }, + "databricks/databricks-claude-fable-5-2": { + "cache_creation_input_token_cost": 8e-06, + "cache_read_input_token_cost": 8e-06, + "input_cost_per_token": 8e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "metadata": {"notes":"Claude Fable 5.2 on Databricks is available only through Unity Gateway with the model service name system.ai.claude-fable-5-2, and Databricks hosts it through Amazon Bedrock. The Databricks pricing page lists no row for it yet, so input and output are inherited from the catalog Bedrock row anthropic.claude-fable-5-2, no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 4e-05, + "prompt_cache_min_tokens": 512.0, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supports_adaptive_thinking": true, + "supports_anthropic_thinking_payload": true, + "supports_assistant_prefill": false, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "thinking_always_on": true + }, + "databricks/system.ai.claude-fable-5-2": { + "cache_creation_input_token_cost": 8e-06, + "cache_read_input_token_cost": 8e-06, + "input_cost_per_token": 8e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "metadata": {"notes":"Claude Fable 5.2 on Databricks is available only through Unity Gateway with the model service name system.ai.claude-fable-5-2, and Databricks hosts it through Amazon Bedrock. The Databricks pricing page lists no row for it yet, so input and output are inherited from the catalog Bedrock row anthropic.claude-fable-5-2, no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 4e-05, + "prompt_cache_min_tokens": 512.0, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supports_adaptive_thinking": true, + "supports_anthropic_thinking_payload": true, + "supports_assistant_prefill": false, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "thinking_always_on": true } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 0ee57858b17..092987ce69e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -21878,7 +21878,7 @@ "thinking_always_on": true }, "databricks/claude-fable-latest": { - "alias_of": "databricks/databricks-claude-fable-5-1" + "alias_of": "databricks/databricks-claude-fable-5-2" }, "databricks/databricks-claude-haiku-4-5": { "cache_creation_input_token_cost": 1.24999e-06, @@ -22804,7 +22804,10 @@ "input_cost_per_token_priority": 2.45e-06, "output_cost_per_token_priority": 7.7e-06, "cache_read_input_token_cost_priority": 4.55e-07, - "cache_creation_input_token_cost_priority": 2.45e-06 + "cache_creation_input_token_cost_priority": 2.45e-06, + "deprecation_date": "2027-02-04", + "deprecation_source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "successor_model": "databricks/databricks-glm-5-3" }, "databricks/databricks-glm-5-3": { "cache_creation_input_token_cost": 1.4e-06, @@ -23471,7 +23474,7 @@ "input_cost_per_token": 1.5001e-07, "input_dbu_cost_per_token": 2.1429999999999996e-06, "litellm_provider": "databricks", - "max_input_tokens": 200000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." @@ -99977,5 +99980,55 @@ "supported_output_modalities": [ "video" ] + }, + "databricks/databricks-claude-fable-5-2": { + "cache_creation_input_token_cost": 8e-06, + "cache_read_input_token_cost": 8e-06, + "input_cost_per_token": 8e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "metadata": {"notes":"Claude Fable 5.2 on Databricks is available only through Unity Gateway with the model service name system.ai.claude-fable-5-2, and Databricks hosts it through Amazon Bedrock. The Databricks pricing page lists no row for it yet, so input and output are inherited from the catalog Bedrock row anthropic.claude-fable-5-2, no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 4e-05, + "prompt_cache_min_tokens": 512.0, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supports_adaptive_thinking": true, + "supports_anthropic_thinking_payload": true, + "supports_assistant_prefill": false, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "thinking_always_on": true + }, + "databricks/system.ai.claude-fable-5-2": { + "cache_creation_input_token_cost": 8e-06, + "cache_read_input_token_cost": 8e-06, + "input_cost_per_token": 8e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "metadata": {"notes":"Claude Fable 5.2 on Databricks is available only through Unity Gateway with the model service name system.ai.claude-fable-5-2, and Databricks hosts it through Amazon Bedrock. The Databricks pricing page lists no row for it yet, so input and output are inherited from the catalog Bedrock row anthropic.claude-fable-5-2, no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 4e-05, + "prompt_cache_min_tokens": 512.0, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supports_adaptive_thinking": true, + "supports_anthropic_thinking_payload": true, + "supports_assistant_prefill": false, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_mid_conversation_system": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "thinking_always_on": true } } From 11af2eb22dc799dff033c2d3ba71be991b6a8201 Mon Sep 17 00:00:00 2001 From: yujonglee Date: Wed, 11 Nov 2026 10:57:54 +0900 Subject: [PATCH 35/86] refactor(rust_bridge): route every Rust-to-Python call through host modules (#50822) * refactor(rust_bridge): route every Rust-to-Python call through host modules Rust now reaches Python only through litellm/rust_bridge/host/ (plus the execution driver), and no Python module outside host/ imports it. Library calls that reproduce Python behavior (httpx and OSError error text, huggingface_hub downloads, the random generator, os.environ) go through host functions too. The rule is written into host/AGENTS.md The live litellm.model_cost table, its version and every Python writer move to litellm_core_utils/model_cost_table.py, and host/model_cost.py keeps only the functions Rust calls. The cost map fetch passes use_http_settings instead of handing Rust an http_settings() snapshot * refactor(rust_bridge): read REDIS_USERNAME from the process environment in Rust The Redis bridge asked Python for all of os.environ to read one key. CPython's os.environ writes through to the process environment, so Rust reads it with ProcessEnvironment like the rest of the module. Also drops doc comments that restated their code. Co-Authored-By: Claude Opus 5.5 * ci(rust): raise the native extension size cap to 55 MB Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/scripts/verify_linux_native_wheel.py | 2 +- litellm-rust/crates/python-bridge/AGENTS.md | 1 + litellm-rust/crates/python-bridge/src/http.rs | 15 +- .../crates/python-bridge/src/logger/mod.rs | 2 +- .../python-bridge/src/model_cost/fetch.rs | 9 +- .../crates/python-bridge/src/preflight.rs | 10 +- .../python-bridge/src/python_settings.rs | 9 +- .../crates/python-bridge/src/redis/breaker.rs | 2 +- .../src/redis/client/credentials.rs | 4 +- .../python-bridge/src/redis/client/mod.rs | 65 +----- .../crates/python-bridge/src/router/host.rs | 10 +- .../python-bridge/src/routes/messages/host.rs | 17 +- .../src/routes/responses/host.rs | 9 +- .../python-bridge/src/secrets/callback.rs | 38 ++-- .../crates/python-bridge/src/secrets/mod.rs | 1 + .../python-bridge/src/secrets/mutation.rs | 28 +-- .../python-bridge/src/secrets/python.rs | 4 +- .../python-bridge/src/secrets/runtime.rs | 4 +- .../crates/python-bridge/src/secrets/vault.rs | 32 +-- .../crates/python-bridge/src/tokenizer.rs | 24 +-- litellm/litellm_core_utils/AGENTS.md | 16 +- .../litellm_core_utils/get_model_cost_map.py | 27 ++- .../litellm_core_utils/model_cost_table.py | 112 ++++++++++ .../bedrock/audio_transcription/__init__.py | 3 +- .../llms/fireworks_ai/chat/transformation.py | 2 +- .../proxy/common_utils/model_deprecation.py | 2 +- litellm/proxy/proxy_server.py | 2 +- litellm/rust_bridge/AGENTS.md | 2 +- litellm/rust_bridge/_native.pyi | 29 +-- litellm/rust_bridge/host/AGENTS.md | 50 +++-- litellm/rust_bridge/{ => host}/logger.py | 0 .../rust_bridge/host/model_capabilities.py | 2 +- litellm/rust_bridge/host/model_cost.py | 117 +---------- litellm/rust_bridge/{ => host}/preflight.py | 0 litellm/rust_bridge/host/randomness.py | 11 + litellm/rust_bridge/host/routes/responses.py | 6 + litellm/rust_bridge/host/secret_manager.py | 193 ++++-------------- litellm/rust_bridge/host/timeouts.py | 10 +- litellm/rust_bridge/host/tokenizers.py | 17 ++ litellm/rust_bridge/public_call.py | 10 + litellm/rust_bridge/responses/detectors.py | 10 +- litellm/rust_bridge/responses/websocket.py | 2 +- litellm/secret_managers/native_config.py | 159 +++++++++++++++ litellm/utils.py | 14 +- ...edrock_native_output_register_model_sdk.py | 2 +- .../sdk/test_model_cost_fetch_sdk.py | 2 +- .../unit/caching/test_llm_caching_handler.py | 2 +- tests/unit/conftest.py | 2 +- tests/unit/integrations/arize/test_arize.py | 2 +- .../integrations/arize/test_arize_phoenix.py | 2 +- tests/unit/integrations/opik/test_opik.py | 2 +- .../test_anthropic_cache_control_hook.py | 2 +- tests/unit/integrations/test_helicone.py | 2 +- ...llm_core_utils_prompt_templates_factory.py | 2 +- .../test_get_llm_provider_logic.py | 2 +- .../test_model_cost_table.py | 57 ++++++ .../native/test_anthropic_messages_handler.py | 2 +- .../test_azure_chat_gpt_transformation.py | 2 +- .../test_azure_ai_flux2_image_generation.py | 2 +- .../test_azure_ai_fw_models_metadata.py | 2 +- .../test_azure_ai_kimi_k26_metadata.py | 2 +- .../test_amazon_nova_canvas_image_edit.py | 2 +- .../llms/custom_httpx/test_http_handler.py | 2 +- tests/unit/llms/openai/test_openai.py | 2 +- .../parallel_ai/test_parallel_ai_search.py | 2 +- tests/unit/llms/test_custom_llm.py | 2 +- ...test_vertex_and_google_ai_studio_gemini.py | 2 +- .../common_utils/test_config_sync_pubsub.py | 2 +- .../common_utils/test_http_parsing_utils.py | 2 +- .../common_utils/test_model_deprecation.py | 2 +- .../proxy/hooks/test_dynamic_rate_limiter.py | 2 +- .../hooks/test_prompt_injection_detection.py | 2 +- .../test_sso_helper_utils.py | 2 +- .../proxy_server/test_routes_model_info.py | 2 +- .../proxy/proxy_server/test_routes_utils.py | 2 +- ...test_proxy_server_endpoints_and_startup.py | 2 +- tests/unit/responses/test_responses_utils.py | 2 +- .../unit/router_strategy/test_lowest_cost.py | 2 +- .../router_strategy/test_lowest_tpm_rpm_v2.py | 2 +- .../unit/rust_bridge/host/test_model_cost.py | 61 +----- .../rust_bridge/{ => host}/test_preflight.py | 6 +- .../rust_bridge/host/test_secret_manager.py | 3 +- tests/unit/rust_bridge/test_logger.py | 3 +- tests/unit/test_cost_calculator.py | 2 +- tests/unit/test_main.py | 2 +- tests/unit/test_model_cost_aliases.py | 2 +- .../test_no_top_level_test_invocations.py | 2 +- tests/unit/test_pydantic_namespaces.py | 2 +- .../test_register_model_custom_pricing.py | 4 +- tests/unit/test_router/test_router.py | 2 +- .../unit/test_router_model_cost_isolation.py | 2 +- tests/unit/test_scheduler.py | 2 +- tests/unit/test_utils.py | 18 +- tests/unit/test_utils_get_model_info.py | 2 +- tests/unit/types/test_files.py | 2 +- 95 files changed, 701 insertions(+), 621 deletions(-) create mode 100644 litellm/litellm_core_utils/model_cost_table.py rename litellm/rust_bridge/{ => host}/logger.py (100%) rename litellm/rust_bridge/{ => host}/preflight.py (100%) create mode 100644 litellm/rust_bridge/host/randomness.py create mode 100644 litellm/rust_bridge/host/tokenizers.py create mode 100644 litellm/secret_managers/native_config.py create mode 100644 tests/unit/litellm_core_utils/test_model_cost_table.py rename tests/unit/rust_bridge/{ => host}/test_preflight.py (92%) diff --git a/.github/scripts/verify_linux_native_wheel.py b/.github/scripts/verify_linux_native_wheel.py index 4c4cb399dce..571092263ed 100644 --- a/.github/scripts/verify_linux_native_wheel.py +++ b/.github/scripts/verify_linux_native_wheel.py @@ -214,7 +214,7 @@ def main( native_module: Final = load_native_module(native_path) native_module_loads: Final = native_module is not None panic_test_hook_absent: Final = native_module is not None and not hasattr(native_module, "_panic_for_test") - native_size_limit: Final = 48_000_000 + native_size_limit: Final = 55_000_000 native_size_within_limit: Final = native_member.file_size <= native_size_limit validations: Final = ( (f"Python tag is {EXPECTED_PYTHON_TAG}", python_tag == EXPECTED_PYTHON_TAG), diff --git a/litellm-rust/crates/python-bridge/AGENTS.md b/litellm-rust/crates/python-bridge/AGENTS.md index 26858b2afe2..b18a1e2ca19 100644 --- a/litellm-rust/crates/python-bridge/AGENTS.md +++ b/litellm-rust/crates/python-bridge/AGENTS.md @@ -81,6 +81,7 @@ GIL handling to `litellm-host-python`. ## Bridge Shape +- Reach Python only through `litellm/rust_bridge/host/` and the `litellm.rust_bridge.execution` driver. Never import another LiteLLM module or read a `litellm` global. `litellm/rust_bridge/host/AGENTS.md` lists what may be imported directly - Prefer one stable method per top-level LiteLLM route, for example `messages(...)`, calling the matching `litellm-inference-` entrypoint. - Do not add one exported PyO3 function per provider helper unless there is a diff --git a/litellm-rust/crates/python-bridge/src/http.rs b/litellm-rust/crates/python-bridge/src/http.rs index c6a8b98747c..57c58c3f7cd 100644 --- a/litellm-rust/crates/python-bridge/src/http.rs +++ b/litellm-rust/crates/python-bridge/src/http.rs @@ -112,19 +112,22 @@ pub(crate) fn call_config( Ok(resolved(py, settings)) } -/// The client config for a value of `litellm.rust_bridge.host.settings.http_settings()` the caller handed in, under -/// the process environment. `None` reads the environment alone pub(crate) fn settings_config( py: Python<'_>, - http_settings: Option<&Bound<'_, PyAny>>, + use_http_settings: bool, ) -> PyResult { - let configured = match http_settings { - Some(value) => configured(&PythonSettings::Http.snapshot(value.clone()))?, + let snapshot = if use_http_settings { + PythonSettings::Http.read_or_unset(py)? + } else { + None + }; + let layer = match snapshot { + Some(snapshot) => configured(&snapshot)?, None => HttpSettingsLayer::default(), }; let settings = HttpSettings::from_layers([ HttpSettingsLayer::from_environment(&ProcessEnvironment), - configured, + layer, ]); Ok(resolved(py, settings)) } diff --git a/litellm-rust/crates/python-bridge/src/logger/mod.rs b/litellm-rust/crates/python-bridge/src/logger/mod.rs index 8f40d94f458..5032b418404 100644 --- a/litellm-rust/crates/python-bridge/src/logger/mod.rs +++ b/litellm-rust/crates/python-bridge/src/logger/mod.rs @@ -7,7 +7,7 @@ use litellm_tracing::{DiagnosticInput, Level, Logger, Metadata, Policy, Processo use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; -const MODULE: &str = "litellm.rust_bridge.logger"; +const MODULE: &str = "litellm.rust_bridge.host.logger"; type NativeDiagnosticOutput = (String, Option, Option, Vec, bool); #[pyclass] diff --git a/litellm-rust/crates/python-bridge/src/model_cost/fetch.rs b/litellm-rust/crates/python-bridge/src/model_cost/fetch.rs index 77ec88cc1b9..dff6296cf91 100644 --- a/litellm-rust/crates/python-bridge/src/model_cost/fetch.rs +++ b/litellm-rust/crates/python-bridge/src/model_cost/fetch.rs @@ -20,13 +20,12 @@ pub(crate) struct NativeCostMapSource { #[pymethods] impl NativeCostMapSource { - /// `http_settings` is `litellm.rust_bridge.host.settings.http_settings()`, or `None` for the environment alone #[new] - #[pyo3(signature = (url, *, http_settings))] - fn new(py: Python<'_>, url: String, http_settings: Option>) -> PyResult { + #[pyo3(signature = (url, *, use_http_settings))] + fn new(py: Python<'_>, url: String, use_http_settings: bool) -> PyResult { Ok(Self { url, - http: crate::http::settings_config(py, http_settings.as_ref())?, + http: crate::http::settings_config(py, use_http_settings)?, }) } @@ -226,7 +225,7 @@ mod tests { }); Python::initialize(); Python::attach(|py| { - let source = NativeCostMapSource::new(py, url, None).unwrap(); + let source = NativeCostMapSource::new(py, url, false).unwrap(); let policy = Bound::new(py, NativeFetchPolicy::new(3, 30.0, 5.0, 2.0).unwrap()).unwrap(); let outcome = source.fetch_blocking(py, policy.borrow(), None).unwrap(); diff --git a/litellm-rust/crates/python-bridge/src/preflight.rs b/litellm-rust/crates/python-bridge/src/preflight.rs index bc692498880..2a07723b94c 100644 --- a/litellm-rust/crates/python-bridge/src/preflight.rs +++ b/litellm-rust/crates/python-bridge/src/preflight.rs @@ -7,7 +7,7 @@ use pyo3::{ }; use strum::{IntoStaticStr, VariantArray}; -const MODULE: &str = "litellm.rust_bridge.preflight"; +const MODULE: &str = "litellm.rust_bridge.host.preflight"; /// The litellm globals the preflight still reads through Python. `preflight_contract.json` /// pins each function's parameters on both sides. @@ -115,7 +115,7 @@ mod tests { /// tests that install it run one at a time. static PREFLIGHT_MODULE: Mutex<()> = Mutex::new(()); - /// A fresh stand-in for `litellm.rust_bridge.preflight` that records every call, then + /// A fresh stand-in for `litellm.rust_bridge.host.preflight` that records every call, then /// `script` run against it with the module bound as `preflight`. fn preflight_stubs<'py>(py: Python<'py>, script: &std::ffi::CStr) -> Bound<'py, PyDict> { let locals = PyDict::new(py); @@ -124,15 +124,15 @@ mod tests { import sys import types -for name in ('litellm', 'litellm.rust_bridge'): +for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.host'): sys.modules.setdefault(name, types.ModuleType(name)) -preflight = types.ModuleType('litellm.rust_bridge.preflight') +preflight = types.ModuleType('litellm.rust_bridge.host.preflight') preflight.warnings = [] preflight.checked = [] preflight.credential_list = lambda: [] preflight.warn_unknown_credential = lambda name, loaded: preflight.warnings.append((name, loaded)) preflight.check_limits = lambda kwargs: preflight.checked.append(kwargs) -sys.modules['litellm.rust_bridge.preflight'] = preflight +sys.modules['litellm.rust_bridge.host.preflight'] = preflight ", Some(&locals), Some(&locals), diff --git a/litellm-rust/crates/python-bridge/src/python_settings.rs b/litellm-rust/crates/python-bridge/src/python_settings.rs index 93377abb7e9..1cb1813049c 100644 --- a/litellm-rust/crates/python-bridge/src/python_settings.rs +++ b/litellm-rust/crates/python-bridge/src/python_settings.rs @@ -26,10 +26,15 @@ pub(crate) struct Snapshot<'py> { value: Bound<'py, PyAny>, } -impl Snapshot<'_> { +impl<'py> Snapshot<'py> { pub(crate) fn read(&self, spec: &FieldSpec) -> Result { spec.read(&self.value, self.group.into()) } + + pub(crate) fn attribute(&self, name: &str) -> PyResult>> { + let value = self.value.getattr(name)?; + Ok((!value.is_none()).then_some(value)) + } } impl PythonSettings { @@ -56,7 +61,7 @@ impl PythonSettings { } } - /// A snapshot of a value Python handed in, read with the same field rules as the accessor's + #[cfg(test)] pub(crate) fn snapshot(self, value: Bound<'_, PyAny>) -> Snapshot<'_> { Snapshot { group: self, value } } diff --git a/litellm-rust/crates/python-bridge/src/redis/breaker.rs b/litellm-rust/crates/python-bridge/src/redis/breaker.rs index 733cf1bd969..5fdac76cb02 100644 --- a/litellm-rust/crates/python-bridge/src/redis/breaker.rs +++ b/litellm-rust/crates/python-bridge/src/redis/breaker.rs @@ -72,7 +72,7 @@ impl NativeRedisBreaker { redis_kwargs: RedisSpec, settings: BreakerSettings, ) -> PyResult { - let endpoint = resolved_endpoint(py, &redis_kwargs)?; + let endpoint = resolved_endpoint(&redis_kwargs); Self::build(py, &settings, endpoint) } diff --git a/litellm-rust/crates/python-bridge/src/redis/client/credentials.rs b/litellm-rust/crates/python-bridge/src/redis/client/credentials.rs index 21a37947c30..6995981a03e 100644 --- a/litellm-rust/crates/python-bridge/src/redis/client/credentials.rs +++ b/litellm-rust/crates/python-bridge/src/redis/client/credentials.rs @@ -5,7 +5,7 @@ use litellm_redis_connect::{CredentialSource, CredentialStage, Error, Expiring, use litellm_secret_value::SecretValue; use pyo3::prelude::*; -const MODULE: &str = "litellm.rust_bridge.host.redis_credentials"; +const CREDENTIALS_MODULE: &str = "litellm.rust_bridge.host.redis_credentials"; /// A redis-py `CredentialProvider` the SDK caller passed in `cache_kwargs`, asked through the host /// module the way redis-py's async connection asks it. @@ -29,7 +29,7 @@ impl CredentialSource for PythonCredentialSource { let context = self.context.clone(); Box::pin(async move { let (username, password) = attach_blocking(context, move |py| { - py.import(MODULE)? + py.import(CREDENTIALS_MODULE)? .getattr("credentials")? .call1((provider.bind(py),))? .extract::<(Option, String)>() diff --git a/litellm-rust/crates/python-bridge/src/redis/client/mod.rs b/litellm-rust/crates/python-bridge/src/redis/client/mod.rs index 989f3dc9247..5e5ab7e6f5a 100644 --- a/litellm-rust/crates/python-bridge/src/redis/client/mod.rs +++ b/litellm-rust/crates/python-bridge/src/redis/client/mod.rs @@ -3,7 +3,7 @@ mod credentials; use std::{collections::BTreeMap, sync::Arc, time::Duration}; use litellm_circuit_breaker::Config as BreakerConfig; -use litellm_core_utils::settings::ProcessEnvironment; +use litellm_core_utils::settings::{Lookup, ProcessEnvironment}; use litellm_host_python::PythonContext; use litellm_python_compat::number::{self, SecondsError}; use litellm_redis_client::{RedisClient, RedisEvents}; @@ -92,7 +92,7 @@ fn shared_client( ) -> PyResult> { let environment: SharedEnvironment = Arc::new(ProcessEnvironment); let logger = crate::logger::capture(py); - let resolved = match logger.scope(|| resolve_spec(py, &spec))? { + let resolved = match logger.scope(|| resolve_spec(&spec)) { Ok(resolution) => { logger.scope(|| { for ignored in &resolution.ignored { @@ -174,39 +174,23 @@ impl NativeRedisClient { } /// The Redis server `spec` names, whatever its db, credentials or namespace, or none when the Rust client cannot reach it. -pub(crate) fn resolved_endpoint(py: Python<'_>, spec: &RedisSpec) -> PyResult> { - Ok(resolve_spec(py, spec)? +pub(crate) fn resolved_endpoint(spec: &RedisSpec) -> Option { + resolve_spec(spec) .ok() - .and_then(|resolution| resolution.target.endpoint())) + .and_then(|resolution| resolution.target.endpoint()) } // A live registry entry's client keeps its provider alive, so no live entry shares its address -fn resolve_spec( - py: Python<'_>, - spec: &RedisSpec, -) -> PyResult> { +fn resolve_spec(spec: &RedisSpec) -> Result { let handle = spec .credential_provider .as_ref() .map(|provider| ProviderHandle::new(provider.as_ptr() as usize as u64)); - resolve(&spec.kwargs, handle, &py.import("os")?.getattr("environ")?) -} - -/// What redis-py makes of the kwargs, with `REDIS_USERNAME` read from `environ` the way Python's Azure AD auth -/// reads `os.environ`. -fn resolve( - kwargs: &Kwargs, - provider: Option, - environ: &Bound<'_, PyAny>, -) -> PyResult> { - let redis_username: Option = environ - .call_method1("get", ("REDIS_USERNAME",))? - .extract()?; - Ok(litellm_redis_resolve::interpret( - kwargs, - provider, - redis_username.as_deref(), - )) + litellm_redis_resolve::interpret( + &spec.kwargs, + handle, + ProcessEnvironment.get("REDIS_USERNAME").as_deref(), + ) } fn unresolved( @@ -257,9 +241,6 @@ fn seconds_error(error: SecondsError) -> PyErr { #[cfg(test)] mod tests { - use std::ffi::CStr; - - use litellm_redis_types::Auth; use rstest::rstest; use serde_json::json; @@ -270,30 +251,6 @@ mod tests { litellm_redis_resolve::kwargs(fields) } - #[rstest] - #[case::set(c"environ = {'REDIS_USERNAME': 'azure-user'}", Some("azure-user"))] - #[case::empty_is_unset(c"environ = {'REDIS_USERNAME': ''}", None)] - fn azure_auth_takes_its_username_from_the_environ_like_python( - #[case] source: &CStr, - #[case] expected: Option<&str>, - ) { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run(source, Some(&locals), Some(&locals)).unwrap(); - let environ = locals.get_item("environ").unwrap().unwrap(); - let kwargs = kwargs(json!({"host": "redis", "azure_redis_ad_token": "true"})); - - let resolved = resolve(&kwargs, None, &environ).unwrap().unwrap(); - - assert!( - matches!(&resolved.auth, Auth::AzureAd(azure) if azure.username.as_deref() == expected), - "{:?}", - resolved.auth - ); - }); - } - #[rstest] #[case::no_target(json!({"port": 6379}), Some("ValueError"))] #[case::unenforceable(json!({"host": "redis", "ssl": true, "ssl_validate_ocsp": true}), Some("TypeError"))] diff --git a/litellm-rust/crates/python-bridge/src/router/host.rs b/litellm-rust/crates/python-bridge/src/router/host.rs index 27f9fe347e0..04d19e70edc 100644 --- a/litellm-rust/crates/python-bridge/src/router/host.rs +++ b/litellm-rust/crates/python-bridge/src/router/host.rs @@ -717,6 +717,8 @@ pub(crate) fn deployment_record(deployment: &Deployment) -> Value { deployment.record() } +const RANDOMNESS_MODULE: &str = "litellm.rust_bridge.host.randomness"; + /// The process's `random` module: a Python router draws its shuffle and retry jitter there, so /// a seeded process picks the same deployments on either core. The bare module functions share /// one generator, so these calls interleave with every other draw the process makes. @@ -725,7 +727,9 @@ pub(crate) struct PythonRandomness; impl Randomness for PythonRandomness { fn next_f64(&self) -> f64 { Python::attach(|py| -> PyResult { - py.import("random")?.call_method0("random")?.extract() + py.import(RANDOMNESS_MODULE)? + .call_method0("next_float")? + .extract() }) .unwrap_or_else(|_| ThreadRandomness.next_f64()) } @@ -733,8 +737,8 @@ impl Randomness for PythonRandomness { fn index_below(&self, len: usize) -> usize { // `random.choice` is `seq[self._randbelow(len(seq))]`, and so is `randrange(len)`. Python::attach(|py| -> PyResult { - py.import("random")? - .call_method1("randrange", (len,))? + py.import(RANDOMNESS_MODULE)? + .call_method1("index_below", (len,))? .extract() }) .unwrap_or_else(|_| ThreadRandomness.index_below(len)) diff --git a/litellm-rust/crates/python-bridge/src/routes/messages/host.rs b/litellm-rust/crates/python-bridge/src/routes/messages/host.rs index 2a5f79ca393..c975ac339c2 100644 --- a/litellm-rust/crates/python-bridge/src/routes/messages/host.rs +++ b/litellm-rust/crates/python-bridge/src/routes/messages/host.rs @@ -24,11 +24,12 @@ use crate::{ ArgumentError, optional_timeout, project_optional_fields, public_response, python_timeout_seconds, }, - python_settings::missing_module, + python_settings::PythonSettings, routes::RequestView, }; const ROUTE_HOST_MODULE: &str = "litellm.rust_bridge.host.routes.messages"; +const MODEL_CAPABILITIES_MODULE: &str = "litellm.rust_bridge.host.model_capabilities"; fn merge_headers( forwarded: Option>, @@ -40,16 +41,10 @@ fn merge_headers( (!merged.is_empty()).then_some(merged) } -/// Reads `litellm.`, the module global a param spec names, the way -/// `VertexBase.get_vertex_ai_project` reads `litellm.vertex_project`. fn module_global<'py>(py: Python<'py>, name: &str) -> PyResult>> { - let module = match py.import("litellm") { - Ok(module) => module, - Err(error) if missing_module(py, &error, "litellm")? => return Ok(None), - Err(error) => return Err(error), - }; - let value = module.getattr(name)?; - Ok((!value.is_none()).then_some(value)) + PythonSettings::ProviderDefaults + .read_or_unset(py)? + .map_or(Ok(None), |defaults| defaults.attribute(name)) } /// The caller's litellm params, read from the kwargs by the names the typed params declare, @@ -173,7 +168,7 @@ impl MessagesPythonHost { ) -> PyResult { let module = py.import(ROUTE_HOST_MODULE)?; let capabilities: MessagesModelCapabilities = from_py( - &py.import("litellm.rust_bridge.host.model_capabilities")? + &py.import(MODEL_CAPABILITIES_MODULE)? .getattr("anthropic_model_capabilities")? .call1((model, custom_llm_provider))?, )?; diff --git a/litellm-rust/crates/python-bridge/src/routes/responses/host.rs b/litellm-rust/crates/python-bridge/src/routes/responses/host.rs index b797e334c6a..019f8452737 100644 --- a/litellm-rust/crates/python-bridge/src/routes/responses/host.rs +++ b/litellm-rust/crates/python-bridge/src/routes/responses/host.rs @@ -44,12 +44,9 @@ pub(super) fn project( let value = match (name.as_str(), value) { ("previous_response_id", serde_json::Value::String(id)) => { let decoded: String = py - .import("litellm.responses.utils")? - .getattr("ResponsesAPIRequestUtils")? - .call_method1( - "decode_previous_response_id_to_original_previous_response_id", - (id,), - )? + .import(super::ROUTE_HOST_MODULE)? + .getattr("original_previous_response_id")? + .call1((id,))? .extract()?; serde_json::Value::String(decoded) } diff --git a/litellm-rust/crates/python-bridge/src/secrets/callback.rs b/litellm-rust/crates/python-bridge/src/secrets/callback.rs index af6485a56e1..a2afb42b540 100644 --- a/litellm-rust/crates/python-bridge/src/secrets/callback.rs +++ b/litellm-rust/crates/python-bridge/src/secrets/callback.rs @@ -15,7 +15,6 @@ use pyo3::{ use super::error::{external_error, read_error}; -const HANDLER_MODULE: &str = "litellm.secret_managers.secret_manager_handler"; const DIAGNOSTICS_MODULE: &str = "litellm.rust_bridge.host.diagnostics"; /// A secret manager whose reads execute in Python: a custom manager, a legacy compatible @@ -61,8 +60,8 @@ impl PythonClient { None => kwargs.set_item("key_management_settings", py.None())?, } let result = py - .import(HANDLER_MODULE)? - .getattr("get_secret_from_manager")? + .import(super::SECRET_MANAGER_MODULE)? + .getattr("read_from_manager")? .call((), Some(&kwargs))?; if result.is_instance_of::() { result.extract().map(Some) @@ -133,7 +132,8 @@ mod tests { use litellm_host_python::PythonContext; - use super::{DIAGNOSTICS_MODULE, HANDLER_MODULE, PythonSecretManager, python_name}; + use super::{DIAGNOSTICS_MODULE, PythonSecretManager, python_name}; + use crate::secrets::SECRET_MANAGER_MODULE; use crate::secrets::python_error; /// `sys.modules` is interpreter-global, so tests that install or rely on the handler module @@ -169,12 +169,12 @@ class Manager: raise failure manager = Manager() import sys, types -for name in ('litellm', 'litellm.secret_managers'): +for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.host'): sys.modules.setdefault(name, types.ModuleType(name)) -handler = sys.modules.setdefault('litellm.secret_managers.secret_manager_handler', types.ModuleType('litellm.secret_managers.secret_manager_handler')) -def get_secret_from_manager(**kwargs): +handler = sys.modules.setdefault('litellm.rust_bridge.host.secret_manager', types.ModuleType('litellm.rust_bridge.host.secret_manager')) +def read_from_manager(**kwargs): return kwargs['client'].sync_read_secret(kwargs['secret_name']) -handler.get_secret_from_manager = get_secret_from_manager +handler.read_from_manager = read_from_manager ", Some(&locals), Some(&locals), @@ -317,7 +317,7 @@ sys.modules.setdefault('litellm._logging', logging) }); } - /// Installs a fake `get_secret_from_manager` that records its kwargs, runs `body`, and + /// Installs a fake `read_from_manager` that records its kwargs, runs `body`, and /// removes the fake handler again; parent package stubs persist for concurrent tests. /// Callers hold `handler_guard` before attaching so the GIL is never held while waiting on it. fn with_fake_handler<'py>(py: Python<'py>, body: impl FnOnce(&Bound<'py, PyDict>)) { @@ -325,16 +325,16 @@ sys.modules.setdefault('litellm._logging', logging) py.run( c" import sys, types -previous_handler = sys.modules.get('litellm.secret_managers.secret_manager_handler') +previous_handler = sys.modules.get('litellm.rust_bridge.host.secret_manager') calls = [] -def get_secret_from_manager(**kwargs): +def read_from_manager(**kwargs): calls.append(kwargs) return 'handled-' + kwargs['secret_name'] -handler = types.ModuleType('litellm.secret_managers.secret_manager_handler') -handler.get_secret_from_manager = get_secret_from_manager -for name in ('litellm', 'litellm.secret_managers'): +handler = types.ModuleType('litellm.rust_bridge.host.secret_manager') +handler.read_from_manager = read_from_manager +for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.host'): sys.modules.setdefault(name, types.ModuleType(name)) -sys.modules['litellm.secret_managers.secret_manager_handler'] = handler +sys.modules['litellm.rust_bridge.host.secret_manager'] = handler ", Some(&locals), Some(&locals), @@ -344,9 +344,9 @@ sys.modules['litellm.secret_managers.secret_manager_handler'] = handler py.run( c" if previous_handler is None: - sys.modules.pop('litellm.secret_managers.secret_manager_handler', None) + sys.modules.pop('litellm.rust_bridge.host.secret_manager', None) else: - sys.modules['litellm.secret_managers.secret_manager_handler'] = previous_handler + sys.modules['litellm.rust_bridge.host.secret_manager'] = previous_handler ", Some(&locals), Some(&locals), @@ -366,7 +366,7 @@ else: with_fake_handler(py, |locals| { locals.set_item("expression", expression).unwrap(); py.run( - c"handler.get_secret_from_manager = lambda **kwargs: eval(expression)", + c"handler.read_from_manager = lambda **kwargs: eval(expression)", Some(locals), Some(locals), ) @@ -417,7 +417,7 @@ else: reader.client.read(py, "API_KEY").unwrap().as_deref(), Some("handled-API_KEY") ); - assert!(py.import(HANDLER_MODULE).is_ok()); + assert!(py.import(SECRET_MANAGER_MODULE).is_ok()); let calls = locals.get_item("calls").unwrap().unwrap(); let call = calls.get_item(0).unwrap().cast_into::().unwrap(); assert!(call.get_item("client").unwrap().unwrap().is(&client)); diff --git a/litellm-rust/crates/python-bridge/src/secrets/mod.rs b/litellm-rust/crates/python-bridge/src/secrets/mod.rs index 57042e6a32e..0840ecdfc6c 100644 --- a/litellm-rust/crates/python-bridge/src/secrets/mod.rs +++ b/litellm-rust/crates/python-bridge/src/secrets/mod.rs @@ -21,6 +21,7 @@ use resolved::ResolvedSecrets; use crate::{coercion::FieldSpec, python_settings::PythonSettings}; +const SECRET_MANAGER_MODULE: &str = "litellm.rust_bridge.host.secret_manager"; const NATIVE: FieldSpec = FieldSpec::new("native", |field| field.schema_bool()); /// Where a Rust route reads provider secrets from. Python's `get_secret_str` when the settings diff --git a/litellm-rust/crates/python-bridge/src/secrets/mutation.rs b/litellm-rust/crates/python-bridge/src/secrets/mutation.rs index b04060651ac..47cd36c4015 100644 --- a/litellm-rust/crates/python-bridge/src/secrets/mutation.rs +++ b/litellm-rust/crates/python-bridge/src/secrets/mutation.rs @@ -2,7 +2,7 @@ use super::operations::{PythonMutationError, PythonMutationResponse}; use litellm_error::Report; use litellm_host_python::{json_loads, to_py}; use litellm_secrets::cyberark; -use pyo3::{prelude::*, types::PyDict}; +use pyo3::prelude::*; pub(super) fn mutation_value( result: Result, @@ -62,14 +62,11 @@ fn cyberark_message( http_message(py, "POST", url, status)? } cyberark::Error::Http(source) if authentication => match os_error_code(&source) { - Some(code) => { - let reason = py.import("os")?.getattr("strerror")?.call1((code,))?; - py.import("builtins")? - .getattr("OSError")? - .call1((code, reason))? - .str()? - .extract()? - } + Some(code) => py + .import(super::SECRET_MANAGER_MODULE)? + .getattr("os_error_message")? + .call1((code,))? + .extract()?, None => Report::new(&cyberark::Error::Http(source)).to_string(), }, cyberark::Error::Http(source) if source.is_connect() => { @@ -103,13 +100,8 @@ pub(super) fn http_message( url: &str, status: u16, ) -> PyResult { - let httpx = py.import("httpx")?; - let request = httpx.getattr("Request")?.call1((method, url))?; - let kwargs = PyDict::new(py); - kwargs.set_item("request", request)?; - let response = httpx.getattr("Response")?.call((status,), Some(&kwargs))?; - match response.call_method0("raise_for_status") { - Err(error) => error.value(py).str()?.extract(), - Ok(_) => Ok(format!("HTTP {status}")), - } + py.import(super::SECRET_MANAGER_MODULE)? + .getattr("http_status_message")? + .call1((method, url, status))? + .extract() } diff --git a/litellm-rust/crates/python-bridge/src/secrets/python.rs b/litellm-rust/crates/python-bridge/src/secrets/python.rs index c5d9e3565b3..a92cf9a497a 100644 --- a/litellm-rust/crates/python-bridge/src/secrets/python.rs +++ b/litellm-rust/crates/python-bridge/src/secrets/python.rs @@ -9,8 +9,6 @@ use pyo3::prelude::*; use super::error::external_error; -const SECRET_MANAGER_MODULE: &str = "litellm.rust_bridge.host.secret_manager"; - /// Reads each secret through the host module's `read_secret_str`, so the configured manager, the key /// management settings and the environment fallback behave exactly as they do in Python. pub(super) struct PythonSecrets { @@ -21,7 +19,7 @@ pub(super) struct PythonSecrets { impl PythonSecrets { pub(super) fn new(py: Python<'_>) -> PyResult { Ok(Self::reading_with( - py.import(SECRET_MANAGER_MODULE)? + py.import(super::SECRET_MANAGER_MODULE)? .getattr("read_secret_str")? .unbind(), PythonContext::capture(py)?, diff --git a/litellm-rust/crates/python-bridge/src/secrets/runtime.rs b/litellm-rust/crates/python-bridge/src/secrets/runtime.rs index 0d52b0aa629..8061d1682ed 100644 --- a/litellm-rust/crates/python-bridge/src/secrets/runtime.rs +++ b/litellm-rust/crates/python-bridge/src/secrets/runtime.rs @@ -12,8 +12,6 @@ use pyo3::{exceptions::PyAttributeError, prelude::*, types::PyDict}; use super::ManagerError; use crate::execution::{run_async_value, run_sync_value}; -const SECRET_MANAGER_MODULE: &str = "litellm.rust_bridge.host.secret_manager"; - #[derive(Clone, PartialEq)] struct Configuration { system: KeyManagementSystem, @@ -98,7 +96,7 @@ impl NativeSecretManager { return Ok(Some(native)); } let config = py - .import(SECRET_MANAGER_MODULE)? + .import(super::SECRET_MANAGER_MODULE)? .getattr("native_secret_manager_config")? .call1((client,))?; if config.is_none() { diff --git a/litellm-rust/crates/python-bridge/src/secrets/vault.rs b/litellm-rust/crates/python-bridge/src/secrets/vault.rs index 30e3bd2c413..5f14727abaa 100644 --- a/litellm-rust/crates/python-bridge/src/secrets/vault.rs +++ b/litellm-rust/crates/python-bridge/src/secrets/vault.rs @@ -134,31 +134,21 @@ fn timeout_message( String::new() }); } - let kwargs = pyo3::types::PyDict::new(py); - kwargs.set_item( - "message", - transport_timeout_message( - TimeoutPhase::Read, - context.timeout, - Some(elapsed.as_secs_f64()), - ), - )?; - kwargs.set_item("model", "default-model-name")?; - kwargs.set_item("llm_provider", "litellm-httpx-handler")?; - py.import("litellm.exceptions")? - .getattr("Timeout")? - .call((), Some(&kwargs))? - .str()? + let message = transport_timeout_message( + TimeoutPhase::Read, + context.timeout, + Some(elapsed.as_secs_f64()), + ); + py.import(super::SECRET_MANAGER_MODULE)? + .getattr("timeout_message")? + .call1((message,))? .extract() } fn response_text(py: Python<'_>, body: &[u8]) -> PyResult { - let kwargs = pyo3::types::PyDict::new(py); - kwargs.set_item("content", pyo3::types::PyBytes::new(py, body))?; - py.import("httpx")? - .getattr("Response")? - .call((200,), Some(&kwargs))? - .getattr("text")? + py.import(super::SECRET_MANAGER_MODULE)? + .getattr("response_text")? + .call1((pyo3::types::PyBytes::new(py, body),))? .extract() } diff --git a/litellm-rust/crates/python-bridge/src/tokenizer.rs b/litellm-rust/crates/python-bridge/src/tokenizer.rs index 2026b2fdc42..11b21bd5673 100644 --- a/litellm-rust/crates/python-bridge/src/tokenizer.rs +++ b/litellm-rust/crates/python-bridge/src/tokenizer.rs @@ -38,16 +38,19 @@ use litellm_token_counter::huggingface::{ #[cfg(feature = "tiktoken")] use litellm_token_counter::tiktoken::{TiktokenTokenizer, Vocabulary}; +#[cfg(any(feature = "tiktoken", feature = "huggingface"))] +const HOST_MODULE: &str = "litellm.rust_bridge.host.tokenizers"; + #[cfg(feature = "tiktoken")] pub(crate) fn load_tiktoken(py: Python<'_>, encoding: &str) -> PyResult { enter_native()?; - let resource: std::path::PathBuf = - PyModule::import(py, "litellm.litellm_core_utils.tokenizers")? - .getattr("__file__")? - .extract()?; + let resources: std::path::PathBuf = PyModule::import(py, HOST_MODULE)? + .getattr("tiktoken_resource_dir")? + .call0()? + .extract()?; release_gil(py, || { TiktokenTokenizer::from_cached_ranks(encoding, |file| { - std::fs::read_to_string(resource.with_file_name(file)) + std::fs::read_to_string(resources.join(file)) }) }) .map_err(|error| token_count_error_to_pyerr(error.into())) @@ -197,14 +200,9 @@ impl Tokenizer { #[cfg(feature = "huggingface")] { enter_native()?; - let kwargs = PyDict::new(py); - kwargs.set_item("repo_id", identifier)?; - kwargs.set_item("filename", "tokenizer.json")?; - kwargs.set_item("revision", revision)?; - kwargs.set_item("token", token)?; - let path: String = PyModule::import(py, "huggingface_hub")? - .getattr("hf_hub_download")? - .call((), Some(&kwargs))? + let path: String = PyModule::import(py, HOST_MODULE)? + .getattr("download_tokenizer")? + .call1((identifier, revision, token))? .extract()?; let json = release_gil(py, || std::fs::read_to_string(path)) .map_err(TokenizerError::Download)?; diff --git a/litellm/litellm_core_utils/AGENTS.md b/litellm/litellm_core_utils/AGENTS.md index e817eca0ac4..9cfeccba44c 100644 --- a/litellm/litellm_core_utils/AGENTS.md +++ b/litellm/litellm_core_utils/AGENTS.md @@ -3,7 +3,7 @@ - This module decides when to fetch and install. Fetching, parsing, integrity checks and alias expansion run in Rust - The install order is publish, `add_known_models`, router replay, then `model_cost_inputs.reapply` - Registrations land by lookup in the published table, so they are written after publishing -- Startup and `register_model(url)` fetch with `http_settings=None`, which reads env vars only like main's bare httpx. Reload passes `http_settings()` +- Startup and `register_model(url)` fetch with `use_http_settings=False`, which reads env vars only like main's bare httpx. Reload passes `use_http_settings=True`, so Rust layers the configured HTTP settings on top - Startup falls back to the bundled map on any failure. Reload never falls back and keeps the current map - `register_model(url)` makes one attempt and registers nothing on failure - `utils._register_on_current_map` reruns the whole pass on the new table after `NativeTableSwapped`, and its last attempt passes `rows=None`, so no entry is lost @@ -12,4 +12,16 @@ - `GetModelCostMap.validate_model_cost_map` and `_check_*`, since Rust checks fetched documents - `ModelCostMapReloaded.model_cost_map`, use `GetModelCostMap.loaded_model_cost_map()` - the `sleep`, `rng` and `client` parameters, which are ignored with a `DeprecationWarning` - - `AliasedCostEntry` re-exported here, which now lives in `rust_bridge/host/model_cost.py` + - `AliasedCostEntry` re-exported here, which now lives in `model_cost_table.py` + +## model_cost_table.py + +- This module owns the live `litellm.model_cost` table. Python code reads and writes it here, and `rust_bridge/host/model_cost.py` calls it for Rust +- `publish`, `write` and `changed_in_place` are the only writers. Each moves `ModelCostVersion` +- The version is `(id(table), len(table), writes)`, so replacing the dict or changing its size moves it without a writer + - An in-place value edit does not, so code that edits rows in place calls `changed_in_place()` +- Anything derived from the table is cached under the version (`@VersionCached`, `model_cost_version()`), never cleared by hand +- Compat names exist only so code written against main keeps working. New code never calls them, and they never gain behavior of their own + - `utils._invalidate_model_cost_lowercase_map` forwards to `changed_in_place` + - `utils.get_model_cost_mutation_generation` forwards to `model_cost_version().writes` + - `utils._model_cost_lowercase_map` is always `None` diff --git a/litellm/litellm_core_utils/get_model_cost_map.py b/litellm/litellm_core_utils/get_model_cost_map.py index 80393a72631..6f4405066ca 100644 --- a/litellm/litellm_core_utils/get_model_cost_map.py +++ b/litellm/litellm_core_utils/get_model_cost_map.py @@ -32,9 +32,13 @@ from litellm.constants import ( MODEL_COST_MAP_MAX_SHRINK_RATIO, MODEL_COST_MAP_MIN_MODEL_COUNT, ) +from litellm.litellm_core_utils import model_cost_table from litellm.litellm_core_utils.fallback_generalizations import ( set_fallback_generalizations, ) +from litellm.litellm_core_utils.model_cost_table import ( + AliasedCostEntry as AliasedCostEntry, # noqa: PLC0414 # main exported the row type from here +) from litellm.litellm_core_utils.offline_mode import is_offline_mode from litellm.rust_bridge._native import ( NativeCostMapSource, @@ -44,11 +48,6 @@ from litellm.rust_bridge._native import ( NativeModelCostMap, NativeRejected, ) -from litellm.rust_bridge.host import model_cost as model_cost_host -from litellm.rust_bridge.host.model_cost import ( - AliasedCostEntry as AliasedCostEntry, # noqa: PLC0414 # main exported the row type from here -) -from litellm.rust_bridge.host.settings import HttpSettings _CLI_ENTRYPOINT_NAMES: Final = frozenset({"lite", "litellm-proxy"}) _CATALOG_ADAPTER: Final = TypeAdapter(dict[str, dict[str, object]]) @@ -259,8 +258,8 @@ def get_model_cost_map_loaded_at() -> "datetime | None": return _LastLoad.current.loaded_at -def cost_map_source(url: str, http_settings: HttpSettings | None) -> NativeCostMapSource: - return NativeCostMapSource(url, http_settings=http_settings) +def cost_map_source(url: str, use_http_settings: bool) -> NativeCostMapSource: + return NativeCostMapSource(url, use_http_settings=use_http_settings) def fetch_policy(timeout: float, max_attempts: int) -> NativeFetchPolicy: @@ -288,7 +287,7 @@ def adopt_model_cost_map(new_model_cost_map: dict) -> int: # mutable-ok: main's import litellm from litellm import utils - model_cost_host.publish(new_model_cost_map) + model_cost_table.publish(new_model_cost_map) litellm.add_known_models(model_cost_map=new_model_cost_map) utils.reapply_runtime_model_cost_registrations() return len(new_model_cost_map) @@ -354,15 +353,13 @@ async def refetch_model_cost_map( on failure it returns ``ModelCostMapReloadUnavailable`` and the map already installed stays. """ - from litellm.rust_bridge.host.settings import http_settings - _warn_ignored_fetch_hooks(sleep, rng, client) if _uses_bundled_map_only(): _mark_rows_with_rules_set("local", None, True, None, datetime.now(timezone.utc)) return ModelCostMapReloaded(model_count=_install(model_cost_inputs.install_bundled())) - fetched: Final = await cost_map_source(url, http_settings()).fetch(fetch_policy(timeout, max_attempts)) + fetched: Final = await cost_map_source(url, use_http_settings=True).fetch(fetch_policy(timeout, max_attempts)) if isinstance(fetched, NativeFetchFailed): verbose_logger.warning( "LiteLLM: model cost map reload failed: %s. Keeping the currently loaded map.", fetched.reason @@ -382,7 +379,9 @@ def _retry_remote_fetch_in_background( url: str, timeout: int, max_attempts: int, first_failure: NativeFetchFailed ) -> None: try: - fetched: Final = cost_map_source(url, None).fetch_blocking(fetch_policy(timeout, max_attempts), first_failure) + fetched: Final = cost_map_source(url, use_http_settings=False).fetch_blocking( + fetch_policy(timeout, max_attempts), first_failure + ) if isinstance(fetched, NativeFetchFailed): verbose_logger.warning( "LiteLLM: Failed to fetch remote model cost map from %s after %d attempts; keeping local backup", @@ -425,7 +424,7 @@ def get_model_cost_map( _mark_rows_with_rules_set("local", None, True, None, started) return _rows_with_rules_set(model_cost_inputs.install_bundled()) - fetched: Final = cost_map_source(url, None).fetch_blocking(fetch_policy(timeout, 1)) + fetched: Final = cost_map_source(url, use_http_settings=False).fetch_blocking(fetch_policy(timeout, 1)) if isinstance(fetched, NativeFetchFailed): if fetched.retryable and max_attempts > 1: threading.Thread( @@ -459,7 +458,7 @@ def get_model_cost_map( def fetch_registration_document(url: str) -> dict[str, dict[str, object]] | None: """The document behind ``register_model(url)``, fetched in one attempt, or ``None`` when the fetch failed.""" - fetched: Final = cost_map_source(url, None).fetch_blocking(fetch_policy(5, 1)) + fetched: Final = cost_map_source(url, use_http_settings=False).fetch_blocking(fetch_policy(5, 1)) if isinstance(fetched, NativeFetchFailed): verbose_logger.warning("LiteLLM: register_model could not fetch %s: %s", url, fetched.reason) return None diff --git a/litellm/litellm_core_utils/model_cost_table.py b/litellm/litellm_core_utils/model_cost_table.py new file mode 100644 index 00000000000..c28098d674c --- /dev/null +++ b/litellm/litellm_core_utils/model_cost_table.py @@ -0,0 +1,112 @@ +"""The live ``litellm.model_cost`` table, every write to it, and its version.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from functools import lru_cache +from typing import Final, Generic, ParamSpec, TypeVar + +from litellm.constants import DEFAULT_MAX_LRU_CACHE_SIZE + +P = ParamSpec("P") +R = TypeVar("R") + + +class AliasedCostEntry(dict[str, object]): + """A ``litellm.model_cost`` row, the one object every alias name of a model is served under.""" + + __slots__ = ("names", "registered_fields") + + def __init__( + self, entry: Mapping[str, object], names: tuple[str, ...], registered_fields: frozenset[str] = frozenset() + ) -> None: + super().__init__(entry) + self.names: tuple[str, ...] = names + self.registered_fields: frozenset[str] = registered_fields + + +@dataclass(frozen=True, slots=True) +class ModelCostVersion: + table: int + size: int + writes: int + + +class _Writes: + __slots__ = ("count",) + + def __init__(self) -> None: + self.count: int = 0 + + def record(self) -> None: + self.count += 1 + + +_WRITES: Final = _Writes() + + +def model_cost_version() -> ModelCostVersion: + import litellm + + table: Final = litellm.model_cost + return ModelCostVersion(table=id(table), size=len(table), writes=_WRITES.count) + + +def publish(table: dict[str, object]) -> None: + """Makes ``table`` the live ``litellm.model_cost``.""" + import litellm + + litellm.model_cost = table + _WRITES.record() + + +def write(rows: dict[str, object], written: Mapping[str, object]) -> None: + """Writes one registration's rows into ``rows``, the published table.""" + rows.update(written) + _WRITES.record() + + +def changed_in_place() -> None: + """Moves the version and drops the name index after an in-place edit of ``litellm.model_cost``.""" + from litellm.litellm_core_utils.get_model_cost_map import model_cost_inputs + + model_cost_inputs.invalidate() + _WRITES.record() + + +class VersionCached(Generic[P, R]): + """Decorates ``function`` with an ``lru_cache`` keyed on the ``litellm.model_cost`` version each call read, and + exposes that cache's ``cache_clear`` and ``cache_info``.""" + + __slots__ = ("_at", "cache_clear", "cache_info") + + def __init__(self, function: Callable[P, R]) -> None: + def at( + _version: ModelCostVersion, + *args: P.args, + **kwargs: P.kwargs, # kwargs-ok: forwards a ParamSpec unchanged + ) -> R: + return function(*args, **kwargs) + + cached: Final = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(at) + self._at: Final = cached + self.cache_clear: Final = cached.cache_clear + self.cache_info: Final = cached.cache_info + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # kwargs-ok: forwards a ParamSpec unchanged + return self._at(model_cost_version(), *args, **kwargs) + + +@dataclass(frozen=True, slots=True) +class Landing: + key: str + base: Mapping[str, object] + matched_builtin: bool + + +def published() -> dict[str, object]: + """The table published as ``litellm.model_cost`` now.""" + import litellm + + return litellm.model_cost diff --git a/litellm/llms/bedrock/audio_transcription/__init__.py b/litellm/llms/bedrock/audio_transcription/__init__.py index bc74e9c38ab..dcda72c9a60 100644 --- a/litellm/llms/bedrock/audio_transcription/__init__.py +++ b/litellm/llms/bedrock/audio_transcription/__init__.py @@ -10,8 +10,7 @@ from pydantic import BaseModel, ConfigDict from litellm.litellm_core_utils.audio_utils.utils import process_audio_file from litellm.rust_bridge import runtime from litellm.rust_bridge.catalog import Route, RouteContext -from litellm.rust_bridge.host.timeouts import timeout_to_seconds -from litellm.rust_bridge.public_call import NativeCall +from litellm.rust_bridge.public_call import NativeCall, timeout_to_seconds from litellm.rust_bridge.transcription.native import ( NATIVE_ATRANSCRIPTION, NATIVE_TRANSCRIPTION, diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 9db1d50d730..55a75423042 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -13,13 +13,13 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.llm_response_utils.get_headers import ( get_response_headers, ) +from litellm.litellm_core_utils.model_cost_table import ModelCostVersion, model_cost_version from litellm.litellm_core_utils.prompt_templates.common_utils import ( strip_litellm_response_only_fields, unpack_legacy_defs, ) from litellm.litellm_core_utils.reasoning_effort_utils import OPAQUE_VALUE, warn_dropped_reasoning from litellm.litellm_core_utils.url_utils import join_api_base -from litellm.rust_bridge.host.model_cost import ModelCostVersion, model_cost_version from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ( AllMessageValues, diff --git a/litellm/proxy/common_utils/model_deprecation.py b/litellm/proxy/common_utils/model_deprecation.py index b50890089e4..f33bb82b49b 100644 --- a/litellm/proxy/common_utils/model_deprecation.py +++ b/litellm/proxy/common_utils/model_deprecation.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Final, TypeAlias import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap -from litellm.rust_bridge.host.model_cost import AliasedCostEntry +from litellm.litellm_core_utils.model_cost_table import AliasedCostEntry from litellm.types.proxy.model_deprecation import ( DEFAULT_DEPRECATION_WARN_DAYS, DeprecationStatus, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 888276f7c31..f7a27e4896c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7896,7 +7896,7 @@ class ProxyConfig: else: raise ValueError("Invalid Key Management System selected") - from litellm.rust_bridge.host.secret_manager import capture_secret_manager + from litellm.secret_managers.native_config import capture_secret_manager if litellm.secret_manager_client is not previous_client: capture_secret_manager(litellm.secret_manager_client, key_management_system) diff --git a/litellm/rust_bridge/AGENTS.md b/litellm/rust_bridge/AGENTS.md index c2a030ca4d9..167b91c9b89 100644 --- a/litellm/rust_bridge/AGENTS.md +++ b/litellm/rust_bridge/AGENTS.md @@ -9,7 +9,7 @@ - provider execution, which is Rust core - CPython runtime mechanics, which is `litellm-host-python` - legacy callback sharing and dispatch policy, which is `callbacks-legacy-python` -- `host/` holds the modules Rust imports by name to read Python-owned state. `host/AGENTS.md` states that contract +- `host/` holds the modules Rust imports by name to reach Python-owned state and behavior. Only Rust calls them, and Rust reaches Python nowhere else. `host/AGENTS.md` states that contract # Routing diff --git a/litellm/rust_bridge/_native.pyi b/litellm/rust_bridge/_native.pyi index 221968e07ed..ebbd45c7382 100644 --- a/litellm/rust_bridge/_native.pyi +++ b/litellm/rust_bridge/_native.pyi @@ -8,7 +8,6 @@ from typing_extensions import disjoint_base from litellm.redis.store_settings import RedisBreakerSettings, RedisStoreSettings from litellm.rust_bridge.execution import Execution -from litellm.rust_bridge.host.settings import HttpSettings from litellm.rust_bridge.public_call import NativeCall _T_co = TypeVar("_T_co", covariant=True) @@ -22,10 +21,9 @@ class NativeRedisStoreError(Exception): ... def trace_encode_error(message: str) -> bytes: ... def bundled_model_cost_map() -> bytes: ... def native_redis_breaker_states() -> list[str]: ... - @final class NativeCostMapSource: - def __new__(cls, url: str, *, http_settings: HttpSettings | None) -> NativeCostMapSource: ... + def __new__(cls, url: str, *, use_http_settings: bool) -> NativeCostMapSource: ... def fetch(self, policy: NativeFetchPolicy) -> Future[NativeFetched | NativeFetchFailed]: ... def fetch_blocking( self, policy: NativeFetchPolicy, resume_from: NativeFetchFailed | None = None @@ -230,9 +228,7 @@ class NativeRouterUsage: def attach(self, client: NativeRedisClient) -> bool: ... @property def has_redis(self) -> bool: ... - def increment( - self, counter: _UsageCounter, value: float, ttl_seconds: int - ) -> Future[float]: ... + def increment(self, counter: _UsageCounter, value: float, ttl_seconds: int) -> Future[float]: ... def increment_deployment( self, deployment_id: str, @@ -241,12 +237,8 @@ class NativeRouterUsage: tokens: float, requests: int, ) -> Future[None]: ... - def get_many( - self, counters: Sequence[_UsageCounter] - ) -> Future[list[float | None]]: ... - def increment_sync( - self, counter: _UsageCounter, value: float, ttl_seconds: int - ) -> float: ... + def get_many(self, counters: Sequence[_UsageCounter]) -> Future[list[float | None]]: ... + def increment_sync(self, counter: _UsageCounter, value: float, ttl_seconds: int) -> float: ... def increment_deployment_sync( self, deployment_id: str, @@ -255,9 +247,7 @@ class NativeRouterUsage: tokens: float, requests: int, ) -> None: ... - def get_many_sync( - self, counters: Sequence[_UsageCounter] - ) -> list[float | None]: ... + def get_many_sync(self, counters: Sequence[_UsageCounter]) -> list[float | None]: ... def flush_local_counters(self) -> None: ... def snapshot(self) -> dict[str, float]: ... @@ -433,7 +423,9 @@ class NativeAuthCache: def get_sync(self, key: str) -> object: ... def set(self, key: str, value: object, ttl_seconds: float | None = None) -> Future[None]: ... def set_sync(self, key: str, value: object, ttl_seconds: float | None = None) -> None: ... - def declare_set(self, key: str, value: object, ttl_seconds: float | None, send: NativeSend) -> NativePending[None]: ... + def declare_set( + self, key: str, value: object, ttl_seconds: float | None, send: NativeSend + ) -> NativePending[None]: ... def declare_delete(self, key: str, send: NativeSend) -> NativePending[None]: ... def set_many(self, entries: Sequence[tuple[str, object]], ttl_seconds: float | None = None) -> Future[None]: ... def write_back(self, entries: Sequence[tuple[str, object, float | None]], send: NativeSend) -> Future[None]: ... @@ -550,7 +542,6 @@ def messages( def amessages( call: NativeCall, ) -> Execution: ... - @final class ResponsesWebSocketConnection: def __new__(cls, _uninstantiable: Never, /) -> Never: ... @@ -1189,7 +1180,9 @@ class RouterCoreHandle: callbacks: Mapping[str, bool] | None = None, ) -> RouterCall[_SuccessReportSteps]: ... # Count a success against any id, driven like `report_failure`; `done` carries `key`. - def count_success(self, id: str, callbacks: Mapping[str, bool] | None = None) -> RouterCall[_SuccessReportSteps]: ... + def count_success( + self, id: str, callbacks: Mapping[str, bool] | None = None + ) -> RouterCall[_SuccessReportSteps]: ... # [(id, tpm_key, rpm_key)] for this minute, or None for a group with no deployment. def usage_keys(self, model_group: str) -> list[tuple[str, str, str]] | None: ... # Every deployment as a `model_list` entry, in config order. diff --git a/litellm/rust_bridge/host/AGENTS.md b/litellm/rust_bridge/host/AGENTS.md index 4cb87a85c28..c249eb82ee0 100644 --- a/litellm/rust_bridge/host/AGENTS.md +++ b/litellm/rust_bridge/host/AGENTS.md @@ -1,24 +1,48 @@ # Rust host modules -Python state Rust needs but does not own, behind one module per question. Rust imports a module here by path and calls a function by name +Python behavior Rust needs but does not own, behind one module per question. Rust imports a module here by path and calls a function by name -- One module answers one question: `model_capabilities` (what a model can do), `callbacks_legacy_python` (the legacy callback registries), `settings` (configured snapshots), `cache` (the live configured cache), `diagnostics` (legacy failure logging), `secret_manager` (the configured secret manager and native config capture), `http_transport` (which async transport is in use), `timeouts` (timeout to seconds), `redis_events` (reporting native Redis calls and breaker transitions to `ServiceLogging`), `redis_credentials` (what a caller's redis-py `CredentialProvider` authenticates with), `model_cost` (the live `litellm.model_cost` table, every write to it and its version) -- A module is the only place Rust reads that Python state. Rust never does `py.import("litellm").getattr(...)` on a global; it asks here +## Boundary + +- This package is the only place Rust reaches into Python. `python-bridge` and `callbacks-legacy-python` import no other LiteLLM module, and never do `py.import("litellm").getattr(...)` on a global + - The one exception is `litellm.rust_bridge.execution`, the driver's step classes. It is runtime protocol, not Python state, and the Python entrypoints import it too +- Only Rust calls these modules. No Python module outside `host/` imports one. Tests may, to set up the Python state Rust reads + - Logic both sides need lives with its Python owner (`public_call`, `secret_managers`, the route's types), and the host module calls it +- A Python call that reproduces Python behavior goes through a host function, including standard library and third-party calls: error text built from `httpx`, `OSError` or `litellm.exceptions`, `huggingface_hub` downloads, the process `random` generator + - Rust reads the process environment itself (`ProcessEnvironment`). CPython's `os.environ` writes through to it, so there is nothing to ask Python for + - Rust imports a module directly only to get a type or a Python builtin it applies itself: `isinstance` checks (`os.PathLike`, `ssl.SSLContext`), exception classes it raises (`redis.exceptions`), `builtins.object` and `inspect.getattr_static` for Python attribute semantics + - `litellm-host-python` is the generic runtime and keeps its own `asyncio`, `contextvars` and `json` imports +- Check these invariants by review whenever a Rust file adds a `py.import`, an `import_exception!` or a `*_MODULE` constant, or a Python file imports from `litellm.rust_bridge.host` + +## Modules + +- One module answers one question + - `model_capabilities`: what a model can do + - `callbacks_legacy_python`: the legacy callback registries + - `settings`: configured snapshots + - `cache`: the live configured cache + - `diagnostics`: legacy failure logging + - `logger`: where native diagnostics are emitted + - `preflight`: the SDK globals the native preflight borrows + - `secret_manager`: the configured secret manager, its native config and the error text Python's managers produce + - `http_transport`: which async transport is in use + - `timeouts`: timeout to seconds + - `tokenizers`: bundled tiktoken files and Hugging Face tokenizer downloads + - `randomness`: the process `random` generator the Python router draws from + - `redis_events`: reporting native Redis calls and breaker transitions to `ServiceLogging` + - `redis_credentials`: what a caller's redis-py `CredentialProvider` authenticates with + - `model_cost`: where a `register_model` entry lands in the published table and the row it writes +- `routes/.py` holds the questions one route asks, under the same function names on every route (`response`, `map_failure`, `connection_defaults`), so Rust names only the module. A question two routes ask goes in a top-level module here instead - Each module reuses the Python implementation the Python route runs (`AnthropicModelInfo`, `Logging`). Do not re-derive those rules here or in Rust; one resolver, two callers -- The module path and function signature are a contract. The Rust side pins the path in a `const MODULE` in the crate that asks (`python-bridge/src/routes/messages/host.rs`, `python-bridge/src/secrets/`, `callbacks-legacy-python/src/python.rs`); change both sides together and keep `tests/unit/rust_bridge/host/` green + +## Contract + +- The module path and function signature are a contract. Each Rust crate declares a host module's path once, in a `const *_MODULE` next to the code that asks (`python-bridge/src/secrets/mod.rs`, `python-bridge/src/routes//`, `callbacks-legacy-python/src/python.rs`). Change both sides together and keep `tests/unit/rust_bridge/host/` green - Inputs and outputs use values and frozen snapshots. Retain caller-owned clients and caches as opaque references when identity is required; Rust never discovers them through module globals - Moving a question into Rust means replacing the function body with a native call, not adding a second resolver. The Python signature stays -- `routes/.py` holds the questions one route asks, under the same function names on every route (`response`, `map_failure`, `connection_defaults`), so Rust names only the module. A question two routes ask goes in a top-level module here instead ## model_cost -- `publish`, `write` and `changed_in_place` are the only writers. Each moves `ModelCostVersion` -- The version is `(id(table), len(table), writes)`, so replacing the dict or changing its size moves it without the host - - An in-place value edit does not, so code that edits rows in place calls `changed_in_place()` -- Anything derived from the table is cached under the version (`@VersionCached`, `model_cost_version()`), never cleared by hand +- `model_cost` answers what Rust needs while registering models: `entry`, `published`, `write`, `resolve`, `merged` and `kept`. The live table, its version and every Python writer belong to `litellm_core_utils/model_cost_table.py` - `resolve`, `merged` and `kept` are shims until `get_model_info` and a merge generic over Python values move to Rust. Then their bodies become native calls and the signatures stay - `resolve` returns `None` when it was handed a table that is no longer published. That is how a pass learns a reload swapped the table -- Compat names exist only so code written against main keeps working. New code never calls them, and they never gain behavior of their own - - `utils._invalidate_model_cost_lowercase_map` forwards to `changed_in_place` - - `utils.get_model_cost_mutation_generation` forwards to `model_cost_version().writes` - - `utils._model_cost_lowercase_map` is always `None` diff --git a/litellm/rust_bridge/logger.py b/litellm/rust_bridge/host/logger.py similarity index 100% rename from litellm/rust_bridge/logger.py rename to litellm/rust_bridge/host/logger.py diff --git a/litellm/rust_bridge/host/model_capabilities.py b/litellm/rust_bridge/host/model_capabilities.py index 6fd8b42520e..00a00b9e79b 100644 --- a/litellm/rust_bridge/host/model_capabilities.py +++ b/litellm/rust_bridge/host/model_capabilities.py @@ -6,7 +6,7 @@ from typing import Final, TypeAlias import litellm from litellm.litellm_core_utils.core_helpers import as_str_mapping -from litellm.rust_bridge.host.model_cost import ModelCostVersion, model_cost_version +from litellm.litellm_core_utils.model_cost_table import ModelCostVersion, model_cost_version class _Held: diff --git a/litellm/rust_bridge/host/model_cost.py b/litellm/rust_bridge/host/model_cost.py index 328e0c6252e..cfedbb64592 100644 --- a/litellm/rust_bridge/host/model_cost.py +++ b/litellm/rust_bridge/host/model_cost.py @@ -1,118 +1,21 @@ -"""The live ``litellm.model_cost`` table, every write to it, and its version.""" +"""The ``litellm.model_cost`` questions Rust asks while registering models.""" from __future__ import annotations -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from functools import lru_cache -from typing import Final, Generic, ParamSpec, TypeVar +from collections.abc import Mapping +from typing import Final -from litellm.constants import DEFAULT_MAX_LRU_CACHE_SIZE - -P = ParamSpec("P") -R = TypeVar("R") +from litellm.litellm_core_utils import model_cost_table +from litellm.litellm_core_utils.model_cost_table import AliasedCostEntry, Landing -class AliasedCostEntry(dict[str, object]): - """A ``litellm.model_cost`` row, the one object every alias name of a model is served under.""" - - __slots__ = ("names", "registered_fields") - - def __init__( - self, entry: Mapping[str, object], names: tuple[str, ...], registered_fields: frozenset[str] = frozenset() - ) -> None: - super().__init__(entry) - self.names: tuple[str, ...] = names - self.registered_fields: frozenset[str] = registered_fields +def entry(fields: Mapping[str, object], names: tuple[str, ...]) -> AliasedCostEntry: + """The row object every name in ``names`` shares, the owner first.""" + return AliasedCostEntry(fields, names) -entry: Final = AliasedCostEntry - - -@dataclass(frozen=True, slots=True) -class ModelCostVersion: - table: int - size: int - writes: int - - -class _Writes: - __slots__ = ("count",) - - def __init__(self) -> None: - self.count: int = 0 - - def record(self) -> None: - self.count += 1 - - -_WRITES: Final = _Writes() - - -def model_cost_version() -> ModelCostVersion: - import litellm - - table: Final = litellm.model_cost - return ModelCostVersion(table=id(table), size=len(table), writes=_WRITES.count) - - -def publish(table: dict[str, object]) -> None: - """Makes ``table`` the live ``litellm.model_cost``.""" - import litellm - - litellm.model_cost = table - _WRITES.record() - - -def write(rows: dict[str, object], written: Mapping[str, object]) -> None: - """Writes one registration's rows into ``rows``, the published table.""" - rows.update(written) - _WRITES.record() - - -def changed_in_place() -> None: - """Moves the version and drops the name index after an in-place edit of ``litellm.model_cost``.""" - from litellm.litellm_core_utils.get_model_cost_map import model_cost_inputs - - model_cost_inputs.invalidate() - _WRITES.record() - - -class VersionCached(Generic[P, R]): - """Decorates ``function`` with an ``lru_cache`` keyed on the ``litellm.model_cost`` version each call read, and - exposes that cache's ``cache_clear`` and ``cache_info``.""" - - __slots__ = ("_at", "cache_clear", "cache_info") - - def __init__(self, function: Callable[P, R]) -> None: - def at( - _version: ModelCostVersion, - *args: P.args, - **kwargs: P.kwargs, # kwargs-ok: forwards a ParamSpec unchanged - ) -> R: - return function(*args, **kwargs) - - cached: Final = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(at) - self._at: Final = cached - self.cache_clear: Final = cached.cache_clear - self.cache_info: Final = cached.cache_info - - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # kwargs-ok: forwards a ParamSpec unchanged - return self._at(model_cost_version(), *args, **kwargs) - - -@dataclass(frozen=True, slots=True) -class Landing: - key: str - base: Mapping[str, object] - matched_builtin: bool - - -def published() -> dict[str, object]: - """The table published as ``litellm.model_cost`` now.""" - import litellm - - return litellm.model_cost +published: Final = model_cost_table.published +write: Final = model_cost_table.write def resolve( diff --git a/litellm/rust_bridge/preflight.py b/litellm/rust_bridge/host/preflight.py similarity index 100% rename from litellm/rust_bridge/preflight.py rename to litellm/rust_bridge/host/preflight.py diff --git a/litellm/rust_bridge/host/randomness.py b/litellm/rust_bridge/host/randomness.py new file mode 100644 index 00000000000..71d841833e4 --- /dev/null +++ b/litellm/rust_bridge/host/randomness.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import random + + +def next_float() -> float: + return random.random() + + +def index_below(length: int) -> int: + return random.randrange(length) diff --git a/litellm/rust_bridge/host/routes/responses.py b/litellm/rust_bridge/host/routes/responses.py index 22e8cbbf3b0..0568326d18b 100644 --- a/litellm/rust_bridge/host/routes/responses.py +++ b/litellm/rust_bridge/host/routes/responses.py @@ -20,6 +20,12 @@ def connection_defaults(provider: str) -> tuple[str | None, str | None]: return litellm.api_key or provider_key, litellm.api_base +def original_previous_response_id(previous_response_id: str) -> str: + from litellm.responses.utils import ResponsesAPIRequestUtils + + return ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(previous_response_id) + + def response(value: Mapping[str, object]) -> ResponsesAPIResponse: return ResponsesAPIResponse.model_validate(value) diff --git a/litellm/rust_bridge/host/secret_manager.py b/litellm/rust_bridge/host/secret_manager.py index 4fb3350165c..b236b4ee19d 100644 --- a/litellm/rust_bridge/host/secret_manager.py +++ b/litellm/rust_bridge/host/secret_manager.py @@ -1,165 +1,58 @@ from __future__ import annotations import os -from collections.abc import Mapping -from dataclasses import dataclass, field -from importlib import import_module from typing import Final -from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem +import httpx - -@dataclass(frozen=True, slots=True) -class NativeSecretManagerConfig: - system: str - environment: tuple[tuple[str, str], ...] = field(repr=False) - settings: Mapping[str, object] = field(repr=False) - enterprise_enabled: bool - owner_type: type[object] - environment_attributes: tuple[tuple[str, str], ...] - settings_attributes: tuple[str, ...] - methods: tuple[tuple[str, object], ...] = field(repr=False) - - -@dataclass(frozen=True, slots=True) -class _ClientAdapter: - system: KeyManagementSystem - module: str - name: str - methods: tuple[str, ...] - environment_attributes: tuple[tuple[str, str], ...] = () - settings_attributes: tuple[str, ...] = () - enterprise_enabled: bool = False - - -_ADAPTERS: Final = ( - _ClientAdapter( - KeyManagementSystem.AWS_SECRET_MANAGER, - "litellm.secret_managers.aws_secret_manager_v2", - "AWSSecretsManagerV2", - ("sync_read_secret", "async_read_secret"), - settings_attributes=( - "aws_region_name", - "aws_role_name", - "aws_session_name", - "aws_external_id", - "aws_profile_name", - "aws_web_identity_token", - "aws_sts_endpoint", - "replica_regions", - "kms_key_id", - ), - ), - _ClientAdapter( - KeyManagementSystem.HASHICORP_VAULT, - "litellm.secret_managers.hashicorp_secret_manager", - "HashicorpSecretManager", - ("sync_read_secret", "async_read_secret", "async_write_secret", "async_delete_secret", "async_rotate_secret"), - environment_attributes=( - ("HCP_VAULT_ADDR", "vault_addr"), - ("HCP_VAULT_TOKEN", "vault_token"), - ("HCP_VAULT_NAMESPACE", "vault_namespace"), - ("HCP_VAULT_LOGIN_NAMESPACE", "login_namespace_override"), - ("HCP_VAULT_SECRET_NAMESPACE", "secret_namespace_override"), - ("HCP_VAULT_MOUNT_NAME", "vault_mount_name"), - ("HCP_VAULT_PATH_PREFIX", "vault_path_prefix"), - ("HCP_VAULT_CLIENT_CERT", "tls_cert_path"), - ("HCP_VAULT_CLIENT_KEY", "tls_key_path"), - ("HCP_VAULT_CERT_ROLE", "vault_cert_role"), - ("HCP_VAULT_APPROLE_ROLE_ID", "approle_role_id"), - ("HCP_VAULT_APPROLE_SECRET_ID", "approle_secret_id"), - ("HCP_VAULT_APPROLE_MOUNT_PATH", "approle_mount_path"), - ("HCP_VAULT_REFRESH_INTERVAL", "cache.default_ttl"), - ), - enterprise_enabled=True, - ), - _ClientAdapter( - KeyManagementSystem.CYBERARK, - "litellm.secret_managers.cyberark_secret_manager", - "CyberArkSecretManager", - ("sync_read_secret", "async_read_secret", "async_write_secret", "async_delete_secret", "async_rotate_secret"), - environment_attributes=( - ("CYBERARK_API_BASE", "conjur_addr"), - ("CYBERARK_ACCOUNT", "conjur_account"), - ("CYBERARK_USERNAME", "conjur_username"), - ("CYBERARK_API_KEY", "conjur_api_key"), - ("CYBERARK_CLIENT_CERT", "tls_cert_path"), - ("CYBERARK_CLIENT_KEY", "tls_key_path"), - ("CYBERARK_SSL_VERIFY", "ssl_verify"), - ("CYBERARK_REFRESH_INTERVAL", "cache.default_ttl"), - ), - enterprise_enabled=True, - ), - _ClientAdapter( - KeyManagementSystem.GOOGLE_SECRET_MANAGER, - "litellm.secret_managers.google_secret_manager", - "GoogleSecretManager", - ("get_secret_from_google_secret_manager",), - environment_attributes=( - ("GOOGLE_SECRET_MANAGER_PROJECT_ID", "PROJECT_ID"), - ("GOOGLE_SECRET_MANAGER_REFRESH_INTERVAL", "cache.default_ttl"), - ("GOOGLE_SECRET_MANAGER_ALWAYS_READ_SECRET_MANAGER", "always_read_secret_manager"), - ), - enterprise_enabled=True, - ), -) - -_SDK_ADAPTERS: Final = ( - _ClientAdapter(KeyManagementSystem.AZURE_KEY_VAULT, "azure.keyvault.secrets", "SecretClient", ("get_secret",)), - _ClientAdapter(KeyManagementSystem.GOOGLE_KMS, "google.cloud.kms_v1", "KeyManagementServiceClient", ("decrypt",)), -) - - -def _capture(client: object, adapter: _ClientAdapter) -> NativeSecretManagerConfig: - prefixes: Final = ("AWS_", "AZURE_", "GOOGLE_", "VERTEX_", "GCS_", "HCP_VAULT_", "CYBERARK_") - config: Final = NativeSecretManagerConfig( - system=adapter.system.value, - environment=tuple( - (name, value) - for name, value in os.environ.items() - if name.startswith(prefixes) or name == "SECRET_MANAGER_REFRESH_INTERVAL" - ), - settings=KeyManagementSettings().model_dump(mode="json"), - enterprise_enabled=adapter.enterprise_enabled, - owner_type=type(client), - environment_attributes=adapter.environment_attributes, - settings_attributes=adapter.settings_attributes, - methods=tuple((name, getattr(type(client), name)) for name in adapter.methods), - ) - vars(client)["_litellm_native_secret_config"] = config - return config - - -def capture_secret_manager(client: object, system: str) -> None: - for adapter in (*_ADAPTERS, *_SDK_ADAPTERS): - if ( - adapter.system.value == system - and type(client).__name__ == adapter.name - and type(client) is getattr(import_module(adapter.module), adapter.name) - ): - _capture(client, adapter) - return - if ( - system == KeyManagementSystem.AWS_KMS.value - and type(client).__module__ == "botocore.client" - and type(client).__name__ == "KMS" - ): - _capture(client, _ClientAdapter(KeyManagementSystem.AWS_KMS, "botocore.client", "KMS", ("decrypt",))) +from litellm.secret_managers.native_config import NativeSecretManagerConfig, captured_config +from litellm.types.secret_managers.main import KeyManagementSettings def native_secret_manager_config(client: object) -> NativeSecretManagerConfig | None: - captured: Final = getattr(client, "_litellm_native_secret_config", None) - if isinstance(captured, NativeSecretManagerConfig): - return captured - for adapter in _ADAPTERS: - if type(client).__module__ == adapter.module and type(client) is getattr( - import_module(adapter.module), adapter.name - ): - return _capture(client, adapter) - return None + return captured_config(client) def read_secret_str(name: str) -> str | None: from litellm.secret_managers.main import get_secret_str return get_secret_str(name) + + +def read_from_manager( + client: object, + key_manager: str, + secret_name: str, + key_management_settings: KeyManagementSettings | None, +) -> str | None: + from litellm.secret_managers.secret_manager_handler import get_secret_from_manager + + return get_secret_from_manager( + client=client, + key_manager=key_manager, + secret_name=secret_name, + key_management_settings=key_management_settings, + ) + + +def timeout_message(message: str) -> str: + from litellm.exceptions import Timeout + + return str(Timeout(message=message, model="default-model-name", llm_provider="litellm-httpx-handler")) + + +def response_text(body: bytes) -> str: + return httpx.Response(200, content=body).text + + +def http_status_message(method: str, url: str, status: int) -> str: + response: Final = httpx.Response(status, request=httpx.Request(method, url)) + try: + _ = response.raise_for_status() + except httpx.HTTPStatusError as error: + return str(error) + return f"HTTP {status}" + + +def os_error_message(code: int) -> str: + return str(OSError(code, os.strerror(code))) diff --git a/litellm/rust_bridge/host/timeouts.py b/litellm/rust_bridge/host/timeouts.py index 5fde4397b55..fe9abd71c1e 100644 --- a/litellm/rust_bridge/host/timeouts.py +++ b/litellm/rust_bridge/host/timeouts.py @@ -1,13 +1,9 @@ -"""Shared timeout conversion for the native Rust bridges.""" - from __future__ import annotations import httpx +from litellm.rust_bridge import public_call + def timeout_to_seconds(timeout: float | httpx.Timeout | None) -> float | None: - if timeout is None: - return None - if isinstance(timeout, httpx.Timeout): - return timeout.read - return float(timeout) + return public_call.timeout_to_seconds(timeout) diff --git a/litellm/rust_bridge/host/tokenizers.py b/litellm/rust_bridge/host/tokenizers.py new file mode 100644 index 00000000000..e86e9b54aa7 --- /dev/null +++ b/litellm/rust_bridge/host/tokenizers.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from pathlib import Path + + +def tiktoken_resource_dir() -> str: + from litellm.litellm_core_utils import tokenizers + + return str(Path(tokenizers.__file__).parent) + + +def download_tokenizer(repo_id: str, revision: str, token: str | None) -> str: + import huggingface_hub + + return huggingface_hub.hf_hub_download( # pyright: ignore[reportUnknownMemberType] # untyped user_agent dict in its signature + repo_id=repo_id, filename="tokenizer.json", revision=revision, token=token + ) diff --git a/litellm/rust_bridge/public_call.py b/litellm/rust_bridge/public_call.py index 1ab3d5e10c9..0572d498b7d 100644 --- a/litellm/rust_bridge/public_call.py +++ b/litellm/rust_bridge/public_call.py @@ -9,6 +9,8 @@ from dataclasses import dataclass from types import MappingProxyType from typing import Final, TypeVar, cast # noqa: TID251 # narrows caller-owned containers without copying them +import httpx + from litellm.utils import ( _get_wrapper_timeout, # pyright: ignore[reportPrivateUsage] # the @client wrapper's own failure stamp ) @@ -49,6 +51,14 @@ def optional_sequence(value: object) -> Sequence[object] | None: return cast("Sequence[object]", value) # cast-ok: the same caller-owned object is handed on unchanged +def timeout_to_seconds(timeout: float | httpx.Timeout | None) -> float | None: + if timeout is None: + return None + if isinstance(timeout, httpx.Timeout): + return timeout.read + return float(timeout) + + def known_params(names: Collection[str], request: Mapping[str, object]) -> Mapping[str, object]: return {name: value for name, value in request.items() if name in names and value is not None} diff --git a/litellm/rust_bridge/responses/detectors.py b/litellm/rust_bridge/responses/detectors.py index 2fdb2c44b54..f8d5b4462a1 100644 --- a/litellm/rust_bridge/responses/detectors.py +++ b/litellm/rust_bridge/responses/detectors.py @@ -12,11 +12,12 @@ import litellm from litellm.llms.opencode.common_utils import is_opencode_api_base from litellm.rust_bridge.detectors import api_base, call_callbacks, overridden, present from litellm.rust_bridge.public_call import NativeCall, optional_mapping, optional_sequence, optional_str +from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams if TYPE_CHECKING: from openai.types.responses import ResponseInputParam - from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponsesAPIOptionalRequestParams + from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS def client_headers(request: NativeCall) -> bool: @@ -160,9 +161,12 @@ _PARAMS: Final = TypeAdapter(dict[str, object]) def model_param_gating(request: NativeCall) -> bool: """Python clamps, drops or remaps Responses params according to the model's capabilities.""" from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig - from litellm.rust_bridge.host.routes.responses import PARAMETERS - params: Final = {name: request.resolved[name] for name in PARAMETERS if request.resolved.get(name) is not None} + params: Final = { + name: request.resolved[name] + for name in ResponsesAPIOptionalRequestParams.__annotations__ + if request.resolved.get(name) is not None + } mapped: Final = _PARAMS.validate_python( OpenAIResponsesAPIConfig().map_openai_params( # pyright: ignore[reportUnknownMemberType] # returns a bare dict cast("ResponsesAPIOptionalRequestParams", params), # cast-ok: the handler passes these kwargs diff --git a/litellm/rust_bridge/responses/websocket.py b/litellm/rust_bridge/responses/websocket.py index 83a1fa53376..e27e88fa9aa 100644 --- a/litellm/rust_bridge/responses/websocket.py +++ b/litellm/rust_bridge/responses/websocket.py @@ -10,7 +10,7 @@ import httpx from websockets.exceptions import ConnectionClosedOK from litellm.rust_bridge import _native -from litellm.rust_bridge.host.timeouts import timeout_to_seconds +from litellm.rust_bridge.public_call import timeout_to_seconds class RustResponsesWebSocket(Protocol): diff --git a/litellm/secret_managers/native_config.py b/litellm/secret_managers/native_config.py new file mode 100644 index 00000000000..c75fe94bae0 --- /dev/null +++ b/litellm/secret_managers/native_config.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from importlib import import_module +from typing import Final + +from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem + + +@dataclass(frozen=True, slots=True) +class NativeSecretManagerConfig: + system: str + environment: tuple[tuple[str, str], ...] = field(repr=False) + settings: Mapping[str, object] = field(repr=False) + enterprise_enabled: bool + owner_type: type[object] + environment_attributes: tuple[tuple[str, str], ...] + settings_attributes: tuple[str, ...] + methods: tuple[tuple[str, object], ...] = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class _ClientAdapter: + system: KeyManagementSystem + module: str + name: str + methods: tuple[str, ...] + environment_attributes: tuple[tuple[str, str], ...] = () + settings_attributes: tuple[str, ...] = () + enterprise_enabled: bool = False + + +_ADAPTERS: Final = ( + _ClientAdapter( + KeyManagementSystem.AWS_SECRET_MANAGER, + "litellm.secret_managers.aws_secret_manager_v2", + "AWSSecretsManagerV2", + ("sync_read_secret", "async_read_secret"), + settings_attributes=( + "aws_region_name", + "aws_role_name", + "aws_session_name", + "aws_external_id", + "aws_profile_name", + "aws_web_identity_token", + "aws_sts_endpoint", + "replica_regions", + "kms_key_id", + ), + ), + _ClientAdapter( + KeyManagementSystem.HASHICORP_VAULT, + "litellm.secret_managers.hashicorp_secret_manager", + "HashicorpSecretManager", + ("sync_read_secret", "async_read_secret", "async_write_secret", "async_delete_secret", "async_rotate_secret"), + environment_attributes=( + ("HCP_VAULT_ADDR", "vault_addr"), + ("HCP_VAULT_TOKEN", "vault_token"), + ("HCP_VAULT_NAMESPACE", "vault_namespace"), + ("HCP_VAULT_LOGIN_NAMESPACE", "login_namespace_override"), + ("HCP_VAULT_SECRET_NAMESPACE", "secret_namespace_override"), + ("HCP_VAULT_MOUNT_NAME", "vault_mount_name"), + ("HCP_VAULT_PATH_PREFIX", "vault_path_prefix"), + ("HCP_VAULT_CLIENT_CERT", "tls_cert_path"), + ("HCP_VAULT_CLIENT_KEY", "tls_key_path"), + ("HCP_VAULT_CERT_ROLE", "vault_cert_role"), + ("HCP_VAULT_APPROLE_ROLE_ID", "approle_role_id"), + ("HCP_VAULT_APPROLE_SECRET_ID", "approle_secret_id"), + ("HCP_VAULT_APPROLE_MOUNT_PATH", "approle_mount_path"), + ("HCP_VAULT_REFRESH_INTERVAL", "cache.default_ttl"), + ), + enterprise_enabled=True, + ), + _ClientAdapter( + KeyManagementSystem.CYBERARK, + "litellm.secret_managers.cyberark_secret_manager", + "CyberArkSecretManager", + ("sync_read_secret", "async_read_secret", "async_write_secret", "async_delete_secret", "async_rotate_secret"), + environment_attributes=( + ("CYBERARK_API_BASE", "conjur_addr"), + ("CYBERARK_ACCOUNT", "conjur_account"), + ("CYBERARK_USERNAME", "conjur_username"), + ("CYBERARK_API_KEY", "conjur_api_key"), + ("CYBERARK_CLIENT_CERT", "tls_cert_path"), + ("CYBERARK_CLIENT_KEY", "tls_key_path"), + ("CYBERARK_SSL_VERIFY", "ssl_verify"), + ("CYBERARK_REFRESH_INTERVAL", "cache.default_ttl"), + ), + enterprise_enabled=True, + ), + _ClientAdapter( + KeyManagementSystem.GOOGLE_SECRET_MANAGER, + "litellm.secret_managers.google_secret_manager", + "GoogleSecretManager", + ("get_secret_from_google_secret_manager",), + environment_attributes=( + ("GOOGLE_SECRET_MANAGER_PROJECT_ID", "PROJECT_ID"), + ("GOOGLE_SECRET_MANAGER_REFRESH_INTERVAL", "cache.default_ttl"), + ("GOOGLE_SECRET_MANAGER_ALWAYS_READ_SECRET_MANAGER", "always_read_secret_manager"), + ), + enterprise_enabled=True, + ), +) + +_SDK_ADAPTERS: Final = ( + _ClientAdapter(KeyManagementSystem.AZURE_KEY_VAULT, "azure.keyvault.secrets", "SecretClient", ("get_secret",)), + _ClientAdapter(KeyManagementSystem.GOOGLE_KMS, "google.cloud.kms_v1", "KeyManagementServiceClient", ("decrypt",)), +) + + +def _capture(client: object, adapter: _ClientAdapter) -> NativeSecretManagerConfig: + prefixes: Final = ("AWS_", "AZURE_", "GOOGLE_", "VERTEX_", "GCS_", "HCP_VAULT_", "CYBERARK_") + config: Final = NativeSecretManagerConfig( + system=adapter.system.value, + environment=tuple( + (name, value) + for name, value in os.environ.items() + if name.startswith(prefixes) or name == "SECRET_MANAGER_REFRESH_INTERVAL" + ), + settings=KeyManagementSettings().model_dump(mode="json"), + enterprise_enabled=adapter.enterprise_enabled, + owner_type=type(client), + environment_attributes=adapter.environment_attributes, + settings_attributes=adapter.settings_attributes, + methods=tuple((name, getattr(type(client), name)) for name in adapter.methods), + ) + vars(client)["_litellm_native_secret_config"] = config + return config + + +def capture_secret_manager(client: object, system: str) -> None: + for adapter in (*_ADAPTERS, *_SDK_ADAPTERS): + if ( + adapter.system.value == system + and type(client).__name__ == adapter.name + and type(client) is getattr(import_module(adapter.module), adapter.name) + ): + _capture(client, adapter) + return + if ( + system == KeyManagementSystem.AWS_KMS.value + and type(client).__module__ == "botocore.client" + and type(client).__name__ == "KMS" + ): + _capture(client, _ClientAdapter(KeyManagementSystem.AWS_KMS, "botocore.client", "KMS", ("decrypt",))) + + +def captured_config(client: object) -> NativeSecretManagerConfig | None: + captured: Final = getattr(client, "_litellm_native_secret_config", None) + if isinstance(captured, NativeSecretManagerConfig): + return captured + for adapter in _ADAPTERS: + if type(client).__module__ == adapter.module and type(client) is getattr( + import_module(adapter.module), adapter.name + ): + return _capture(client, adapter) + return None diff --git a/litellm/utils.py b/litellm/utils.py index 4a4f2e8edfe..c6ade2603c4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -107,6 +107,13 @@ from litellm.litellm_core_utils.get_model_cost_map import ( fetch_registration_document, model_cost_inputs, ) +from litellm.litellm_core_utils.model_cost_table import ( + AliasedCostEntry, + Landing, + VersionCached, + changed_in_place, + model_cost_version, +) from litellm.litellm_core_utils.model_param_helper import peek_reasoning_summary_aliases from litellm.litellm_core_utils.model_retirement import is_hidden_as_retired from litellm.litellm_core_utils.offline_mode import is_offline_mode @@ -121,13 +128,6 @@ from litellm.rust_bridge import tokenizer as tokenizer_dispatch from litellm.rust_bridge._native import NativeModelCostMap, NativeRegistered from litellm.rust_bridge.catalog import decision from litellm.rust_bridge.configuration import Decision -from litellm.rust_bridge.host.model_cost import ( - AliasedCostEntry, - Landing, - VersionCached, - changed_in_place, - model_cost_version, -) _CachingHandlerResponse = None _LLMCachingHandler = None diff --git a/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py b/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py index 921552455b7..0aa36635c65 100644 --- a/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py +++ b/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py @@ -10,7 +10,7 @@ from pydantic import JsonValue, TypeAdapter import litellm from litellm import ModelResponse, Router -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish _OPUS_4_8: Final = "us.anthropic.claude-opus-4-8" _JSON_TOOL: Final = "json_tool_call" diff --git a/tests/integration/sdk/test_model_cost_fetch_sdk.py b/tests/integration/sdk/test_model_cost_fetch_sdk.py index 206ffa99909..fadd8386d17 100644 --- a/tests/integration/sdk/test_model_cost_fetch_sdk.py +++ b/tests/integration/sdk/test_model_cost_fetch_sdk.py @@ -28,7 +28,7 @@ from litellm.litellm_core_utils.get_model_cost_map import ( model_cost_inputs, refetch_model_cost_map, ) -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests.test_litellm_rust.support.child_interpreter import run_child_interpreter FALLBACK_GENERALIZATIONS_KEY: Final = "fallback_generalizations" diff --git a/tests/unit/caching/test_llm_caching_handler.py b/tests/unit/caching/test_llm_caching_handler.py index be0c5e311da..16ad2235a57 100644 --- a/tests/unit/caching/test_llm_caching_handler.py +++ b/tests/unit/caching/test_llm_caching_handler.py @@ -28,7 +28,7 @@ from litellm.llms.custom_httpx.http_handler import( HTTPHandler, ) from litellm.types.utils import LlmProviders -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome from tests.unit.caching._event_loop_reuse import runs_until_a_loop_address_repeats diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index fe21cb23caf..88784814ba4 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -134,7 +134,7 @@ os.environ["PYTHON_DOTENV_DISABLED"] = "1" os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" import litellm # noqa: E402 # litellm reads LITELLM_LOCAL_MODEL_COST_MAP at import -from litellm.rust_bridge.host.model_cost import publish # noqa: E402 # same import-time dependency +from litellm.litellm_core_utils.model_cost_table import publish # noqa: E402 # same import-time dependency from litellm._logging import ALL_LOGGERS # noqa: E402 # same import-time dependency from litellm.anthropic_beta_headers_manager import ( reload_beta_headers_config, # noqa: E402 # same import-time dependency diff --git a/tests/unit/integrations/arize/test_arize.py b/tests/unit/integrations/arize/test_arize.py index 42dd5e01ce9..331e0503db0 100644 --- a/tests/unit/integrations/arize/test_arize.py +++ b/tests/unit/integrations/arize/test_arize.py @@ -16,7 +16,7 @@ from litellm.integrations.arize.arize import ArizeConfig, ArizeLogger from litellm.integrations.opentelemetry import OpenTelemetryConfig from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/integrations/arize/test_arize_phoenix.py b/tests/unit/integrations/arize/test_arize_phoenix.py index 73e6b62ec4c..08aa400c339 100644 --- a/tests/unit/integrations/arize/test_arize_phoenix.py +++ b/tests/unit/integrations/arize/test_arize_phoenix.py @@ -17,7 +17,7 @@ from litellm.integrations.arize.arize_phoenix import ( ) from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/integrations/opik/test_opik.py b/tests/unit/integrations/opik/test_opik.py index 0bf35ffdabf..30d6fffee78 100644 --- a/tests/unit/integrations/opik/test_opik.py +++ b/tests/unit/integrations/opik/test_opik.py @@ -12,7 +12,7 @@ import pytest import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/integrations/test_anthropic_cache_control_hook.py b/tests/unit/integrations/test_anthropic_cache_control_hook.py index 4b6f59a78ab..c6f99a36ec4 100644 --- a/tests/unit/integrations/test_anthropic_cache_control_hook.py +++ b/tests/unit/integrations/test_anthropic_cache_control_hook.py @@ -19,7 +19,7 @@ from openai.types.chat import ChatCompletionMessage from pydantic import BaseModel, ConfigDict import litellm -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from litellm import Router from litellm._logging import verbose_logger from litellm.integrations.anthropic_cache_control_hook import ( diff --git a/tests/unit/integrations/test_helicone.py b/tests/unit/integrations/test_helicone.py index 90fc2f97ee8..f979090dc60 100644 --- a/tests/unit/integrations/test_helicone.py +++ b/tests/unit/integrations/test_helicone.py @@ -7,7 +7,7 @@ from collections.abc import Iterator from pathlib import Path from typing import Final from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome from unittest.mock import MagicMock diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index fafc098ed5d..01b94f01891 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -86,7 +86,7 @@ from litellm.utils import ( function_setup, validate_and_fix_openai_messages, ) -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome READ_FILE: Final[list[object]] = [{"name": "read_file", "description": "Read the contents of a file."}] diff --git a/tests/unit/litellm_core_utils/test_get_llm_provider_logic.py b/tests/unit/litellm_core_utils/test_get_llm_provider_logic.py index ea270fbf7c8..3eb87947f0c 100644 --- a/tests/unit/litellm_core_utils/test_get_llm_provider_logic.py +++ b/tests/unit/litellm_core_utils/test_get_llm_provider_logic.py @@ -21,7 +21,7 @@ from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.litellm_core_utils.model_latest_aliases import get_model_latest_aliases from litellm.types.router import LiteLLM_Params -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome from unittest.mock import patch diff --git a/tests/unit/litellm_core_utils/test_model_cost_table.py b/tests/unit/litellm_core_utils/test_model_cost_table.py new file mode 100644 index 00000000000..1873dab8129 --- /dev/null +++ b/tests/unit/litellm_core_utils/test_model_cost_table.py @@ -0,0 +1,57 @@ +from typing import Final + +import pytest + +import litellm +from litellm.cost_calculator import cost_per_token +from litellm.litellm_core_utils.get_model_cost_map import model_cost_inputs +from litellm.litellm_core_utils.model_cost_table import changed_in_place, publish, write + +pytestmark = pytest.mark.usefixtures("local_model_cost_map") + + +_MODEL: Final = "lit-table-model" + + +def _row(price: float) -> dict[str, object]: + return {"litellm_provider": "openai", "mode": "chat", "input_cost_per_token": price, "output_cost_per_token": price} + + +def _billed() -> float: + prompt, completion = cost_per_token( + model=_MODEL, custom_llm_provider="openai", prompt_tokens=1, completion_tokens=0 + ) + return prompt + completion + + +def test_a_published_table_is_read_in_place_of_what_was_cached_for_the_one_before() -> None: + publish({**litellm.model_cost, _MODEL: _row(1.0)}) + assert litellm.get_model_info(_MODEL)["input_cost_per_token"] == 1.0 + assert _billed() == 1.0 + + publish({**litellm.model_cost, _MODEL: _row(2.0)}) + + assert litellm.get_model_info(_MODEL)["input_cost_per_token"] == 2.0 + assert _billed() == 2.0 + + +def test_rows_written_into_the_live_table_replace_a_cached_value_of_the_same_size() -> None: + publish({**litellm.model_cost, _MODEL: _row(1.0)}) + assert _billed() == 1.0 + + write(litellm.model_cost, {_MODEL: _row(3.0)}) + + assert litellm.get_model_info(_MODEL)["input_cost_per_token"] == 3.0 + assert _billed() == 3.0 + + +def test_a_provider_changed_in_place_regroups_the_key_once_the_change_is_reported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert "gpt-4o" not in model_cost_inputs.keys_in(litellm.model_cost, "lit-moved") + + monkeypatch.setitem(litellm.model_cost["gpt-4o"], "litellm_provider", "lit-moved") + changed_in_place() + + assert model_cost_inputs.keys_in(litellm.model_cost, "lit-moved") == ["gpt-4o"] + assert "gpt-4o" not in model_cost_inputs.keys_in(litellm.model_cost, "openai") diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py index b4bf1120935..f6300b2e70c 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py @@ -32,7 +32,7 @@ from litellm.types.utils import ( StandardLoggingPayloadErrorInformation, StreamingChoices, ) -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish import unittest.mock diff --git a/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py b/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py index 2cb8b9cf6c5..cec8c07ca1a 100644 --- a/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py +++ b/tests/unit/llms/azure/chat/test_azure_chat_gpt_transformation.py @@ -27,7 +27,7 @@ from litellm.llms.azure.chat.gpt_reasoning_transformation import AzureOpenAIGPT5 from litellm.llms.azure.chat.gpt_transformation import AzureOpenAIConfig from litellm.router import Router from litellm.utils import get_optional_params -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome _MAPPED_PARAMS: Final = TypeAdapter(dict[str, object]) diff --git a/tests/unit/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py b/tests/unit/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py index 5ead4ad3986..90a8939e2dd 100644 --- a/tests/unit/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py +++ b/tests/unit/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py @@ -16,7 +16,7 @@ from litellm.llms.azure_ai.image_generation.flux_transformation import ( ) from litellm.types.utils import ImageObject, ImageResponse from litellm.utils import get_optional_params_image_gen -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish @pytest.fixture(autouse=True) diff --git a/tests/unit/llms/azure_ai/test_azure_ai_fw_models_metadata.py b/tests/unit/llms/azure_ai/test_azure_ai_fw_models_metadata.py index 927833b530d..9dc29e9860d 100644 --- a/tests/unit/llms/azure_ai/test_azure_ai_fw_models_metadata.py +++ b/tests/unit/llms/azure_ai/test_azure_ai_fw_models_metadata.py @@ -19,7 +19,7 @@ def use_local_model_cost_map(): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") import litellm - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish original_model_cost = litellm.model_cost litellm.model_cost = json.loads( diff --git a/tests/unit/llms/azure_ai/test_azure_ai_kimi_k26_metadata.py b/tests/unit/llms/azure_ai/test_azure_ai_kimi_k26_metadata.py index e23be01df55..be5e3a5ce18 100644 --- a/tests/unit/llms/azure_ai/test_azure_ai_kimi_k26_metadata.py +++ b/tests/unit/llms/azure_ai/test_azure_ai_kimi_k26_metadata.py @@ -14,7 +14,7 @@ def use_local_model_cost_map(): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") import litellm - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish original_model_cost = litellm.model_cost litellm.model_cost = json.loads( diff --git a/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index d17c2569b92..690350223ea 100644 --- a/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -27,7 +27,7 @@ def ensure_nova_canvas_image_edit_model_cost_flags(monkeypatch): Full ``model_prices_and_context_window.json`` includes these flags, but CI or alternate cost maps may omit them—merge minimal entries so tests match production. """ - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish for key in ( "amazon.nova-canvas-v1:0", diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index b0071287746..4e031531180 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -42,7 +42,7 @@ from litellm.types.llms.custom_http import VerifyTypes from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from litellm.exceptions import Timeout as LitellmTimeout from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/llms/openai/test_openai.py b/tests/unit/llms/openai/test_openai.py index bdeff226af2..1936fc1d2df 100644 --- a/tests/unit/llms/openai/test_openai.py +++ b/tests/unit/llms/openai/test_openai.py @@ -42,7 +42,7 @@ from litellm.types.utils import ( PromptTokensDetails, ) from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from openai.types.beta.assistant import Assistant from openai.types.beta.assistant_deleted import AssistantDeleted from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/llms/parallel_ai/test_parallel_ai_search.py b/tests/unit/llms/parallel_ai/test_parallel_ai_search.py index 1400fcc7d4e..d067aa49352 100644 --- a/tests/unit/llms/parallel_ai/test_parallel_ai_search.py +++ b/tests/unit/llms/parallel_ai/test_parallel_ai_search.py @@ -57,7 +57,7 @@ def bundled_cost_map(monkeypatch): litellm caches model-info lookups, so swapping ``model_cost`` only takes effect once those caches are invalidated -- on the way in and back out. """ - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) publish(litellm.model_cost) diff --git a/tests/unit/llms/test_custom_llm.py b/tests/unit/llms/test_custom_llm.py index e731d79ed99..a9668ebb485 100644 --- a/tests/unit/llms/test_custom_llm.py +++ b/tests/unit/llms/test_custom_llm.py @@ -36,7 +36,7 @@ from litellm.types.utils import ( StreamingChoices, ) from litellm.utils import ModelResponseIterator -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 8c2cb643943..531c5d0b1e6 100644 --- a/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -37,7 +37,7 @@ from litellm.types.llms.base import CachedTokensDetails from litellm.types.llms.vertex_ai import GeminiFinishReason, RequestBody, UsageMetadata from litellm.types.utils import ChoiceLogprobs, ModelInfo, ModelResponseStream, Usage from litellm.utils import CustomStreamWrapper -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.integrations.custom_logger import CustomLogger from litellm.llms.vertex_ai.gemini.transformation import ( diff --git a/tests/unit/proxy/common_utils/test_config_sync_pubsub.py b/tests/unit/proxy/common_utils/test_config_sync_pubsub.py index feda96c46b7..bd5941b880e 100644 --- a/tests/unit/proxy/common_utils/test_config_sync_pubsub.py +++ b/tests/unit/proxy/common_utils/test_config_sync_pubsub.py @@ -1086,7 +1086,7 @@ async def test_model_cost_map_reload_does_not_publish_config_change() -> None: from litellm.proxy import proxy_server from litellm.proxy.proxy_server import ProxyConfig, _set_redis_usage_cache from litellm.proxy.utils import litellm_config_cache - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish litellm_config_cache.flush_cache() prisma_client = _reload_config_prisma_client() diff --git a/tests/unit/proxy/common_utils/test_http_parsing_utils.py b/tests/unit/proxy/common_utils/test_http_parsing_utils.py index 588411cd57a..8423dee9886 100644 --- a/tests/unit/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/unit/proxy/common_utils/test_http_parsing_utils.py @@ -34,7 +34,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( ) from fastapi import Request as Request_http_parsing from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from starlette.types import Message from starlette.websockets import WebSocket from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/proxy/common_utils/test_model_deprecation.py b/tests/unit/proxy/common_utils/test_model_deprecation.py index 57839d65cfb..8961c0a1a34 100644 --- a/tests/unit/proxy/common_utils/test_model_deprecation.py +++ b/tests/unit/proxy/common_utils/test_model_deprecation.py @@ -15,7 +15,7 @@ import pytest import litellm from litellm import Router from litellm._logging import verbose_logger -from litellm.rust_bridge.host.model_cost import AliasedCostEntry +from litellm.litellm_core_utils.model_cost_table import AliasedCostEntry from litellm.proxy.common_utils.model_deprecation import ( _classify, _parse_deprecation_date, diff --git a/tests/unit/proxy/hooks/test_dynamic_rate_limiter.py b/tests/unit/proxy/hooks/test_dynamic_rate_limiter.py index c24d04b3a8b..87bb193dfbf 100644 --- a/tests/unit/proxy/hooks/test_dynamic_rate_limiter.py +++ b/tests/unit/proxy/hooks/test_dynamic_rate_limiter.py @@ -14,7 +14,7 @@ from litellm import DualCache as DualCache_dynamic_rate, Router from litellm._uuid import uuid from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.proxy._types import UserAPIKeyAuth -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome from typing import Optional diff --git a/tests/unit/proxy/hooks/test_prompt_injection_detection.py b/tests/unit/proxy/hooks/test_prompt_injection_detection.py index 1b50de938b6..1db5e9e6e18 100644 --- a/tests/unit/proxy/hooks/test_prompt_injection_detection.py +++ b/tests/unit/proxy/hooks/test_prompt_injection_detection.py @@ -17,7 +17,7 @@ from litellm.proxy.utils import ProxyLogging from litellm.router import Router from litellm import Router as Router_prompt_injection from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/proxy/management_endpoints/test_sso_helper_utils.py b/tests/unit/proxy/management_endpoints/test_sso_helper_utils.py index 0b488618636..1e5bb5a8531 100644 --- a/tests/unit/proxy/management_endpoints/test_sso_helper_utils.py +++ b/tests/unit/proxy/management_endpoints/test_sso_helper_utils.py @@ -9,7 +9,7 @@ import os import pytest import litellm from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome from litellm.proxy._types import LitellmUserRoles diff --git a/tests/unit/proxy/proxy_server/test_routes_model_info.py b/tests/unit/proxy/proxy_server/test_routes_model_info.py index a988250159f..f0459e7b927 100644 --- a/tests/unit/proxy/proxy_server/test_routes_model_info.py +++ b/tests/unit/proxy/proxy_server/test_routes_model_info.py @@ -27,7 +27,7 @@ from litellm.caching.llm_caching_handler import LLMClientCache from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy import proxy_server from litellm.proxy._types import LiteLLM_ProxyModelTable, UserAPIKeyAuth -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from .conftest import normalize, route_entry_points_to_stubs # type: ignore[import-not-found] diff --git a/tests/unit/proxy/proxy_server/test_routes_utils.py b/tests/unit/proxy/proxy_server/test_routes_utils.py index 551c4b76766..cdbdb70e3ac 100644 --- a/tests/unit/proxy/proxy_server/test_routes_utils.py +++ b/tests/unit/proxy/proxy_server/test_routes_utils.py @@ -17,7 +17,7 @@ import pytest import respx import litellm -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from litellm.litellm_core_utils import get_llm_provider_logic from litellm.proxy import proxy_server diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 9bbdc7165dd..5b423421458 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -59,7 +59,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import WindowSpendUpdateQueue from litellm.proxy.hooks.parallel_request_limiter_v3 import RequestRateLimiterStash from litellm.proxy.proxy_server import app, aws_error_type_exception_handler, initialize, openai_exception_handler -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from litellm.proxy.common_utils.banner import LITELLM_BANNER from tests._master_key import MASTER_KEY from litellm.redis.stores.connection import connect diff --git a/tests/unit/responses/test_responses_utils.py b/tests/unit/responses/test_responses_utils.py index e3eb9961228..94f60fa5b30 100644 --- a/tests/unit/responses/test_responses_utils.py +++ b/tests/unit/responses/test_responses_utils.py @@ -13,7 +13,7 @@ from litellm.responses.litellm_completion_transformation.transformation import L from litellm.responses.utils import ResponseAPILoggingUtils, ResponsesAPIRequestUtils from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIOptionalRequestParams, ResponsesAPIResponse from litellm.types.utils import ModelInfo, Usage -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish class TestResponsesAPIRequestUtils: diff --git a/tests/unit/router_strategy/test_lowest_cost.py b/tests/unit/router_strategy/test_lowest_cost.py index f71d1ad9144..e23b7628c35 100644 --- a/tests/unit/router_strategy/test_lowest_cost.py +++ b/tests/unit/router_strategy/test_lowest_cost.py @@ -7,7 +7,7 @@ import litellm from litellm.caching.caching import DualCache from litellm.router_strategy.lowest_cost import LowestCostLoggingHandler from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome DEPLOYMENT_ID = "9876" diff --git a/tests/unit/router_strategy/test_lowest_tpm_rpm_v2.py b/tests/unit/router_strategy/test_lowest_tpm_rpm_v2.py index 1139de6c0fc..60cad628598 100644 --- a/tests/unit/router_strategy/test_lowest_tpm_rpm_v2.py +++ b/tests/unit/router_strategy/test_lowest_tpm_rpm_v2.py @@ -11,7 +11,7 @@ import traceback from typing import Dict, Final, Iterator from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome from unittest.mock import patch diff --git a/tests/unit/rust_bridge/host/test_model_cost.py b/tests/unit/rust_bridge/host/test_model_cost.py index 82a52a941a1..66f411da83e 100644 --- a/tests/unit/rust_bridge/host/test_model_cost.py +++ b/tests/unit/rust_bridge/host/test_model_cost.py @@ -3,16 +3,8 @@ from typing import Final import pytest import litellm -from litellm.cost_calculator import cost_per_token -from litellm.litellm_core_utils.get_model_cost_map import model_cost_inputs -from litellm.rust_bridge.host.model_cost import ( - changed_in_place, - entry, - merged, - publish, - resolve, - write, -) +from litellm.litellm_core_utils.model_cost_table import AliasedCostEntry +from litellm.rust_bridge.host.model_cost import merged, resolve pytestmark = pytest.mark.usefixtures("local_model_cost_map") @@ -25,56 +17,9 @@ def test_resolve_lands_on_the_row_a_lookup_in_the_published_table_resolves() -> def test_merged_keeps_the_alias_names_and_adds_only_the_fields_a_registration_set() -> None: - current: Final = entry({"mode": "chat", "x": 1}, ("owner", "alias"), frozenset({"mode"})) + current: Final = AliasedCostEntry({"mode": "chat", "x": 1}, ("owner", "alias"), frozenset({"mode"})) row: Final = merged(current, {"x": 2}, {"litellm_provider": "openai", "output_cost_per_token": None}) assert row == {"mode": "chat", "x": 2, "litellm_provider": "openai"} assert (row.names, row.registered_fields) == (("owner", "alias"), frozenset({"mode", "litellm_provider"})) - - -_MODEL: Final = "lit-table-model" - - -def _row(price: float) -> dict[str, object]: - return {"litellm_provider": "openai", "mode": "chat", "input_cost_per_token": price, "output_cost_per_token": price} - - -def _billed() -> float: - prompt, completion = cost_per_token( - model=_MODEL, custom_llm_provider="openai", prompt_tokens=1, completion_tokens=0 - ) - return prompt + completion - - -def test_a_published_table_is_read_in_place_of_what_was_cached_for_the_one_before() -> None: - publish({**litellm.model_cost, _MODEL: _row(1.0)}) - assert litellm.get_model_info(_MODEL)["input_cost_per_token"] == 1.0 - assert _billed() == 1.0 - - publish({**litellm.model_cost, _MODEL: _row(2.0)}) - - assert litellm.get_model_info(_MODEL)["input_cost_per_token"] == 2.0 - assert _billed() == 2.0 - - -def test_rows_written_into_the_live_table_replace_a_cached_value_of_the_same_size() -> None: - publish({**litellm.model_cost, _MODEL: _row(1.0)}) - assert _billed() == 1.0 - - write(litellm.model_cost, {_MODEL: _row(3.0)}) - - assert litellm.get_model_info(_MODEL)["input_cost_per_token"] == 3.0 - assert _billed() == 3.0 - - -def test_a_provider_changed_in_place_regroups_the_key_once_the_change_is_reported( - monkeypatch: pytest.MonkeyPatch, -) -> None: - assert "gpt-4o" not in model_cost_inputs.keys_in(litellm.model_cost, "lit-moved") - - monkeypatch.setitem(litellm.model_cost["gpt-4o"], "litellm_provider", "lit-moved") - changed_in_place() - - assert model_cost_inputs.keys_in(litellm.model_cost, "lit-moved") == ["gpt-4o"] - assert "gpt-4o" not in model_cost_inputs.keys_in(litellm.model_cost, "openai") diff --git a/tests/unit/rust_bridge/test_preflight.py b/tests/unit/rust_bridge/host/test_preflight.py similarity index 92% rename from tests/unit/rust_bridge/test_preflight.py rename to tests/unit/rust_bridge/host/test_preflight.py index a8b1a40a00b..ebf8638f72e 100644 --- a/tests/unit/rust_bridge/test_preflight.py +++ b/tests/unit/rust_bridge/host/test_preflight.py @@ -8,8 +8,8 @@ import pytest from pydantic import TypeAdapter import litellm -from litellm.rust_bridge import preflight -from litellm.rust_bridge.preflight import check_limits +from litellm.rust_bridge.host import preflight +from litellm.rust_bridge.host.preflight import check_limits @pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) @@ -47,7 +47,7 @@ def test_check_limits_refuses_a_call_over_the_budget(monkeypatch: pytest.MonkeyP check_limits({"model": "mistral/mistral-ocr-latest"}) -CONTRACT_PATH: Final = Path(__file__).parents[3] / "litellm-rust/crates/python-bridge/preflight_contract.json" +CONTRACT_PATH: Final = Path(__file__).parents[4] / "litellm-rust/crates/python-bridge/preflight_contract.json" _SHIMS: Final[MappingProxyType[str, Callable[..., object]]] = MappingProxyType( { "credential_list": preflight.credential_list, diff --git a/tests/unit/rust_bridge/host/test_secret_manager.py b/tests/unit/rust_bridge/host/test_secret_manager.py index 1f8acc5792e..0705b3c806d 100644 --- a/tests/unit/rust_bridge/host/test_secret_manager.py +++ b/tests/unit/rust_bridge/host/test_secret_manager.py @@ -18,7 +18,8 @@ from pydantic import JsonValue import litellm from litellm.rust_bridge import _native as native -from litellm.rust_bridge.host.secret_manager import capture_secret_manager, native_secret_manager_config +from litellm.rust_bridge.host.secret_manager import native_secret_manager_config +from litellm.secret_managers.native_config import capture_secret_manager from litellm.secret_managers.aws_secret_manager_v2 import AWSSecretsManagerV2 from litellm.secret_managers.cyberark_secret_manager import CyberArkSecretManager from litellm.secret_managers.hashicorp_secret_manager import HashicorpSecretManager diff --git a/tests/unit/rust_bridge/test_logger.py b/tests/unit/rust_bridge/test_logger.py index df3aedf4cb9..97a32609538 100644 --- a/tests/unit/rust_bridge/test_logger.py +++ b/tests/unit/rust_bridge/test_logger.py @@ -18,7 +18,8 @@ from litellm.litellm_core_utils.secret_redaction import ( python_redact_string, python_redact_structured_value, ) -from litellm.rust_bridge import diagnostics, logger +from litellm.rust_bridge import diagnostics +from litellm.rust_bridge.host import logger def test_native_records_preserve_metadata_and_redact_before_custom_handlers( diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py index 8af9ae29c67..07b6111aa67 100644 --- a/tests/unit/test_cost_calculator.py +++ b/tests/unit/test_cost_calculator.py @@ -39,7 +39,7 @@ from litellm.types.rerank import RerankResponse from litellm.types.utils import CacheCreationTokenDetails, CallTypes, Choices, CompletionTokensDetailsWrapper, EmbeddingResponse, ImageObject, ImageResponse, ImageUsage, ImageUsageInputTokensDetails, LiteLLMRealtimeStreamLoggingObject, Message, ModelInfo, ModelResponse, PromptTokensDetails, PromptTokensDetailsWrapper, Usage from litellm.types.videos.main import VideoObject from litellm.utils import cached_get_model_info_helper, supports_prompt_caching -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish import os import threading from unittest.mock import MagicMock, patch diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index b300562b029..c4879587038 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5653,7 +5653,7 @@ def _priced_at(prompt_tokens, completion_tokens): def local_cost_map(monkeypatch): """The prices these tests assert are the checked-in ones. Setting the environment variable alone does not reload the map, so pin the map itself.""" - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) diff --git a/tests/unit/test_model_cost_aliases.py b/tests/unit/test_model_cost_aliases.py index c01e4d07e3e..3dfee102004 100644 --- a/tests/unit/test_model_cost_aliases.py +++ b/tests/unit/test_model_cost_aliases.py @@ -12,7 +12,7 @@ from typing import Final import pytest from litellm.rust_bridge._native import NativeInstalled, NativeModelCostMap, NativeRejected -from litellm.rust_bridge.host.model_cost import AliasedCostEntry +from litellm.litellm_core_utils.model_cost_table import AliasedCostEntry def _install(document: dict[str, dict[str, object]]) -> NativeInstalled: diff --git a/tests/unit/test_no_top_level_test_invocations.py b/tests/unit/test_no_top_level_test_invocations.py index 386c71d779e..eae026bbf71 100644 --- a/tests/unit/test_no_top_level_test_invocations.py +++ b/tests/unit/test_no_top_level_test_invocations.py @@ -8,7 +8,7 @@ import pytest import litellm from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome LOCAL_TESTING_DIR = Path(__file__).parents[1] / "local_testing" diff --git a/tests/unit/test_pydantic_namespaces.py b/tests/unit/test_pydantic_namespaces.py index fc21ea501db..3a4c1459d14 100644 --- a/tests/unit/test_pydantic_namespaces.py +++ b/tests/unit/test_pydantic_namespaces.py @@ -7,7 +7,7 @@ import pytest import litellm from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/test_register_model_custom_pricing.py b/tests/unit/test_register_model_custom_pricing.py index d4f591e7291..969ca0269cf 100644 --- a/tests/unit/test_register_model_custom_pricing.py +++ b/tests/unit/test_register_model_custom_pricing.py @@ -28,7 +28,7 @@ from litellm.litellm_core_utils.get_model_cost_map import ( model_cost_inputs, ) from litellm.rust_bridge._native import NativeTableSwapped -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from litellm.types.utils import ( Choices, CompletionTokensDetailsWrapper, @@ -258,7 +258,7 @@ def test_register_model_inherits_builtin_cache_pricing_for_unmapped_key(monkeypa litellm.model_cost.pop(registered_key, None) litellm.model_cost = original_model_cost os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish publish(litellm.model_cost) diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 6557db7be32..e84d187f937 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -101,7 +101,7 @@ from litellm.llms.base_llm.vector_store.transformation import ( RouterVectorStoreEmbeddingExecutor, ) from litellm.types.utils import CallTypes, CredentialItem, Usage -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome from tests.fake_openai_endpoint import FAKE_OPENAI_API_BASE from tests.large_text import text diff --git a/tests/unit/test_router_model_cost_isolation.py b/tests/unit/test_router_model_cost_isolation.py index 8d15ba02a38..8bc201b030a 100644 --- a/tests/unit/test_router_model_cost_isolation.py +++ b/tests/unit/test_router_model_cost_isolation.py @@ -48,7 +48,7 @@ from litellm.types.router import ( from litellm.utils import ( reapply_runtime_model_cost_registrations, ) -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish _COPILOT_TOKEN_EXPIRES_AT: Final = 4_102_444_800 diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 5856caf7740..832b73a0730 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -21,7 +21,7 @@ from litellm.caching.in_memory_cache import InMemoryCache from litellm.caching.redis_cache import RedisCache from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.scheduler import FlowItem, Scheduler, SchedulerCacheKeys -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 8d8c123ef49..6fece4d8c66 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -120,7 +120,7 @@ from litellm.utils import ( validate_environment, validate_chat_completion_tool_choice, ) -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish @pytest.mark.parametrize("root_base_model", [42, ["gemini-3.5-flash"], {"model": "gemini-3.5-flash"}]) @@ -2529,7 +2529,7 @@ def test_register_model_with_scientific_notation(): test_model_name = f"test-scientific-notation-model-{uuid.uuid4().hex[:12]}" # Clear LRU caches that might have stale data - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish publish(litellm.model_cost) model_cost_dict = { @@ -5173,7 +5173,7 @@ def test_reapply_runtime_registrations_replays_register_model_overrides( from litellm.utils import ( reapply_runtime_model_cost_registrations, ) - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish # Only the recorded half is under test here; the live-router rebuild is covered # in test_router_model_cost_isolation.py. Routers built by earlier tests in this @@ -5230,7 +5230,7 @@ def test_reapply_runtime_registrations_drops_request_scoped_registrations(monkey from litellm.utils import ( reapply_runtime_model_cost_registrations, ) - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish saved_model_cost = litellm.model_cost try: @@ -5257,7 +5257,7 @@ def test_reapply_runtime_registrations_drops_request_scoped_registrations(monkey def test_model_cost_walks_survive_a_registration_landing_mid_walk(monkeypatch: pytest.MonkeyPatch) -> None: - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish class _EntryRegisteredMidWalk(dict): def get(self, field: str, default: object = None) -> object: @@ -5306,7 +5306,7 @@ def test_request_path_cost_map_walks_survive_a_registration_mid_walk( from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig from litellm.llms.ollama.common_utils import OllamaModelInfo from litellm.llms.openai.chat.gpt_reasoning_transformation import _catalogue_declares_default_effort - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish walks: Final[Mapping[str, Callable[[], object]]] = { "fal_ai": lambda: _keyed_rows("lit-5853-model", "standard"), @@ -8537,7 +8537,7 @@ def test_get_model_info_gemini(monkeypatch): def test_register_model_without_a_provider_never_inherits_another_providers_entry( monkeypatch: pytest.MonkeyPatch, ) -> None: - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish key: Final = "azure/claude-sonnet-4-6" monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost)) @@ -8570,7 +8570,7 @@ def test_register_model_without_a_provider_never_inherits_another_providers_entr def test_register_model_over_a_builtin_row_bills_explicit_cache_hits( monkeypatch: pytest.MonkeyPatch, registered_prices: dict[str, float], billed_cache_read_rate: float ) -> None: - from litellm.rust_bridge.host.model_cost import publish + from litellm.litellm_core_utils.model_cost_table import publish key: Final = "dashscope/qwen-register-explicit-cache-test" monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost)) @@ -11659,7 +11659,7 @@ def test_the_catalog_with_register_model_entries_lists_each_model_once_and_leave ) def test_runtime_model_cost_registrations_resolve_bedrock_routing(monkeypatch: pytest.MonkeyPatch, key: str) -> None: from litellm import utils as model_utils - from litellm.rust_bridge.host.model_cost import AliasedCostEntry + from litellm.litellm_core_utils.model_cost_table import AliasedCostEntry rates: Final = {"input_cost_per_token": 0.001, "output_cost_per_token": 0.002} alias: Final = "openai.test-converse-alias" diff --git a/tests/unit/test_utils_get_model_info.py b/tests/unit/test_utils_get_model_info.py index cde0a8978c0..0ebe291cf62 100644 --- a/tests/unit/test_utils_get_model_info.py +++ b/tests/unit/test_utils_get_model_info.py @@ -14,7 +14,7 @@ from litellm.llms.bedrock.common_utils import BedrockModelInfo from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.types.utils import ModelInfoBase from litellm.utils import supports_native_structured_output -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish import os from unittest.mock import MagicMock, patch diff --git a/tests/unit/types/test_files.py b/tests/unit/types/test_files.py index 5b2c82b69db..b55dde3d44b 100644 --- a/tests/unit/types/test_files.py +++ b/tests/unit/types/test_files.py @@ -16,7 +16,7 @@ from litellm.types.files import ( get_file_mime_type_from_extension, get_file_type_from_extension, ) -from litellm.rust_bridge.host.model_cost import publish +from litellm.litellm_core_utils.model_cost_table import publish from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome From 35e501974e227acccd203289737c6d31857e764c Mon Sep 17 00:00:00 2001 From: tavren27 <348969968+tavren27@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:19:17 -0800 Subject: [PATCH 36/86] fix(spend): write a background response's settling read in-process when the spend collector is on (#50957) * fix(passthrough): bill a background response once however many times it is read * fix(passthrough): keep the background response flag on spend events sent to the collector * test(spend): type the background response test helper and its settlement parameter * fix(proxy): keep a background response's settling read in-process instead of adding a spend event field An older collector refuses a spend event with a field it does not know and drops the whole charge, so a newer gateway sending to an older collector lost the background response's cost. The read that settles a background response now stays in-process, the same way video status polls do, and the spend event keeps its shape * fix(proxy): keep a batch retrieve in-process when message redaction hides the logged batch * refactor(proxy): split the is_offloadable_success docstring into one line per reason --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../proxy/hooks/proxy_track_cost_callback.py | 2 +- litellm/proxy/spend_tracking/spend_event.py | 22 ++++++++-- .../hooks/test_proxy_track_cost_callback.py | 40 ++++++++++++++++--- .../proxy/spend_tracking/test_spend_event.py | 26 +++++++++--- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index dd7a24ae879..554fc7ed1d5 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -425,7 +425,7 @@ class _ProxyDBLogger(CustomLogger): async def async_log_success_event( self, kwargs: ObjectMapping, response_obj: object, start_time: datetime, end_time: datetime ) -> None: - if self.spend_event_producer is None or not is_offloadable_success(response_obj, kwargs.get("call_type")): + if self.spend_event_producer is None or not is_offloadable_success(response_obj, kwargs): await self._PROXY_track_cost_callback(dict(kwargs), response_obj, start_time, end_time) return event: Final = build_spend_event( diff --git a/litellm/proxy/spend_tracking/spend_event.py b/litellm/proxy/spend_tracking/spend_event.py index d054519cb9c..52b9acf44c0 100644 --- a/litellm/proxy/spend_tracking/spend_event.py +++ b/litellm/proxy/spend_tracking/spend_event.py @@ -23,10 +23,15 @@ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError from typing_extensions import NotRequired, ReadOnly, TypedDict import litellm +from litellm.litellm_core_utils.batch_utils import is_batch_retrieve_call_type from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.db.spend_log_tool_index import response_tool_call_names from litellm.types.interactions import InteractionsAPIResponse from litellm.types.llms.base import LiteLLMBaseModel +from litellm.types.passthrough_endpoints.pass_through_endpoints import ( + SETTLES_BACKGROUND_RESPONSE_KWARG, + BackgroundResponseSettlement, +) from litellm.types.utils import LiteLLMBatch, Usage from litellm.types.videos.utils import is_video_status_call_type @@ -206,10 +211,19 @@ class SpendEventDecodeError: reason: str -def is_offloadable_success(response_obj: object, call_type: object) -> bool: - """Batch retrieves and interaction polls branch on the concrete response class, and a video status poll on - the status and video id the event leaves out, so they stay in-process.""" - return not isinstance(response_obj, _NOT_OFFLOADED_RESPONSE_TYPES) and not is_video_status_call_type(call_type) +def is_offloadable_success(response_obj: object, kwargs: ObjectMapping) -> bool: + """A success stays in-process when its cost logic reads something the spend event leaves out. + + Batches and interaction polls need the concrete response class. A batch retrieve needs the polled batch id, + which message redaction can hide. A video status poll needs the status and the video id. A background + response's settling read needs the settlement flag.""" + call_type: Final = kwargs.get("call_type") + return ( + not isinstance(response_obj, _NOT_OFFLOADED_RESPONSE_TYPES) + and not is_batch_retrieve_call_type(call_type) + and not is_video_status_call_type(call_type) + and kwargs.get(SETTLES_BACKGROUND_RESPONSE_KWARG) is not BackgroundResponseSettlement.SETTLES + ) def _json_fallback(value: object) -> str: diff --git a/tests/unit/proxy/hooks/test_proxy_track_cost_callback.py b/tests/unit/proxy/hooks/test_proxy_track_cost_callback.py index 43f529faa6b..dcec934188f 100644 --- a/tests/unit/proxy/hooks/test_proxy_track_cost_callback.py +++ b/tests/unit/proxy/hooks/test_proxy_track_cost_callback.py @@ -18,7 +18,9 @@ from fastapi import FastAPI, HTTPException import litellm from litellm._logging import verbose_proxy_logger from litellm.caching.dual_cache import DualCache +from litellm.litellm_core_utils.batch_utils import POLLED_BATCH_COST_IS_FINAL_KWARG, POLLED_BATCH_ID_KWARG from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY +from litellm.litellm_core_utils.redact_messages import perform_redaction from litellm.proxy._types import ProxyException, SpendLogsPayload, UserAPIKeyAuth from litellm.proxy.collector import SpendEventConsumer from litellm.proxy.db.db_lookup_gate import DBLookupDeadlineExceeded @@ -47,6 +49,10 @@ from litellm.proxy.utils import ( ) from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys from litellm.types.router import Deployment, LiteLLM_Params +from litellm.types.passthrough_endpoints.pass_through_endpoints import ( + SETTLES_BACKGROUND_RESPONSE_KWARG, + BackgroundResponseSettlement, +) from litellm.types.utils import CallTypes, LiteLLMBatch, ModelResponse, PromptTokensDetailsWrapper, Usage from litellm.types.videos.main import VideoObject @@ -3692,17 +3698,41 @@ def _completed_batch() -> LiteLLMBatch: ) +def _redacted_completed_batch() -> object: + return perform_redaction({}, _completed_batch()) + + def _completed_video() -> VideoObject: return VideoObject(id="video_abc", object="video", status="completed") @pytest.mark.asyncio @pytest.mark.parametrize( - ("call_type", "polled_job"), - [(CallTypes.aretrieve_batch.value, _completed_batch), ("avideo_status", _completed_video)], + ("job_kwargs", "polled_job"), + [ + ({"call_type": CallTypes.aretrieve_batch.value}, _completed_batch), + ( + { + "call_type": CallTypes.aretrieve_batch.value, + POLLED_BATCH_ID_KWARG: "batch_abc", + POLLED_BATCH_COST_IS_FINAL_KWARG: True, + }, + _redacted_completed_batch, + ), + ({"call_type": "avideo_status"}, _completed_video), + ( + { + "call_type": "pass_through_endpoint", + SETTLES_BACKGROUND_RESPONSE_KWARG: BackgroundResponseSettlement.SETTLES, + }, + _offload_response, + ), + ], + ids=["batch_retrieve", "redacted_batch_retrieve", "video_status_poll", "background_response_settling_read"], ) -async def test_async_log_success_event_keeps_job_polls_in_process(call_type, polled_job): - """The spend event leaves out the polled video's status and id, which decide whether the poll charges the job.""" +async def test_async_log_success_event_keeps_job_polls_in_process(job_kwargs, polled_job): + """The spend event leaves out the polled video's status and id and a background response's settlement flag, + which decide whether the poll charges the job and which spend row it writes.""" producer = SpendEventProducer( address=UnixAddress(path="/nonexistent/spend.sock"), on_unavailable="drop", @@ -3711,7 +3741,7 @@ async def test_async_log_success_event_keeps_job_polls_in_process(call_type, pol fallback=_no_fallback, ) logger = ProxyDBLogger(producer) - kwargs = {**_offload_kwargs(), "call_type": call_type} + kwargs = {**_offload_kwargs(), **job_kwargs} completed_job = polled_job() with ( diff --git a/tests/unit/proxy/spend_tracking/test_spend_event.py b/tests/unit/proxy/spend_tracking/test_spend_event.py index e1912d36d91..52422533213 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_event.py +++ b/tests/unit/proxy/spend_tracking/test_spend_event.py @@ -16,6 +16,10 @@ from litellm.proxy.spend_tracking.spend_event import ( is_offloadable_success, spend_event_callback_args, ) +from litellm.types.passthrough_endpoints.pass_through_endpoints import ( + SETTLES_BACKGROUND_RESPONSE_KWARG, + BackgroundResponseSettlement, +) from litellm.types.utils import LiteLLMBatch, ModelResponse, Usage _BIG_PROMPT: Final = "x" * 20_000 @@ -194,11 +198,23 @@ def test_undecodable_line_is_an_error_value(): assert isinstance(decode_spend_event(b"not json\n"), SpendEventDecodeError) +@pytest.mark.parametrize( + ("settlement", "offloadable"), + [(BackgroundResponseSettlement.SETTLES, False), (None, True), ("settles", True)], + ids=["settling_read_stays_in_process", "other_read_is_offloaded", "lookalike_string_is_offloaded"], +) +def test_only_a_background_response_settling_read_stays_in_process( + settlement: BackgroundResponseSettlement | str | None, offloadable: bool +) -> None: + kwargs: Final = {"call_type": "pass_through_endpoint", SETTLES_BACKGROUND_RESPONSE_KWARG: settlement} + assert is_offloadable_success(None, kwargs) is offloadable + + def test_batch_retrieves_and_video_status_polls_stay_in_process(): - assert is_offloadable_success(_response(), "acompletion") is True - assert is_offloadable_success(None, None) is True - assert is_offloadable_success(None, "avideo_generation") is True - assert is_offloadable_success(None, "avideo_status") is False + assert is_offloadable_success(_response(), {"call_type": "acompletion"}) is True + assert is_offloadable_success(None, {}) is True + assert is_offloadable_success(None, {"call_type": "avideo_generation"}) is True + assert is_offloadable_success(None, {"call_type": "avideo_status"}) is False assert ( is_offloadable_success( LiteLLMBatch( @@ -210,7 +226,7 @@ def test_batch_retrieves_and_video_status_polls_stay_in_process(): object="batch", status="completed", ), - "aretrieve_batch", + {"call_type": "aretrieve_batch"}, ) is False ) From 8092edc7a1fe953e3aaa71a70e6b2fd41699025b Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Tue, 10 Nov 2026 18:28:49 -0800 Subject: [PATCH 37/86] fix(proxy): judge the zero-cost budget waiver by the model a key or team alias serves (#51058) * fix(proxy): judge the zero-cost budget waiver by the model a key or team alias serves The zero-cost budget waiver judged the name the client sent, but key `aliases` and team `model_aliases` are applied later during request preparation. An over-budget key got a 422 for an alias of a $0 model, and an alias named like a $0 group that points at a paid model was waived and served past the budget. For a string body `model` on chat, completions, responses and messages, the key-path, centralized and custom-auth waivers now judge the model request preparation serves. That model comes from the same team, key, global and router alias functions that request preparation uses, so the two cannot drift. Other routes and the JWT path keep judging the requested model. common_checks no longer overrides a caller's explicit decision not to skip budgets, and proxy-made sub-calls are judged by their own model. Resolves LIT-10283 * fix(proxy): waive only once auth has applied the router aliases, and keep empty team alias targets The served-model waiver now applies only after auth's router-alias rewrite has run on the request body. Custom auth without common checks runs its waiver before that rewrite, so it keeps judging the requested model instead of a key or team alias of a name the router rewrites elsewhere. The waiver also keeps an empty-string team alias target, matching request preparation. Resolves LIT-10283 * fix(proxy): waive by the served model only for a client's own body-model request The waiver resolved aliases for any request on a chat, completions, responses or messages path, including the HTTP request the Responses WebSocket builds for first-frame admission. That session dispatches the model as an endpoint model, which skips the team alias, so a team alias of a paid model to a free one waived the paid model's budget. The waiver now resolves aliases only when FastAPI matched the request to one of the body model routes, which requests built inside the proxy and path model routes never are. The waiver tests are folded into one table, share a priced router fixture, and cover the stale team alias flag cache. Resolves LIT-10283 --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- .../routing_common_utils.py | 25 --- litellm/proxy/auth/auth_checks.py | 20 +- litellm/proxy/auth/user_api_key_auth.py | 107 +++++++++-- litellm/proxy/common_request_processing.py | 37 ++-- litellm/proxy/litellm_pre_call_utils.py | 178 ++++++++++++------ .../auth/test_custom_auth_end_user_budget.py | 64 ++++++- tests/unit/proxy/auth/test_model_checks.py | 55 ++++++ .../test_user_api_key_auth_request_flow.py | 93 ++++++++- tests/unit/proxy/conftest.py | 24 +++ .../unit/proxy/test_litellm_pre_call_utils.py | 58 ++++++ .../test_zero_cost_model_budget_bypass.py | 170 ++++++++++++++++- .../test_router_utils_common_utils.py | 20 -- 12 files changed, 685 insertions(+), 166 deletions(-) diff --git a/litellm/litellm_core_utils/routing_common_utils.py b/litellm/litellm_core_utils/routing_common_utils.py index 154c3942edc..363854f70de 100644 --- a/litellm/litellm_core_utils/routing_common_utils.py +++ b/litellm/litellm_core_utils/routing_common_utils.py @@ -38,7 +38,6 @@ _V = TypeVar("_V") MID_STREAM_CONTINUATION_KWARG: Final = "_mid_stream_continuation" ROUTER_ONLY_CALL_KWARGS: Final = frozenset({"silent_model", "include_fallback_errors", MID_STREAM_CONTINUATION_KWARG}) -_OBJECT_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[object, object]]] = TypeAdapter(Mapping[object, object]) _DEPLOYMENT_ROWS_ADAPTER: Final[TypeAdapter[tuple[Mapping[str, object], ...]]] = TypeAdapter( tuple[Mapping[str, object], ...] ) @@ -215,30 +214,6 @@ def resolve_model_group_alias(model_group_alias: object, model: str) -> str | No return target -def resolve_served_model( - model: str, - model_alias_map: object, - key_aliases: object, - model_group_alias: object, -) -> str: - """Model the request preparation serves for `model`. - - Apply the global alias map, then key aliases, then `router_settings.model_group_alias`. - """ - global_aliases: Final = ( - _OBJECT_MAPPING_ADAPTER.validate_python(model_alias_map) if isinstance(model_alias_map, Mapping) else None - ) - global_target: Final = global_aliases.get(model) if global_aliases is not None else None - after_global: Final = global_target if isinstance(global_target, str) else model - key_alias_mapping: Final = ( - _OBJECT_MAPPING_ADAPTER.validate_python(key_aliases) if isinstance(key_aliases, Mapping) else None - ) - key_target: Final = key_alias_mapping.get(after_global) if key_alias_mapping is not None else None - after_key: Final = key_target if isinstance(key_target, str) else after_global - router_target: Final = resolve_model_group_alias(model_group_alias, after_key) - return router_target if router_target is not None else after_key - - def truncate_fallback_error_detail(detail: str) -> str: """ Bound a fallback failure detail before it is logged or appended to an exception message. diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index f0c9632837d..fa331b37950 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -557,7 +557,7 @@ def _get_router_zero_cost_cache(llm_router: RegistryReader) -> dict[str, bool] | return cache if isinstance(cache, dict) else None -def is_model_cost_zero(model: str | list[str] | None, llm_router: RegistryReader | None) -> bool: +def is_model_cost_zero(model: str | Sequence[str] | None, llm_router: RegistryReader | None) -> bool: """ Check if a model has zero cost (no configured pricing). @@ -1122,7 +1122,7 @@ def route_skips_budget_checks(route: str) -> bool: def request_skips_budget_checks( route: str, - model: str | list[str] | None, + model: str | Sequence[str] | None, llm_router: Router | None, *, request_body: Mapping[str, object] | None = None, @@ -1146,7 +1146,7 @@ async def common_checks( proxy_logging_obj: ProxyLogging, valid_token: UserAPIKeyAuth | None, request: Request, - skip_budget_checks: bool = False, + skip_budget_checks: bool | None = None, project_object: LiteLLM_ProjectTableCachedObj | None = None, ) -> bool: """ @@ -1181,11 +1181,15 @@ async def common_checks( team_id=valid_token.team_id if valid_token is not None else None, ) - skip_all_budget_checks: Final = skip_budget_checks or request_skips_budget_checks( - route=route, - model=_model, - llm_router=llm_router, - request_body=_typed_request_body(request_body), + skip_all_budget_checks: Final = ( + skip_budget_checks + if skip_budget_checks is not None + else request_skips_budget_checks( + route=route, + model=_model, + llm_router=llm_router, + request_body=_typed_request_body(request_body), + ) ) membership_user_id: Final = ( diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 5bf4db6a725..d7a8d4a0700 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -41,7 +41,7 @@ from litellm.integrations.otel.model.config import is_otel_v2_enabled from litellm.integrations.otel.runtime import phase_event, phase_span, seed_request_identity from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value -from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias, resolve_served_model +from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias from litellm.models.end_user import LiteLLM_EndUserTable from litellm.proxy._types import * from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_from_headers @@ -95,6 +95,7 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo route_skips_budget_checks, stamp_matched_model_access_groups, team_member_max_budget_alert_check, + team_model_aliases_for_auth_check, virtual_key_max_budget_alert_check, virtual_key_max_budget_check, virtual_key_soft_budget_check, @@ -173,7 +174,11 @@ from litellm.proxy.common_utils.user_api_key_cache import ( from litellm.proxy.db.db_lookup_gate import bounded_db_lookup from litellm.proxy.db.db_span import db_span from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler -from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup +from litellm.proxy.litellm_pre_call_utils import ( + LiteLLMProxyRequestSetup, + pre_call_served_model, + request_served_model, +) from litellm.proxy.spend_tracking.carried_budget_state import ( carry_committed_budget_spend, carry_team_and_user_budget_state, @@ -332,6 +337,65 @@ def _is_codex_native_litellm_turn(route: str, model: object, llm_router: litellm return routes_to_litellm(model, llm_router, team_id) +_SERVED_MODEL_BUDGET_WAIVER_ROUTES: Final = frozenset( + { + "/chat/completions", + "/v1/chat/completions", + "/completions", + "/v1/completions", + "/responses", + "/v1/responses", + "/messages", + "/v1/messages", + } +) + + +def _inference_model_overridden() -> bool: + from litellm.proxy import proxy_server + + cli_model: Final[object] = proxy_server.user_model # pyright: ignore[reportUnknownMemberType] # legacy untyped CLI global + completion_model: Final = proxy_server.general_settings_view().get("completion_model") + return (isinstance(completion_model, str) and completion_model != "") or ( + isinstance(cli_model, str) and cli_model != "" + ) + + +async def budget_served_model( + model: str | Sequence[str] | None, + request_data: Mapping[str, object], + request: Request | None, + llm_router: litellm.Router | None, + valid_token: UserAPIKeyAuth, +) -> str | Sequence[str] | None: + """The model the zero-cost budget waiver judges. For a client's own HTTP request to a chat, completions, + responses or messages route that names a string body `model`, it is the model request preparation serves it + with through the caller's team, key, global and router aliases. Everywhere else it stays the requested model: + requests built inside the proxy, a path or query `model`, a configured `completion_model` or CLI `--model`, + and requests whose body auth has not yet passed through the router aliases.""" + if request is None or llm_router is None or not isinstance(model, str): + return model + if get_request_route_template(request) not in _SERVED_MODEL_BUDGET_WAIVER_ROUTES: + return model + if request.scope.get(MODEL_GROUP_ALIAS_RESOLVED_SCOPE_KEY) is not True or request_data.get("model") != model: + return model + if "model" in safe_get_request_query_params(request=request) or _inference_model_overridden(): + return model + if not ( + key_model_aliases_for_auth_check(valid_token) + or team_model_aliases_for_auth_check(valid_token) + or model in litellm.model_alias_map + ): + return model + from litellm.proxy.proxy_server import prisma_client, proxy_config, proxy_logging_obj + + settings: Final = await proxy_config.get_hierarchical_router_settings( + user_api_key_dict=valid_token, prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj + ) + group_alias: Final[object] = settings.get("model_group_alias") if isinstance(settings, Mapping) else None + return request_served_model(model, valid_token, group_alias, llm_router) + + async def _resolve_router_settings_model_group_alias( request_data: dict[str, object], # mutable-ok: the request body is rewritten in place for every downstream reader valid_token: UserAPIKeyAuth, @@ -971,9 +1035,6 @@ async def _try_team_member_model_budget_fallback( model_group_aliases: Final[object] = ( router_settings.get("model_group_alias") if router_settings is not None else None ) - key_aliases: Final[object] = cast( # cast-ok: auth aliases are dynamically typed at runtime - object, valid_token.aliases - ) resolved_fallback_candidates: Final[tuple[tuple[str, str], ...]] = tuple( ( candidate, @@ -985,12 +1046,7 @@ async def _try_team_member_model_budget_fallback( ( fallback_candidate, fallback_model, - resolve_served_model( - fallback_model, - litellm.model_alias_map, - key_aliases, - model_group_aliases, - ), + pre_call_served_model(fallback_model, valid_token, model_group_aliases), ) for fallback_candidate, fallback_model in resolved_fallback_candidates ) @@ -3137,7 +3193,10 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e team_id=valid_token.team_id, ) skip_budget_checks: Final = request_skips_budget_checks( - route=route, model=model, llm_router=llm_router, request_body=request_data + route=route, + model=await budget_served_model(model, request_data, request, llm_router, valid_token), + llm_router=llm_router, + request_body=request_data, ) # Check 3. Check if user is in their team budget @@ -3968,12 +4027,13 @@ async def run_centralized_common_checks( dict(end_user_model_max_budget) if end_user_model_max_budget else None ) - skip_budget_checks: Final = _should_skip_budget_checks( + skip_budget_checks: Final = await _should_skip_budget_checks( request_data=request_data, route=route, request=request, llm_router=llm_router, - team_id=user_api_key_auth_obj.team_id, + valid_token=user_api_key_auth_obj, + sub_call=sub_call, ) typed_team_object: Final[LiteLLM_TeamTable | None] = ( team_object if isinstance(team_object, LiteLLM_TeamTable) else None @@ -4216,21 +4276,27 @@ async def _reserve_budget_after_common_checks( request.state.budget_reservation = reservation # rebind-ok: read by the release middleware -def _should_skip_budget_checks( +async def _should_skip_budget_checks( request_data: dict[str, object], route: str, request: Request | None, llm_router: litellm.Router | None, - team_id: str | None = None, + valid_token: UserAPIKeyAuth, + sub_call: bool = False, ) -> bool: model: Final = _get_model_from_request_context( request_data=request_data, route=route, request=request, llm_router=llm_router, - team_id=team_id, + team_id=valid_token.team_id, + ) + return request_skips_budget_checks( + route=route, + model=(model if sub_call else await budget_served_model(model, request_data, request, llm_router, valid_token)), + llm_router=llm_router, + request_body=request_data, ) - return request_skips_budget_checks(route=route, model=model, llm_router=llm_router, request_body=request_data) def _resolve_request_principal(request: Request, valid_token: UserAPIKeyAuth) -> Principal: @@ -4908,7 +4974,10 @@ async def _run_post_custom_auth_checks( # every budget check for these; this path did not, so the same request could # be refused under custom auth and served under the other two. skip_budget_checks: Final = ( - is_model_cost_zero(model=current_model, llm_router=llm_router) + is_model_cost_zero( + model=await budget_served_model(current_model, request_data, request, llm_router, valid_token), + llm_router=llm_router, + ) if current_model is not None and llm_router is not None else False ) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index bfa559f86c5..46a9f9910d7 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -89,7 +89,7 @@ from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import ( from litellm.litellm_core_utils.llm_response_utils.get_headers import ( get_response_headers, ) -from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias, resolve_served_model +from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.served_output_texts import ( record_served_output_texts, @@ -310,6 +310,8 @@ from litellm.proxy.litellm_pre_call_utils import ( LiteLLMProxyRequestSetup, add_litellm_data_to_request, get_metadata_variable_name, + global_then_key_alias_target, + pre_call_served_model, refresh_proxy_server_request_body_snapshot, reject_url_valued_destination, sanitize_for_log, @@ -1017,13 +1019,11 @@ _ROUTER_SETTINGS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = Type def _fallback_probe_data( - pristine: object, fallback_model: str, key_aliases: object, group_alias: object + pristine: object, fallback_model: str, user_api_key_dict: UserAPIKeyAuth, group_alias: object ) -> Mapping[str, object]: return { **_REQUEST_FIELDS.validate_python(pristine), - "model": resolve_served_model( - fallback_model, _DECODED_JSON.validate_python(litellm.model_alias_map), key_aliases, group_alias - ), + "model": pre_call_served_model(fallback_model, user_api_key_dict, group_alias), } @@ -2246,13 +2246,9 @@ async def _team_member_within_fallback_model_budget( if proxy_config is not None and llm_router is not None else None ) - raw_aliases: Final[object] = cast( # cast-ok: auth aliases are dynamically typed at runtime - object, user_api_key_dict.aliases - ) - served_model: Final = resolve_served_model( + served_model: Final = pre_call_served_model( model, - litellm.model_alias_map, - raw_aliases, + user_api_key_dict, router_settings.get("model_group_alias") if router_settings is not None else None, ) if is_model_cost_zero(model=served_model, llm_router=llm_router): @@ -2714,17 +2710,9 @@ class ProxyBaseLLMRequestProcessing: ### MODEL ALIAS MAPPING ### # check if model name in model alias map # get the actual model name - if isinstance(self.data["model"], str) and self.data["model"] in litellm.model_alias_map: - self.data["model"] = litellm.model_alias_map[self.data["model"]] - - # Check key-specific aliases - if ( - isinstance(self.data["model"], str) - and user_api_key_dict.aliases - and isinstance(user_api_key_dict.aliases, dict) - and self.data["model"] in user_api_key_dict.aliases - ): - self.data["model"] = user_api_key_dict.aliases[self.data["model"]] + prepared_model: Final = self.data["model"] + if isinstance(prepared_model, str): + self.data["model"] = global_then_key_alias_target(prepared_model, user_api_key_dict) if llm_router is not None and proxy_config is not None: self.data.update( @@ -2945,9 +2933,6 @@ class ProxyBaseLLMRequestProcessing: if isinstance(raw_router_settings, Mapping) else None ) - key_aliases: Final[object] = cast( # cast-ok: auth aliases are dynamically typed at runtime - object, user_api_key_dict.aliases - ) try: for fallback_model in fallback_models: if fallback_model == original_model: @@ -2965,7 +2950,7 @@ class ProxyBaseLLMRequestProcessing: try: if endpoint_unsupported_by_model( route_type, - _fallback_probe_data(pristine, fallback_model, key_aliases, group_alias), + _fallback_probe_data(pristine, fallback_model, user_api_key_dict, group_alias), llm_router, user_api_key_dict.team_id, ): diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index d5eac099fb6..447b41f9cfd 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -14,7 +14,6 @@ from typing import ( Any, Final, Literal, - Protocol, TypeAlias, cast, # noqa: TID251 # narrows untyped deployment and credential payloads ) @@ -57,7 +56,6 @@ from litellm.litellm_core_utils.core_helpers import ( attribute_or_none, is_codex_user_agent, normalize_drop_params, - require_attributes, ) from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( @@ -325,6 +323,7 @@ def sanitize_for_log(value: object) -> str: return _sanitize_for_log(value) +from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias from litellm.router import Router from litellm.secret_managers.main import get_secret_bool from litellm.types.litellm_params import AGENTIC_LOOP_KWARG_NAMES, STREAM_USAGE_INJECTED_FIELD @@ -3604,12 +3603,8 @@ def _warn_stale_team_alias_once(warning_key: str, message: str, *args: str) -> N verbose_proxy_logger.warning(message, *args) -class _SupportsStartswith(Protocol): - def startswith(self, prefix: str, /) -> bool: ... - - def _update_model_if_team_alias_exists( - data: dict, + data: dict[str, object], # mutable-ok: request preparation rewrites the body model in place user_api_key_dict: UserAPIKeyAuth, ) -> None: """ @@ -3634,63 +3629,91 @@ def _update_model_if_team_alias_exists( Keeping the requested model name lets it resolve against the deployments that still exist (e.g. a gateway-level model group shared with the team). """ - _model: Final = data.get("model") - team_model_aliases: Final = as_str_mapping(user_api_key_dict.team_model_aliases) - if not _model or not team_model_aliases or _model not in team_model_aliases: - return - from litellm.proxy.proxy_server import llm_router - # Skip alias rewrite if this model resolves to team-specific deployments - # (team models use team_public_model_name, not model_aliases) - aliased_target: Final = team_model_aliases[_model] + rewrite: Final = _team_alias_rewrite(data.get("model"), user_api_key_dict, llm_router) + if rewrite.warning is not None: + _warn_stale_team_alias_once(rewrite.warning.key, rewrite.warning.message, *rewrite.warning.args) + if rewrite.target is not None: + data["model"] = rewrite.target - # Optional bypass for stale aliases from pre-PR deployments: - # only enabled via feature flag to preserve backwards compatibility. - # Cached at module level to avoid hot-path secret lookups on every request. - global _ENABLE_TEAM_STALE_ALIAS_BYPASS - if _ENABLE_TEAM_STALE_ALIAS_BYPASS is None: - _ENABLE_TEAM_STALE_ALIAS_BYPASS = get_secret_bool("LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS", False) - enable_stale_alias_bypass: Final = _ENABLE_TEAM_STALE_ALIAS_BYPASS - # Check if the alias points to a team-scoped UUID name - # (format: "model_name_{team_id}_{uuid}") - is_stale_team_alias: Final = require_attributes(aliased_target, _SupportsStartswith, "startswith").startswith( - f"model_name_{user_api_key_dict.team_id}_" - ) - if is_stale_team_alias and llm_router: - if aliased_target not in llm_router.registry().index("model_name"): - _warn_stale_team_alias_once( - f"deleted:{user_api_key_dict.team_id}:{_model}:{aliased_target}", + +@dataclass(frozen=True, slots=True) +class _StaleTeamAliasWarning: + key: str + message: str + args: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class _TeamAliasRewrite: + target: str | None + warning: _StaleTeamAliasWarning | None = None + + +_NO_TEAM_ALIAS_REWRITE: Final = _TeamAliasRewrite(target=None) + + +def team_alias_target(model: object, user_api_key_dict: UserAPIKeyAuth, llm_router: Router | None) -> str | None: + return _team_alias_rewrite(model, user_api_key_dict, llm_router).target + + +def _team_alias_rewrite( + model: object, user_api_key_dict: UserAPIKeyAuth, llm_router: Router | None +) -> _TeamAliasRewrite: + team_model_aliases: Final = as_str_mapping(user_api_key_dict.team_model_aliases) + if not isinstance(model, str) or not model or not team_model_aliases or model not in team_model_aliases: + return _NO_TEAM_ALIAS_REWRITE + aliased_target: Final = team_model_aliases[model] + if not isinstance(aliased_target, str): + return _NO_TEAM_ALIAS_REWRITE + is_stale_team_alias: Final = aliased_target.startswith(f"model_name_{user_api_key_dict.team_id}_") + if not is_stale_team_alias or not llm_router: + return _TeamAliasRewrite(target=aliased_target) + if aliased_target not in llm_router.registry().index("model_name"): + return _TeamAliasRewrite( + target=None, + warning=_StaleTeamAliasWarning( + f"deleted:{user_api_key_dict.team_id}:{model}:{aliased_target}", "Team model alias for model='%s', team_id='%s' targets '%s', which has no live " "deployment. Routing with the requested model name instead; remove the stale " "entry from the team's model_aliases to silence this warning.", - _sanitize_for_log(_model), - _sanitize_for_log(user_api_key_dict.team_id), - _sanitize_for_log(aliased_target), - ) - return - # This is a stale alias from pre-PR deployments. - # Check if current team deployments exist for the public name. - key: Final = (user_api_key_dict.team_id, _model) - if key in llm_router.registry().index("team_model"): - if enable_stale_alias_bypass: - # Team deployments exist; skip stale alias - return - _warn_stale_team_alias_once( - f"{user_api_key_dict.team_id}:{_model}:{aliased_target}", - "Stale team model alias detected for model='%s', team_id='%s'. " - "New sibling deployments may be unreachable. " - "Set LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS=true to enable " - "team-scoped sibling routing.", - _sanitize_for_log(_model), - _sanitize_for_log(user_api_key_dict.team_id), - ) + ( + _sanitize_for_log(model), + _sanitize_for_log(user_api_key_dict.team_id), + _sanitize_for_log(aliased_target), + ), + ), + ) + if (user_api_key_dict.team_id, model) not in llm_router.registry().index("team_model"): + return _TeamAliasRewrite(target=aliased_target) + if _stale_team_alias_bypass_enabled(): + return _NO_TEAM_ALIAS_REWRITE + return _TeamAliasRewrite( + target=aliased_target, + warning=_StaleTeamAliasWarning( + f"{user_api_key_dict.team_id}:{model}:{aliased_target}", + "Stale team model alias detected for model='%s', team_id='%s'. " + "New sibling deployments may be unreachable. " + "Set LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS=true to enable " + "team-scoped sibling routing.", + (_sanitize_for_log(model), _sanitize_for_log(user_api_key_dict.team_id)), + ), + ) - data["model"] = aliased_target + +def _stale_team_alias_bypass_enabled() -> bool: + global _ENABLE_TEAM_STALE_ALIAS_BYPASS + cached: Final = _ENABLE_TEAM_STALE_ALIAS_BYPASS + if cached is not None: + return cached + enabled: Final = get_secret_bool("LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS", False) is True + _ENABLE_TEAM_STALE_ALIAS_BYPASS = enabled + return enabled def _update_model_if_key_alias_exists( - data: dict, + data: dict[str, object], # mutable-ok: request preparation rewrites the body model in place user_api_key_dict: UserAPIKeyAuth, ) -> None: """ @@ -3705,14 +3728,45 @@ def _update_model_if_key_alias_exists( } - requested_model = "xai/grok-4-fast-non-reasoning" """ - _model: Final = data.get("model") - if ( - _model - and user_api_key_dict.aliases - and isinstance(user_api_key_dict.aliases, dict) - and _model in user_api_key_dict.aliases - ): - data["model"] = user_api_key_dict.aliases[_model] + requested: Final = data.get("model") + if isinstance(requested, str): + data["model"] = key_alias_target(requested, user_api_key_dict) + + +def key_alias_target(model: str, user_api_key_dict: UserAPIKeyAuth) -> str: + from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # auth checks import request preparation + key_model_aliases_for_auth_check, + ) + + key_aliases: Final = key_model_aliases_for_auth_check(user_api_key_dict) + return key_aliases.get(model, model) if key_aliases else model + + +def caller_alias_target(model: str, user_api_key_dict: UserAPIKeyAuth, llm_router: Router | None) -> str: + """The model `add_litellm_data_to_request` leaves after the team's and then the key's aliases.""" + team_target: Final = team_alias_target(model, user_api_key_dict, llm_router) + return key_alias_target(team_target if team_target is not None else model, user_api_key_dict) + + +def global_then_key_alias_target(model: str, user_api_key_dict: UserAPIKeyAuth) -> str: + """The model `common_processing_pre_call_logic` leaves after the global `model_alias_map` and then the + key's aliases, ahead of the key's or team's `router_settings.model_group_alias`.""" + return key_alias_target(litellm.model_alias_map.get(model, model), user_api_key_dict) + + +def pre_call_served_model(model: str, user_api_key_dict: UserAPIKeyAuth, model_group_alias: object) -> str: + """The model group `common_processing_pre_call_logic` sends `model` to.""" + prepared: Final = global_then_key_alias_target(model, user_api_key_dict) + return resolve_model_group_alias(model_group_alias, prepared) or prepared + + +def request_served_model( + model: str, user_api_key_dict: UserAPIKeyAuth, model_group_alias: object, llm_router: Router | None +) -> str: + """The model group a request whose body names `model` is sent to, through every alias rewrite.""" + return pre_call_served_model( + caller_alias_target(model, user_api_key_dict, llm_router), user_api_key_dict, model_group_alias + ) def _apply_credential_overrides_from_model_config( diff --git a/tests/unit/proxy/auth/test_custom_auth_end_user_budget.py b/tests/unit/proxy/auth/test_custom_auth_end_user_budget.py index 63ded55c630..e75a77325c6 100644 --- a/tests/unit/proxy/auth/test_custom_auth_end_user_budget.py +++ b/tests/unit/proxy/auth/test_custom_auth_end_user_budget.py @@ -1,8 +1,9 @@ from typing import Final -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from starlette.requests import Request +from starlette.routing import Route import litellm from litellm.proxy._types import ( @@ -451,3 +452,64 @@ def test_apply_budget_limits_copies_per_model_limits_onto_end_user_params(): "end_user_model_tpm_limit": {"gpt-4o": 400000}, "end_user_model_rpm_limit": {"gpt-4o": 200}, } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "requested_model, key_aliases, run_common_checks, expect_enforced", + [ + ("free-key", {"free-key": "free-model"}, True, False), + ("free-model", {"free-model": "paid-model"}, True, True), + ("free-key", {"free-key": "free-model"}, False, True), + ], +) +async def test_custom_auth_judges_the_zero_cost_waiver_by_the_key_alias_target( + requested_model: str, + key_aliases: dict[str, str], + run_common_checks: bool, + expect_enforced: bool, + free_and_paid_router: litellm.Router, +) -> None: + valid_token: Final = UserAPIKeyAuth( + token="test_token", + aliases=key_aliases, + end_user_id="end-user", + end_user_model_max_budget={requested_model: {"budget_limit": 1.0, "time_period": "1d"}}, + ) + limiter: Final = MagicMock() + limiter.is_end_user_within_model_budget = AsyncMock( + side_effect=litellm.BudgetExceededError(current_cost=5.0, max_budget=1.0) + ) + http_request: Final = Request( + { + "type": "http", + "method": "POST", + "path": "/v1/chat/completions", + "headers": [], + "query_string": b"", + "route": Route("/v1/chat/completions", endpoint=lambda request: None), + } + ) + + async def run() -> UserAPIKeyAuth: + return await _run_post_custom_auth_checks( + valid_token=valid_token, + request=http_request, + request_data={"model": requested_model}, + route="/v1/chat/completions", + parent_otel_span=None, + ) + + with ( + patch("litellm.proxy.proxy_server.llm_router", free_and_paid_router), + patch("litellm.proxy.proxy_server.model_max_budget_limiter", limiter), + patch("litellm.proxy.proxy_server.general_settings", {"custom_auth_run_common_checks": run_common_checks}), + ): + if expect_enforced: + with pytest.raises(litellm.BudgetExceededError): + await run() + assert limiter.is_end_user_within_model_budget.await_args.kwargs["model"] == requested_model + else: + result: Final = await run() + assert result.token == "test_token" + limiter.is_end_user_within_model_budget.assert_not_awaited() diff --git a/tests/unit/proxy/auth/test_model_checks.py b/tests/unit/proxy/auth/test_model_checks.py index cd5410ffd18..1c576b1ee24 100644 --- a/tests/unit/proxy/auth/test_model_checks.py +++ b/tests/unit/proxy/auth/test_model_checks.py @@ -2484,3 +2484,58 @@ def test_prefixed_alias_over_a_bare_catch_all_lists_ids_that_route_through_it(mo ] assert f"team-partial-route/{_ROUTE_OPENAI_ID}" in v1_models assert unrouted_ids == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "requested_model, token_aliases", + [ + ("free-key", {"aliases": {"free-key": "free-model"}}), + ("free-team", {"team_model_aliases": {"free-team": "free-model"}}), + ("free-team", {"team_model_aliases": {"free-team": "free-model"}, "aliases": {"free-model": "paid-model"}}), + ("free-model", {"aliases": {"paid-model": "free-model"}}), + ("free-team", {"team_model_aliases": {"free-team": ""}}), + ( + "free-team", + {"team_id": "t1", "team_model_aliases": {"free-team": "model_name_t1_deleted"}}, + ), + ], +) +async def test_caller_alias_target_matches_the_model_add_litellm_data_to_request_dispatches( + requested_model: str, token_aliases: Mapping[str, object] +) -> None: + from unittest.mock import MagicMock + + from fastapi import Request + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.litellm_pre_call_utils import caller_alias_target + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request + + router: Final = Router( + model_list=[ + {"model_name": "free-model", "litellm_params": {"model": "openai/free-backend", "api_key": "sk-fixture"}}, + {"model_name": "paid-model", "litellm_params": {"model": "openai/paid-backend", "api_key": "sk-fixture"}}, + ] + ) + valid_token: Final = UserAPIKeyAuth(api_key="hashed-key", **token_aliases) + request: Final = MagicMock(spec=Request) + request.url = MagicMock() + request.url.path = "/v1/chat/completions" + request.method = "POST" + request.query_params = {} + request.headers = {"Content-Type": "application/json"} + request.client = MagicMock() + request.client.host = "127.0.0.1" + + with patch("litellm.proxy.proxy_server.llm_router", router): + dispatched: Final = await add_litellm_data_to_request( + data={"model": requested_model}, + request=request, + user_api_key_dict=valid_token, + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + served: Final = caller_alias_target(requested_model, valid_token, router) + assert served == dispatched["model"] diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 8a5d4f7969b..0d2433d9707 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -21,6 +21,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch import fastapi import orjson import pytest +from starlette.routing import Route from litellm.redis.stores.connection import connect from litellm.redis.stores.spend_counters import SpendCounterStore @@ -13915,6 +13916,89 @@ async def test_exhausted_key_budget_spares_only_zero_spend_mcp_discovery( assert result.spend == 5.0 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "requested_model, key_aliases, team_model_aliases, expect_blocked", + [ + ("free-key", {"free-key": "free-model"}, None, False), + ("free-team", None, {"free-team": "free-model"}, False), + ("free-model", {"free-model": "paid-model"}, None, True), + ("free-model", None, {"free-model": "paid-model"}, True), + ("free-model", None, None, False), + ("paid-model", None, None, True), + ], +) +async def test_exhausted_key_budget_is_waived_only_when_the_served_model_is_free( + requested_model: str, + key_aliases: dict[str, str] | None, + team_model_aliases: dict[str, str] | None, + expect_blocked: bool, + free_and_paid_router: litellm.Router, +) -> None: + from litellm.proxy.utils import hash_token + + api_key: Final = "sk-exhausted-aliased-free-model" + hashed_token: Final = hash_token(api_key) + user_api_key_cache: Final = DualCache() + await cache_key_object( + hashed_token=hashed_token, + user_api_key_obj=UserAPIKeyAuth( + token=hashed_token, + max_budget=2.0, + spend=5.0, + aliases=key_aliases or {}, + team_model_aliases=team_model_aliases, + ), + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=None, + ) + + mock_request: Final = MagicMock() + mock_request.url.path = "/v1/chat/completions" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {api_key}"} + mock_request.query_params = {} + mock_request.state = SimpleNamespace() + mock_request.scope = {"route": Route("/v1/chat/completions", endpoint=lambda request: None)} + + proxy_logging_obj: Final = MagicMock() + proxy_logging_obj.budget_alerts = AsyncMock() + proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + + async def authenticate() -> UserAPIKeyAuth: + return await user_api_key_auth_builder( + request=mock_request, + api_key=f"Bearer {api_key}", + azure_api_key_header="", + anthropic_api_key_header=None, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data={"model": requested_model}, + ) + + with ( + patch("litellm.proxy.proxy_server.general_settings", {}), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.llm_router", free_and_paid_router), + patch("litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache), + patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj), + patch( + "litellm.proxy.auth.user_api_key_auth.virtual_key_max_budget_alert_check", + new_callable=AsyncMock, + ), + ): + if expect_blocked: + with pytest.raises(ProxyException) as exc_info: + await authenticate() + assert "Budget has been exceeded! Key=key Current cost: 5.0, Max budget: 2.0" in exc_info.value.message + else: + result: Final = await authenticate() + assert result.token == hashed_token + assert result.spend == 5.0 + + +@pytest.mark.asyncio @pytest.mark.parametrize( "route, request_data, expected", [ @@ -13924,9 +14008,14 @@ async def test_exhausted_key_budget_spares_only_zero_spend_mcp_discovery( ("/mcp-rest/tools/call", {}, False), ], ) -def test_should_skip_budget_checks_spares_only_zero_spend_mcp_discovery( +async def test_should_skip_budget_checks_spares_only_zero_spend_mcp_discovery( route: str, request_data: dict[str, object], expected: bool ) -> None: from litellm.proxy.auth.user_api_key_auth import _should_skip_budget_checks - assert _should_skip_budget_checks(request_data=request_data, route=route, request=None, llm_router=None) is expected + assert ( + await _should_skip_budget_checks( + request_data=request_data, route=route, request=None, llm_router=None, valid_token=UserAPIKeyAuth() + ) + is expected + ) diff --git a/tests/unit/proxy/conftest.py b/tests/unit/proxy/conftest.py index 8d8781be0a8..7ff922179ce 100644 --- a/tests/unit/proxy/conftest.py +++ b/tests/unit/proxy/conftest.py @@ -546,3 +546,27 @@ def pbkdf2_on_event_loop(monkeypatch: pytest.MonkeyPatch) -> list[bool]: monkeypatch.setattr(hashlib, "pbkdf2_hmac", recording_pbkdf2) return on_event_loop + + +def _per_token_priced(model_name: str, backend: str, input_cost: float, output_cost: float) -> dict[str, object]: + return { + "model_name": model_name, + "litellm_params": { + "model": backend, + "api_key": "sk-fixture", + "input_cost_per_token": input_cost, + "output_cost_per_token": output_cost, + }, + } + + +@pytest.fixture +def free_and_paid_router() -> "litellm.Router": + import litellm + + return litellm.Router( + model_list=[ + _per_token_priced("free-model", "openai/free-backend", 0.0, 0.0), + _per_token_priced("paid-model", "openai/paid-backend", 1e-6, 2e-6), + ] + ) diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py index e8ee31b27f5..f4a836b4b4b 100644 --- a/tests/unit/proxy/test_litellm_pre_call_utils.py +++ b/tests/unit/proxy/test_litellm_pre_call_utils.py @@ -11607,3 +11607,61 @@ def test_caller_tags_reach_litellm_metadata_whether_metadata_is_a_dict_or_a_json LiteLLMProxyRequestSetup.merge_caller_body_tags(request_data=data) assert set(data["litellm_metadata"]["tags"]) == {"key-tag", "client-tag"} assert data["metadata"] == caller_metadata + + +@pytest.mark.parametrize( + ("model", "global_aliases", "key_aliases", "group_alias", "expected"), + [ + pytest.param("a", {"a": "b"}, {"b": "c"}, {"c": "d"}, "d", id="global-then-key-then-router"), + pytest.param("a", {}, {}, {"a": {"model": "x"}}, "x", id="router-item-entry"), + pytest.param("a", {"a": "b"}, {}, None, "b", id="global-only"), + pytest.param("a", {}, {}, None, "a", id="no-alias"), + ], +) +def test_pre_call_served_model_applies_aliases_in_request_preparation_order( + monkeypatch: pytest.MonkeyPatch, + model: str, + global_aliases: dict[str, str], + key_aliases: dict[str, str], + group_alias: object, + expected: str, +) -> None: + from litellm.proxy.litellm_pre_call_utils import pre_call_served_model + + monkeypatch.setattr(litellm, "model_alias_map", global_aliases) + + assert pre_call_served_model(model, UserAPIKeyAuth(aliases=key_aliases), group_alias) == expected + + +@pytest.mark.parametrize( + ("token_fields", "expected"), + [ + pytest.param({"aliases": {"requested": 7}}, "requested", id="non-string-key-alias"), + pytest.param({"team_model_aliases": {"requested": 7}}, "requested", id="non-string-team-alias"), + pytest.param({"aliases": {"mid": 7}, "team_model_aliases": {"requested": "mid"}}, "mid", id="after-team"), + ], +) +def test_request_served_model_ignores_a_malformed_alias_like_request_preparation_does( + token_fields: dict[str, object], expected: str +) -> None: + from litellm.proxy.litellm_pre_call_utils import _update_model_if_key_alias_exists, _update_model_if_team_alias_exists + from litellm.proxy.litellm_pre_call_utils import request_served_model + + valid_token: Final = UserAPIKeyAuth(**token_fields) + prepared: Final[dict[str, object]] = {"model": "requested"} # mutable-ok: the rewrite functions edit the body + with patch("litellm.proxy.proxy_server.llm_router", None): + _update_model_if_team_alias_exists(data=prepared, user_api_key_dict=valid_token) + _update_model_if_key_alias_exists(data=prepared, user_api_key_dict=valid_token) + + assert request_served_model("requested", valid_token, None, None) == prepared["model"] == expected + + +def test_stale_team_alias_bypass_flag_is_read_once_per_process(monkeypatch: pytest.MonkeyPatch) -> None: + import litellm.proxy.litellm_pre_call_utils as pre_call_utils + + monkeypatch.setattr(pre_call_utils, "_ENABLE_TEAM_STALE_ALIAS_BYPASS", None) + monkeypatch.setenv("LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS", "true") + first: Final = pre_call_utils._stale_team_alias_bypass_enabled() + monkeypatch.setenv("LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS", "false") + + assert (first, pre_call_utils._stale_team_alias_bypass_enabled()) == (True, True) diff --git a/tests/unit/proxy/test_zero_cost_model_budget_bypass.py b/tests/unit/proxy/test_zero_cost_model_budget_bypass.py index ce1dfce1646..35e75a85520 100644 --- a/tests/unit/proxy/test_zero_cost_model_budget_bypass.py +++ b/tests/unit/proxy/test_zero_cost_model_budget_bypass.py @@ -6,12 +6,15 @@ to models with zero cost (e.g., on-premises models). """ import asyncio -from typing import Optional +from typing import Final, Optional from unittest.mock import MagicMock, patch import pytest +from starlette.requests import Request as StarletteRequest +from starlette.routing import Route import litellm +from litellm.constants import MODEL_GROUP_ALIAS_RESOLVED_SCOPE_KEY from litellm.caching.caching import DualCache from litellm.proxy._types import ( LiteLLM_BudgetTable, @@ -653,8 +656,8 @@ class TestOverBudgetRequestThroughModelGroupAlias: router = self._router() request_data = {"model": model} - skip_budget_checks = _should_skip_budget_checks( - request_data=request_data, route=self.ROUTE, request=None, llm_router=router + skip_budget_checks = await _should_skip_budget_checks( + request_data=request_data, route=self.ROUTE, request=None, llm_router=router, valid_token=UserAPIKeyAuth() ) return await common_checks( request_body=request_data, @@ -692,3 +695,164 @@ class TestOverBudgetRequestThroughModelGroupAlias: async def test_over_budget_request_for_paid_model_is_blocked(self, mock_proxy_logging): with pytest.raises(litellm.BudgetExceededError): await self._request("paid-model", mock_proxy_logging) + + +_CHAT: Final = "/v1/chat/completions" + + +def _routed_request( + template: str | None = _CHAT, query: bytes = b"", router_aliases_applied: bool = True +) -> StarletteRequest: + scope: Final[dict[str, object]] = { # mutable-ok: ASGI scope literal + "type": "http", + "method": "POST", + "path": _CHAT, + "headers": [], + "query_string": query, + MODEL_GROUP_ALIAS_RESOLVED_SCOPE_KEY: router_aliases_applied, + } + if template is not None: + scope["route"] = Route(template, endpoint=lambda request: None) + return StarletteRequest(scope) + + +_KEY_FREE: Final = {"aliases": {"free-key": "free-model"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body_model", "token_fields", "request_kwargs", "settings", "expected_skip"), + [ + pytest.param("free-key", _KEY_FREE, {}, {}, True, id="key-alias-to-free"), + pytest.param("free-team", {"team_model_aliases": {"free-team": "free-model"}}, {}, {}, True, id="team-to-free"), + pytest.param("free-model", {"aliases": {"free-model": "paid-model"}}, {}, {}, False, id="key-alias-to-paid"), + pytest.param( + "free-model", {"team_model_aliases": {"free-model": "paid-model"}}, {}, {}, False, id="team-alias-to-paid" + ), + pytest.param( + "free-team", + {"team_model_aliases": {"free-team": "free-model"}, "aliases": {"free-model": "paid-model"}}, + {}, + {}, + False, + id="team-then-key", + ), + pytest.param( + "free-team", + { + "team_model_aliases": {"free-team": "free-model"}, + "router_settings": {"model_group_alias": {"free-model": "paid-model"}}, + }, + {}, + {}, + False, + id="router-alias-after-team", + ), + pytest.param(["free-key"], _KEY_FREE, {}, {}, False, id="list-body"), + pytest.param("free-key", _KEY_FREE, {"query": b"model=free-key"}, {}, False, id="query-model"), + pytest.param( + "free-key", _KEY_FREE, {"template": "/engines/{model:path}/chat/completions"}, {}, False, id="path" + ), + pytest.param("free-key", _KEY_FREE, {"template": None}, {}, False, id="request-built-inside-the-proxy"), + pytest.param("free-key", _KEY_FREE, {"router_aliases_applied": False}, {}, False, id="before-router-alias"), + pytest.param("free-key", _KEY_FREE, {}, {"completion_model": "free-model"}, False, id="completion-model-set"), + ], +) +async def test_budget_waiver_judges_the_model_request_preparation_serves( + body_model: str | list[str], + token_fields: dict[str, object], + request_kwargs: dict[str, object], + settings: dict[str, object], + expected_skip: bool, + free_and_paid_router: Router, +) -> None: + from litellm.proxy.auth.user_api_key_auth import _should_skip_budget_checks + + with patch("litellm.proxy.proxy_server.general_settings", settings): + skip: Final = await _should_skip_budget_checks( + request_data={"model": body_model}, + route=_CHAT, + request=_routed_request(**request_kwargs), + llm_router=free_and_paid_router, + valid_token=UserAPIKeyAuth(token="t", **token_fields), + ) + + assert skip is expected_skip + + +@pytest.mark.asyncio +async def test_sub_call_is_judged_by_its_own_model_not_the_callers_aliases(free_and_paid_router: Router) -> None: + from litellm.proxy.auth.user_api_key_auth import _should_skip_budget_checks + + assert ( + await _should_skip_budget_checks( + request_data={"model": "paid-model"}, + route=_CHAT, + request=_routed_request(), + llm_router=free_and_paid_router, + valid_token=UserAPIKeyAuth(token="t", aliases={"paid-model": "free-model"}), + sub_call=True, + ) + is False + ) + + +@pytest.mark.asyncio +async def test_common_checks_honours_the_callers_decision_not_to_skip_budgets( + mock_proxy_logging: ProxyLogging, free_and_paid_router: Router +) -> None: + with pytest.raises(litellm.BudgetExceededError): + await common_checks( + request_body={"model": "free-model"}, + team_object=None, + user_object=LiteLLM_UserTable(user_id="test-user", spend=100.0, max_budget=50.0), + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route=_CHAT, + llm_router=free_and_paid_router, + proxy_logging_obj=mock_proxy_logging, + valid_token=UserAPIKeyAuth(token="t", aliases={"free-model": "paid-model"}), + request=MagicMock(), + skip_budget_checks=False, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("bypass_flag", "expected_skip"), [("false", True), ("true", False)]) +async def test_waiver_skips_a_stale_team_alias_exactly_when_dispatch_does( + monkeypatch: pytest.MonkeyPatch, bypass_flag: str, expected_skip: bool +) -> None: + import litellm.proxy.litellm_pre_call_utils as pre_call_utils + from litellm.proxy.auth.user_api_key_auth import _should_skip_budget_checks + + monkeypatch.setenv("LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS", bypass_flag) + monkeypatch.setattr(pre_call_utils, "_ENABLE_TEAM_STALE_ALIAS_BYPASS", None) + team_deployment: Final = {"api_key": "sk-fixture", "output_cost_per_token": 0.0} + router: Final = Router( + model_list=[ + { + "model_name": "model_name_team-1_legacy", + "litellm_params": {"model": "openai/free-backend", "input_cost_per_token": 0.0, **team_deployment}, + "model_info": {"team_id": "team-1", "team_public_model_name": "legacy"}, + }, + { + "model_name": "model_name_team-1_current", + "litellm_params": {"model": "openai/paid-backend", "input_cost_per_token": 1e-6, **team_deployment}, + "model_info": {"team_id": "team-1", "team_public_model_name": "team-model"}, + }, + ] + ) + + assert ( + await _should_skip_budget_checks( + request_data={"model": "team-model"}, + route=_CHAT, + request=_routed_request(), + llm_router=router, + valid_token=UserAPIKeyAuth( + token="t", team_id="team-1", team_model_aliases={"team-model": "model_name_team-1_legacy"} + ), + ) + is expected_skip + ) diff --git a/tests/unit/router_utils/test_router_utils_common_utils.py b/tests/unit/router_utils/test_router_utils_common_utils.py index 42b21692a8f..ea384d837fd 100644 --- a/tests/unit/router_utils/test_router_utils_common_utils.py +++ b/tests/unit/router_utils/test_router_utils_common_utils.py @@ -19,7 +19,6 @@ from litellm.litellm_core_utils.routing_common_utils import ( format_fallback_outcome_message, provider_for_generic_call, resolve_model_group_alias, - resolve_served_model, truncate_fallback_error_detail, PROVIDER_SCOPED_CREDENTIAL_PARAMS, warn_on_provider_credential_mismatch, @@ -579,25 +578,6 @@ class TestResolveModelGroupAlias: assert router.get_model_from_alias("group-b") is None -@pytest.mark.parametrize( - "model, model_alias_map, key_aliases, model_group_alias, expected", - [ - pytest.param("a", {"a": "b"}, {"b": "c"}, {"c": "d"}, "d", id="global-key-router-order"), - pytest.param("a", {}, ["a", "b"], {}, "a", id="non-mapping-key-aliases-ignored"), - pytest.param("a", {}, {}, {"a": {"model": "x"}}, "x", id="router-item-entry"), - pytest.param("a", {}, {}, {}, "a", id="no-alias"), - ], -) -def test_resolves_served_model_aliases_in_order( - model: str, - model_alias_map: object, - key_aliases: object, - model_group_alias: object, - expected: str, -) -> None: - assert resolve_served_model(model, model_alias_map, key_aliases, model_group_alias) == expected - - class TestTruncateFallbackErrorDetail: def test_short_detail_is_returned_unchanged(self): assert truncate_fallback_error_detail("boom") == "boom" From 9a4a587e75e589294f073ba94ad53243018332ea Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:32:16 -0800 Subject: [PATCH 38/86] feat(docker)!: one shipped image and one entrypoint beside the Dockerfile (#43332) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .circleci/config.yml | 2 +- .dockerignore | 1 - .github/ci-coverage-allowlist.yml | 9 - .github/workflows/compat-matrix-image.yml | 33 +- .github/workflows/image-scan.yml | 338 +++++------ .github/workflows/test-merge-smoke.yml | 64 +- CONTRIBUTING.md | 4 +- Dockerfile | 61 +- backend/Dockerfile | 146 ----- ci_cd/security_scans_readme.md | 1 - .../litellm-ollama-docker-image/Dockerfile | 25 - .../requirements.txt | 1 - cookbook/litellm-ollama-docker-image/start.sh | 2 - cookbook/litellm-ollama-docker-image/test.py | 35 -- docker-compose.hardened.yml | 44 -- docker-compose.yml | 84 ++- docker-entrypoint.sh | 109 ++++ docker/Dockerfile.database | 172 ------ docker/Dockerfile.non_root | 208 ------- docker/README.md | 95 +-- .../build_from_pip/Dockerfile.build_from_pip | 64 -- docker/build_from_pip/Readme.md | 9 - docker/component_entrypoint.sh | 16 - docker/entrypoint.sh | 16 - docker/install_auto_router.sh | 4 - docker/prod_entrypoint.sh | 16 - docker/tests/nonroot.yaml | 18 - gateway/Dockerfile | 170 ------ litellm/proxy/start.sh | 2 - migrations/Dockerfile | 152 ----- .../build_from_pip_config.yaml | 4 +- tests/e2e/claude_code/cron_vm/Dockerfile | 34 -- tests/e2e/claude_code/cron_vm/README.md | 67 ++- tests/e2e/claude_code/cron_vm/run_daily.sh | 4 +- tests/integration/_support/process.py | 4 +- .../test_collector_image_serves_socket.py | 174 ++++++ .../test_component_image_serves_offline.py | 9 +- .../image/test_offline_image_migration.py | 11 +- .../image/test_ui_image_serves_offline.py | 6 +- .../test_prometheus_series_cap_chaos.py | 2 +- tests/integration/run.py | 1 + tests/unit/test_component_entrypoint.py | 491 --------------- tests/unit/test_docker_entrypoint.py | 557 ++++++++++++++++++ .../test_dockerfile_bedrock_realtime_extra.py | 9 +- tests/unit/test_dockerfile_non_root.py | 40 +- ui/Dockerfile | 48 -- ui/nginx.conf | 11 +- 47 files changed, 1283 insertions(+), 2090 deletions(-) delete mode 100644 backend/Dockerfile delete mode 100644 cookbook/litellm-ollama-docker-image/Dockerfile delete mode 100644 cookbook/litellm-ollama-docker-image/requirements.txt delete mode 100644 cookbook/litellm-ollama-docker-image/start.sh delete mode 100644 cookbook/litellm-ollama-docker-image/test.py delete mode 100644 docker-compose.hardened.yml create mode 100755 docker-entrypoint.sh delete mode 100644 docker/Dockerfile.database delete mode 100644 docker/Dockerfile.non_root delete mode 100644 docker/build_from_pip/Dockerfile.build_from_pip delete mode 100644 docker/build_from_pip/Readme.md delete mode 100755 docker/component_entrypoint.sh delete mode 100755 docker/entrypoint.sh delete mode 100755 docker/install_auto_router.sh delete mode 100644 docker/prod_entrypoint.sh delete mode 100644 docker/tests/nonroot.yaml delete mode 100644 gateway/Dockerfile delete mode 100755 litellm/proxy/start.sh delete mode 100644 migrations/Dockerfile rename docker/build_from_pip/litellm_config.yaml => tests/basic_proxy_startup_tests/build_from_pip_config.yaml (77%) delete mode 100644 tests/e2e/claude_code/cron_vm/Dockerfile create mode 100644 tests/integration/database/image/test_collector_image_serves_socket.py delete mode 100644 tests/unit/test_component_entrypoint.py create mode 100644 tests/unit/test_docker_entrypoint.py delete mode 100644 ui/Dockerfile diff --git a/.circleci/config.yml b/.circleci/config.yml index 95ee0a6412c..cf1a7b94fe5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1099,7 +1099,7 @@ jobs: docker build \ --label org.opencontainers.image.revision="$(git rev-parse HEAD)" \ -t litellm-docker-database:ci \ - -f docker/Dockerfile.database . + -f Dockerfile . fi python3 .circleci/scripts/run_migration_tests.py record-image diff --git a/.dockerignore b/.dockerignore index f3a80fee3e4..8459d0cd482 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,7 +7,6 @@ tests .devcontainer *.tgz log.txt -docker/Dockerfile.* # Claude Flow generated files (must be excluded from Docker build) .claude/ diff --git a/.github/ci-coverage-allowlist.yml b/.github/ci-coverage-allowlist.yml index 5dffa78776d..76f8e3ef7e7 100644 --- a/.github/ci-coverage-allowlist.yml +++ b/.github/ci-coverage-allowlist.yml @@ -57,15 +57,6 @@ test_paths: - tests/integration/test_oci_proxy_integration.py dockerfiles: - - reason: >- - The dashboard container is a static Next.js export served by nginx, and the dashboard build - and lint workflows already exercise that output, so building the image adds no signal about it - paths: - - ui/Dockerfile - - reason: >- - An example image under cookbook/ that is documentation rather than a shipped artifact - paths: - - cookbook/litellm-ollama-docker-image/Dockerfile - reason: >- The Rust gateway image compiles the whole workspace in release mode, which is too slow for a per-pull-request job while the gateway binary is still being assembled; the Rust lint, diff --git a/.github/workflows/compat-matrix-image.yml b/.github/workflows/compat-matrix-image.yml index ddd2aa1a50b..73a77c1e861 100644 --- a/.github/workflows/compat-matrix-image.yml +++ b/.github/workflows/compat-matrix-image.yml @@ -26,17 +26,24 @@ jobs: with: persist-credentials: false - - name: Build the Render cron image - run: docker build -f tests/e2e/claude_code/cron_vm/Dockerfile -t compat-matrix:${{ github.sha }} tests/e2e - - - name: Resolve and install the Claude Code CLI as the cron user + - name: Install the pinned uv the cron image ships + env: + UV_VERSION: 0.10.9 + UV_SHA256: 20d79708222611fa540b5c9ed84f352bcd3937740e51aacc0f8b15b271c57594 run: | - docker run --rm compat-matrix:${{ github.sha }} bash -c ' - set -euo pipefail - whoami - gh --version - uv --version - version="$(uv run --no-project --python 3.12 python /opt/litellm/tests/e2e/cli_harness/npm_version_resolver.py @anthropic-ai/claude-code)" - /opt/litellm/tests/e2e/claude_code/cron_vm/install_claude_code.sh "${version}" /tmp/claude-cli - /tmp/claude-cli/claude --version - ' + set -euo pipefail + curl -fsSLo "$RUNNER_TEMP/uv.tar.gz" "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" + echo "${UV_SHA256} $RUNNER_TEMP/uv.tar.gz" | sha256sum -c - + mkdir -p "$RUNNER_TEMP/bin" + tar -xzf "$RUNNER_TEMP/uv.tar.gz" -C "$RUNNER_TEMP/bin" --strip-components=1 uv-x86_64-unknown-linux-gnu/uv + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + + - name: Resolve and install the Claude Code CLI as an unprivileged user + run: | + set -euo pipefail + whoami + gh --version + uv --version + version="$(uv run --no-project --python 3.12 python tests/e2e/cli_harness/npm_version_resolver.py @anthropic-ai/claude-code)" + tests/e2e/claude_code/cron_vm/install_claude_code.sh "${version}" "$RUNNER_TEMP/claude-cli" + "$RUNNER_TEMP/claude-cli/claude" --version diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml index d769b6ab8af..6e13b49eb15 100644 --- a/.github/workflows/image-scan.yml +++ b/.github/workflows/image-scan.yml @@ -8,31 +8,25 @@ on: - "litellm_**" paths: - Dockerfile - - docker/Dockerfile.non_root - - docker/Dockerfile.database - - migrations/Dockerfile + - .dockerignore + - docker-entrypoint.sh - migrations/run.py - - gateway/Dockerfile - - gateway/launch.py - gateway/main.py + - gateway/launch.py - gateway/routes/allowlist.py - litellm_cli/** - - backend/Dockerfile - backend/main.py + - backend/routes/allowlist.py - litellm-rust/** - litellm/proxy/lens/** - - docker/component_entrypoint.sh - - docker/entrypoint.sh - litellm/proxy/prisma_migration.py - litellm/proxy/admin_mcp.py - litellm/proxy/proxy_server.py - - backend/routes/allowlist.py - pyproject.toml - litellm-proxy-extras/** - tests/integration/database/image/** - uv.lock - ui/litellm-dashboard/package-lock.json - - ui/Dockerfile - ui/nginx.conf - .github/workflows/image-scan.yml - .grype.yaml @@ -46,9 +40,13 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + IMAGE: litellm-image-scan:${{ github.sha }} + ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }} + jobs: - image-scan: - name: image-scan + build: + name: build runs-on: ubuntu-latest if: >- github.event_name != 'pull_request' || @@ -61,7 +59,129 @@ jobs: with: persist-credentials: false + - name: Build the image + run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci -t "$IMAGE" . + + - name: Save the image for the verify matrix + run: docker save "$IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-image.tar.zst" + + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: litellm-image + path: ${{ runner.temp }}/litellm-image.tar.zst + retention-days: 1 + compression-level: 0 + + - name: Build the Admin MCP schema image + run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci --target builder -t "$ADMIN_MCP_SCHEMA_IMAGE" . + + - name: Save the Admin MCP schema image for the verify matrix + run: docker save "$ADMIN_MCP_SCHEMA_IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-admin-mcp-schema.tar.zst" + + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: litellm-admin-mcp-schema + path: ${{ runner.temp }}/litellm-admin-mcp-schema.tar.zst + retention-days: 1 + compression-level: 0 + + verify: + name: verify (${{ matrix.check }}) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + check: [scan, migrations, gateway, backend, ui, collector] + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: litellm-image + path: ${{ runner.temp }} + + - name: Load the image + run: zstd -d --stdout "$RUNNER_TEMP/litellm-image.tar.zst" | docker load + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: matrix.check == 'migrations' || matrix.check == 'backend' + with: + name: litellm-admin-mcp-schema + path: ${{ runner.temp }} + + - name: Load the Admin MCP schema image + if: matrix.check == 'migrations' || matrix.check == 'backend' + run: zstd -d --stdout "$RUNNER_TEMP/litellm-admin-mcp-schema.tar.zst" | docker load + + - name: Set up Python + if: matrix.check != 'scan' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install pytest + if: matrix.check != 'scan' + run: python -m pip install "pytest==9.0.3" + + # The prisma bake must migrate a fresh DB with no egress as an arbitrary + # non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyFilesystem). + # `docker run` as the default uid with network hides a broken bake because + # the migration entrypoint exits 0 even when it applied nothing; asserting + # the schema was created is what catches it. + - name: Verify offline migration as a non-root uid + if: matrix.check == 'migrations' + env: + LITELLM_IMAGE: ${{ env.IMAGE }} + LITELLM_ADMIN_MCP_SCHEMA_IMAGE: ${{ env.ADMIN_MCP_SCHEMA_IMAGE }} + run: | + python -m pytest --noconftest -v \ + tests/integration/database/image/test_offline_image_migration.py \ + tests/integration/database/image/test_image_bedrock_realtime_extra.py \ + tests/integration/database/image/test_image_admin_mcp.py + + - name: Verify the gateway component serves offline as a non-root uid + if: matrix.check == 'gateway' + env: + LITELLM_IMAGE: ${{ env.IMAGE }} + LITELLM_COMPONENT: gateway + LITELLM_IMAGE_COMPONENT: gateway + run: | + python -m pytest --noconftest -v \ + tests/integration/database/image/test_component_image_serves_offline.py \ + tests/integration/database/image/test_image_admin_mcp.py + + - name: Verify the backend component serves offline as a non-root uid + if: matrix.check == 'backend' + env: + LITELLM_IMAGE: ${{ env.IMAGE }} + LITELLM_COMPONENT: backend + LITELLM_IMAGE_COMPONENT: backend + LITELLM_ADMIN_MCP_SCHEMA_IMAGE: ${{ env.ADMIN_MCP_SCHEMA_IMAGE }} + run: | + python -m pytest --noconftest -v \ + tests/integration/database/image/test_component_image_serves_offline.py \ + tests/integration/database/image/test_image_admin_mcp.py + + - name: Verify the ui component serves as an arbitrary uid with a read-only root fs + if: matrix.check == 'ui' + env: + LITELLM_IMAGE: ${{ env.IMAGE }} + run: python -m pytest --noconftest -v tests/integration/database/image/test_ui_image_serves_offline.py + + - name: Verify the collector creates its socket as the image user + if: matrix.check == 'collector' + env: + LITELLM_IMAGE: ${{ env.IMAGE }} + run: python -m pytest --noconftest -v tests/integration/database/image/test_collector_image_serves_socket.py + - name: Download Grype v0.114.0 + if: matrix.check == 'scan' run: | curl -fsSL --retry 3 -o "$RUNNER_TEMP/grype.tar.gz" \ https://github.com/anchore/grype/releases/download/v0.114.0/grype_0.114.0_linux_amd64.tar.gz @@ -69,33 +189,6 @@ jobs: tar xzf "$RUNNER_TEMP/grype.tar.gz" -C "$RUNNER_TEMP" grype chmod +x "$RUNNER_TEMP/grype" - # Dockerfile.non_root is the rootless variant we ship. The other - # Dockerfiles share the same wolfi base and apk set, so OS-layer coverage - # is the same; matrix-scan if those variants ever diverge. - - name: Build runtime image - run: docker build -f docker/Dockerfile.non_root -t litellm-image-scan:${{ github.sha }} . - - - name: Tag the cached builder for Admin MCP schema setup - run: docker build --target builder -f docker/Dockerfile.non_root -t litellm-admin-mcp-schema:${{ github.sha }} . - - # The prisma bake must migrate a fresh DB with no egress as an arbitrary - # non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyRootFilesystem). - # `docker run` as the default uid with network hides a broken bake because - # the migration entrypoint exits 0 even when it applied nothing; asserting - # the schema was created is what catches it. - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Verify offline migration as a non-root uid - env: - LITELLM_IMAGE: litellm-image-scan:${{ github.sha }} - LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }} - run: | - python -m pip install "pytest==9.0.3" - python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v - # Scans the whole shipped artifact: OS/apk plus every language package # baked into the image, including ones no lockfile declares (e.g. prisma's # vendored node engine) that osv-scan cannot see. osv-scan stays the fast @@ -103,175 +196,12 @@ jobs: # free OSS, run as a pinned, checksum-verified binary; no GitHub Action # dependency and no vendor SaaS callout. - name: Scan image for fixable HIGH/CRITICAL CVEs + if: matrix.check == 'scan' env: GRYPE_MATCH_PYTHON_USING_CPES: "true" run: | - "$RUNNER_TEMP/grype" litellm-image-scan:${{ github.sha }} \ + "$RUNNER_TEMP/grype" "$IMAGE" \ --config .grype.yaml \ --only-fixed \ --fail-on high \ --output table - - runtime-image: - name: runtime-image (${{ matrix.dockerfile }}) - runs-on: ubuntu-latest - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - timeout-minutes: 45 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - dockerfile: [Dockerfile, docker/Dockerfile.database] - steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - - name: Build runtime image - run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci -f "${{ matrix.dockerfile }}" -t litellm-runtime-scan:${{ github.sha }} . - - - name: Tag the cached builder for Admin MCP schema setup - run: docker build --target builder -f "${{ matrix.dockerfile }}" -t litellm-admin-mcp-schema:${{ github.sha }} . - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Verify offline migration as a non-root uid - env: - LITELLM_IMAGE: litellm-runtime-scan:${{ github.sha }} - LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }} - run: | - python -m pip install "pytest==9.0.3" - python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v - - - migrations-image: - name: migrations-image - runs-on: ubuntu-latest - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - - name: Build migrations image - run: docker build -f migrations/Dockerfile -t litellm-migrations-scan:${{ github.sha }} . - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Verify offline migration as a non-root uid - env: - LITELLM_IMAGE: litellm-migrations-scan:${{ github.sha }} - LITELLM_MIGRATION_INTERPRETER: python3 - LITELLM_MIGRATION_SCRIPT: /app/run.py - run: | - python -m pip install "pytest==9.0.3" - python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py -v - - gateway-image: - name: gateway-image - runs-on: ubuntu-latest - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - - name: Build gateway image - run: docker build -f gateway/Dockerfile -t litellm-gateway-scan:${{ github.sha }} . - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Verify the gateway serves offline as a non-root uid - env: - LITELLM_IMAGE: litellm-gateway-scan:${{ github.sha }} - LITELLM_COMPONENT_PORT: "4000" - LITELLM_IMAGE_COMPONENT: gateway - run: | - python -m pip install "pytest==9.0.3" - python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v - - ui-image: - name: ui-image - runs-on: ubuntu-latest - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - - name: Build UI image - run: docker build -f ui/Dockerfile -t litellm-ui-scan:${{ github.sha }} . - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Verify the UI serves offline as an arbitrary uid with a read-only root fs - env: - LITELLM_IMAGE: litellm-ui-scan:${{ github.sha }} - run: | - python -m pip install "pytest==9.0.3" - python -m pytest --noconftest tests/integration/database/image/test_ui_image_serves_offline.py -v - - backend-image: - name: backend-image - runs-on: ubuntu-latest - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - - name: Build backend image - run: docker build -f backend/Dockerfile -t litellm-backend-scan:${{ github.sha }} . - - - name: Tag the cached builder for Admin MCP schema setup - run: docker build --target builder -f backend/Dockerfile -t litellm-admin-mcp-schema:${{ github.sha }} . - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Verify the backend serves offline as a non-root uid - env: - LITELLM_IMAGE: litellm-backend-scan:${{ github.sha }} - LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }} - LITELLM_COMPONENT_PORT: "4001" - LITELLM_IMAGE_COMPONENT: backend - run: | - python -m pip install "pytest==9.0.3" - python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_admin_mcp.py -v diff --git a/.github/workflows/test-merge-smoke.yml b/.github/workflows/test-merge-smoke.yml index 99dbd1c101b..912fc90fbe5 100644 --- a/.github/workflows/test-merge-smoke.yml +++ b/.github/workflows/test-merge-smoke.yml @@ -200,8 +200,36 @@ jobs: - name: Install the test client dependencies run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --extra proxy --group dev --python 3.12 - - name: Build the image from the published package - run: docker build -t litellm-build-from-pip:smoke -f docker/build_from_pip/Dockerfile.build_from_pip . + - name: Cache Prisma binaries + timeout-minutes: 3 + uses: ./.github/actions/cache-prisma-binaries + + - name: Install the published proxy into its own venv + run: | + uv --no-config venv --python 3.13 /tmp/litellm-pip + uv --no-config pip install --python /tmp/litellm-pip/bin/python \ + "litellm[proxy,proxy-runtime]==1.83.0" \ + "google-cloud-aiplatform==1.133.0" \ + "google-genai==1.37.0" \ + "anthropic[vertex]==0.84.0" \ + "grpcio==1.78.0" \ + "prometheus-client==0.20.0" \ + "langfuse==2.59.7" \ + "opentelemetry-api==1.28.0" \ + "opentelemetry-sdk==1.28.0" \ + "opentelemetry-exporter-otlp==1.28.0" \ + "ddtrace==4.11.0" \ + "sentry-sdk==2.21.0" \ + "mangum==0.17.0" \ + "azure-ai-contentsafety==1.0.0" \ + "azure-storage-file-datalake==12.20.0" \ + "pypdf==6.7.5" \ + "llm-sandbox==0.3.31" \ + "detect-secrets==1.5.0" \ + "prisma==0.11.0" \ + "openai==2.24.0" + export PATH="/tmp/litellm-pip/bin:$PATH" + /tmp/litellm-pip/bin/prisma generate --schema=schema.prisma - name: Start the fake OpenAI endpoint run: | @@ -219,23 +247,21 @@ jobs: echo "::add-mask::${key}" echo "LITELLM_MASTER_KEY=${key}" >> "${GITHUB_ENV}" - - name: Start the proxy container + - name: Start the published proxy + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/circle_test + LITELLM_LICENSE: bad-license + FAKE_OPENAI_API_BASE: http://localhost:8190 + OTEL_EXPORTER: in_memory + USE_DDTRACE: "True" + DD_TRACE_OPENAI_ENABLED: "False" + GCS_FLUSH_INTERVAL: "1" + LITELLM_LOG: ERROR run: | - docker run -d \ - -p 4000:4000 \ - -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ - -e LITELLM_MASTER_KEY \ - -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ - -e OTEL_EXPORTER=in_memory \ - -e USE_DDTRACE=True \ - -e GCS_FLUSH_INTERVAL=1 \ - -e LITELLM_LOG=ERROR \ - --add-host host.docker.internal:host-gateway \ - --name litellm-build-from-pip \ - -v "${GITHUB_WORKSPACE}/docker/build_from_pip/litellm_config.yaml:/app/config.yaml" \ - litellm-build-from-pip:smoke \ - --config /app/config.yaml \ - --port 4000 + cd /tmp/litellm-pip + nohup /tmp/litellm-pip/bin/ddtrace-run /tmp/litellm-pip/bin/litellm \ + --config "${GITHUB_WORKSPACE}/tests/basic_proxy_startup_tests/build_from_pip_config.yaml" \ + --port 4000 > /tmp/litellm-pip.log 2>&1 & for _ in $(seq 1 150); do curl -sf --max-time 5 -o /dev/null http://localhost:4000 && exit 0 sleep 2 @@ -270,7 +296,7 @@ jobs: - name: Show the proxy logs if: always() - run: docker logs litellm-build-from-pip || true + run: cat /tmp/litellm-pip.log || true smoke-passed: name: smoke passed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e956b27d5da..426cf986d4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -305,8 +305,8 @@ uv run litellm --config your_config.yaml If you want to build the Docker image yourself: ```bash -# Build using the non-root Dockerfile -docker build -f docker/Dockerfile.non_root -t litellm_dev . +# Build the image (runs as a non-root user by default) +docker build -t litellm_dev . # Generate a master key. Requests send it as the bearer token export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)" diff --git a/Dockerfile b/Dockerfile index 94557bcf68c..205f0cc981d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,8 +119,12 @@ RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/ npm_config_cache=/root/.npm \ prisma generate --schema=./schema.prisma -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ - sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh +RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh + +RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \ + cp -r litellm/proxy/_experimental/out/. /var/lib/litellm/ui/ && \ + cp litellm/proxy/logo.png /var/lib/litellm/assets/logo.png && \ + touch /var/lib/litellm/ui/.litellm_ui_ready FROM $LITELLM_BUILD_IMAGE AS liteadmin-builder COPY --from=uvbin /uv /usr/local/bin/uv @@ -145,24 +149,35 @@ USER root # https://github.com/BerriAI/litellm/issues/33518 RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories -# node (without npm) is required by the prisma CLI at runtime -RUN apk add --no-cache bash openssl libssl3 tzdata nodejs python-3.13 libsndfile libevent +RUN for i in 1 2 3; do \ + apk add --no-cache bash openssl libssl3 tzdata nodejs python-3.13 libsndfile libatomic libevent nginx && break; \ + [ "$i" = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ + sleep 5; \ + done COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer WORKDIR /app + ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONPATH="/app" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + HOME=/app \ + LITELLM_NON_ROOT=true \ PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \ PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ + PRISMA_SKIP_POSTINSTALL_GENERATE=1 \ + PRISMA_HIDE_UPDATE_MESSAGE=1 \ + PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 \ PRISMA_OFFLINE_MODE=true # Copy only what runtime needs. The application is installed inside the venv; # the rest of the builder's /app is source and build metadata that must not # ship (manifest-scanning tools attribute everything in it to this image). -# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path. COPY --from=builder /app/.venv /app/.venv COPY --from=liteadmin-builder /opt/liteadmin /opt/liteadmin -COPY --from=builder /app/docker /app/docker +COPY --from=builder /app/docker-entrypoint.sh /app/docker-entrypoint.sh COPY --from=builder /app/schema.prisma /app/schema.prisma COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/prisma_migration.py # enterprise/ is imported by source path at runtime (proxy_cli puts the @@ -170,21 +185,33 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr # enterprise.enterprise_hooks from it) COPY --from=builder /app/enterprise /app/enterprise COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras -# Prisma CLI + engines are baked under /opt/prisma, a fixed path every -# runtime uid can read and that no cache volume mount shadows. The paths are -# pinned via PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH and recorded into the -# generated client at build time, so `prisma migrate deploy` on a fresh -# database needs no npm and no network access (#33650, #24554). +COPY --from=builder /app/gateway /app/gateway +COPY --from=builder /app/backend /app/backend +COPY --from=builder /app/migrations /app/migrations COPY --from=builder /opt/prisma /opt/prisma +COPY --from=builder /var/lib/litellm /var/lib/litellm +COPY ui/nginx.conf /etc/nginx/nginx.conf +COPY ui/docker-entrypoint.d/40-litellm-runtime-config.sh /app/ui/docker-entrypoint.d/40-litellm-runtime-config.sh -RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ - find /app/.venv -type d -path "*/tornado/test" -delete && \ +RUN find /app/.venv -depth -type d -path "*/tornado/test" -exec rm -rf {} + && \ + mkdir -p /app/.cache /var/run/litellm && \ + chown -R 65532:0 /app /var/lib/litellm /var/run/litellm && \ + chmod -R g=u,g+w /app/.cache /var/lib/litellm /var/run/litellm && \ + PRISMA_PATH="$(python -c 'import os, prisma; print(os.path.dirname(prisma.__file__))')" && \ + PROXY_EXTRAS_PATH="$(python -c 'import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))')" && \ + chmod -R g=u,g+w "$PRISMA_PATH" "$PROXY_EXTRAS_PATH" && \ chmod -R a+rX /opt/prisma && \ test -x /opt/prisma/binaries/node_modules/.bin/prisma && \ test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \ - python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" + ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null && \ + python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" && \ + python -c "from litellm.rust_bridge import _native" && \ + python -c "import gateway.launch, backend.main" -EXPOSE 4000/tcp +USER 65532:65532 -ENTRYPOINT ["docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] +RUN nginx -t + +EXPOSE 4000/tcp 4001/tcp 3000/tcp + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index aad59ba17c6..00000000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,146 +0,0 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a - -FROM $UV_IMAGE AS uvbin - -# ---------- Rust extension ---------- -# Copies only the native build's inputs, so the fat-LTO compile stays cached across Python-only changes -FROM $LITELLM_BUILD_IMAGE AS rust-native - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /uvx /usr/local/bin/ - -RUN for i in 1 2 3; do \ - apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl openssl-dev && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -ENV UV_PYTHON_DOWNLOADS=0 - -COPY pyproject.toml README.md rust-toolchain.toml ./ -COPY .cargo ./.cargo -COPY litellm-rust ./litellm-rust -COPY model_prices_and_context_window.json ./ - -RUN --mount=type=cache,target=/root/.cache/uv \ - mkdir -p litellm/rust_bridge /wheels && \ - touch litellm/__init__.py litellm/rust_bridge/__init__.py && \ - uvx --python python3.13 --from maturin==1.15.0 --with puccinialin==0.1.14 \ - python -c "import maturin; maturin.build_wheel('/wheels')" - -# ---------- Builder ---------- -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /uvx /usr/local/bin/ - -# nodejs/npm so `prisma generate` uses Wolfi's Node via PRISMA_USE_GLOBAL_NODE -# instead of nodeenv downloading one whose dynamic deps may not be in Wolfi -# (e.g. Node 26.2.0 needs libatomic). Retry for transient apk.cgr.dev flakes. -RUN for i in 1 2 3; do \ - apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl libssl3 openssl-dev libsndfile nodejs npm && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -# UV_COMPILE_BYTECODE=1 precompiles .pyc at install time → faster cold start. -# UV_LINK_MODE=copy avoids hardlink warnings when uv installs from a -# BuildKit cache mount (different filesystem). -# UV_PYTHON_DOWNLOADS=0 force uv to use the apk-installed CPython instead of -# silently pulling a managed interpreter. -# PRISMA_USE_GLOBAL_NODE explicit (matches default) so an env override can't -# silently re-enable nodeenv's Node download. -ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_PYTHON_DOWNLOADS=0 \ - PRISMA_USE_GLOBAL_NODE=true \ - PATH="/app/.venv/bin:${PATH}" - -# Stage 1 — install dependencies only. -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=enterprise/pyproject.toml,target=enterprise/pyproject.toml \ - --mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \ - uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --python python3.13 - -# Stage 2 — copy source and install the project + workspace members. -COPY . . - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-install-project --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --python python3.13 - -RUN --mount=type=bind,from=rust-native,source=/wheels,target=/wheels \ - uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm-*.whl - -RUN cp "$(python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"/litellm/rust_bridge/_native*.so litellm/rust_bridge/ - -RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - npm_config_cache=/root/.npm \ - prisma generate --schema=./schema.prisma - -RUN sed -i 's/\r$//' docker/component_entrypoint.sh && chmod +x docker/component_entrypoint.sh - -# ---------- Runtime ---------- -FROM $LITELLM_RUNTIME_IMAGE AS runtime -ARG LITELLM_RELEASE_TAG="" -ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} - -USER root - -RUN for i in 1 2 3; do \ - apk add --no-cache bash openssl libssl3 tzdata python-3.13 libsndfile libatomic && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -# wolfi-base ships an unprivileged `nonroot` account (UID/GID 65532) with -# /home/nonroot. We run the backend as that user -WORKDIR /app -ENV HOME=/home/nonroot \ - PATH="/app/.venv/bin:${PATH}" \ - PYTHONPATH="/app" \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries - -COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv -COPY --from=builder --chown=nonroot:nonroot /app/litellm /app/litellm -COPY --from=builder --chown=nonroot:nonroot /app/enterprise /app/enterprise -COPY --from=builder --chown=nonroot:nonroot /app/backend /app/backend -COPY --from=builder --chown=nonroot:nonroot /app/docker/component_entrypoint.sh /app/docker/component_entrypoint.sh -COPY --from=builder --chown=nonroot:nonroot /app/schema.prisma /app/schema.prisma -COPY --from=builder /opt/prisma /opt/prisma - -RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ - find /app/.venv -type d -path "*/tornado/test" -delete && \ - chmod -R a+rX /opt/prisma && \ - python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" - -USER nonroot - -EXPOSE 4001/tcp - -ENTRYPOINT ["/app/docker/component_entrypoint.sh", "uvicorn", "backend.main:app"] -CMD ["--host", "0.0.0.0", "--port", "4001"] diff --git a/ci_cd/security_scans_readme.md b/ci_cd/security_scans_readme.md index dd64b01c296..3003ea1d105 100644 --- a/ci_cd/security_scans_readme.md +++ b/ci_cd/security_scans_readme.md @@ -4,6 +4,5 @@ - Trivy scan on `./docs/` (HIGH/CRITICAL/MEDIUM) - Trivy scan on `./ui/` (HIGH/CRITICAL/MEDIUM) -- Grype scan on `Dockerfile.database` (fails on CRITICAL) - Grype scan on main `Dockerfile` (fails on CRITICAL) - Grype CVSS ≥ 4.0 scan on main `Dockerfile` (fails any vulnerabilities with CVSS ≥ 4.0) diff --git a/cookbook/litellm-ollama-docker-image/Dockerfile b/cookbook/litellm-ollama-docker-image/Dockerfile deleted file mode 100644 index be237a4df77..00000000000 --- a/cookbook/litellm-ollama-docker-image/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM ollama/ollama as ollama - -RUN echo "auto installing llama2" - -# auto install ollama/llama2 -RUN ollama serve & sleep 2 && ollama pull llama2 - -RUN echo "installing litellm" - -RUN apt-get update - -# Install Python -RUN apt-get install -y python3 python3-pip - -# Set the working directory in the container -WORKDIR /app - -# Copy the current directory contents into the container at /app -COPY . /app - -# Install any needed packages specified in requirements.txt - -RUN python3 -m pip install litellm -COPY start.sh /start.sh -ENTRYPOINT [ "/bin/bash", "/start.sh" ] diff --git a/cookbook/litellm-ollama-docker-image/requirements.txt b/cookbook/litellm-ollama-docker-image/requirements.txt deleted file mode 100644 index 9b9181b2360..00000000000 --- a/cookbook/litellm-ollama-docker-image/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -litellm==1.83.14 diff --git a/cookbook/litellm-ollama-docker-image/start.sh b/cookbook/litellm-ollama-docker-image/start.sh deleted file mode 100644 index ecc03ce73c5..00000000000 --- a/cookbook/litellm-ollama-docker-image/start.sh +++ /dev/null @@ -1,2 +0,0 @@ -ollama serve & -litellm \ No newline at end of file diff --git a/cookbook/litellm-ollama-docker-image/test.py b/cookbook/litellm-ollama-docker-image/test.py deleted file mode 100644 index 93b9c6ac4a7..00000000000 --- a/cookbook/litellm-ollama-docker-image/test.py +++ /dev/null @@ -1,35 +0,0 @@ -import openai - -api_base = "http://0.0.0.0:8000" - -openai.api_base = api_base -openai.api_key = "temp-key" -print(openai.api_base) - - -print("LiteLLM: response from proxy with streaming") -response = openai.ChatCompletion.create( - model="ollama/llama2", - messages=[ - { - "role": "user", - "content": "this is a test request, acknowledge that you got it", - } - ], - stream=True, -) - -for chunk in response: - print(f"LiteLLM: streaming response from proxy {chunk}") - -response = openai.ChatCompletion.create( - model="ollama/llama2", - messages=[ - { - "role": "user", - "content": "this is a test request, acknowledge that you got it", - } - ], -) - -print(f"LiteLLM: response from proxy {response}") diff --git a/docker-compose.hardened.yml b/docker-compose.hardened.yml deleted file mode 100644 index 84a23faa054..00000000000 --- a/docker-compose.hardened.yml +++ /dev/null @@ -1,44 +0,0 @@ -services: - # Hardened stack: for testing the proxy under non-root, read-only, proxy-enforced constraints. - # Keep this file focused on hardening/QA scenarios; leave the main docker-compose.yml for default dev usage. - litellm: - build: - context: . - dockerfile: docker/Dockerfile.non_root - target: runtime - depends_on: - - squid - user: "101:101" - group_add: - - "2345" - read_only: true - cap_drop: - - ALL - security_opt: - - no-new-privileges:true - tmpfs: - - /app/cache:rw,noexec,nosuid,nodev,size=128m,uid=101,gid=101,mode=1777 - - /app/migrations:rw,noexec,nosuid,nodev,size=64m,uid=101,gid=101,mode=1777 - volumes: - - ./proxy_server_config.yaml:/app/config.yaml:ro - environment: - LITELLM_NON_ROOT: "true" - PRISMA_BINARY_CACHE_DIR: "/app/cache/prisma-python/binaries" - XDG_CACHE_HOME: "/app/cache" - LITELLM_MIGRATION_DIR: "/app/migrations" - HTTP_PROXY: "http://squid:3128" - HTTPS_PROXY: "http://squid:3128" - NO_PROXY: "localhost,127.0.0.1,db" - command: - - "--port" - - "4000" - - "--config" - - "/app/config.yaml" - squid: - image: sameersbn/squid:3.5.27-2 - restart: unless-stopped - ports: - - "3128:3128" - tmpfs: - - /var/spool/squid:rw,noexec,nosuid,nodev,size=64m - - /var/log/squid:rw,noexec,nosuid,nodev,size=16m diff --git a/docker-compose.yml b/docker-compose.yml index 80e1f289aad..b012c3763de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,52 +1,71 @@ +# LiteLLM plus a Postgres database that stores models, virtual keys and spend +# logs. Used by https://docs.litellm.ai/docs/proxy/docker_quick_start +# +# curl -sSLO https://github.com/BerriAI/litellm/raw/main/docker-compose.yml +# printf 'LITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env +# docker compose up -d +# +# `docker compose up` runs the published image. From a checkout of the repo, +# `docker compose up --build` builds the Dockerfile next to this file instead. +# `docker compose --profile monitoring up -d` also starts Prometheus on 9090. +# +# Compose reads .env from this directory. Keep it: regenerating LITELLM_SALT_KEY +# makes credentials already stored in the database unreadable. A database +# created without LITELLM_SALT_KEY encrypted them with the master key, so when +# upgrading one, write only LITELLM_MASTER_KEY (the existing one) to .env. For anything +# beyond local evaluation, pin the image to a specific release tag. services: litellm: + image: docker.litellm.ai/berriai/litellm:main-stable build: context: . - args: - target: runtime - image: docker.litellm.ai/berriai/litellm:main-stable - ######################################### - ## Uncomment these lines to start proxy with a config.yaml file ## - # volumes: - # - ./config.yaml:/app/config.yaml - # command: - # - "--config=/app/config.yaml" - ############################################## + target: runtime + pull_policy: missing ports: - - "4000:4000" # Map the container port to the host, change the host port if necessary + - "4000:4000" + # The same image runs every component. The first word of `command` picks + # it: proxy (default), gateway, backend, ui, migrations, metrics, + # collector. See docker/README.md + # command: ["--config", "/app/config.yaml"] + # volumes: + # - ./config.yaml:/app/config.yaml:ro environment: - DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm" - # Optional: route read-only queries (find_*, count, group_by, query_raw/_first) - # to a separate reader endpoint, e.g. an Aurora reader. Leave unset for - # single-DB deployments. With IAM_TOKEN_DB_AUTH enabled, the reader URL - # is auto-refreshed alongside the writer. - # DATABASE_URL_READ_REPLICA: "postgresql://llmproxy:dbpassword9090@db-reader:5432/litellm" - STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI + LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?set it in .env, see the header of this file} + LITELLM_MIGRATION_DIR: /tmp/litellm-migrations + DATABASE_URL: postgresql://llmproxy:dbpassword9090@db:5432/litellm + STORE_MODEL_IN_DB: "True" env_file: - - .env # Load local .env file + - path: .env + required: false + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 + - /app/.cache:rw,noexec,nosuid,nodev,size=128m,mode=1777 depends_on: - - db # Indicates that this service depends on the 'db' service, ensuring 'db' starts first - healthcheck: # Defines the health check configuration for the container + db: + condition: service_healthy + healthcheck: test: - CMD-SHELL - - python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check - interval: 30s # Perform health check every 30 seconds - timeout: 10s # Health check command times out after 10 seconds - retries: 3 # Retry up to 3 times if health check fails - start_period: 40s # Wait 40 seconds after container start before beginning health checks + - python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s db: image: postgres:16 restart: always - container_name: litellm_db environment: POSTGRES_DB: litellm POSTGRES_USER: llmproxy POSTGRES_PASSWORD: dbpassword9090 - ports: - - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data # Persists Postgres data across container restarts + - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"] interval: 1s @@ -55,6 +74,8 @@ services: prometheus: image: prom/prometheus + profiles: + - monitoring volumes: - prometheus_data:/prometheus - ./prometheus.yml:/etc/prometheus/prometheus.yml @@ -68,6 +89,5 @@ services: volumes: prometheus_data: - driver: local postgres_data: - name: litellm_postgres_data # Named volume for Postgres data persistence + name: litellm_postgres_data diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 00000000000..7bcb62253a7 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# LiteLLM image entrypoint: docker-entrypoint.sh [COMPONENT] [ARGS...] +# +# COMPONENT selects the process this container runs; it can also be given as +# LITELLM_COMPONENT when the command carries no component. Anything after it is +# passed to that process. A first argument starting with "-" (or no argument at +# all) keeps the historical behaviour of running the monolithic proxy. +# +# proxy litellm ARGS everything in one process (default) +# gateway python -m gateway.launch ARGS inference routes, 0.0.0.0:4000 +# backend uvicorn backend.main:app ARGS management routes, 0.0.0.0:4001 +# ui nginx serving the admin UI port 3000 +# migrations python migrations/run.py prisma migrate deploy, then exit +# metrics python -m litellm.proxy.prometheus_metrics_server ARGS +# collector python -m litellm.proxy.collector ARGS +# --admin-agent the bundled LiteAdmin Slack agent (litellm-admin-agent --web ARGS) +# +# gateway and backend get their host and port defaults first, so ARGS such as +# --port 8080 override them. An unknown first word is executed as-is (docker run +# sh), through ddtrace-run like every component when USE_DDTRACE=true. PgBouncer is not a component: LITELLM_PGBOUNCER_ENABLED=true +# starts it inside proxy and gateway. Components that write Prometheus samples +# start with an empty PROMETHEUS_MULTIPROC_DIR; metrics, collector and raw +# readers keep the workers' files, other raw commands clear stale samples. +set -eu + +usage() { + awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" >&2 +} + +component="${LITELLM_COMPONENT:-proxy}" +case "${1:-}" in + proxy|gateway|backend|ui|migrations|metrics|collector) + component="$1" + shift + ;; + --admin-agent) + shift + export CONNECTION_AUTH_MODE=native + exec /opt/liteadmin/bin/litellm-admin-agent --web "$@" + ;; + ""|-*) + ;; + help) + usage + exit 0 + ;; + *) + if [ -n "${PROMETHEUS_MULTIPROC_DIR:-}" ]; then + mkdir -p "$PROMETHEUS_MULTIPROC_DIR" + case "$*" in + *litellm.proxy.prometheus_metrics_server*|*litellm.proxy.collector*) ;; + *) rm -f "$PROMETHEUS_MULTIPROC_DIR"/*.db "$PROMETHEUS_MULTIPROC_DIR"/litellm_admitted_series_* ;; + esac + fi + component=raw + ;; +esac + +case "$component" in + raw) + ;; + proxy) + set -- litellm "$@" + ;; + gateway) + set -- python -m gateway.launch --workers "${NUM_WORKERS:-1}" --host 0.0.0.0 --port 4000 "$@" + ;; + backend) + set -- uvicorn backend.main:app --host 0.0.0.0 --port 4001 "$@" + ;; + ui) + sh "$(dirname "$0")/ui/docker-entrypoint.d/40-litellm-runtime-config.sh" + exec nginx -g 'daemon off;' "$@" + ;; + migrations) + set -- python /app/migrations/run.py "$@" + ;; + metrics) + set -- python -m litellm.proxy.prometheus_metrics_server "$@" + ;; + collector) + set -- python -m litellm.proxy.collector "$@" + ;; + *) + echo "docker-entrypoint.sh: unknown LITELLM_COMPONENT '$component'" >&2 + usage + exit 64 + ;; +esac + +if [ -n "${PROMETHEUS_MULTIPROC_DIR:-}" ]; then + case "$component" in + raw) ;; + metrics|collector) mkdir -p "$PROMETHEUS_MULTIPROC_DIR" ;; + *) + mkdir -p "$PROMETHEUS_MULTIPROC_DIR" + rm -f "$PROMETHEUS_MULTIPROC_DIR"/*.db "$PROMETHEUS_MULTIPROC_DIR"/litellm_admitted_series_* + ;; + esac +fi + +case "${USE_DDTRACE:-}" in + [Tt][Rr][Uu][Ee]) + export DD_TRACE_OPENAI_ENABLED="False" + exec ddtrace-run "$@" + ;; +esac + +exec "$@" diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database deleted file mode 100644 index 2ebffc50b5e..00000000000 --- a/docker/Dockerfile.database +++ /dev/null @@ -1,172 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 - -# Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a -# Pinned by digest like the other base images; bump explicitly on Node upgrades. -ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 -# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) -ARG PGBOUNCER_VERSION=1.26.0 -ARG PGBOUNCER_SHA256=afd25dd61ee6775d37b40629b87ce08736b3e6955f3057bb212e410fbf21c71d - -FROM $UV_IMAGE AS uvbin - -FROM $LITELLM_BUILD_IMAGE AS pgbouncer-builder -ARG PGBOUNCER_VERSION -ARG PGBOUNCER_SHA256 -USER root -RUN apk add --no-cache build-base pkgconf libevent-dev openssl-dev curl -WORKDIR /build -RUN curl -fsSL -o pgbouncer.tar.gz "https://www.pgbouncer.org/downloads/files/${PGBOUNCER_VERSION}/pgbouncer-${PGBOUNCER_VERSION}.tar.gz" && \ - echo "${PGBOUNCER_SHA256} pgbouncer.tar.gz" | sha256sum -c - && \ - tar xzf pgbouncer.tar.gz --strip-components=1 && \ - ./configure --prefix=/usr/local --with-openssl=/usr && \ - make -j"$(nproc)" pgbouncer && \ - install -m 0755 pgbouncer /usr/local/bin/pgbouncer - -# Admin UI builder. Pinned to the build platform so the architecture-independent -# Next.js static export compiles once natively even in a multi-arch build, -# instead of once per target arch under QEMU. -FROM --platform=$BUILDPLATFORM $UI_BUILD_IMAGE AS ui-builder - -ENV NEXT_TELEMETRY_DISABLED=1 \ - npm_config_fund=false \ - npm_config_audit=false - -WORKDIR /ui - -COPY ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json ./ -COPY ui/litellm-dashboard/vendor/ ./vendor/ -RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline - -COPY ui/litellm-dashboard/ ./ -RUN npm run build - -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /usr/local/bin/uv -COPY --from=uvbin /uvx /usr/local/bin/uvx - -RUN apk add --no-cache \ - bash \ - gcc \ - python-3.13 \ - python-3.13-dev \ - openssl \ - libssl3 \ - openssl-dev \ - nodejs \ - npm \ - libsndfile - -ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - UV_PYTHON_DOWNLOADS=0 \ - PATH="/app/.venv/bin:${PATH}" - -# Copy dependency metadata first for layer caching -COPY pyproject.toml uv.lock ./ -COPY enterprise/pyproject.toml enterprise/ -COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ -COPY packaging/litellm-proxy/pyproject.toml packaging/litellm-proxy/ - -# Install third-party dependencies (cached unless pyproject.toml/uv.lock change) -RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --extra bedrock-realtime \ - --python python3.13 - -# Copy full source tree -COPY . . - -# Replace the committed UI bundle with the one built from this exact source. -# Clearing first drops the committed bundle's content-hashed chunks that COPY -# would otherwise leave behind alongside the fresh ones. -RUN rm -rf litellm/proxy/_experimental/out -COPY --from=ui-builder /ui/out/. litellm/proxy/_experimental/out/ - -# Build Admin UI before final sync (applies the enterprise color override when present) -RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.sh && ./docker/build_admin_ui.sh - -# Install project and workspace packages (fast - deps already cached) -RUN uv build --package litellm-proxy --wheel --out-dir /wheels -RUN uv sync --frozen --no-install-package litellm-proxy --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --extra bedrock-realtime \ - --python python3.13 - -RUN uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm_proxy-*.whl - -RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - npm_config_cache=/root/.npm \ - prisma generate --schema=./schema.prisma - -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ - sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh - -FROM $LITELLM_RUNTIME_IMAGE AS runtime -ARG LITELLM_RELEASE_TAG="" -ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} - -USER root - -# node (without npm) is required by the prisma CLI at runtime -RUN apk add --no-cache bash openssl libssl3 tzdata nodejs python-3.13 libsndfile libevent -COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer - -WORKDIR /app -ENV PATH="/app/.venv/bin:${PATH}" \ - PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \ - PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ - PRISMA_OFFLINE_MODE=true - -# Copy only what runtime needs. The application is installed inside the venv; -# the rest of the builder's /app is source and build metadata that must not -# ship (manifest-scanning tools attribute everything in it to this image). -# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path. -COPY --from=builder /app/.venv /app/.venv -COPY --from=builder /app/docker /app/docker -COPY --from=builder /app/schema.prisma /app/schema.prisma -COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/prisma_migration.py -# enterprise/ is imported by source path at runtime (proxy_cli puts the -# working directory on sys.path; litellm/proxy/hooks resolves -# enterprise.enterprise_hooks from it) -COPY --from=builder /app/enterprise /app/enterprise -COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras -# Prisma CLI + engines are baked under /opt/prisma, a fixed path every -# runtime uid can read and that no cache volume mount shadows (unlike -# /app/.cache or $HOME/.cache in readOnlyRootFilesystem + emptyDir setups). -# The paths are pinned via PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH and -# recorded into the generated client at build time, so `prisma migrate -# deploy` on a fresh database needs no npm and no network access -# (#33650, #24554). -COPY --from=builder /opt/prisma /opt/prisma - -RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ - find /app/.venv -type d -path "*/tornado/test" -delete && \ - chmod -R a+rX /opt/prisma && \ - test -x /opt/prisma/binaries/node_modules/.bin/prisma && \ - test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \ - python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" - -EXPOSE 4000/tcp - -ENTRYPOINT ["docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root deleted file mode 100644 index 90bcc523d9e..00000000000 --- a/docker/Dockerfile.non_root +++ /dev/null @@ -1,208 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# Base images -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a -# Pinned by digest like the other base images; bump explicitly on Node upgrades. -ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 -# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) -ARG PGBOUNCER_VERSION=1.26.0 -ARG PGBOUNCER_SHA256=afd25dd61ee6775d37b40629b87ce08736b3e6955f3057bb212e410fbf21c71d - -FROM $UV_IMAGE AS uvbin - -FROM $LITELLM_BUILD_IMAGE AS pgbouncer-builder -ARG PGBOUNCER_VERSION -ARG PGBOUNCER_SHA256 -USER root -RUN apk add --no-cache build-base pkgconf libevent-dev openssl-dev curl -WORKDIR /build -RUN curl -fsSL -o pgbouncer.tar.gz "https://www.pgbouncer.org/downloads/files/${PGBOUNCER_VERSION}/pgbouncer-${PGBOUNCER_VERSION}.tar.gz" && \ - echo "${PGBOUNCER_SHA256} pgbouncer.tar.gz" | sha256sum -c - && \ - tar xzf pgbouncer.tar.gz --strip-components=1 && \ - ./configure --prefix=/usr/local --with-openssl=/usr && \ - make -j"$(nproc)" pgbouncer && \ - install -m 0755 pgbouncer /usr/local/bin/pgbouncer - -# Admin UI builder. Pinned to the build platform so the architecture-independent -# Next.js static export compiles once natively even in a multi-arch build, -# instead of once per target arch under QEMU. -FROM --platform=$BUILDPLATFORM $UI_BUILD_IMAGE AS ui-builder - -ENV NEXT_TELEMETRY_DISABLED=1 \ - npm_config_fund=false \ - npm_config_audit=false - -WORKDIR /ui - -COPY ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json ./ -COPY ui/litellm-dashboard/vendor/ ./vendor/ -RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline - -COPY ui/litellm-dashboard/ ./ -RUN npm run build - -FROM $LITELLM_BUILD_IMAGE AS builder -WORKDIR /app -USER root - -COPY --from=uvbin /uv /usr/local/bin/uv -COPY --from=uvbin /uvx /usr/local/bin/uvx - -RUN for i in 1 2 3; do \ - apk add --no-cache \ - python-3.13 \ - python-3.13-dev \ - gcc \ - rust \ - bash \ - coreutils \ - curl \ - openssl \ - libssl3 \ - libsndfile \ - nodejs \ - npm && break || sleep 5; \ - done - -ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - UV_PYTHON_DOWNLOADS=0 \ - PATH="/app/.venv/bin:${PATH}" \ - LITELLM_NON_ROOT=true \ - XDG_CACHE_HOME=/app/.cache - -# Copy dependency metadata first for layer caching -COPY pyproject.toml uv.lock ./ -COPY enterprise/pyproject.toml enterprise/ -COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ - -# Install third-party dependencies (cached unless pyproject.toml/uv.lock change) -RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ - uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --extra bedrock-realtime \ - --python python3.13 - -# Copy full source tree -COPY . . - -# Replace the committed UI bundle with the one built from this exact source. -# Clearing first drops the committed bundle's content-hashed chunks that COPY -# would otherwise leave behind alongside the fresh ones. -RUN rm -rf litellm/proxy/_experimental/out -COPY --from=ui-builder /ui/out/. litellm/proxy/_experimental/out/ - -# Set non-root flag for build time consistency -ENV LITELLM_NON_ROOT=true - -RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \ - cp -r /app/litellm/proxy/_experimental/out/. /var/lib/litellm/ui/ && \ - cp /app/litellm/proxy/logo.png /var/lib/litellm/assets/logo.png && \ - touch /var/lib/litellm/ui/.litellm_ui_ready - -RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ - uv sync --frozen --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --extra bedrock-realtime \ - --python python3.13 - -RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - npm_config_cache=/root/.npm \ - prisma generate --schema=./schema.prisma - -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ - sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh - -FROM $LITELLM_RUNTIME_IMAGE AS runtime -ARG LITELLM_RELEASE_TAG="" -ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} -WORKDIR /app -USER root - -RUN for i in 1 2 3; do \ - apk upgrade --no-cache && break || sleep 5; \ - done && \ - for i in 1 2 3; do \ - apk add --no-cache python-3.13 bash openssl libssl3 tzdata libsndfile nodejs libevent && break || sleep 5; \ - done -COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer - -# Copy only what runtime needs. The application is installed inside the venv; -# the rest of the builder's /app is source and build metadata that must not -# ship (manifest-scanning tools attribute everything in it to this image). -# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path. -COPY --from=builder /app/.venv /app/.venv -COPY --from=builder /app/docker /app/docker -COPY --from=builder /app/schema.prisma /app/schema.prisma -COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/prisma_migration.py -# enterprise/ is imported by source path at runtime (proxy_cli puts the -# working directory on sys.path; litellm/proxy/hooks resolves -# enterprise.enterprise_hooks from it) -COPY --from=builder /app/enterprise /app/enterprise -COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras -# Prisma CLI + engines are baked under /opt/prisma, a fixed path every runtime -# uid can read and that no cache volume mount shadows (unlike /app/.cache or -# $HOME/.cache under readOnlyRootFilesystem + emptyDir or arbitrary-uid setups). -# PRISMA_CLI_QUERY_ENGINE_TYPE=binary makes the CLI use the baked binary query -# engine directly, so `prisma migrate deploy` on a fresh database needs no npm -# and no network access; without it the CLI looks for the library engine, which -# prisma stopped baking, and falls back to a download that fails offline or as a -# non-writable uid (#33650, #24554). -COPY --from=builder /opt/prisma /opt/prisma -COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui -COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets - -# XDG_CACHE_HOME is intentionally left unset so it falls back to $HOME/.cache -# (/app/.cache, writable by the runtime uid). The prisma bake at the read-only -# /opt/prisma is anchored by PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH, so -# nothing needs XDG to point there; pointing it at the read-only bake would -# deny any XDG-aware library that writes a cache at runtime. -ENV PATH="/app/.venv/bin:${PATH}" \ - PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \ - PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ - HOME=/app \ - LITELLM_NON_ROOT=true \ - PRISMA_SKIP_POSTINSTALL_GENERATE=1 \ - PRISMA_HIDE_UPDATE_MESSAGE=1 \ - PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 \ - PRISMA_OFFLINE_MODE=true - -RUN mkdir -p /nonexistent /app/.cache /var/lib/litellm/assets /var/lib/litellm/ui && \ - chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent && \ - PRISMA_PATH=$(python -c "import os, prisma; print(os.path.dirname(prisma.__file__))") && \ - chown -R nobody:nogroup "$PRISMA_PATH" && \ - LITELLM_PKG_MIGRATIONS_PATH="$(python -c 'import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))' 2>/dev/null || echo '')/migrations" && \ - [ -n "$LITELLM_PKG_MIGRATIONS_PATH" ] && chown -R nobody:nogroup "$LITELLM_PKG_MIGRATIONS_PATH" || true && \ - LITELLM_PROXY_EXTRAS_PATH=$(python -c "import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))" 2>/dev/null || echo "") && \ - chgrp -R 0 "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \ - [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chgrp -R 0 "$LITELLM_PROXY_EXTRAS_PATH" || true && \ - chmod -R g=u "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \ - [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g=u "$LITELLM_PROXY_EXTRAS_PATH" || true && \ - chmod -R g+w "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \ - [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w "$LITELLM_PROXY_EXTRAS_PATH" || true && \ - chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \ - chmod -R a+rX /opt/prisma && \ - test -x /opt/prisma/binaries/node_modules/.bin/prisma && \ - test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \ - ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null 2>&1 && \ - python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" - -USER 65534 - -EXPOSE 4000/tcp - -ENTRYPOINT ["/app/docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] diff --git a/docker/README.md b/docker/README.md index ac82c6caebf..3ec26219420 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,15 +2,15 @@ This guide provides instructions for building and running the LiteLLM application using Docker and Docker Compose. -> **Just want to run LiteLLM?** This guide builds from source. To run the published -> image instead, use `docker-compose.quickstart.yml` in this directory — the -> two-service stack (gateway + Postgres) that the -> [Docker quickstart](https://docs.litellm.ai/docs/proxy/docker_quick_start) documents: +> **Just want to run LiteLLM?** `docker-compose.yml` in the repository root runs +> the published image with a Postgres database, the stack the +> [Docker quickstart](https://docs.litellm.ai/docs/proxy/docker_quick_start) documents, +> and it works on its own outside a checkout: > > ```bash -> curl -sSLO https://github.com/BerriAI/litellm/raw/main/docker/docker-compose.quickstart.yml +> curl -sSLO https://github.com/BerriAI/litellm/raw/main/docker-compose.yml > printf 'LITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env -> docker compose -f docker-compose.quickstart.yml up -d +> docker compose up -d > ``` ## Prerequisites @@ -20,33 +20,52 @@ This guide provides instructions for building and running the LiteLLM applicatio ## Building and Running the Application -To build and run the application, you will use the `docker-compose.yml` file located in the root of the project. This file is configured to use the `Dockerfile.non_root` for a secure, non-root container environment. +The same `docker-compose.yml` builds from source when you pass `--build`: it builds the `Dockerfile` in the repository root, the one image LiteLLM ships. `docker-entrypoint.sh` next to it is the only entrypoint script; every container starts through it -### 1. Set the Master Key +## One image, many components -The application requires a `LITELLM_MASTER_KEY` for signing and validating tokens. You must set this key as an environment variable before running the application. +Every LiteLLM container runs the same image. The first word of the container command (or the `LITELLM_COMPONENT` environment variable when the command carries only flags) picks the process the container runs, and everything after it is handed to that process unchanged: -Create a `.env` file in the root of the project and add the following line: +| Component | Runs | Port | +|--------------|-----------------------------------------------------|------| +| `proxy` | `litellm ...` (everything in one process, default) | 4000 | +| `gateway` | `python -m gateway.launch ...` (inference routes) | 4000 | +| `backend` | `uvicorn backend.main:app ...` (management routes) | 4001 | +| `ui` | nginx serving the static admin UI | 3000 | +| `migrations` | `python migrations/run.py`, `prisma migrate deploy` then exit | | +| `metrics` | `python -m litellm.proxy.prometheus_metrics_server ...` | `--port` | +| `collector` | `python -m litellm.proxy.collector ...` | | -``` -LITELLM_MASTER_KEY=your-secret-key +```bash +docker run -p 4000:4000 litellm --config /app/config.yaml # proxy, exactly as before +docker run -p 4000:4000 litellm gateway --port 4000 # componentized data plane +docker run -p 4001:4001 -e LITELLM_COMPONENT=backend litellm # same, chosen through the env +docker run -p 3000:3000 --read-only --tmpfs /tmp litellm ui # admin UI behind nginx +docker run -e DATABASE_URL=... litellm migrations # one-off schema migration job +docker run -it litellm sh # anything else runs verbatim ``` -Replace `your-secret-key` with a strong, randomly generated secret. +PgBouncer is not a separate component: `LITELLM_PGBOUNCER_ENABLED=true` starts an in-container PgBouncer in front of `DATABASE_URL` inside `proxy` and `gateway`. `USE_DDTRACE=true` wraps whichever component runs with `ddtrace-run`, and `PROMETHEUS_MULTIPROC_DIR` is emptied of stale samples before any workers fork (the `metrics` and `collector` sidecars only read it, so their restart keeps the live samples) + +The image runs as uid `65532` (`nonroot` in the Wolfi base) and also works as an arbitrary uid in gid 0, the shape OpenShift `restricted-v2` assigns, because everything it writes at runtime lives under `/app/.cache`, `/var/lib/litellm` and `/tmp`. Mount those (or set `readOnlyRootFilesystem` with emptyDirs there) for a read-only root filesystem. Prisma's CLI and engines are baked under `/opt/prisma`, so migrations need neither network nor a writable home + +### 1. Set the Master and Salt Keys + +The proxy signs virtual keys with `LITELLM_MASTER_KEY` and encrypts stored provider credentials with `LITELLM_SALT_KEY`. Compose reads both from a `.env` file in the directory you run it from, so generate them once: + +```bash +printf 'LITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env +``` + +Keep the file: regenerating `LITELLM_SALT_KEY` makes credentials already stored in the database unreadable. A database created without `LITELLM_SALT_KEY` encrypted them with the master key, so when upgrading one, write only the existing `LITELLM_MASTER_KEY` to `.env` and leave `LITELLM_SALT_KEY` out. Provider keys such as `OPENAI_API_KEY` go in the same file, and the whole file is passed to the container ### 2. Build and Run the Containers -Once you have set the `LITELLM_MASTER_KEY`, you can build and run the containers using the following command: - ```bash docker compose up -d --build ``` -This command will: - -- Build the Docker image using `Dockerfile.non_root`. -- Start the `litellm`, `litellm_db`, and `prometheus` services in detached mode (`-d`). -- The `--build` flag ensures that the image is rebuilt if there are any changes to the Dockerfile or the application code. +This command builds the image from the root `Dockerfile` and starts the `litellm` and `db` services in detached mode. Without `--build`, `docker compose up` pulls the published `main-stable` image instead. Add `--profile monitoring` to also start Prometheus on port 9090, scraping the proxy with the root `prometheus.yml` ### 3. Verifying the Application is Running @@ -80,13 +99,13 @@ LITELLM_LICENSE="your-enterprise-license" PROXY_BASE_URL=https://gateway.example.com ``` -For the unified source deployment described above, put them in its `.env` file and rebuild: +For the compose deployment described above, put them in its `.env` file and rebuild: ```bash docker compose up -d --build ``` -In componentized deployments, set the flag and license on the backend container and route `/admin/mcp` to the backend service. The gateway component excludes this endpoint. The unified, database, non-root, and backend image builds bundle the connector +In componentized deployments, set the flag and license on the `backend` component and route `/admin/mcp` to it. The `gateway` component excludes this endpoint. The image bundles the connector for every component Hosting is disabled by default. Opting in requires a valid base Enterprise license; an unlicensed opt-in or invalid flag value prevents startup. Enabling it reserves `/admin`, so rename any MCP server alias called `admin` first @@ -96,34 +115,18 @@ Embedded responses default to `full`; selecting `LITELLM_ADMIN_RESPONSE_VIEW=com See the [LiteAdmin MCP guide](https://docs.litellm.ai/docs/proxy/liteadmin_mcp#run-liteadmin-mcp-inside-litellm) for client configuration, tool restrictions, and verification -## Hardened / Offline Testing +## Hardening -To ensure changes are safe for non-root, read-only root filesystems and restricted egress, always validate with the hardened compose file: +The compose file runs the proxy the way a locked-down cluster would: as the image's non-root user with a read-only root filesystem, every capability dropped, `no-new-privileges` set, and tmpfs mounts only at `/tmp` and `/app/.cache`. The image is built for that, so a change that makes the proxy write anywhere else fails here before it fails in Kubernetes. To try an arbitrary uid in gid 0, the shape OpenShift `restricted-v2` assigns, add `user: "101:0"` to the `litellm` service + +Prisma's CLI and engines are baked under `/opt/prisma`, so migrations run without network access. Verify with: ```bash -docker compose -f docker-compose.yml -f docker-compose.hardened.yml build --no-cache -docker compose -f docker-compose.yml -f docker-compose.hardened.yml up -d +docker run --rm --network none --entrypoint prisma docker.litellm.ai/berriai/litellm:main-stable --version ``` -This setup: -- Builds from `docker/Dockerfile.non_root` with Prisma engines and Node toolchain baked into the image. -- Runs the proxy as a non-root user with a read-only rootfs and only writable tmpfs mounts: - - `/app/cache` (Prisma/NPM cache; backing `PRISMA_BINARY_CACHE_DIR`, `NPM_CONFIG_CACHE`, `XDG_CACHE_HOME`) - - `/app/migrations` (Prisma migration workspace; backing `LITELLM_MIGRATION_DIR`) -- Pre-builds and serves the admin UI from read-only paths: - - `/var/lib/litellm/ui` (pre-restructured Next.js UI with `.litellm_ui_ready` marker) - - `/var/lib/litellm/assets` (UI logos and assets) -- Routes all outbound traffic through a local Squid proxy that denies egress, so Prisma migrations must use the cached CLI and engines. - -You should also verify offline Prisma behaviour with: - -```bash -docker run --rm --network none --entrypoint prisma ghcr.io/berriai/litellm:main-stable --version -``` - -This command should succeed (showing engine versions) even with `--network none`, confirming that Prisma binaries are available without network access. - ## Troubleshooting -- **`build_admin_ui.sh: not found`**: This error can occur if the Docker build context is not set correctly. Ensure that you are running the `docker-compose` command from the root of the project. -- **`Master key is not initialized`**: This error means the `LITELLM_MASTER_KEY` environment variable is not set. Make sure you have created a `.env` file in the project root with the `LITELLM_MASTER_KEY` defined. +- **`required variable LITELLM_MASTER_KEY is missing a value`**: Compose did not find a `.env` file with `LITELLM_MASTER_KEY` in the directory you ran it from. Generate one as shown above. +- **`password authentication failed for user "llmproxy"`**: the `litellm_postgres_data` volume was initialised by an older compose file with different credentials. `docker compose down -v` drops it and the next `up` recreates the database. +- **`build_admin_ui.sh: not found`**: the build context is wrong. Run `docker compose` from the root of the repository. diff --git a/docker/build_from_pip/Dockerfile.build_from_pip b/docker/build_from_pip/Dockerfile.build_from_pip deleted file mode 100644 index 25a7ed2fa97..00000000000 --- a/docker/build_from_pip/Dockerfile.build_from_pip +++ /dev/null @@ -1,64 +0,0 @@ -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.10.9@sha256:10902f58a1606787602f303954cea099626a4adb02acbac4c69920fe9d278f82 -FROM $UV_IMAGE AS uvbin - -FROM python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d - -ARG LITELLM_VERSION=1.83.0 - -WORKDIR /app - -COPY --from=uvbin /uv /usr/local/bin/uv -COPY --from=uvbin /uvx /usr/local/bin/uvx - -RUN apt-get update && \ - apt-get install -y --no-install-recommends gcc libffi-dev nodejs npm && \ - rm -rf /var/lib/apt/lists/* - -ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - PATH="/app/.venv/bin:${PATH}" - -COPY schema.prisma . - -# This image is specifically for validating/installing the published PyPI -# artifact, not the checked-out source tree. -# Keep the moved proxy-runtime packages explicit until the published PyPI -# artifact includes that extra; newer releases will simply dedupe these. -RUN uv venv --python python && \ - uv pip install --python /app/.venv/bin/python \ - "litellm[proxy,proxy-runtime]==${LITELLM_VERSION}" \ - "google-cloud-aiplatform==1.133.0" \ - "google-genai==1.37.0" \ - "anthropic[vertex]==0.84.0" \ - "grpcio==1.84.1" \ - "prometheus-client==0.20.0" \ - "langfuse==2.59.7" \ - "opentelemetry-api==1.28.0" \ - "opentelemetry-sdk==1.28.0" \ - "opentelemetry-exporter-otlp==1.28.0" \ - "ddtrace==4.11.0" \ - "sentry-sdk==2.21.0" \ - "mangum==0.17.0" \ - "azure-ai-contentsafety==1.0.0" \ - "azure-storage-file-datalake==12.20.0" \ - "pypdf==6.7.5" \ - "llm-sandbox==0.3.31" \ - "detect-secrets==1.5.0" \ - "prisma==0.11.0" \ - "openai==2.24.0" - -RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - npm_config_cache=/root/.npm \ - prisma generate --schema=./schema.prisma && \ - chmod -R a+rX /opt/prisma && \ - python -c "import sys; from prisma.client import BINARY_PATHS; bad = sorted(p for group in BINARY_PATHS.model_dump().values() for p in group.values() if not p.startswith('/opt/prisma/')); sys.exit('prisma engines baked outside /opt/prisma: %r' % bad) if bad else None" - -ENV PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries - -COPY docker/prod_entrypoint.sh /app/docker/prod_entrypoint.sh -RUN sed -i 's/\r$//' /app/docker/prod_entrypoint.sh && chmod +x /app/docker/prod_entrypoint.sh - -EXPOSE 4000/tcp - -ENTRYPOINT ["/app/docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] diff --git a/docker/build_from_pip/Readme.md b/docker/build_from_pip/Readme.md deleted file mode 100644 index ad043588f33..00000000000 --- a/docker/build_from_pip/Readme.md +++ /dev/null @@ -1,9 +0,0 @@ -# Docker to build LiteLLM Proxy from litellm pip package - -### When to use this ? - -If you need to build LiteLLM Proxy from litellm pip package, you can use this Dockerfile as a reference. - -### Why build from pip package ? - -- If your company has a strict requirement around security / building images you can follow steps outlined here \ No newline at end of file diff --git a/docker/component_entrypoint.sh b/docker/component_entrypoint.sh deleted file mode 100755 index 3cd530e3009..00000000000 --- a/docker/component_entrypoint.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -# stale samples from a previous container incarnation would be summed into the aggregate -if [ -n "$PROMETHEUS_MULTIPROC_DIR" ]; then - mkdir -p "$PROMETHEUS_MULTIPROC_DIR" - rm -f "$PROMETHEUS_MULTIPROC_DIR"/*.db "$PROMETHEUS_MULTIPROC_DIR"/litellm_admitted_series_* -fi - -case "$USE_DDTRACE" in - [Tt][Rr][Uu][Ee]) - export DD_TRACE_OPENAI_ENABLED="False" - exec ddtrace-run "$@" - ;; -esac - -exec "$@" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100755 index 003d9b21db8..00000000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -set -euo pipefail - -REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -VENV_PYTHON="$REPO_ROOT/.venv/bin/python" -MIGRATION_SCRIPT="$REPO_ROOT/litellm/proxy/prisma_migration.py" - -if [ -x "$VENV_PYTHON" ]; then - "$VENV_PYTHON" "$MIGRATION_SCRIPT" -elif command -v uv >/dev/null 2>&1; then - (cd "$REPO_ROOT" && uv run --no-sync python "$MIGRATION_SCRIPT") -else - python3 "$MIGRATION_SCRIPT" -fi - -echo "Migration script ran successfully!" diff --git a/docker/install_auto_router.sh b/docker/install_auto_router.sh deleted file mode 100755 index 4fedf201b41..00000000000 --- a/docker/install_auto_router.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# semantic-router dependencies are installed via `uv sync`. diff --git a/docker/prod_entrypoint.sh b/docker/prod_entrypoint.sh deleted file mode 100644 index 4386be65a32..00000000000 --- a/docker/prod_entrypoint.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -if [ "$1" = "--admin-agent" ]; then - shift - export CONNECTION_AUTH_MODE=native - exec /opt/liteadmin/bin/litellm-admin-agent --web "$@" -fi - -case "$USE_DDTRACE" in - [Tt][Rr][Uu][Ee]) - export DD_TRACE_OPENAI_ENABLED="False" - exec ddtrace-run litellm "$@" - ;; -esac - -exec litellm "$@" diff --git a/docker/tests/nonroot.yaml b/docker/tests/nonroot.yaml deleted file mode 100644 index 36118ca8c59..00000000000 --- a/docker/tests/nonroot.yaml +++ /dev/null @@ -1,18 +0,0 @@ -schemaVersion: 2.0.0 - -metadataTest: - entrypoint: ["docker/prod_entrypoint.sh"] - user: "65534" - workdir: "/app" - -fileExistenceTests: - - name: "Prisma Folder" - path: "/usr/local/lib/python3.13/site-packages/prisma/" - shouldExist: true - uid: 65534 - gid: 65534 - - name: "Prisma Schema" - path: "/usr/local/lib/python3.13/site-packages/prisma/schema.prisma" - shouldExist: true - uid: 65534 - gid: 65534 diff --git a/gateway/Dockerfile b/gateway/Dockerfile deleted file mode 100644 index 1e1c7d73091..00000000000 --- a/gateway/Dockerfile +++ /dev/null @@ -1,170 +0,0 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a -# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) -ARG PGBOUNCER_VERSION=1.26.0 -ARG PGBOUNCER_SHA256=afd25dd61ee6775d37b40629b87ce08736b3e6955f3057bb212e410fbf21c71d - -FROM $UV_IMAGE AS uvbin - -FROM $LITELLM_BUILD_IMAGE AS pgbouncer-builder -ARG PGBOUNCER_VERSION -ARG PGBOUNCER_SHA256 -USER root -RUN apk add --no-cache build-base pkgconf libevent-dev openssl-dev curl -WORKDIR /build -RUN curl -fsSL -o pgbouncer.tar.gz "https://www.pgbouncer.org/downloads/files/${PGBOUNCER_VERSION}/pgbouncer-${PGBOUNCER_VERSION}.tar.gz" && \ - echo "${PGBOUNCER_SHA256} pgbouncer.tar.gz" | sha256sum -c - && \ - tar xzf pgbouncer.tar.gz --strip-components=1 && \ - ./configure --prefix=/usr/local --with-openssl=/usr && \ - make -j"$(nproc)" pgbouncer && \ - install -m 0755 pgbouncer /usr/local/bin/pgbouncer - -# ---------- Rust extension ---------- -# Copies only the native build's inputs, so the fat-LTO compile stays cached across Python-only changes -FROM $LITELLM_BUILD_IMAGE AS rust-native - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /uvx /usr/local/bin/ - -RUN for i in 1 2 3; do \ - apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl openssl-dev && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -ENV UV_PYTHON_DOWNLOADS=0 - -COPY pyproject.toml README.md rust-toolchain.toml ./ -COPY .cargo ./.cargo -COPY litellm-rust ./litellm-rust -COPY model_prices_and_context_window.json ./ -COPY packaging/litellm-proxy/pyproject.toml packaging/litellm-proxy/ - -RUN --mount=type=cache,target=/root/.cache/uv \ - mkdir -p litellm/rust_bridge /wheels && \ - touch litellm/__init__.py litellm/rust_bridge/__init__.py && \ - uvx --python python3.13 --from maturin==1.15.0 --with puccinialin==0.1.14 \ - python -c "import maturin; maturin.build_wheel('/wheels')" && \ - cd packaging/litellm-proxy && \ - uvx --python python3.13 --from maturin==1.15.0 --with puccinialin==0.1.14 \ - python -c "import maturin; maturin.build_wheel('/wheels')" - -# ---------- Builder ---------- -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /uvx /usr/local/bin/ - -# nodejs/npm so `prisma generate` uses Wolfi's Node via PRISMA_USE_GLOBAL_NODE -# instead of nodeenv downloading one whose dynamic deps may not be in Wolfi -# (e.g. Node 26.2.0 needs libatomic). Retry for transient apk.cgr.dev flakes. -RUN for i in 1 2 3; do \ - apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl libssl3 openssl-dev libsndfile nodejs npm && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -# UV_COMPILE_BYTECODE=1 precompiles .pyc at install time → faster cold start. -# UV_LINK_MODE=copy avoids hardlink warnings when uv installs from a -# BuildKit cache mount (different filesystem). -# UV_PYTHON_DOWNLOADS=0 force uv to use the apk-installed CPython instead of -# silently pulling a managed interpreter. -# PRISMA_USE_GLOBAL_NODE explicit (matches default) so an env override can't -# silently re-enable nodeenv's Node download. -ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_PYTHON_DOWNLOADS=0 \ - PRISMA_USE_GLOBAL_NODE=true \ - PATH="/app/.venv/bin:${PATH}" - -# Stage 1 — install dependencies only. -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=enterprise/pyproject.toml,target=enterprise/pyproject.toml \ - --mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \ - --mount=type=bind,source=packaging/litellm-proxy/pyproject.toml,target=packaging/litellm-proxy/pyproject.toml \ - uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra bedrock-realtime \ - --python python3.13 - -# Stage 2 — copy source and install the workspace members. -COPY . . - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-install-project --no-install-package litellm-proxy --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --group admin-mcp \ - --extra extra_proxy \ - --extra semantic-router \ - --extra bedrock-realtime \ - --python python3.13 - -RUN --mount=type=bind,from=rust-native,source=/wheels,target=/wheels \ - uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm-*.whl /wheels/litellm_proxy-*.whl - -# PYTHONPATH=/app makes the source tree shadow the installed package, so the -# compiled Rust extension must live next to the source or it is never imported. -RUN cp "$(python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"/litellm/rust_bridge/_native*.so litellm/rust_bridge/ - -RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - npm_config_cache=/root/.npm \ - prisma generate --schema=./schema.prisma - -RUN sed -i 's/\r$//' docker/component_entrypoint.sh && chmod +x docker/component_entrypoint.sh - -# ---------- Runtime ---------- -FROM $LITELLM_RUNTIME_IMAGE AS runtime - -USER root - -RUN for i in 1 2 3; do \ - apk add --no-cache bash openssl libssl3 tzdata python-3.13 libsndfile libatomic libevent && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -# wolfi-base ships an unprivileged `nonroot` account (UID/GID 65532) with -# /home/nonroot. We run the proxy as that user. -WORKDIR /app -ENV HOME=/home/nonroot \ - PATH="/app/.venv/bin:${PATH}" \ - PYTHONPATH="/app" \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries - -COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv -COPY --from=builder --chown=nonroot:nonroot /app/litellm /app/litellm -COPY --from=builder --chown=nonroot:nonroot /app/enterprise /app/enterprise -COPY --from=builder --chown=nonroot:nonroot /app/gateway /app/gateway -COPY --from=builder --chown=nonroot:nonroot /app/litellm_cli /app/litellm_cli -COPY --from=builder --chown=nonroot:nonroot /app/docker/component_entrypoint.sh /app/docker/component_entrypoint.sh -COPY --from=builder --chown=nonroot:nonroot /app/schema.prisma /app/schema.prisma -COPY --from=builder /opt/prisma /opt/prisma -COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer - -RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ - find /app/.venv -type d -path "*/tornado/test" -delete && \ - chmod -R a+rX /opt/prisma && \ - python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" && \ - python -c "import litellm, litellm.rust_bridge._native; assert litellm.__file__ == '/app/litellm/__init__.py', litellm.__file__" - -USER nonroot - -EXPOSE 4000/tcp - -ENTRYPOINT ["sh", "-c", "exec /app/docker/component_entrypoint.sh python -m gateway.launch --workers \"${NUM_WORKERS:-1}\" \"$@\"", "--"] -CMD ["--host", "0.0.0.0", "--port", "4000"] diff --git a/litellm/proxy/start.sh b/litellm/proxy/start.sh deleted file mode 100755 index 44df50aaabf..00000000000 --- a/litellm/proxy/start.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -python3 proxy_cli.py \ No newline at end of file diff --git a/migrations/Dockerfile b/migrations/Dockerfile deleted file mode 100644 index 3ffa9140543..00000000000 --- a/migrations/Dockerfile +++ /dev/null @@ -1,152 +0,0 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7 -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a - -FROM $UV_IMAGE AS uvbin - -# ---------- Builder ---------- -# -# Minimal install for `prisma migrate deploy`. We deliberately skip the heavy -# `proxy-runtime` (otel, sentry, ddtrace, pypdf, google-genai, anthropic-vertex, -# ...) and `semantic-router` extras that the gateway/backend pull in — the -# migration engine doesn't need them. We DO install `--extra proxy` so the -# DB-URL helper from `litellm.proxy.auth.rds_iam_token` is importable, which -# is how the gateway and backend assemble `DATABASE_URL` at pod startup when -# `IAM_TOKEN_DB_AUTH=true` (see backend/main.py:17, gateway/main.py:22). And -# `--extra extra_proxy` provides the `prisma` CLI + the secret-manager -# backends `litellm.secret_managers.main` lazily imports. -# -# `prisma generate` runs once at BUILD time to (a) install the Node-based -# Prisma CLI into the binary cache and (b) download the migration / query -# engine binaries. The Python client it also produces is unused by this -# image's runtime entrypoint — that's fine, it's a few hundred KB and the -# alternative (`prisma py fetch`) doesn't reliably trigger engine downloads -# under nodeenv. Crucially we do NOT run `prisma generate` at RUNTIME; the -# old migration job did, on every pod start, which is the wasteful behaviour -# the componentization is fixing. -# Copies only the native build's inputs, so the fat-LTO compile stays cached across Python-only changes -FROM $LITELLM_BUILD_IMAGE AS rust-native - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /uvx /usr/local/bin/ - -RUN for i in 1 2 3; do \ - apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl openssl-dev && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -ENV UV_PYTHON_DOWNLOADS=0 - -COPY pyproject.toml README.md rust-toolchain.toml ./ -COPY .cargo ./.cargo -COPY litellm-rust ./litellm-rust -COPY model_prices_and_context_window.json ./ - -RUN --mount=type=cache,target=/root/.cache/uv \ - mkdir -p litellm/rust_bridge /wheels && \ - touch litellm/__init__.py litellm/rust_bridge/__init__.py && \ - uvx --python python3.13 --from maturin==1.15.0 --with puccinialin==0.1.14 \ - python -c "import maturin; maturin.build_wheel('/wheels')" - -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /uvx /usr/local/bin/ - -# nodejs/npm so `prisma generate` uses Wolfi's Node via PRISMA_USE_GLOBAL_NODE -# instead of nodeenv downloading one whose dynamic deps may not be in Wolfi -# (e.g. Node 26.2.0 needs libatomic). Retry for transient apk.cgr.dev flakes. -RUN for i in 1 2 3; do \ - apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl libssl3 openssl-dev libsndfile nodejs npm && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_PYTHON_DOWNLOADS=0 \ - PRISMA_USE_GLOBAL_NODE=true \ - PATH="/app/.venv/bin:${PATH}" - -# Stage 1 — install third-party deps only (cached by pyproject.toml/uv.lock). -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=enterprise/pyproject.toml,target=enterprise/pyproject.toml \ - --mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \ - uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra extra_proxy \ - --python python3.13 - -# Stage 2 — copy source and install the workspace members. -COPY . . - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-install-project --no-default-groups --no-editable \ - --extra proxy \ - --extra extra_proxy \ - --python python3.13 - -RUN --mount=type=bind,from=rust-native,source=/wheels,target=/wheels \ - uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm-*.whl - -RUN cp "$(python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"/litellm/rust_bridge/_native*.so litellm/rust_bridge/ - -COPY migrations/run.py /app/run.py - -# Pre-warm the Prisma binary cache so the Job pod doesn't reach the -# internet on first start. This matches what the backend Dockerfile does: -# `prisma generate` runs nodeenv (downloads Node), installs the prisma npm -# CLI, downloads the engine binaries for each `binaryTarget` in -# schema.prisma, AND emits the generated Python client. We don't need the -# client at runtime — the migration job invokes `prisma migrate deploy` -# via subprocess — but having it cached is harmless and the alternative -# (`prisma py fetch`) doesn't reliably trigger engine downloads. -RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - npm_config_cache=/root/.npm \ - prisma generate --schema=./schema.prisma - -# ---------- Runtime ---------- -FROM $LITELLM_RUNTIME_IMAGE AS runtime - -USER root - -RUN for i in 1 2 3; do \ - apk add --no-cache bash openssl libssl3 tzdata python-3.13 nodejs libsndfile libatomic && break; \ - [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ - sleep 5; \ - done - -# wolfi-base ships an unprivileged `nonroot` account (UID/GID 65532). The -# Prisma engine binaries are dynamically linked against libssl/libcrypto, so -# openssl stays in the runtime layer. -WORKDIR /app -ENV HOME=/home/nonroot \ - PATH="/app/.venv/bin:${PATH}" \ - PYTHONPATH="/app" \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ - PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \ - PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ - PRISMA_OFFLINE_MODE=true - -COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv -COPY --from=builder --chown=nonroot:nonroot /app/litellm /app/litellm -COPY --from=builder --chown=nonroot:nonroot /app/run.py /app/run.py -COPY --from=builder /opt/prisma /opt/prisma - -RUN chmod -R a+rX /opt/prisma && \ - test -x /opt/prisma/binaries/node_modules/.bin/prisma && \ - test -f /opt/prisma/binaries/node_modules/prisma/build/index.js - -USER nonroot - -ENTRYPOINT ["python3", "/app/run.py"] diff --git a/docker/build_from_pip/litellm_config.yaml b/tests/basic_proxy_startup_tests/build_from_pip_config.yaml similarity index 77% rename from docker/build_from_pip/litellm_config.yaml rename to tests/basic_proxy_startup_tests/build_from_pip_config.yaml index f54647853ef..ff0db25e33f 100644 --- a/docker/build_from_pip/litellm_config.yaml +++ b/tests/basic_proxy_startup_tests/build_from_pip_config.yaml @@ -5,5 +5,5 @@ model_list: api_key: fake-key api_base: os.environ/FAKE_OPENAI_API_BASE -general_settings: - alerting: ["slack"] \ No newline at end of file +general_settings: + alerting: ["slack"] diff --git a/tests/e2e/claude_code/cron_vm/Dockerfile b/tests/e2e/claude_code/cron_vm/Dockerfile deleted file mode 100644 index 05c1387c5a2..00000000000 --- a/tests/e2e/claude_code/cron_vm/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 - -ARG GH_VERSION=2.101.0 -ARG GH_SHA256=9bca2d1c16825f109907a23307628a2f0698fbf99662b73a5cf0b020293072b8 -ARG UV_VERSION=0.10.9 -ARG UV_SHA256=20d79708222611fa540b5c9ed84f352bcd3937740e51aacc0f8b15b271c57594 - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl git jq procps iproute2 \ - && rm -rf /var/lib/apt/lists/* - -RUN curl -fsSLo /tmp/gh.tar.gz "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ - && echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - \ - && tar -xzf /tmp/gh.tar.gz -C /usr/local/bin --strip-components=2 "gh_${GH_VERSION}_linux_amd64/bin/gh" \ - && rm /tmp/gh.tar.gz - -RUN curl -fsSLo /tmp/uv.tar.gz "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" \ - && echo "${UV_SHA256} /tmp/uv.tar.gz" | sha256sum -c - \ - && tar -xzf /tmp/uv.tar.gz -C /usr/local/bin --strip-components=1 uv-x86_64-unknown-linux-gnu/uv \ - && rm /tmp/uv.tar.gz - -RUN groupadd --gid 1000 populator && useradd --uid 1000 --gid 1000 --create-home populator - -ENV HOME=/home/populator \ - LITELLM_REPO=/opt/litellm \ - DISABLE_AUTOUPDATER=1 - -COPY --chown=populator:populator . /opt/litellm/tests/e2e/ - -USER populator -WORKDIR /home/populator -CMD ["/opt/litellm/tests/e2e/claude_code/cron_vm/run_daily.sh"] diff --git a/tests/e2e/claude_code/cron_vm/README.md b/tests/e2e/claude_code/cron_vm/README.md index 7451e86e09b..4e47426e860 100644 --- a/tests/e2e/claude_code/cron_vm/README.md +++ b/tests/e2e/claude_code/cron_vm/README.md @@ -1,12 +1,12 @@ # Render cron job for the Claude Code compatibility-matrix populator The populator runs daily as the Render cron job `litellm-compat-matrix` -(Docker runtime, built from the `Dockerfile` in this directory) rather +(Docker runtime; the image recipe is in [Image](#image) below) rather than as a GitHub Action or on a dedicated VM. Trade-offs: -- ✅ No machine to keep on or patch. Render builds the image from this - directory on every push to `main` that touches `tests/e2e/**` and - runs it on the schedule. +- ✅ No machine to keep on or patch. Render builds the image on every + push to `main` that touches `tests/e2e/**` and runs it on the + schedule. - ✅ Credentials live in Render env vars and secret files, scoped to this one service, instead of on a VM filesystem. - ✅ The publish token still uses the `mateo-berri` account, which is a @@ -25,7 +25,6 @@ than as a GitHub Action or on a dedicated VM. Trade-offs: | File | Purpose | | --- | --- | -| `Dockerfile` | The image Render builds: Debian bookworm-slim plus pinned, checksum-verified `gh` and `uv`, with this `tests/e2e/` tree copied to `/opt/litellm/tests/e2e/`. Runs as the non-root user `populator` (uid/gid 1000, which is what Render's secret files are readable by). | | `run_daily.sh` | The actual cron job. Resolves versions, clones the worktree, installs the Claude Code CLI under test, boots the proxy, runs pytest, builds the JSON, opens (or updates) a docs PR, sweeps stale compat-matrix PRs. | | `install_claude_code.sh` | Downloads one Claude Code release (` `) from the vendor's native release channel, verifies it against the sha256 in that release's `manifest.json`, and refuses a binary whose `--version` disagrees. Run by the cron and by the `compat-matrix-image` GitHub workflow. | | `build_matrix.py` | Tiny Python CLI that wraps `claude_code.matrix_builder.build_from_paths`. Exists only because the bash script needs *some* way to render the per-cell aggregation, and the builder is already Python. | @@ -106,7 +105,7 @@ the same values if it ever has to be rebuilt. | Workspace | Litellm (the one that already builds the other litellm services) | | Type | Cron job, Docker runtime | | Repo / branch | `BerriAI/litellm` @ `main` | -| Dockerfile path | `tests/e2e/claude_code/cron_vm/Dockerfile` | +| Image | Built from the recipe under [Image](#image). Render only builds a Dockerfile that lives in the connected repo, and this repo ships exactly one Dockerfile (the LiteLLM image in its root), so the service has to point at a copy of the recipe kept with the service or at a prebuilt image | | Docker build context | `tests/e2e` (the repo root `.dockerignore` excludes `tests`, so the context has to start below it) | | Build filter | included paths `tests/e2e/**` | | Schedule | `0 6 * * *` (06:00 UTC daily) | @@ -117,7 +116,7 @@ the same values if it ever has to be rebuilt. Render mounts secret files at `/etc/secrets/`, which is where `CREDENTIALS_DIRECTORY` and `GOOGLE_APPLICATION_CREDENTIALS` in the env example point. Render also passes env vars to `docker build` as build -args, which is why the `Dockerfile` declares no `ARG` that could ever +args, which is why the image recipe declares no `ARG` that could ever be given a secret's name. Creating it through the API looks like this (fill `envVars` and @@ -145,13 +144,59 @@ curl -fsS https://api.render.com/v1/services \ "plan": "4c-16g", "region": "oregon", "envSpecificDetails": { - "dockerfilePath": "tests/e2e/claude_code/cron_vm/Dockerfile", + "dockerfilePath": "", "dockerContext": "tests/e2e" } } }' ``` +## Image + +Debian bookworm-slim plus pinned, checksum-verified `gh` and `uv`, with +this `tests/e2e/` tree copied to `/opt/litellm/tests/e2e/`. Runs as the +non-root user `populator` (uid/gid 1000, which is what Render's secret +files are readable by). This is the recipe the Render service builds; +it lives with the service because the repo ships exactly one Dockerfile, +the LiteLLM image in its root + +```dockerfile +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 + +ARG GH_VERSION=2.101.0 +ARG GH_SHA256=9bca2d1c16825f109907a23307628a2f0698fbf99662b73a5cf0b020293072b8 +ARG UV_VERSION=0.10.9 +ARG UV_SHA256=20d79708222611fa540b5c9ed84f352bcd3937740e51aacc0f8b15b271c57594 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git jq procps iproute2 \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSLo /tmp/gh.tar.gz "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + && echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gh.tar.gz -C /usr/local/bin --strip-components=2 "gh_${GH_VERSION}_linux_amd64/bin/gh" \ + && rm /tmp/gh.tar.gz + +RUN curl -fsSLo /tmp/uv.tar.gz "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" \ + && echo "${UV_SHA256} /tmp/uv.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/uv.tar.gz -C /usr/local/bin --strip-components=1 uv-x86_64-unknown-linux-gnu/uv \ + && rm /tmp/uv.tar.gz + +RUN groupadd --gid 1000 populator && useradd --uid 1000 --gid 1000 --create-home populator + +ENV HOME=/home/populator \ + LITELLM_REPO=/opt/litellm \ + DISABLE_AUTOUPDATER=1 + +COPY --chown=populator:populator . /opt/litellm/tests/e2e/ + +USER populator +WORKDIR /home/populator +CMD ["/opt/litellm/tests/e2e/claude_code/cron_vm/run_daily.sh"] +``` + ## Operating it ```bash @@ -185,7 +230,7 @@ curl -fsS "https://api.render.com/v1/services/${CRON_ID}/deploys?limit=1" \ # Build and run the image locally (docker on Apple silicon needs the # platform flag; the context is tests/e2e, see the table above). docker build --platform linux/amd64 \ - -f tests/e2e/claude_code/cron_vm/Dockerfile -t compat-matrix tests/e2e + -f /path/to/compat-matrix.Dockerfile -t compat-matrix tests/e2e docker run --rm --platform linux/amd64 \ --env-file litellm-compat-matrix.env -e SKIP_PUBLISH=1 \ -v "$PWD/secrets:/etc/secrets:ro" compat-matrix @@ -234,8 +279,8 @@ docker run --rm --platform linux/amd64 \ A CLI release that breaks a cell shows up as a green→red flip, which withholds auto-merge on that day's docs PR for review. To rerun the matrix on one specific CLI, set `CLAUDE_CODE_VERSION` on the run. - `gh` and `uv` stay pinned in the `Dockerfile`; bump them in a PR with - the checksum from the release's `gh__checksums.txt` and the + `gh` and `uv` stay pinned in the image recipe; bump them with the + checksum from the release's `gh__checksums.txt` and the tarball's `.sha256` sidecar respectively. - **A local build on Apple silicon only proves the image assembles.** Under QEMU the Claude Code binary (a Bun executable) dies with diff --git a/tests/e2e/claude_code/cron_vm/run_daily.sh b/tests/e2e/claude_code/cron_vm/run_daily.sh index 749d5166a34..31b97a9ab7a 100755 --- a/tests/e2e/claude_code/cron_vm/run_daily.sh +++ b/tests/e2e/claude_code/cron_vm/run_daily.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Daily Claude Code compatibility-matrix populator. # -# Runs daily as the Render cron job `litellm-compat-matrix`, built from -# the Dockerfile in this directory (see README.md). The flow is: +# Runs daily as the Render cron job `litellm-compat-matrix` (see +# README.md for the image it runs in). The flow is: # # 1. Resolve the latest LiteLLM final release tag from the GitHub # Releases API. diff --git a/tests/integration/_support/process.py b/tests/integration/_support/process.py index 262114ee32b..491fc87dce2 100644 --- a/tests/integration/_support/process.py +++ b/tests/integration/_support/process.py @@ -945,7 +945,7 @@ def configless_proxy_process( def owned_gateway_image( gateway: Gateway, directory: Path, overrides: Mapping[str, str], *, config: Path, workers: int ) -> Iterator[OwnedProxy]: - """The componentized gateway started the way its image starts it: `docker/component_entrypoint.sh` running + """The componentized gateway started the way its image starts it: `docker-entrypoint.sh` running `python -m gateway.launch`, with the config handed over as `CONFIG_FILE_PATH`. It serves the data plane only, so keys come from a proxy that shares its database.""" root: Final = _proxy_root() @@ -953,7 +953,7 @@ def owned_gateway_image( output: Final = Path(os.environ.get("INTEGRATION_RESULTS_DIR", str(directory))) output.mkdir(parents=True, exist_ok=True) command: Final = ( - str(root / "docker" / "component_entrypoint.sh"), + str(root / "docker-entrypoint.sh"), sys.executable, "-m", "gateway.launch", diff --git a/tests/integration/database/image/test_collector_image_serves_socket.py b/tests/integration/database/image/test_collector_image_serves_socket.py new file mode 100644 index 00000000000..a9b1d128e79 --- /dev/null +++ b/tests/integration/database/image/test_collector_image_serves_socket.py @@ -0,0 +1,174 @@ +import os +import shutil +import subprocess +import time +import uuid +from collections.abc import Iterator +from typing import Final + +import pytest + +from tests._master_key import MASTER_KEY + +IMAGE: Final = os.getenv("LITELLM_IMAGE") +POSTGRES_IMAGE: Final = os.getenv("LITELLM_TEST_POSTGRES_IMAGE", "postgres:16-alpine") +NON_ROOT_UID: Final = "12345:0" +STARTUP_TIMEOUT_SECONDS: Final = int(os.getenv("LITELLM_COMPONENT_STARTUP_TIMEOUT", "180")) + +pytestmark = [ + pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"), + pytest.mark.skipif(shutil.which("docker") is None, reason="requires the docker CLI"), +] + + +def _docker(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(["docker", *args], capture_output=True, text=True, check=check) + + +def _wait_until_postgres_ready(pg: str, attempts: int = 60) -> None: + for _ in range(attempts): + running = _docker( + "ps", + "--filter", + f"name={pg}", + "--filter", + "status=running", + "--format", + "{{.Names}}", + check=False, + ).stdout + if pg not in running: + logs = _docker("logs", pg, check=False) + pytest.fail(f"postgres container is not running:\n{logs.stdout}\n{logs.stderr}") + ready = _docker("exec", pg, "pg_isready", "-U", "postgres", "-d", "litellm", check=False) + if ready.returncode == 0: + return + time.sleep(1) + pytest.fail(f"postgres never became ready after {attempts}s") + + +def _container_logs(container: str) -> str: + logs = _docker("logs", container, check=False) + return f"stdout:\n{logs.stdout}\nstderr:\n{logs.stderr}" + + +def _is_running(container: str) -> bool: + return bool( + _docker( + "ps", + "--filter", + f"name={container}", + "--filter", + "status=running", + "--format", + "{{.Names}}", + check=False, + ).stdout.strip() + ) + + +@pytest.fixture() +def collector_stack(request: pytest.FixtureRequest) -> Iterator[str]: + user, mount_socket_volume = request.param + run_id = f"collectorsock-{uuid.uuid4().hex[:8]}" + network = f"{run_id}-net" + pg = f"{run_id}-pg" + collector = f"{run_id}-app" + volume = f"{run_id}-sock" + + _docker("network", "create", network) + try: + _docker( + "run", + "-d", + "--name", + pg, + "--network", + network, + "-e", + "POSTGRES_PASSWORD=pw", + "-e", + "POSTGRES_DB=litellm", + POSTGRES_IMAGE, + ) + _wait_until_postgres_ready(pg) + assert IMAGE is not None + _docker( + "run", + "--rm", + "--name", + f"{run_id}-migrations", + "--network", + network, + "-e", + f"DATABASE_URL=postgresql://postgres:pw@{pg}:5432/litellm", + IMAGE, + "migrations", + ) + run_args = [ + "run", + "-d", + "--name", + collector, + "--network", + network, + "-e", + f"DATABASE_URL=postgresql://postgres:pw@{pg}:5432/litellm", + "-e", + f"LITELLM_MASTER_KEY={MASTER_KEY}", + "-e", + "LITELLM_LOCAL_MODEL_COST_MAP=True", + "-e", + "DISABLE_SCHEMA_UPDATE=true", + ] + if user is not None: + run_args += ["--user", user] + if mount_socket_volume: + run_args += ["-v", f"{volume}:/var/run/litellm"] + _docker(*run_args, IMAGE, "collector") + yield collector + finally: + _docker("rm", "-f", collector, check=False) + _docker("rm", "-f", pg, check=False) + _docker("volume", "rm", "-f", volume, check=False) + _docker("network", "rm", network, check=False) + + +@pytest.mark.parametrize( + "collector_stack", + [ + pytest.param((None, False), id="default-user-no-mount"), + pytest.param((None, True), id="default-user-named-volume"), + pytest.param((NON_ROOT_UID, False), id="arbitrary-uid-gid0-no-mount"), + ], + indirect=True, +) +def test_collector_creates_its_socket_as_the_image_user(collector_stack: str) -> None: + collector = collector_stack + + deadline = time.time() + STARTUP_TIMEOUT_SECONDS + socket_seen = False + while time.time() < deadline: + if not _is_running(collector): + pytest.fail(f"the collector exited before creating its socket.\n{_container_logs(collector)}") + probe = _docker( + "exec", + collector, + "sh", + "-c", + "test -S /var/run/litellm/collector.sock", + check=False, + ) + if probe.returncode == 0: + socket_seen = True + break + time.sleep(2) + + inspect = _docker("inspect", "-f", "{{.State.Running}}", collector, check=False).stdout.strip() + assert inspect == "true", ( + f"the collector container is not running (inspect State.Running={inspect!r}).\n{_container_logs(collector)}" + ) + assert socket_seen, ( + f"/var/run/litellm/collector.sock never appeared within {STARTUP_TIMEOUT_SECONDS}s.\n" + f"{_container_logs(collector)}" + ) diff --git a/tests/integration/database/image/test_component_image_serves_offline.py b/tests/integration/database/image/test_component_image_serves_offline.py index c2f0d1e5406..bf6cae5ef9d 100644 --- a/tests/integration/database/image/test_component_image_serves_offline.py +++ b/tests/integration/database/image/test_component_image_serves_offline.py @@ -1,4 +1,4 @@ -"""Image-level regression net for the prisma bake in the componentized images. +"""Image-level regression net for the prisma bake in the `gateway` and `backend` components. The gateway and backend serve requests; they never shell out to the Prisma CLI (``PrismaManager.setup_database`` is reachable only from ``proxy_cli.py``, which @@ -37,7 +37,8 @@ from tests._master_key import MASTER_KEY IMAGE = os.getenv("LITELLM_IMAGE") POSTGRES_IMAGE = os.getenv("LITELLM_TEST_POSTGRES_IMAGE", "postgres:16-alpine") CURL_IMAGE = os.getenv("LITELLM_TEST_CURL_IMAGE", "curlimages/curl:8.11.1") -COMPONENT_PORT = os.getenv("LITELLM_COMPONENT_PORT", "4000") +COMPONENT = os.getenv("LITELLM_COMPONENT", "gateway") +COMPONENT_PORT = os.getenv("LITELLM_COMPONENT_PORT", {"gateway": "4000", "backend": "4001"}[COMPONENT]) NON_ROOT_UID = "12345:0" STARTUP_TIMEOUT_SECONDS = int(os.getenv("LITELLM_COMPONENT_STARTUP_TIMEOUT", "180")) @@ -65,7 +66,7 @@ def offline_stack(): The container runs with DISABLE_SCHEMA_UPDATE, since applying the schema is the migration job's responsibility in this topology and needs the Prisma CLI - these images deliberately omit, and with LITELLM_LOCAL_MODEL_COST_MAP, or the + these components never invoke, and with LITELLM_LOCAL_MODEL_COST_MAP, or the proxy spends the whole startup budget timing out on a cost-map fetch over the network it does not have. @@ -94,7 +95,7 @@ def offline_stack(): "-e", f"LITELLM_MASTER_KEY={MASTER_KEY}", "-e", "DISABLE_SCHEMA_UPDATE=true", "-e", "LITELLM_LOCAL_MODEL_COST_MAP=True", - IMAGE, + IMAGE, COMPONENT, ) yield network, component finally: diff --git a/tests/integration/database/image/test_offline_image_migration.py b/tests/integration/database/image/test_offline_image_migration.py index be2f0537576..2463fd0fd99 100644 --- a/tests/integration/database/image/test_offline_image_migration.py +++ b/tests/integration/database/image/test_offline_image_migration.py @@ -1,6 +1,6 @@ """Image-level regression net for the prisma bake in the shipped runtime image. -Boots a built image's migration entrypoint the way an OpenShift / air-gapped +Boots a built image's `migrations` component the way an OpenShift / air-gapped deployment does (an internal-only network with no egress, an arbitrary non-root uid in GID 0) against a brand-new Postgres, and asserts the schema was created. @@ -14,6 +14,7 @@ the normal unit-test run and exercised only where an image has been built (the image-scan workflow). Requires a working docker CLI. """ +import shlex import shutil import subprocess import uuid @@ -26,10 +27,7 @@ POSTGRES_IMAGE = os.getenv("LITELLM_TEST_POSTGRES_IMAGE", "postgres:16-alpine") MIN_TABLES = int(os.getenv("LITELLM_TEST_MIN_TABLES", "20")) NON_ROOT_UID = "12345:0" # arbitrary uid in GID 0, as OpenShift restricted-v2 assigns -MIGRATION_INTERPRETER = os.getenv("LITELLM_MIGRATION_INTERPRETER", "python") -MIGRATION_SCRIPT = os.getenv( - "LITELLM_MIGRATION_SCRIPT", "litellm/proxy/prisma_migration.py" -) +MIGRATION_ARGS = tuple(shlex.split(os.getenv("LITELLM_MIGRATION_ARGS", "migrations"))) pytestmark = [ pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"), @@ -113,8 +111,7 @@ def test_migration_offline_as_non_root_uid(offline_postgres): "-e", f"DATABASE_URL=postgresql://postgres:pw@{pg}:5432/litellm", "-e", "LITELLM_MASTER_KEY=sk-offline-migration-test", "-e", "DISABLE_SCHEMA_UPDATE=false", - "-w", "/app", "--entrypoint", MIGRATION_INTERPRETER, - IMAGE, MIGRATION_SCRIPT, + IMAGE, *MIGRATION_ARGS, check=False, ) tables = _table_count(pg) diff --git a/tests/integration/database/image/test_ui_image_serves_offline.py b/tests/integration/database/image/test_ui_image_serves_offline.py index 5ff68effd7f..b3092887b75 100644 --- a/tests/integration/database/image/test_ui_image_serves_offline.py +++ b/tests/integration/database/image/test_ui_image_serves_offline.py @@ -1,7 +1,7 @@ -"""Image-level regression net for arbitrary-uid boot of the UI image. +"""Image-level regression net for arbitrary-uid boot of the `ui` component. OpenShift ``restricted-v2`` ignores the image ``USER`` and assigns an -arbitrary uid in GID 0. The stock nginx base expects to start as root, so +arbitrary uid in GID 0. A stock nginx install expects to start as root, so its cache (``/var/cache/nginx``) and pid (``/run``) paths are root-owned 755 and the master process dies at startup with ``mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)``. @@ -63,7 +63,7 @@ def ui_container() -> Iterator[tuple[str, str]]: "run", "-d", "--name", container, "--network", network, "--user", ARBITRARY_UID, "--read-only", "--tmpfs", "/tmp", - IMAGE, + IMAGE, "ui", ) yield network, container finally: diff --git a/tests/integration/observability/test_prometheus_series_cap_chaos.py b/tests/integration/observability/test_prometheus_series_cap_chaos.py index c62c5b4fb0f..fd4e5e9a673 100644 --- a/tests/integration/observability/test_prometheus_series_cap_chaos.py +++ b/tests/integration/observability/test_prometheus_series_cap_chaos.py @@ -422,7 +422,7 @@ def _fill_the_cap(image: CapRig, cell: str) -> frozenset[str]: @pytest.mark.timeout(420) def test_gateway_image_restart_on_a_kept_directory_starts_the_cap_over(tmp_path: Path) -> None: - """D1: the gateway image's launcher (`docker/component_entrypoint.sh` running `python -m gateway.launch`, two + """D1: the gateway image's launcher (`docker-entrypoint.sh` running `python -m gateway.launch`, two workers) restarted on a kept PROMETHEUS_MULTIPROC_DIR: the entrypoint removes the previous container's samples and admitted series before the workers fork, so the second boot shows only its own three keys and a fourth lands on `other`.""" diff --git a/tests/integration/run.py b/tests/integration/run.py index 7ab8d59c02c..b28d9f4e0c6 100644 --- a/tests/integration/run.py +++ b/tests/integration/run.py @@ -28,6 +28,7 @@ GROUPS: Final = MappingProxyType( GITHUB_FILES: Final = frozenset( { "tests/integration/database/test_roi_observed.py", + "tests/integration/database/image/test_collector_image_serves_socket.py", "tests/integration/database/image/test_component_image_serves_offline.py", "tests/integration/database/image/test_image_admin_mcp.py", "tests/integration/database/image/test_image_bedrock_realtime_extra.py", diff --git a/tests/unit/test_component_entrypoint.py b/tests/unit/test_component_entrypoint.py deleted file mode 100644 index 335c7e54416..00000000000 --- a/tests/unit/test_component_entrypoint.py +++ /dev/null @@ -1,491 +0,0 @@ -"""Unit tests for `docker/component_entrypoint.sh` and its wiring into the -componentized `gateway` / `backend` images and Terraform deployments.""" - -import json -import os -import re -import stat -import subprocess -from pathlib import Path - -import pytest - -from litellm.constants import PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX - -REPO_ROOT = Path(__file__).resolve().parents[2] -COMPONENT_ENTRYPOINT = REPO_ROOT / "docker" / "component_entrypoint.sh" -PROD_ENTRYPOINT = REPO_ROOT / "docker" / "prod_entrypoint.sh" -GATEWAY_DOCKERFILE = REPO_ROOT / "gateway" / "Dockerfile" -BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile" -BUILD_FROM_PIP_DOCKERFILE = REPO_ROOT / "docker" / "build_from_pip" / "Dockerfile.build_from_pip" -TERRAFORM_ECS = REPO_ROOT / "terraform" / "litellm" / "aws" / "ecs.tf" -TERRAFORM_CLOUDRUN = REPO_ROOT / "terraform" / "litellm" / "gcp" / "cloudrun.tf" - -IMAGE_ENTRYPOINT_PATH = "/app/docker/component_entrypoint.sh" - -TRUTHY_USE_DDTRACE = ("true", "True", "TRUE", "tRuE") -FALSY_USE_DDTRACE = (None, "", "false", "False", "1", "yes", "on", "truex") - -PYTHONPATH_SENTINEL = "/lit-entrypoint-sentinel:/app" - -_STUB_TEMPLATE = """#!/bin/sh -{{ - echo "exec={name}" - echo "args=$*" - echo "DD_TRACE_OPENAI_ENABLED=${{DD_TRACE_OPENAI_ENABLED-}}" - echo "PYTHONPATH=${{PYTHONPATH-}}" -}} >> "$RECORD" -""" - -_ENTRYPOINT_RE = re.compile(r"^ENTRYPOINT\s+(\[.*\])\s*$", re.MULTILINE) -_CMD_RE = re.compile(r"^CMD\s+(\[.*\])\s*$", re.MULTILINE) -_COPY_RE = re.compile(r"^COPY\s+(?!--from)(\S+)\s+(\S+)\s*$", re.MULTILINE) -_APP_TARGET_RE = re.compile(r"(?:gateway|backend)\.main:app|gateway\.launch") -_TF_STRING_LOCAL_RE = re.compile(r'^\s*(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$', re.MULTILINE) -_TF_INTERPOLATION_RE = re.compile(r"\$\{(local|var)\.(\w+)\}") - -TERRAFORM_LAUNCH_SITES = {TERRAFORM_ECS: 2, TERRAFORM_CLOUDRUN: 2} -TERRAFORM_VAR_STUBS = {"gateway_num_workers": "2"} -COMPONENT_LAUNCHERS = { - "gateway": ("python", "-m", "gateway.launch"), - "backend": ("uvicorn", "backend.main:app"), -} -_MAX_INTERPOLATION_PASSES = 5 - - -def _write_stubs(bin_dir: Path, names: tuple[str, ...]) -> None: - for name in names: - stub = bin_dir / name - stub.write_text(_STUB_TEMPLATE.format(name=name)) - stub.chmod(0o755) - - -def _run_entrypoint( - script: Path, - argv: tuple[str, ...], - use_ddtrace: str | None, - tmp_path: Path, -) -> tuple[str, ...]: - """Run `script` with stubbed executables on PATH and return the recorded lines.""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir(parents=True) - _write_stubs(bin_dir, ("ddtrace-run", "uvicorn", "python", "litellm")) - record = tmp_path / "record.txt" - - env = { - **os.environ, - "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", - "RECORD": str(record), - "PYTHONPATH": PYTHONPATH_SENTINEL, - } - env.pop("USE_DDTRACE", None) - env.pop("DD_TRACE_OPENAI_ENABLED", None) - if use_ddtrace is not None: - env["USE_DDTRACE"] = use_ddtrace - - result = subprocess.run( - ["sh", str(script), *argv], - env=env, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" - return tuple(record.read_text().splitlines()) if record.exists() else () - - -def _run_shell_command(command: str, bin_dir: Path, record: Path, use_ddtrace: str | None) -> tuple[str, ...]: - """Run a resolved Terraform launch command through `sh -c` and return the recorded lines.""" - env = { - **os.environ, - "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", - "RECORD": str(record), - "PYTHONPATH": PYTHONPATH_SENTINEL, - } - env.pop("USE_DDTRACE", None) - env.pop("DD_TRACE_OPENAI_ENABLED", None) - if use_ddtrace is not None: - env["USE_DDTRACE"] = use_ddtrace - - result = subprocess.run(["sh", "-c", command], env=env, capture_output=True, text=True, check=False) - assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" - return tuple(record.read_text().splitlines()) if record.exists() else () - - -def _resolve_tf_local(terraform_file: Path, name: str) -> str: - """Read a string local out of a .tf file and expand its `local.` / `var.` interpolations.""" - values = { - m.group(1): m.group(2).replace('\\"', '"') for m in _TF_STRING_LOCAL_RE.finditer(terraform_file.read_text()) - } - - assert name in values, f"{terraform_file} defines no {name} local" - resolved = values[name] - for _ in range(_MAX_INTERPOLATION_PASSES): - if "${" not in resolved: - return resolved - resolved = _TF_INTERPOLATION_RE.sub( - lambda m: values[m.group(2)] if m.group(1) == "local" else TERRAFORM_VAR_STUBS[m.group(2)], - resolved, - ) - raise AssertionError(f"{terraform_file}:{name} still has unresolved interpolations: {resolved}") - - -def _entrypoint_argv(dockerfile: Path) -> tuple[str, ...]: - matches = _ENTRYPOINT_RE.findall(dockerfile.read_text()) - assert matches, f"no exec-form ENTRYPOINT found in {dockerfile}" - parsed = json.loads(matches[-1]) - return tuple(str(part) for part in parsed) - - -def _cmd_argv(dockerfile: Path) -> tuple[str, ...]: - matches = _CMD_RE.findall(dockerfile.read_text()) - assert matches, f"no exec-form CMD found in {dockerfile}" - return tuple(str(part) for part in json.loads(matches[-1])) - - -def test_ddtrace_enabled_wraps_the_command_and_disables_the_openai_integration( - tmp_path: Path, -) -> None: - """`USE_DDTRACE=true` must prefix the command with `ddtrace-run`, turn the openai - integration off, and leave PYTHONPATH alone. - - `ddtrace-run` installs its instrumentation by PREPENDING a bootstrap directory to - PYTHONPATH, and the images set PYTHONPATH=/app so the app package is importable. A - wrapper that assigned PYTHONPATH instead of inheriting it would either drop the - bootstrap (silently disabling tracing) or drop /app (breaking the import), so the - recorded value is asserted verbatim. - - PYTHONPATH_SENTINEL deliberately differs from the images' own /app: with /app as the - fixture value, a wrapper that overwrote PYTHONPATH with /app would still satisfy this - assertion and the check would prove nothing. - - The openai integration is disabled because under `ddtrace-run` the bootstrap patches - it before any litellm code runs, so litellm's in-process `patch_all(..., openai=False)` - can no longer suppress it; leaving it on double-reports every LLM call. - """ - recorded = _run_entrypoint( - COMPONENT_ENTRYPOINT, - ("uvicorn", "gateway.main:app", "--workers", "2", "--port", "4000"), - use_ddtrace="true", - tmp_path=tmp_path, - ) - - assert recorded == ( - "exec=ddtrace-run", - "args=uvicorn gateway.main:app --workers 2 --port 4000", - "DD_TRACE_OPENAI_ENABLED=False", - f"PYTHONPATH={PYTHONPATH_SENTINEL}", - ) - - -def test_ddtrace_disabled_execs_the_command_directly(tmp_path: Path) -> None: - recorded = _run_entrypoint( - COMPONENT_ENTRYPOINT, - ("uvicorn", "backend.main:app", "--port", "4001"), - use_ddtrace=None, - tmp_path=tmp_path, - ) - - assert recorded == ( - "exec=uvicorn", - "args=backend.main:app --port 4001", - "DD_TRACE_OPENAI_ENABLED=", - f"PYTHONPATH={PYTHONPATH_SENTINEL}", - ) - - -@pytest.mark.parametrize( - "use_ddtrace, traced", - [*((v, True) for v in TRUTHY_USE_DDTRACE), *((v, False) for v in FALSY_USE_DDTRACE)], -) -def test_gating_matches_the_monolithic_entrypoint_and_get_secret_bool( - use_ddtrace: str | None, traced: bool, tmp_path: Path -) -> None: - """Both entrypoints must accept exactly the spellings `get_secret_bool` accepts. - - `ProxyStartupEvent._init_dd_tracer` reads `USE_DDTRACE` through `get_secret_bool`, which - matches `true` case-insensitively. If the shell gate were stricter, `USE_DDTRACE=True` would - give in-process LLM spans without `ddtrace-run` HTTP spans, a half-enabled state. - """ - component = _run_entrypoint( - COMPONENT_ENTRYPOINT, - ("uvicorn", "gateway.main:app"), - use_ddtrace=use_ddtrace, - tmp_path=tmp_path / "component", - ) - monolith = _run_entrypoint( - PROD_ENTRYPOINT, - ("--port", "4000"), - use_ddtrace=use_ddtrace, - tmp_path=tmp_path / "monolith", - ) - - expected_exec = "exec=ddtrace-run" if traced else "exec=uvicorn" - expected_openai = "DD_TRACE_OPENAI_ENABLED=False" if traced else "DD_TRACE_OPENAI_ENABLED=" - assert component[0] == expected_exec - assert component[2] == expected_openai - assert monolith[0] == ("exec=ddtrace-run" if traced else "exec=litellm") - assert monolith[2] == expected_openai - assert monolith[1] == ("args=litellm --port 4000" if traced else "args=--port 4000") - - -def test_wipes_the_prometheus_multiproc_dir_before_uvicorn_forks(tmp_path: Path) -> None: - """A restarted container inherits the emptyDir of its predecessor, whose worker pids it may reuse, so the - stale .db files must be gone before any worker opens the one carrying its own pid. The admitted-series - files go with them, or the restarted workers would keep counting new label sets on `other` for the - label sets the previous container admitted.""" - multiproc_dir = tmp_path / "multiproc" - multiproc_dir.mkdir() - (multiproc_dir / "gauge_livesum_7.db").write_bytes(b"stale") - (multiproc_dir / "counter_7.db").write_bytes(b"stale") - (multiproc_dir / f"{PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX}litellm_requests_metric").write_bytes(b"stale") - (multiproc_dir / "keep.txt").write_text("not a sample") - - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - _write_stubs(bin_dir, ("uvicorn",)) - record = tmp_path / "record.txt" - env = { - **os.environ, - "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", - "RECORD": str(record), - "PROMETHEUS_MULTIPROC_DIR": str(multiproc_dir), - } - env.pop("USE_DDTRACE", None) - result = subprocess.run( - ["sh", str(COMPONENT_ENTRYPOINT), "uvicorn", "gateway.main:app"], - env=env, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" - assert sorted(p.name for p in multiproc_dir.iterdir()) == ["keep.txt"] - assert record.read_text().splitlines()[0] == "exec=uvicorn" - - -def test_creates_a_missing_prometheus_multiproc_dir(tmp_path: Path) -> None: - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - _write_stubs(bin_dir, ("uvicorn",)) - missing = tmp_path / "multiproc" - env = { - **os.environ, - "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", - "RECORD": str(tmp_path / "record.txt"), - "PROMETHEUS_MULTIPROC_DIR": str(missing), - } - env.pop("USE_DDTRACE", None) - result = subprocess.run( - ["sh", str(COMPONENT_ENTRYPOINT), "uvicorn", "gateway.main:app"], env=env, capture_output=True, text=True - ) - - assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" - assert missing.is_dir() - - -def _copied_script(dockerfile: Path, image_path: str) -> Path: - """Resolve the repo file a Dockerfile `COPY`s to `image_path`, so tests run what the image ships.""" - matches = _COPY_RE.findall(dockerfile.read_text()) - sources = tuple(src for src, dst in matches if dst == image_path) - assert sources, f"{dockerfile} never COPYs anything to {image_path}" - source = REPO_ROOT / sources[-1] - assert source.is_file(), f"{dockerfile} COPYs {sources[-1]}, which does not exist in the build context" - return source - - -@pytest.mark.parametrize( - "use_ddtrace, traced", - [*((v, True) for v in TRUTHY_USE_DDTRACE), *((v, False) for v in FALSY_USE_DDTRACE)], -) -def test_build_from_pip_image_launches_litellm_through_the_prod_entrypoint( - use_ddtrace: str | None, traced: bool, tmp_path: Path -) -> None: - """Run the build_from_pip image's ENTRYPOINT + CMD through the script it actually COPYs. - - The image used to `ENTRYPOINT ["litellm"]`, so `USE_DDTRACE` was inert there at any spelling. - Resolving the ENTRYPOINT path back to its COPY source and executing it with the Dockerfile's - CMD checks the launch the container performs, not just that the Dockerfile mentions the script. - """ - entrypoint = _entrypoint_argv(BUILD_FROM_PIP_DOCKERFILE) - assert len(entrypoint) == 1, ( - f"{BUILD_FROM_PIP_DOCKERFILE} ENTRYPOINT must be the bare script so CMD reaches litellm" - ) - script = _copied_script(BUILD_FROM_PIP_DOCKERFILE, entrypoint[0]) - assert script == PROD_ENTRYPOINT, f"{BUILD_FROM_PIP_DOCKERFILE} bypasses the ddtrace-aware entrypoint" - assert f"chmod +x {entrypoint[0]}" in BUILD_FROM_PIP_DOCKERFILE.read_text() - - cmd = _cmd_argv(BUILD_FROM_PIP_DOCKERFILE) - recorded = _run_entrypoint(script, cmd, use_ddtrace=use_ddtrace, tmp_path=tmp_path) - - cmd_str = " ".join(cmd) - assert recorded == ( - "exec=ddtrace-run" if traced else "exec=litellm", - f"args=litellm {cmd_str}" if traced else f"args={cmd_str}", - "DD_TRACE_OPENAI_ENABLED=False" if traced else "DD_TRACE_OPENAI_ENABLED=", - f"PYTHONPATH={PYTHONPATH_SENTINEL}", - ) - - -def test_entrypoint_script_is_executable() -> None: - mode = COMPONENT_ENTRYPOINT.stat().st_mode - assert mode & stat.S_IXUSR, "entrypoint must be committed executable to run as the image ENTRYPOINT" - assert mode & stat.S_IXOTH, "entrypoint must be executable by the unprivileged `nonroot` user" - - -def test_entrypoint_script_has_no_carriage_returns() -> None: - assert b"\r" not in COMPONENT_ENTRYPOINT.read_bytes() - - -@pytest.mark.parametrize( - "dockerfile, launcher", - [ - (GATEWAY_DOCKERFILE, "python -m gateway.launch"), - (BACKEND_DOCKERFILE, "uvicorn backend.main:app"), - ], -) -def test_component_images_launch_uvicorn_through_the_entrypoint(dockerfile: Path, launcher: str) -> None: - entrypoint = " ".join(_entrypoint_argv(dockerfile)) - - assert IMAGE_ENTRYPOINT_PATH in entrypoint, f"{dockerfile} bypasses the ddtrace-aware entrypoint" - assert launcher in entrypoint - assert entrypoint.index(IMAGE_ENTRYPOINT_PATH) < entrypoint.index(launcher), ( - f"{dockerfile} must invoke uvicorn through the entrypoint, not the other way around" - ) - - -@pytest.mark.parametrize( - "use_ddtrace, num_workers, expected_exec, expected_args", - [ - (None, "4", "exec=python", "args=-m gateway.launch --workers 4 --host 0.0.0.0 --port 4000"), - (None, None, "exec=python", "args=-m gateway.launch --workers 1 --host 0.0.0.0 --port 4000"), - ("true", "4", "exec=ddtrace-run", "args=python -m gateway.launch --workers 4 --host 0.0.0.0 --port 4000"), - ], -) -def test_gateway_image_execs_the_supervisor_with_its_worker_count( - use_ddtrace: str | None, num_workers: str | None, expected_exec: str, expected_args: str, tmp_path: Path -) -> None: - """Run the gateway image's ENTRYPOINT + CMD and record what the container execs. - - The Dockerfile's `/app/...` script path is resolved to the checked-in script and `python` - is stubbed on PATH, so the assertion is on the argv `gateway.launch` receives, not on the - Dockerfile text. - """ - entrypoint = tuple( - part.replace(IMAGE_ENTRYPOINT_PATH, str(COMPONENT_ENTRYPOINT)) for part in _entrypoint_argv(GATEWAY_DOCKERFILE) - ) - bin_dir = tmp_path / "bin" - bin_dir.mkdir(parents=True) - _write_stubs(bin_dir, ("ddtrace-run", "python", "uvicorn")) - record = tmp_path / "record.txt" - overrides = {"USE_DDTRACE": use_ddtrace, "NUM_WORKERS": num_workers} - env = { - **{k: v for k, v in os.environ.items() if k not in ("DD_TRACE_OPENAI_ENABLED", *overrides)}, - **{k: v for k, v in overrides.items() if v is not None}, - "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", - "RECORD": str(record), - "PYTHONPATH": PYTHONPATH_SENTINEL, - } - - result = subprocess.run( - [*entrypoint, *_cmd_argv(GATEWAY_DOCKERFILE)], env=env, capture_output=True, text=True, check=False - ) - - assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" - assert tuple(record.read_text().splitlines())[:2] == (expected_exec, expected_args) - - -@pytest.mark.parametrize("dockerfile", [GATEWAY_DOCKERFILE, BACKEND_DOCKERFILE]) -def test_component_images_make_the_entrypoint_executable(dockerfile: Path) -> None: - body = dockerfile.read_text() - assert "chmod +x docker/component_entrypoint.sh" in body - - -@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) -@pytest.mark.parametrize("component", ["gateway", "backend"]) -@pytest.mark.parametrize("use_ddtrace", [*TRUTHY_USE_DDTRACE, *FALSY_USE_DDTRACE]) -def test_terraform_launch_command_matches_the_script_contract( - terraform_file: Path, component: str, use_ddtrace: str | None, tmp_path: Path -) -> None: - """The Terraform command and `docker/component_entrypoint.sh` must decide identically. - - The decision deliberately lives in two places. The script is what the image ENTRYPOINT runs; - the Terraform strings are what runs when a deployment overrides that ENTRYPOINT, and they - cannot call the script because the caller supplies the image tag and it may predate the file. - Both modules default to a tag that does. So instead of asserting a shared path, this runs both - implementations under the same environment and asserts they agree on which binary is exec'd - and on whether the openai integration is disabled. - """ - launcher = COMPONENT_LAUNCHERS[component] - app_target = " ".join(launcher[1:]) - command = _resolve_tf_local(terraform_file, f"{component}_launch_cmd") - - bin_dir = tmp_path / "bin" - bin_dir.mkdir(parents=True) - _write_stubs(bin_dir, ("ddtrace-run", "uvicorn", "python")) - from_terraform = _run_shell_command(command, bin_dir, tmp_path / "terraform.txt", use_ddtrace) - - from_script = _run_entrypoint( - COMPONENT_ENTRYPOINT, - launcher, - use_ddtrace=use_ddtrace, - tmp_path=tmp_path / "script", - ) - - assert from_terraform[0] == from_script[0], ( - f"{terraform_file} disagrees with the script on USE_DDTRACE={use_ddtrace}" - ) - assert from_terraform[2] == from_script[2], f"{terraform_file} disagrees with the script on the openai integration" - assert app_target in from_terraform[1] - assert "gateway.main:app" not in from_terraform[1], f"{terraform_file} bypasses the gateway.launch supervisor" - - if use_ddtrace in TRUTHY_USE_DDTRACE: - assert from_terraform[0] == "exec=ddtrace-run" - assert from_terraform[1].startswith(f"args={launcher[0]} ") - assert from_terraform[2] == "DD_TRACE_OPENAI_ENABLED=False" - else: - assert from_terraform[0] == f"exec={launcher[0]}" - assert from_terraform[2] == "DD_TRACE_OPENAI_ENABLED=" - - -@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) -def test_terraform_routes_every_launch_site_through_a_traced_command(terraform_file: Path) -> None: - """Every place Terraform names a component ASGI target has to honor `USE_DDTRACE`. - - The count is pinned as well: a launch site that is deleted or renamed would otherwise drop - out of the scan and let this pass while covering less than it claims. - """ - launch_sites = tuple(line for line in terraform_file.read_text().splitlines() if _APP_TARGET_RE.search(line)) - - assert len(launch_sites) == TERRAFORM_LAUNCH_SITES[terraform_file], ( - f"{terraform_file} launch-site count changed; re-check each one honors USE_DDTRACE" - ) - for line in launch_sites: - assert "ddtrace-run" in line, f"{terraform_file} launches uvicorn without honoring USE_DDTRACE: {line.strip()}" - - body = terraform_file.read_text() - for component in ("gateway", "backend"): - assert f"local.{component}_launch_cmd" in body, ( - f"{terraform_file} defines a {component} launch command but never uses it" - ) - - -@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) -def test_terraform_does_not_depend_on_the_entrypoint_script(terraform_file: Path) -> None: - """Terraform must not reference a file the caller-supplied image may not contain. - - Both modules default to an image tag published before the script existed, so exec'ing that - path would fail at container start rather than degrade to an untraced process. - """ - assert IMAGE_ENTRYPOINT_PATH not in terraform_file.read_text(), ( - f"{terraform_file} depends on a script that images predating it do not ship" - ) - - -def test_gateway_keeps_its_worker_count_and_backend_keeps_a_single_process() -> None: - gateway = " ".join(_entrypoint_argv(GATEWAY_DOCKERFILE)) + " " + " ".join(_cmd_argv(GATEWAY_DOCKERFILE)) - backend = " ".join(_entrypoint_argv(BACKEND_DOCKERFILE)) + " " + " ".join(_cmd_argv(BACKEND_DOCKERFILE)) - - assert "--workers" in gateway and "NUM_WORKERS" in gateway - assert "--workers" not in backend and "NUM_WORKERS" not in backend diff --git a/tests/unit/test_docker_entrypoint.py b/tests/unit/test_docker_entrypoint.py new file mode 100644 index 00000000000..1d8da4a9b62 --- /dev/null +++ b/tests/unit/test_docker_entrypoint.py @@ -0,0 +1,557 @@ +import json +import os +import re +import shlex +import stat +import subprocess +from pathlib import Path +from typing import Final + +import pytest + +from litellm.constants import PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX + +REPO_ROOT: Final = Path(__file__).resolve().parents[2] +DOCKER_ENTRYPOINT: Final = REPO_ROOT / "docker-entrypoint.sh" +DOCKERFILE: Final = REPO_ROOT / "Dockerfile" +TERRAFORM_ECS: Final = REPO_ROOT / "terraform" / "litellm" / "aws" / "ecs.tf" +TERRAFORM_CLOUDRUN: Final = REPO_ROOT / "terraform" / "litellm" / "gcp" / "cloudrun.tf" + +IMAGE_ENTRYPOINT_PATH: Final = "/app/docker-entrypoint.sh" +STUBBED_EXECUTABLES: Final = ("ddtrace-run", "uvicorn", "python", "litellm", "nginx") + +TRUTHY_USE_DDTRACE: Final = ("true", "True", "TRUE", "tRuE") +FALSY_USE_DDTRACE: Final = (None, "", "false", "False", "1", "yes", "on", "truex") + +PYTHONPATH_SENTINEL: Final = "/lit-entrypoint-sentinel:/app" + +_STUB_TEMPLATE: Final = """#!/bin/sh +{{ + echo "exec={name}" + echo "args=$*" + echo "DD_TRACE_OPENAI_ENABLED=${{DD_TRACE_OPENAI_ENABLED-}}" + echo "PYTHONPATH=${{PYTHONPATH-}}" +}} >> "$RECORD" +""" + +_ENTRYPOINT_RE: Final = re.compile(r"^ENTRYPOINT\s+(\[.*\])\s*$", re.MULTILINE) +_CMD_RE: Final = re.compile(r"^CMD\s+(\[.*\])\s*$", re.MULTILINE) +_APP_TARGET_RE: Final = re.compile(r"(?:gateway|backend)\.main:app|gateway\.launch") +_TF_STRING_LOCAL_RE: Final = re.compile(r'^\s*(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$', re.MULTILINE) +_TF_INTERPOLATION_RE: Final = re.compile(r"\$\{(local|var)\.(\w+)\}") + +TERRAFORM_LAUNCH_SITES: Final = {TERRAFORM_ECS: 2, TERRAFORM_CLOUDRUN: 2} +TERRAFORM_VAR_STUBS: Final = {"gateway_num_workers": "2"} +COMPONENT_LAUNCHERS: Final = { + "gateway": ("python", "-m", "gateway.launch"), + "backend": ("uvicorn", "backend.main:app"), +} +_MAX_INTERPOLATION_PASSES: Final = 5 + + +def _write_stubs(bin_dir: Path, names: tuple[str, ...]) -> None: + for name in names: + stub = bin_dir / name + stub.write_text(_STUB_TEMPLATE.format(name=name)) + stub.chmod(0o755) + + +def _entrypoint_env(bin_dir: Path, record: Path, overrides: dict[str, str | None]) -> dict[str, str]: + cleared = ("USE_DDTRACE", "DD_TRACE_OPENAI_ENABLED", "LITELLM_COMPONENT", "NUM_WORKERS", *overrides) + return { + **{k: v for k, v in os.environ.items() if k not in cleared}, + **{k: v for k, v in overrides.items() if v is not None}, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "RECORD": str(record), + "PYTHONPATH": PYTHONPATH_SENTINEL, + } + + +def _run_entrypoint( + argv: tuple[str, ...], + tmp_path: Path, + use_ddtrace: str | None = None, + *, + entrypoint: Path = DOCKER_ENTRYPOINT, + **overrides: str | None, +) -> tuple[str, ...]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + _write_stubs(bin_dir, STUBBED_EXECUTABLES) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {"USE_DDTRACE": use_ddtrace, **overrides}) + + result = subprocess.run(["sh", str(entrypoint), *argv], env=env, capture_output=True, text=True, check=False) + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + return tuple(record.read_text().splitlines()) if record.exists() else () + + +def _entrypoint_with_runtime_config(tmp_path: Path) -> tuple[Path, Path]: + entrypoint: Final = tmp_path / "docker-entrypoint.sh" + entrypoint.write_text(DOCKER_ENTRYPOINT.read_text()) + hook: Final = tmp_path / "ui" / "docker-entrypoint.d" / "40-litellm-runtime-config.sh" + hook.parent.mkdir(parents=True) + runtime_dir: Final = tmp_path / "runtime" + runtime_dir.mkdir() + hook.write_text( + (REPO_ROOT / "ui" / "docker-entrypoint.d" / hook.name) + .read_text() + .replace("runtime_dir=/tmp/litellm-ui", f"runtime_dir={shlex.quote(str(runtime_dir))}") + ) + return entrypoint, runtime_dir / "runtime-config.json" + + +@pytest.mark.parametrize("backend_url", ("https://backend.example.com///", "http://backend.example.com", None, "", "invalid")) +def test_ui_runtime_config_is_refreshed_before_nginx_starts(tmp_path: Path, backend_url: str | None) -> None: + entrypoint, runtime_file = _entrypoint_with_runtime_config(tmp_path) + runtime_file.write_text('{"backend_url":"https://stale.example.com"}') + recorded: Final = _run_entrypoint(("ui",), tmp_path, entrypoint=entrypoint, LITELLM_BACKEND_URL=backend_url) + assert recorded[0] == "exec=nginx" + assert (json.loads(runtime_file.read_text()) if runtime_file.exists() else None) == ( + {"backend_url": backend_url.rstrip("/")} if backend_url and backend_url.startswith(("https://", "http://")) else None + ) + + +def test_backend_leaves_ui_runtime_config_unchanged(tmp_path: Path) -> None: + entrypoint, runtime_file = _entrypoint_with_runtime_config(tmp_path) + runtime_file.write_text('{"backend_url":"https://stale.example.com"}') + recorded: Final = _run_entrypoint( + ("backend",), tmp_path, entrypoint=entrypoint, LITELLM_BACKEND_URL="https://new.example.com" + ) + assert recorded[0] == "exec=uvicorn" + assert json.loads(runtime_file.read_text()) == {"backend_url": "https://stale.example.com"} + + +def _run_shell_command(command: str, bin_dir: Path, record: Path, use_ddtrace: str | None) -> tuple[str, ...]: + env = _entrypoint_env(bin_dir, record, {"USE_DDTRACE": use_ddtrace}) + result = subprocess.run(["sh", "-c", command], env=env, capture_output=True, text=True, check=False) + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + return tuple(record.read_text().splitlines()) if record.exists() else () + + +def _resolve_tf_local(terraform_file: Path, name: str) -> str: + values = { + m.group(1): m.group(2).replace('\\"', '"') for m in _TF_STRING_LOCAL_RE.finditer(terraform_file.read_text()) + } + + assert name in values, f"{terraform_file} defines no {name} local" + resolved = values[name] + for _ in range(_MAX_INTERPOLATION_PASSES): + if "${" not in resolved: + return resolved + resolved = _TF_INTERPOLATION_RE.sub( + lambda m: values[m.group(2)] if m.group(1) == "local" else TERRAFORM_VAR_STUBS[m.group(2)], + resolved, + ) + raise AssertionError(f"{terraform_file}:{name} still has unresolved interpolations: {resolved}") + + +def _entrypoint_argv(dockerfile: Path) -> tuple[str, ...]: + matches = _ENTRYPOINT_RE.findall(dockerfile.read_text()) + assert matches, f"no exec-form ENTRYPOINT found in {dockerfile}" + return tuple(str(part) for part in json.loads(matches[-1])) + + +@pytest.mark.parametrize( + "argv, expected_exec, expected_args", + [ + (("proxy", "--config", "/app/config.yaml"), "exec=litellm", "args=--config /app/config.yaml"), + (("gateway",), "exec=python", "args=-m gateway.launch --workers 1 --host 0.0.0.0 --port 4000"), + ( + ("gateway", "--port", "8080"), + "exec=python", + "args=-m gateway.launch --workers 1 --host 0.0.0.0 --port 4000 --port 8080", + ), + (("backend",), "exec=uvicorn", "args=backend.main:app --host 0.0.0.0 --port 4001"), + (("backend", "--port", "9001"), "exec=uvicorn", "args=backend.main:app --host 0.0.0.0 --port 4001 --port 9001"), + (("ui",), "exec=nginx", "args=-g daemon off;"), + (("migrations",), "exec=python", "args=/app/migrations/run.py"), + (("metrics", "--port", "9090"), "exec=python", "args=-m litellm.proxy.prometheus_metrics_server --port 9090"), + (("collector",), "exec=python", "args=-m litellm.proxy.collector"), + ], +) +def test_the_first_argument_selects_the_component( + argv: tuple[str, ...], expected_exec: str, expected_args: str, tmp_path: Path +) -> None: + recorded = _run_entrypoint(argv, tmp_path) + + assert recorded[:2] == (expected_exec, expected_args) + + +@pytest.mark.parametrize( + "argv, expected", + [ + ((), ("exec=litellm", "args=")), + (("--port", "4000"), ("exec=litellm", "args=--port 4000")), + ( + ("--config", "/app/config.yaml", "--detailed_debug"), + ("exec=litellm", "args=--config /app/config.yaml --detailed_debug"), + ), + ], +) +def test_flags_alone_still_run_the_monolithic_proxy( + argv: tuple[str, ...], expected: tuple[str, str], tmp_path: Path +) -> None: + assert _run_entrypoint(argv, tmp_path)[:2] == expected + + +def test_the_dockerfile_leaves_the_command_empty_so_the_env_var_can_pick_the_component(tmp_path: Path) -> None: + dockerfile_text = DOCKERFILE.read_text() + assert _entrypoint_argv(DOCKERFILE) == (IMAGE_ENTRYPOINT_PATH,), "the image ENTRYPOINT must be the bare dispatcher" + assert not _CMD_RE.search(dockerfile_text), "the Dockerfile must not set a CMD" + + assert _run_entrypoint((), tmp_path)[:2] == ("exec=litellm", "args=") + + +@pytest.mark.parametrize( + "component, argv, expected_exec, expected_args", + [ + ("backend", (), "exec=uvicorn", "args=backend.main:app --host 0.0.0.0 --port 4001"), + ( + "gateway", + ("--port", "4100"), + "exec=python", + "args=-m gateway.launch --workers 1 --host 0.0.0.0 --port 4000 --port 4100", + ), + ("ui", (), "exec=nginx", "args=-g daemon off;"), + ], +) +def test_litellm_component_env_selects_the_component_when_the_command_has_none( + component: str, argv: tuple[str, ...], expected_exec: str, expected_args: str, tmp_path: Path +) -> None: + recorded = _run_entrypoint(argv, tmp_path, LITELLM_COMPONENT=component) + + assert recorded[:2] == (expected_exec, expected_args) + + +def test_an_explicit_component_argument_beats_the_env_var(tmp_path: Path) -> None: + recorded = _run_entrypoint(("backend",), tmp_path, LITELLM_COMPONENT="gateway") + + assert recorded[0] == "exec=uvicorn" + + +def test_the_gateway_honours_num_workers(tmp_path: Path) -> None: + recorded = _run_entrypoint(("gateway",), tmp_path, NUM_WORKERS="4") + + assert recorded[1] == "args=-m gateway.launch --workers 4 --host 0.0.0.0 --port 4000" + + +def test_an_unknown_first_word_is_run_as_the_command(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_stubs(bin_dir, ("some-tool",)) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {}) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), "some-tool", "--flag"], env=env, capture_output=True, text=True, check=False + ) + + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + assert record.read_text().splitlines()[:2] == ["exec=some-tool", "args=--flag"] + + +@pytest.mark.parametrize( + "use_ddtrace, traced", + [*((v, True) for v in TRUTHY_USE_DDTRACE), *((v, False) for v in FALSY_USE_DDTRACE)], +) +def test_ddtrace_wraps_a_raw_command_like_a_component(use_ddtrace: str | None, traced: bool, tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_stubs(bin_dir, ("some-tool", "ddtrace-run")) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {"USE_DDTRACE": use_ddtrace}) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), "some-tool", "--flag"], env=env, capture_output=True, text=True, check=False + ) + + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + assert record.read_text().splitlines()[:2] == ( + ["exec=ddtrace-run", "args=some-tool --flag"] if traced else ["exec=some-tool", "args=--flag"] + ) + + +def test_an_unknown_litellm_component_fails_fast_instead_of_guessing(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_stubs(bin_dir, STUBBED_EXECUTABLES) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {"LITELLM_COMPONENT": "gatway"}) + + result = subprocess.run(["sh", str(DOCKER_ENTRYPOINT)], env=env, capture_output=True, text=True, check=False) + + assert result.returncode == 64 + assert "gatway" in result.stderr + assert not record.exists(), "nothing may be exec'd when the component is unknown" + + +def test_admin_agent_flag_runs_the_bundled_liteadmin_agent_not_the_proxy(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_stubs(bin_dir, STUBBED_EXECUTABLES) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {}) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), "--admin-agent", "--flag"], env=env, capture_output=True, text=True, check=False + ) + + assert "/opt/liteadmin/bin/litellm-admin-agent" in result.stderr + assert result.returncode == 127 + assert not record.exists(), "--admin-agent must not fall through to the proxy" + + +def test_ddtrace_enabled_wraps_the_command_and_disables_the_openai_integration(tmp_path: Path) -> None: + recorded = _run_entrypoint(("gateway",), tmp_path, use_ddtrace="true", NUM_WORKERS="2") + + assert recorded == ( + "exec=ddtrace-run", + "args=python -m gateway.launch --workers 2 --host 0.0.0.0 --port 4000", + "DD_TRACE_OPENAI_ENABLED=False", + f"PYTHONPATH={PYTHONPATH_SENTINEL}", + ) + + +def test_ddtrace_disabled_execs_the_command_directly(tmp_path: Path) -> None: + recorded = _run_entrypoint(("backend", "--workers", "2"), tmp_path) + + assert recorded == ( + "exec=uvicorn", + "args=backend.main:app --host 0.0.0.0 --port 4001 --workers 2", + "DD_TRACE_OPENAI_ENABLED=", + f"PYTHONPATH={PYTHONPATH_SENTINEL}", + ) + + +@pytest.mark.parametrize( + "use_ddtrace, traced", + [*((v, True) for v in TRUTHY_USE_DDTRACE), *((v, False) for v in FALSY_USE_DDTRACE)], +) +def test_ddtrace_gating_matches_get_secret_bool_for_every_component( + use_ddtrace: str | None, traced: bool, tmp_path: Path +) -> None: + component = _run_entrypoint(("gateway",), tmp_path / "component", use_ddtrace=use_ddtrace) + monolith = _run_entrypoint(("--port", "4000"), tmp_path / "monolith", use_ddtrace=use_ddtrace) + + expected_openai = "DD_TRACE_OPENAI_ENABLED=False" if traced else "DD_TRACE_OPENAI_ENABLED=" + assert component[0] == ("exec=ddtrace-run" if traced else "exec=python") + assert component[2] == expected_openai + assert monolith[0] == ("exec=ddtrace-run" if traced else "exec=litellm") + assert monolith[1] == ("args=litellm --port 4000" if traced else "args=--port 4000") + assert monolith[2] == expected_openai + + +def test_wipes_the_prometheus_multiproc_dir_before_uvicorn_forks(tmp_path: Path) -> None: + multiproc_dir = tmp_path / "multiproc" + multiproc_dir.mkdir() + (multiproc_dir / "gauge_livesum_7.db").write_bytes(b"stale") + (multiproc_dir / "counter_7.db").write_bytes(b"stale") + (multiproc_dir / f"{PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX}litellm_requests_metric").write_bytes(b"stale") + (multiproc_dir / "keep.txt").write_text("not a sample") + + recorded = _run_entrypoint(("gateway",), tmp_path, PROMETHEUS_MULTIPROC_DIR=str(multiproc_dir)) + + assert sorted(p.name for p in multiproc_dir.iterdir()) == ["keep.txt"] + assert recorded[0] == "exec=python" + + +@pytest.mark.parametrize( + "argv", + [ + ("python", "-m", "litellm.proxy.prometheus_metrics_server"), + ("python", "-m", "litellm.proxy.collector"), + ("sh", "-c", "exec python -m litellm.proxy.collector --port 4010"), + ], +) +def test_a_raw_metrics_reader_keeps_the_workers_samples(argv: tuple[str, ...], tmp_path: Path) -> None: + multiproc_dir = tmp_path / "multiproc" + multiproc_dir.mkdir() + (multiproc_dir / "counter_7.db").write_bytes(b"live") + (multiproc_dir / f"{PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX}litellm_requests_metric").write_bytes(b"live") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_stubs(bin_dir, ("python",)) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {"PROMETHEUS_MULTIPROC_DIR": str(multiproc_dir)}) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), *argv], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + assert sorted(p.name for p in multiproc_dir.iterdir()) == [ + "counter_7.db", + f"{PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX}litellm_requests_metric", + ] + assert record.read_text().splitlines()[0] == "exec=python" + + +@pytest.mark.parametrize( + "argv", + [ + ("python", "-m", "gateway.launch"), + ("litellm", "--config", "/app/config.yaml"), + ("uvicorn", "backend.main:app"), + ], +) +def test_a_raw_writer_starts_with_an_empty_prometheus_multiproc_dir(argv: tuple[str, ...], tmp_path: Path) -> None: + multiproc_dir = tmp_path / "multiproc" + multiproc_dir.mkdir() + (multiproc_dir / "counter_7.db").write_bytes(b"stale") + (multiproc_dir / "gauge_livesum_7.db").write_bytes(b"stale") + (multiproc_dir / f"{PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX}litellm_requests_metric").write_bytes(b"stale") + (multiproc_dir / "keep.txt").write_text("not a sample") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_stubs(bin_dir, STUBBED_EXECUTABLES) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {"PROMETHEUS_MULTIPROC_DIR": str(multiproc_dir)}) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), *argv], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + assert sorted(p.name for p in multiproc_dir.iterdir()) == ["keep.txt"] + assert record.read_text().splitlines()[0] == f"exec={argv[0]}" + + +def test_a_raw_command_still_gets_the_prometheus_multiproc_dir_created(tmp_path: Path) -> None: + multiproc_dir = tmp_path / "not-yet" / "multiproc" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_stubs(bin_dir, ("python",)) + record = tmp_path / "record.txt" + env = _entrypoint_env(bin_dir, record, {"PROMETHEUS_MULTIPROC_DIR": str(multiproc_dir)}) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), "python", "-m", "gateway.launch"], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + assert multiproc_dir.is_dir() + assert record.read_text().splitlines()[0] == "exec=python" + + +@pytest.mark.parametrize("component", ["metrics", "collector"]) +def test_readers_of_the_prometheus_multiproc_dir_keep_the_workers_samples(component: str, tmp_path: Path) -> None: + multiproc_dir = tmp_path / "multiproc" + multiproc_dir.mkdir() + (multiproc_dir / "counter_12.db").write_bytes(b"live") + (multiproc_dir / "histogram_13.db").write_bytes(b"live") + (multiproc_dir / f"{PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX}litellm_requests_metric").write_bytes(b"live") + + recorded = _run_entrypoint((component,), tmp_path, PROMETHEUS_MULTIPROC_DIR=str(multiproc_dir)) + + assert sorted(p.name for p in multiproc_dir.iterdir()) == [ + "counter_12.db", + "histogram_13.db", + f"{PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX}litellm_requests_metric", + ] + assert recorded[0] == "exec=python" + + +def test_creates_a_missing_prometheus_multiproc_dir(tmp_path: Path) -> None: + missing = tmp_path / "multiproc" + + _run_entrypoint(("backend",), tmp_path, PROMETHEUS_MULTIPROC_DIR=str(missing)) + + assert missing.is_dir() + + +@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) +@pytest.mark.parametrize("component", ["gateway", "backend"]) +@pytest.mark.parametrize("use_ddtrace", [*TRUTHY_USE_DDTRACE, *FALSY_USE_DDTRACE]) +def test_terraform_launch_command_matches_the_script_contract( + terraform_file: Path, component: str, use_ddtrace: str | None, tmp_path: Path +) -> None: + launcher = COMPONENT_LAUNCHERS[component] + app_target = " ".join(launcher[1:]) + command = _resolve_tf_local(terraform_file, f"{component}_launch_cmd") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + _write_stubs(bin_dir, ("ddtrace-run", "uvicorn", "python")) + from_terraform = _run_shell_command(command, bin_dir, tmp_path / "terraform.txt", use_ddtrace) + + from_script = _run_entrypoint((component,), tmp_path / "script", use_ddtrace=use_ddtrace) + + assert from_terraform[0] == from_script[0], ( + f"{terraform_file} disagrees with the script on USE_DDTRACE={use_ddtrace}" + ) + assert from_terraform[2] == from_script[2], f"{terraform_file} disagrees with the script on the openai integration" + assert app_target in from_terraform[1] + assert app_target in from_script[1] + assert "gateway.main:app" not in from_terraform[1], f"{terraform_file} bypasses the gateway.launch supervisor" + + if use_ddtrace in TRUTHY_USE_DDTRACE: + assert from_terraform[0] == "exec=ddtrace-run" + assert from_terraform[1].startswith(f"args={launcher[0]} ") + assert from_terraform[2] == "DD_TRACE_OPENAI_ENABLED=False" + else: + assert from_terraform[0] == f"exec={launcher[0]}" + assert from_terraform[2] == "DD_TRACE_OPENAI_ENABLED=" + + +@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) +def test_terraform_routes_every_launch_site_through_a_traced_command(terraform_file: Path) -> None: + launch_sites = tuple(line for line in terraform_file.read_text().splitlines() if _APP_TARGET_RE.search(line)) + + assert len(launch_sites) == TERRAFORM_LAUNCH_SITES[terraform_file], ( + f"{terraform_file} launch-site count changed; re-check each one honors USE_DDTRACE" + ) + for line in launch_sites: + assert "ddtrace-run" in line, f"{terraform_file} launches uvicorn without honoring USE_DDTRACE: {line.strip()}" + + body = terraform_file.read_text() + for component in ("gateway", "backend"): + assert f"local.{component}_launch_cmd" in body, ( + f"{terraform_file} defines a {component} launch command but never uses it" + ) + + +@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) +def test_terraform_does_not_depend_on_the_entrypoint_script(terraform_file: Path) -> None: + assert IMAGE_ENTRYPOINT_PATH not in terraform_file.read_text(), ( + f"{terraform_file} depends on a script that images predating it do not ship" + ) + + +def test_help_prints_the_header_comment_block_without_shell_source(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + _write_stubs(bin_dir, STUBBED_EXECUTABLES) + env = _entrypoint_env(bin_dir, tmp_path / "record.txt", {}) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), "help"], env=env, capture_output=True, text=True, check=False + ) + + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + assert "--admin-agent" in result.stderr + assert "collector" in result.stderr + assert "set -eu" not in result.stderr + assert result.stderr.rstrip().endswith("other raw commands clear stale samples.") + + +def test_entrypoint_script_is_executable() -> None: + mode = DOCKER_ENTRYPOINT.stat().st_mode + assert mode & stat.S_IXUSR, "entrypoint must be committed executable to run as the image ENTRYPOINT" + assert mode & stat.S_IXOTH, "entrypoint must be executable by the unprivileged `nonroot` user" + + +def test_entrypoint_script_has_no_carriage_returns() -> None: + assert b"\r" not in DOCKER_ENTRYPOINT.read_bytes() diff --git a/tests/unit/test_dockerfile_bedrock_realtime_extra.py b/tests/unit/test_dockerfile_bedrock_realtime_extra.py index e157c982105..f4863b5ff77 100644 --- a/tests/unit/test_dockerfile_bedrock_realtime_extra.py +++ b/tests/unit/test_dockerfile_bedrock_realtime_extra.py @@ -1,5 +1,5 @@ """ -Static checks that every proxy Docker image installs the `bedrock-realtime` extra. +Static checks that the shipped Docker image installs the `bedrock-realtime` extra. Bedrock Nova Sonic speech-to-speech (`/v1/realtime`) needs `aws-sdk-bedrock-runtime`, which only ships in the `bedrock-realtime` extra. An image whose `uv sync` stages @@ -23,12 +23,7 @@ else: REPO_ROOT: Final = os.path.join(os.path.dirname(__file__), "..", "..") -PROXY_DOCKERFILES: Final = ( - "Dockerfile", - os.path.join("docker", "Dockerfile.non_root"), - os.path.join("docker", "Dockerfile.database"), - os.path.join("gateway", "Dockerfile"), -) +PROXY_DOCKERFILES: Final = ("Dockerfile",) CONTINUED_LINE_RE: Final = re.compile(r"(?:\\\n|[^\n])+") UV_SYNC_BOUNDARY_RE: Final = re.compile(r"(?=uv sync)") diff --git a/tests/unit/test_dockerfile_non_root.py b/tests/unit/test_dockerfile_non_root.py index 694da6368e7..d37d06c337d 100644 --- a/tests/unit/test_dockerfile_non_root.py +++ b/tests/unit/test_dockerfile_non_root.py @@ -1,39 +1,26 @@ """ -Static checks on docker/Dockerfile.non_root. +Static checks on the shipped Dockerfile's runtime user. -The non_root image is intended for deployment into hardened Kubernetes -clusters where `securityContext.runAsNonRoot: true` is enforced. The -kubelet validates non-root status by parsing the image's USER field as -an integer — a string name like "nobody" is rejected with -CreateContainerConfigError because the kubelet cannot resolve -/etc/passwd inside the image at admission time. +The image is deployed into hardened Kubernetes clusters where +`securityContext.runAsNonRoot: true` is enforced. The kubelet validates +non-root status by parsing the image's USER field as an integer: a string +name like "nonroot" is rejected with CreateContainerConfigError because the +kubelet cannot resolve /etc/passwd inside the image at admission time. """ import os import re -import pytest - -DOCKERFILE_PATH = os.path.join( - os.path.dirname(__file__), - "..", - "..", - "docker", - "Dockerfile.non_root", -) +DOCKERFILE_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "Dockerfile") def _final_user_directive(dockerfile_text: str) -> str: - """Return the value of the last `USER` directive in the file.""" + """Return the uid of the last `USER` directive in the file (`USER uid` or `USER uid:gid`).""" matches = re.findall(r"^USER\s+(\S+)\s*$", dockerfile_text, re.MULTILINE) - assert matches, "Dockerfile.non_root has no USER directive" - return matches[-1] + assert matches, "Dockerfile has no USER directive" + return matches[-1].split(":", 1)[0] -@pytest.mark.skipif( - not os.path.exists(DOCKERFILE_PATH), - reason="Dockerfile.non_root not present in this checkout", -) def test_final_user_directive_is_numeric(): """The runtime USER must be a numeric UID so kubelet's runAsNonRoot admission check (strconv.Atoi) succeeds.""" @@ -43,12 +30,9 @@ def test_final_user_directive_is_numeric(): final_user = _final_user_directive(contents) assert final_user.isdigit(), ( - f"Dockerfile.non_root final USER is {final_user!r}; must be a numeric UID " + f"Dockerfile final USER is {final_user!r}; must be a numeric UID " "so Kubernetes' runAsNonRoot admission check can verify non-root status. " "See https://kubernetes.io/docs/tasks/configure-pod-container/security-context/" ) - assert int(final_user) != 0, ( - f"Dockerfile.non_root final USER is {final_user} (root); the non_root image " - "must run as a non-zero UID." - ) + assert int(final_user) != 0, f"Dockerfile final USER is {final_user} (root); the image must run as a non-zero UID." diff --git a/ui/Dockerfile b/ui/Dockerfile deleted file mode 100644 index d78a155b874..00000000000 --- a/ui/Dockerfile +++ /dev/null @@ -1,48 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# UI container — Next.js static export served by nginx. - -ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 -ARG NGINX_VERSION=1.31.8-alpine3.24@sha256:1ef7ca94d7879df82421428ce13bd1eaa412404953859cf7a5b6512c25e67a60 - -# ---------- builder ---------- -FROM ${UI_BUILD_IMAGE} AS builder - -ENV NEXT_TELEMETRY_DISABLED=1 \ - npm_config_fund=false \ - npm_config_audit=false - -WORKDIR /app - -# Layer the lockfile-only install above the source copy so source-only -# edits don't bust the install cache. -COPY ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json ./ -COPY ui/litellm-dashboard/vendor/ ./vendor/ -RUN --mount=type=cache,target=/root/.npm \ - npm ci --prefer-offline - -COPY ui/litellm-dashboard/ ./ -RUN npm run build - -# ---------- runtime ---------- -FROM nginx:${NGINX_VERSION} AS runtime - -RUN apk upgrade --no-cache brotli-libs - -# Drop the upstream default :80 server; we own the config. -RUN rm -f /etc/nginx/conf.d/default.conf - -# Static export → web root. -COPY --from=builder /app/out /usr/share/nginx/html - -# Runtime backend discovery config. -COPY ui/docker-entrypoint.d/40-litellm-runtime-config.sh /docker-entrypoint.d/ - -# Routing rules — see ui/nginx.conf for the full description. -COPY ui/nginx.conf /etc/nginx/nginx.conf - -EXPOSE 3000/tcp - -# nginx as PID 1 in foreground; respects SIGTERM out of the box, so -# no tini/dumb-init wrapper needed. -CMD ["nginx", "-g", "daemon off;"] diff --git a/ui/nginx.conf b/ui/nginx.conf index dd43defcde9..e695686c670 100644 --- a/ui/nginx.conf +++ b/ui/nginx.conf @@ -5,6 +5,7 @@ worker_processes auto; # nginx image's /var/cache/nginx and /run are root-owned 755) and works # with readOnlyRootFilesystem when /tmp is an emptyDir. pid /tmp/nginx.pid; +error_log /dev/stderr warn; events { worker_connections 1024; } @@ -15,6 +16,7 @@ http { uwsgi_temp_path /tmp/nginx-uwsgi-temp; scgi_temp_path /tmp/nginx-scgi-temp; + access_log /dev/stdout; include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on; @@ -29,7 +31,6 @@ http { application/javascript application/json text/css - text/html image/svg+xml font/woff font/woff2; @@ -37,16 +38,16 @@ http { server { listen 3000 default_server; server_name _; - root /usr/share/nginx/html; + root /var/lib/litellm/ui; # next.config.mjs sets assetPrefix=/litellm-asset-prefix, which makes # the built HTML reference /litellm-asset-prefix/_next/... — but the # static export only emits files under /_next/. Map the prefix to # the real tree at request time instead of duplicating the directory # at build time. NB: alias rewrites the location prefix, so - # /litellm-asset-prefix/_next/foo.js → /usr/share/nginx/html/_next/foo.js. + # /litellm-asset-prefix/_next/foo.js -> /var/lib/litellm/ui/_next/foo.js. location /litellm-asset-prefix/_next/ { - alias /usr/share/nginx/html/_next/; + alias /var/lib/litellm/ui/_next/; expires 1y; add_header Cache-Control "public, immutable"; } @@ -63,7 +64,7 @@ http { add_header Cache-Control "public, immutable"; } location ^~ /ui/assets/ { - alias /usr/share/nginx/html/assets/; + alias /var/lib/litellm/ui/assets/; } location = /favicon.ico { try_files $uri =404; From 9ebbcc7710821fc3d245417fb3d00a06b906c114 Mon Sep 17 00:00:00 2001 From: paveln83 <349044532+paveln83@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:33:54 -0800 Subject: [PATCH 39/86] feat(anthropic): forward message threads on native /v1/messages (#50976) * feat(anthropic): forward message threads on native /v1/messages Keep the thread body field on native Anthropic legs, add the message-threads-2026-08-12 beta for it, reject it with a 400 on legs that cannot continue a stored thread, and relay error.details of an upstream Anthropic error so thread_not_found reaches SDK thread helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(anthropic): keep a stale thread 404 from cooling the deployment and type the thread beta ignores Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style: sort the cooldown handler imports Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(anthropic): dedupe the message-threads beta map rows and annotate test finals Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(anthropic): bind message threads to the key that created them and answer unsupported continues with thread_not_found Every virtual key on a proxy shares one Anthropic credential, so forwarding thread alone let any key continue another key's thread by its message id. A new default proxy hook binds each thread reply id to its virtual key, or to the signed-in identity when there is no key, and answers a continue naming an id it did not bind with the 404 thread_not_found the SDK replays on. Legs without message threads now serve a create without the thread and answer a continue with that 404 before any upstream call, instead of answering it with no history. * fix(proxy): keep a cached message thread reply with the key that got it first A response cache hit replays the original reply id, and binding it again handed the thread to the second key, so the creator's continue got thread_not_found. * fix(anthropic): keep message threads bound on every route and stable across turns Check the thread binding on every route that carries thread except pass-through, keep bindings in their own cache so they never evict rate-limit counters, add no tool or message derived betas on thread turns, answer a create on a leg without threads with a 400 thread_unsupported_request unless drop_params is set, and forward threads on Claude Platform on AWS * fix(anthropic): reject message threads on /v1/messages passthrough deployments A deployment that forwards /v1/messages untranslated to an OpenAI-compatible server inherited the native config's thread support, so `thread` reached a server that keeps no thread state. It now answers the same 400 thread_unsupported_request on create and 404 thread_not_found on continue as a translated leg * fix(anthropic): resend a thread's create betas on continues and check bound ids on pass-through A thread create now gets the betas LiteLLM derives from its tools and messages again, so an advisor tool create that answered 400 on the previous commit answers 200. Anthropic fingerprints the beta set, so the proxy keeps the anthropic-beta header the create sent upstream in the thread binding and adds it to every continue on /v1/messages. It reads that header from the upstream request, since success logging overwrites the logged request details before the success hook runs A continue on the /anthropic pass-through route of a thread the proxy bound to another key now gets the 404 thread_not_found. Ids the proxy never bound still pass through, as before * fix(anthropic): continue unbound threads off /v1/messages and keep every anthropic-beta on chat and Responses * fix(anthropic): skip non-string anthropic-beta list entries instead of raising * test(anthropic): keep the header merge regression test clear of main's appended tests * fix(anthropic): add a thread's create betas to the client's own on continues and keep base header merging on chat and Responses * fix(anthropic): answer an unbound thread id on chat and Responses with the 404 and drop the stale beta-join tests * fix(proxy): check a thread continue sent inside extra_body against its owner Azure AI promotes extra_body into the request body, so another key could continue a thread by putting thread there, past the ownership hook that read only the top-level field * fix(proxy): write a thread's first owner atomically and bind creates sent inside extra_body Two workers that both miss a reply's binding now agree on the first key stored in Redis, since the claim uses SET NX and each worker caches the stored winner. A create sent inside extra_body now binds its reply like a top-level create. The redundant Claude Platform supports_message_threads override is removed, since the provider already resolves to the native Anthropic beta list * fix(proxy): include agent_id in the key-less message thread owner * fix(proxy): tag message thread ownership Redis calls with their key family --------- Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/anthropic_beta_headers_config.json | 10 +- .../exceptions/exception_mapping_utils.py | 36 +- .../exceptions/exceptions.py | 1 + litellm/constants.py | 1 + .../llms/anthropic/messages/native/handler.py | 39 +- .../messages/native/transformation.py | 29 +- .../llms/anthropic/messages/native/utils.py | 37 ++ .../anthropic_messages/transformation.py | 3 + .../openai_like/messages/transformation.py | 3 + litellm/proxy/hooks/__init__.py | 2 + .../proxy/hooks/message_thread_ownership.py | 319 ++++++++++ litellm/proxy/utils.py | 6 + litellm/router_rust/surface/health.py | 2 + litellm/router_utils/cooldown_handlers.py | 7 + litellm/types/llms/anthropic.py | 13 + .../test_message_threads_wire.py | 413 +++++++++++++ .../test_exception_mapping_utils.py | 45 ++ .../native/test_anthropic_messages_handler.py | 79 +++ .../test_anthropic_messages_transformation.py | 130 ++++ .../test_request_optional_param_utils.py | 8 + .../bedrock/test_claude_platform_provider.py | 27 + .../hooks/test_message_thread_ownership.py | 558 ++++++++++++++++++ .../utils/proxy_logging/test_pre_call_hook.py | 1 + .../router_utils/test_cooldown_handlers.py | 69 +++ 24 files changed, 1826 insertions(+), 12 deletions(-) create mode 100644 litellm/proxy/hooks/message_thread_ownership.py create mode 100644 tests/integration/messages_endpoint/test_message_threads_wire.py create mode 100644 tests/unit/proxy/hooks/test_message_thread_ownership.py diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index bdfddb48522..1b659770181 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -21,6 +21,7 @@ "interleaved-thinking-2025-05-14": "interleaved-thinking-2025-05-14", "mcp-client-2025-11-20": "mcp-client-2025-11-20", "mcp-client-2025-04-04": "mcp-client-2025-04-04", + "message-threads-2026-08-12": "message-threads-2026-08-12", "mcp-servers-2025-12-04": null, "oauth-2025-04-20": "oauth-2025-04-20", "output-128k-2025-02-19": "output-128k-2025-02-19", @@ -56,7 +57,6 @@ "mcp-client-2026-09-15": "mcp-client-2026-09-15", "mcp-tunnels-2026-06-22": "mcp-tunnels-2026-06-22", "message-batches-2024-09-24": "message-batches-2024-09-24", - "message-threads-2026-08-12": "message-threads-2026-08-12", "mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21", "model-context-window-exceeded-2025-08-26": "model-context-window-exceeded-2025-08-26", "output-300k-2026-03-24": "output-300k-2026-03-24", @@ -98,6 +98,7 @@ "interleaved-thinking-2025-05-14": "interleaved-thinking-2025-05-14", "mcp-client-2025-11-20": "mcp-client-2025-11-20", "mcp-client-2025-04-04": "mcp-client-2025-04-04", + "message-threads-2026-08-12": "message-threads-2026-08-12", "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": "output-128k-2025-02-19", "structured-output-2024-03-01": null, @@ -132,7 +133,6 @@ "mcp-client-2026-09-15": "mcp-client-2026-09-15", "mcp-tunnels-2026-06-22": "mcp-tunnels-2026-06-22", "message-batches-2024-09-24": "message-batches-2024-09-24", - "message-threads-2026-08-12": "message-threads-2026-08-12", "mid-conversation-output-config-2026-07-01": "mid-conversation-output-config-2026-07-01", "mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21", "mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01", @@ -177,6 +177,7 @@ "interleaved-thinking-2025-05-14": null, "mcp-client-2025-11-20": null, "mcp-client-2025-04-04": null, + "message-threads-2026-08-12": null, "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": null, "structured-output-2024-03-01": null, @@ -212,6 +213,7 @@ "interleaved-thinking-2025-05-14": null, "mcp-client-2025-11-20": null, "mcp-client-2025-04-04": null, + "message-threads-2026-08-12": null, "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": null, "structured-output-2024-03-01": null, @@ -249,6 +251,7 @@ "fine-grained-tool-streaming-2025-05-14": "fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14": "interleaved-thinking-2025-05-14", "mcp-client-2025-04-04": null, + "message-threads-2026-08-12": null, "mcp-client-2025-11-20": null, "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": "output-128k-2025-02-19", @@ -286,6 +289,7 @@ "interleaved-thinking-2025-05-14": "interleaved-thinking-2025-05-14", "mcp-client-2025-11-20": null, "mcp-client-2025-04-04": null, + "message-threads-2026-08-12": null, "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": "output-128k-2025-02-19", "structured-output-2024-03-01": null, @@ -321,7 +325,6 @@ "mcp-client-2026-09-15": null, "mcp-tunnels-2026-06-22": null, "message-batches-2024-09-24": null, - "message-threads-2026-08-12": null, "mid-conversation-output-config-2026-07-01": "mid-conversation-output-config-2026-07-01", "mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21", "mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01", @@ -386,6 +389,7 @@ "interleaved-thinking-2025-05-14": "interleaved-thinking-2025-05-14", "mcp-client-2025-11-20": null, "mcp-client-2025-04-04": null, + "message-threads-2026-08-12": null, "mcp-servers-2025-12-04": null, "oauth-2025-04-20": null, "output-128k-2025-02-19": "output-128k-2025-02-19", diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index b4c95fafff3..058ac4dcf4c 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -5,15 +5,18 @@ Similar to litellm/litellm_core_utils/exception_mapping_utils.py but for Anthrop """ import json +from collections.abc import Mapping from typing import Final -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from .exceptions import AnthropicErrorDetail, AnthropicErrorResponse, AnthropicErrorType _PARSED_ERROR_BODY: Final = TypeAdapter(object) +_ERROR_DETAILS: Final = TypeAdapter(Mapping[str, object]) +_JSON_DECODER: Final = json.JSONDecoder() # HTTP status code -> Anthropic error type # Source: https://platform.claude.com/docs/en/api/errors @@ -166,12 +169,41 @@ class AnthropicExceptionMapping: else: message = raw_message - return AnthropicExceptionMapping.create_error_response( + response: Final = AnthropicExceptionMapping.create_error_response( status_code=status_code, message=message, request_id=request_id, error_type=error_type, ) + details: Final = AnthropicExceptionMapping.embedded_error_details(raw_message) + if details is None: + return response + with_details: Final[AnthropicErrorResponse] = {**response, "error": {**response["error"], "details": details}} + return with_details + + @staticmethod + def embedded_error_details(raw_message: str) -> Mapping[str, object] | None: + """ + ``error.details`` of the Anthropic error body quoted inside ``raw_message``, when there is one. + + litellm wraps an upstream error as ``" - {upstream json}\n\nLiteLLM: ..."``. Anthropic + puts machine-readable codes such as ``thread_not_found`` under ``error.details``, and the SDKs + branch on them, so the code is lifted next to the wrapped message instead of staying only inside it. + """ + start: Final = raw_message.find("{") + if start < 0: + return None + try: + embedded, _ = _JSON_DECODER.raw_decode(raw_message, start) + except ValueError: + return None + try: + envelope: Final = _ERROR_DETAILS.validate_python(embedded, strict=True) + error: Final = _ERROR_DETAILS.validate_python(envelope.get("error"), strict=True) + details: Final = _ERROR_DETAILS.validate_python(error.get("details"), strict=True) + except ValidationError: + return None + return details if envelope.get("type") == "error" else None class AnthropicErrorSseFrame(str): diff --git a/litellm/anthropic_interface/exceptions/exceptions.py b/litellm/anthropic_interface/exceptions/exceptions.py index 18b3ca37a3b..71d4de0029d 100644 --- a/litellm/anthropic_interface/exceptions/exceptions.py +++ b/litellm/anthropic_interface/exceptions/exceptions.py @@ -27,6 +27,7 @@ class AnthropicErrorDetail(TypedDict): type: AnthropicErrorType message: str + details: NotRequired[ReadOnly[Mapping[str, object]]] provider_specific_fields: NotRequired[ReadOnly[Mapping[str, object]]] litellm_error_type: NotRequired[ReadOnly[str]] litellm_call_id: NotRequired[ReadOnly[str]] diff --git a/litellm/constants.py b/litellm/constants.py index 931ac4f2b81..5d0eab30b90 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -2374,6 +2374,7 @@ ADMIN_ONLY_BATCH_LIMIT_METADATA_KEYS: Final = ( MAX_FILE_DOWNLOADS_PER_MINUTE_KEY, ) FILE_USAGE_MAX_TRACKED_COUNTERS: Final = 20_000 +MESSAGE_THREAD_MAX_TRACKED_OWNERS: Final = 20_000 # Shared read-only empty mapping, for defaulting optional Mapping parameters without # constructing a fresh mutable dict at each call site. diff --git a/litellm/llms/anthropic/messages/native/handler.py b/litellm/llms/anthropic/messages/native/handler.py index 59ffb6fef04..08c3e0b48a8 100644 --- a/litellm/llms/anthropic/messages/native/handler.py +++ b/litellm/llms/anthropic/messages/native/handler.py @@ -15,10 +15,11 @@ from typing import Final, cast from pydantic import TypeAdapter, ValidationError import litellm +from litellm._logging import verbose_logger from litellm.anthropic_beta_headers_manager import aload_beta_headers_config from litellm.constants import PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params from litellm.litellm_core_utils.exception_mapping_utils import exception_type from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.reasoning_effort_capability import ( @@ -29,6 +30,7 @@ from litellm.litellm_core_utils.reasoning_effort_capability import ( resolve_deployment_reasoning_effort, ) from litellm.llms.anthropic.common_utils import ( + AnthropicError, AnthropicModelInfo, strip_provider_specific_fields_from_anthropic_messages, tools_marked_for_a_tool_config_point, @@ -58,7 +60,14 @@ from ..safeguards import ( with_safeguard_results, ) from .interceptors import get_messages_interceptors -from .utils import AnthropicMessagesRequestUtils, mock_response, prepare_native_messages +from .utils import ( + AnthropicMessagesRequestUtils, + continues_message_thread, + message_thread_not_found_body, + message_thread_unsupported_body, + mock_response, + prepare_native_messages, +) __all__ = ("anthropic_messages", "anthropic_messages_handler") @@ -769,6 +778,32 @@ def anthropic_messages_handler( anthropic_messages_provider_config: Final = _native_messages_config( model, custom_llm_provider, kwargs.get("model_info"), api_base ) + thread: Final[object] = kwargs.get("thread") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # **kwargs of the handler is untyped + if thread is not None and ( + anthropic_messages_provider_config is None or not anthropic_messages_provider_config.supports_message_threads() + ): + if continues_message_thread(thread): + raise AnthropicError( + status_code=404, + message=message_thread_not_found_body( + f"message threads are not supported for provider {custom_llm_provider}, " + "so this thread cannot be continued. Send the full conversation to start over" + ), + ) + if not drops_unsupported_params(litellm_params.drop_params): + raise AnthropicError( + status_code=400, + message=message_thread_unsupported_body( + f"message threads are not supported for provider {custom_llm_provider}. " + "Send the request without thread, or set drop_params to serve it without a server-side thread" + ), + ) + verbose_logger.warning( + "Dropping thread for provider %s: message threads are not supported there, " + "so the request is served without a server-side thread", + custom_llm_provider, + ) + kwargs.pop("thread") # pyright: ignore[reportUnknownMemberType] # **kwargs of the handler is untyped if anthropic_messages_provider_config is None: # Route to Responses API for OpenAI / Azure, chat/completions for everything else. if kwargs.get("compaction") is None and _should_route_to_responses_api( diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index aa0693d6f03..b0558292271 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -59,6 +59,7 @@ from .mid_conversation_system import ( convert_mid_conversation_system_turns, is_system_role_message, ) +from .utils import continues_message_thread DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01" @@ -137,6 +138,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): "output_config", "reasoning_effort", "safeguards", + "thread", # TODO: Add Anthropic `metadata` support # "metadata", ] @@ -144,6 +146,11 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): def should_filter_anthropic_beta_headers(self) -> bool: return self._resolved_provider != "anthropic" + def supports_message_threads(self) -> bool: + return is_beta_header_supported_by_bundled_config( + ANTHROPIC_BETA_HEADER_VALUES.MESSAGE_THREADS_2026_08_12.value, self._resolved_provider + ) + def supports_safeguards(self) -> bool: """ Whether the upstream platform answers the ``safeguards`` body field itself. A platform @@ -771,10 +778,13 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): # Auto-strip advisor blocks from history if advisor tool is absent. # Prevents Anthropic 400: advisor_tool_result in history requires advisor tool. + # A thread continue turn omits tools by contract and the stored tools still apply, so it keeps them. tools: Final = anthropic_messages_optional_request_params.get("tools") _tools: Final = tools or [] _has_advisor: Final = any(isinstance(t, dict) and t.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE for t in _tools) - if not _has_advisor: + if not _has_advisor and not continues_message_thread( + anthropic_messages_optional_request_params.get("thread") # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # the request params arrive as a bare dict + ): messages = strip_advisor_blocks_from_messages(messages) gated: Final = ( @@ -797,7 +807,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): ), max_tokens=max_tokens, model=model, - **anthropic_messages_optional_request_params, + **anthropic_messages_optional_request_params, # pyright: ignore[reportUnknownArgumentType] # the request params arrive as a bare dict ) request: Final = ( {**dict(anthropic_messages_request), "thinking": gated.thinking} @@ -877,6 +887,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): drop_params: Whether the deployment or request set drop_params """ beta_values: Final[set[str]] = set() + threaded: Final = optional_params.get("thread") is not None # pyright: ignore[reportUnknownMemberType] # the optional params arrive as a bare dict + scanned_messages: Final = ( + () + if continues_message_thread(optional_params.get("thread")) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # the optional params arrive as a bare dict + else messages + ) existing_beta: Final = tuple( piece.strip() @@ -887,7 +903,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): ) beta_values.update(existing_beta) - if requires_native_compaction_beta(custom_llm_provider, optional_params, messages): + if requires_native_compaction_beta(custom_llm_provider, optional_params, scanned_messages): beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_09_04.value) beta_values.update(context_management_betas(optional_params.get("context_management"))) @@ -911,9 +927,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): ): beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value) - if messages_carry_output_config(messages): + if messages_carry_output_config(scanned_messages): beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value) + if threaded: + beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.MESSAGE_THREADS_2026_08_12.value) + tools: Final = optional_params.get("tools") if any(isinstance(tool, dict) and tool.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE for tool in tools or ()): beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value) @@ -924,7 +943,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): thinking_betas: Final = AnthropicModelInfo().thinking_betas(optional_params.get("thinking")) tool_change_betas: Final = ( (ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) - if AnthropicModelInfo().is_mid_conversation_tool_change_used(messages) + if AnthropicModelInfo().is_mid_conversation_tool_change_used(scanned_messages) else () ) all_beta_values: Final = beta_values.union(thinking_betas, tool_change_betas) diff --git a/litellm/llms/anthropic/messages/native/utils.py b/litellm/llms/anthropic/messages/native/utils.py index 8f33d000c7c..dfeedf1c191 100644 --- a/litellm/llms/anthropic/messages/native/utils.py +++ b/litellm/llms/anthropic/messages/native/utils.py @@ -1,3 +1,4 @@ +import json from collections.abc import Iterable, Mapping, Sequence from functools import lru_cache from typing import TYPE_CHECKING, Final, cast, get_type_hints @@ -13,7 +14,10 @@ from litellm.llms.anthropic.common_utils import ( ) from litellm.llms.anthropic.messages.utils import is_reasoning_auto_summary_enabled from litellm.types.llms.anthropic import ( + MESSAGE_THREAD_NOT_FOUND_ERROR_CODE, + MESSAGE_THREAD_UNSUPPORTED_ERROR_CODE, AnthropicMessagesRequestOptionalParams, + AnthropicMessageThread, AnthropicStopDetails, ) from litellm.types.llms.anthropic_messages.anthropic_response import ( @@ -279,6 +283,39 @@ def prepare_native_messages( ) +_THREAD_ADAPTER: Final = TypeAdapter(AnthropicMessageThread) + + +def continues_message_thread(thread: object) -> bool: + """True when ``thread`` is a well-formed ``{"type": "continue", ...}`` body field.""" + try: + return _THREAD_ADAPTER.validate_python(thread)["type"] == "continue" + except ValidationError: + return False + + +def continued_message_id(thread: object) -> str | None: + try: + validated: Final = _THREAD_ADAPTER.validate_python(thread) + except ValidationError: + return None + return validated.get("previous_message_id") if validated["type"] == "continue" else None + + +def _message_thread_error_body(error_type: str, error_code: str, message: str) -> str: + return json.dumps( + {"type": "error", "error": {"type": error_type, "message": message, "details": {"error_code": error_code}}} + ) + + +def message_thread_not_found_body(message: str) -> str: + return _message_thread_error_body("not_found_error", MESSAGE_THREAD_NOT_FOUND_ERROR_CODE, message) + + +def message_thread_unsupported_body(message: str) -> str: + return _message_thread_error_body("invalid_request_error", MESSAGE_THREAD_UNSUPPORTED_ERROR_CODE, message) + + @lru_cache(maxsize=1) def anthropic_messages_optional_param_keys() -> frozenset[str]: """ diff --git a/litellm/llms/base_llm/anthropic_messages/transformation.py b/litellm/llms/base_llm/anthropic_messages/transformation.py index 487e7aca663..3e999467ad5 100644 --- a/litellm/llms/base_llm/anthropic_messages/transformation.py +++ b/litellm/llms/base_llm/anthropic_messages/transformation.py @@ -145,6 +145,9 @@ class BaseAnthropicMessagesConfig(ABC): def supports_safeguards(self) -> bool: return True + def supports_message_threads(self) -> bool: + return False + def serves_api_base(self, model: str, api_base: str | None) -> bool: return True diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index a5d825a39d7..cea57e3505d 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -37,6 +37,9 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): def is_native_messages_route(self) -> bool: return False + def supports_message_threads(self) -> bool: + return False + def hoists_wrapped_defer_loading(self, model: str) -> bool: return False diff --git a/litellm/proxy/hooks/__init__.py b/litellm/proxy/hooks/__init__.py index 138050ccc9a..49cf8760033 100644 --- a/litellm/proxy/hooks/__init__.py +++ b/litellm/proxy/hooks/__init__.py @@ -16,6 +16,7 @@ from .max_iterations_limiter import ( # noqa: F401 # backwards-compatible pack PROXY_MaxIterationsHandler, _PROXY_MaxIterationsHandler, # pyright: ignore[reportPrivateUsage] # backwards-compatible package export ) +from .message_thread_ownership import MessageThreadOwnership from .parallel_request_limiter import ( # noqa: F401 # backwards-compatible package export PROXY_MaxParallelRequestsHandler, _PROXY_MaxParallelRequestsHandler, # pyright: ignore[reportPrivateUsage] # backwards-compatible package export @@ -39,6 +40,7 @@ PROXY_HOOKS: Final = { "parallel_request_limiter": PROXY_MaxParallelRequestsHandler_v3, "cache_control_check": PROXY_CacheControlCheck, "responses_id_security": ResponsesIDSecurity, + "message_thread_ownership": MessageThreadOwnership, "litellm_skills": SkillsInjectionHook, "max_iterations_limiter": PROXY_MaxIterationsHandler, "max_budget_per_session_limiter": PROXY_MaxBudgetPerSessionHandler, diff --git a/litellm/proxy/hooks/message_thread_ownership.py b/litellm/proxy/hooks/message_thread_ownership.py new file mode 100644 index 00000000000..0f2d9fd1933 --- /dev/null +++ b/litellm/proxy/hooks/message_thread_ownership.py @@ -0,0 +1,319 @@ +import json +import re +from collections.abc import AsyncGenerator, AsyncIterable, Mapping +from typing import TYPE_CHECKING, Final, Protocol + +import httpx +from fastapi import HTTPException +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError + +from litellm._internal_context import with_service_target +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.llms.anthropic.common_utils import merge_anthropic_beta_headers +from litellm.llms.anthropic.messages.native.utils import continued_message_id, message_thread_not_found_body +from litellm.llms.custom_httpx.sse_lines import split_sse_lines_keepends +from litellm.types.utils import CallTypesLiteral, LLMResponseTypes + +if TYPE_CHECKING: + from litellm.caching.caching import DualCache + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import InternalUsageCache + +_PASS_THROUGH_CALL_TYPE: Final = "pass_through_endpoint" +_ANTHROPIC_BETA_HEADER: Final = "anthropic-beta" +_OWNER_KEY_PREFIX: Final = "anthropic_message_thread_owner:" +_OWNER_TTL_SECONDS: Final = 3600 +_SSE_DATA_FIELD: Final = "data:" +_SSE_FRAME_END_PATTERN: Final = re.compile(rb"\r\n\r\n|\r\r|\n\n") +_MAX_SCANNED_BYTES: Final = 65536 +_UNOWNED_THREAD_MESSAGE: Final = ( + "message thread not found for this key. Send the full conversation to start a new thread" +) + +_MAPPING_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_MAPPINGS_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, object], ...]) + + +class _FirstClaimStore(Protocol): + async def async_set_cache(self, key: str, value: object, *, nx: bool, ttl: int) -> object: ... + + async def async_get_cache(self, key: str) -> object: ... + + +class _ThreadBinding(BaseModel): + model_config = ConfigDict(frozen=True) + + owner: str + anthropic_beta: str | None = None + + +_BINDING_ADAPTER: Final = TypeAdapter(_ThreadBinding) + + +def _as_mapping(value: object) -> Mapping[str, object] | None: + try: + return _MAPPING_ADAPTER.validate_python(value, strict=True) + except ValidationError: + return None + + +def _as_binding(value: object) -> _ThreadBinding | None: + try: + return _BINDING_ADAPTER.validate_python(value) + except ValidationError: + return None + + +def _anthropic_beta(headers: Mapping[str, object]) -> str | None: + return next( + ( + value + for name, value in headers.items() + if name.lower() == _ANTHROPIC_BETA_HEADER and isinstance(value, str) and value + ), + None, + ) + + +def _sent_anthropic_beta(request_data: Mapping[str, object]) -> str | None: + logging_obj: Final = request_data.get("litellm_logging_obj") + if not isinstance(logging_obj, Logging): + return None + response: Final[object] = logging_obj.model_call_details.get("httpx_response") + if not isinstance(response, httpx.Response): + return None + try: + sent_headers: Final = response.request.headers + except RuntimeError: + return None + return sent_headers.get(_ANTHROPIC_BETA_HEADER) or None + + +def _with_anthropic_beta(extra_headers: object, anthropic_beta: str) -> Mapping[str, object]: + headers: Final = _as_mapping(extra_headers) or {} + return { + **{name: value for name, value in headers.items() if name.lower() != _ANTHROPIC_BETA_HEADER}, + _ANTHROPIC_BETA_HEADER: merge_anthropic_beta_headers(_anthropic_beta(headers), anthropic_beta), + } + + +def _as_mappings(value: object) -> tuple[Mapping[str, object], ...]: + try: + return _MAPPINGS_ADAPTER.validate_python(value) + except ValidationError: + return () + + +def _client_headers_with_anthropic_beta(scoped: Mapping[str, object], anthropic_beta: str) -> Mapping[str, object]: + client_headers: Final = _as_mapping(scoped.get("extra_headers")) + if client_headers is None or _anthropic_beta(client_headers) is None: + return scoped + return {**scoped, "extra_headers": dict(_with_anthropic_beta(client_headers, anthropic_beta))} + + +def _provider_headers_with_anthropic_beta( + provider_specific_header: object, anthropic_beta: str +) -> Mapping[str, object]: + single: Final = _as_mapping(provider_specific_header) + if single is not None: + return {"provider_specific_header": _client_headers_with_anthropic_beta(single, anthropic_beta)} + scoped_headers: Final = _as_mappings(provider_specific_header) + if not scoped_headers: + return {} + return { + "provider_specific_header": tuple( + _client_headers_with_anthropic_beta(scoped, anthropic_beta) for scoped in scoped_headers + ) + } + + +def _owner_key(message_id: str) -> str: + return f"{_OWNER_KEY_PREFIX}{message_id}" + + +def _owner(user_api_key_dict: "UserAPIKeyAuth") -> str: + if user_api_key_dict.token: + return f"key:{user_api_key_dict.token}" + return "identity:" + json.dumps( + [ + user_api_key_dict.user_id, + user_api_key_dict.team_id, + user_api_key_dict.end_user_id, + user_api_key_dict.org_id, + user_api_key_dict.agent_id, + ] + ) + + +def _threads(data: Mapping[str, object]) -> tuple[object, object]: + extra_body: Final = _as_mapping(data.get("extra_body")) or {} + return data.get("thread"), extra_body.get("thread") + + +def _sends_thread(data: Mapping[str, object]) -> bool: + return any(thread is not None for thread in _threads(data)) + + +def _continued_message_ids(data: Mapping[str, object]) -> tuple[str, ...]: + continued: Final = (continued_message_id(thread) for thread in _threads(data)) + return tuple(message_id for message_id in continued if message_id is not None) + + +def _may_continue(binding: _ThreadBinding | None, owner: str, pass_through: bool) -> bool: + if binding is None: + return pass_through + return binding.owner == owner + + +def _response_message_id(response: object) -> str | None: + message: Final = _as_mapping(response) + if message is None or message.get("type") != "message": + return None + message_id: Final = message.get("id") + return message_id if isinstance(message_id, str) and message_id else None + + +def _message_start_id(event: Mapping[str, object]) -> str | None: + return _response_message_id(event.get("message")) if event.get("type") == "message_start" else None + + +def _frame_event(frame: bytes) -> Mapping[str, object] | None: + data_lines: Final = tuple( + line.strip()[len(_SSE_DATA_FIELD) :].strip() + for line in split_sse_lines_keepends(frame.decode("utf-8", errors="ignore")) + if line.strip().startswith(_SSE_DATA_FIELD) + ) + payload: Final = "".join(data_lines) + if not payload or payload == "[DONE]": + return None + try: + return _MAPPING_ADAPTER.validate_json(payload) + except ValidationError: + return None + + +class _StreamedMessageIdScanner: + def __init__(self, finished: bool) -> None: + self._head = b"" + self.finished = finished + + def scan(self, chunk: object) -> str | None: + if isinstance(chunk, (bytes, bytearray)): + return self._scan_encoded(bytes(chunk)) + if isinstance(chunk, str): + return self._scan_encoded(chunk.encode("utf-8")) + event: Final = _as_mapping(chunk) + if event is None: + self.finished = True + return None + return self._settled(event) + + def _settled(self, event: Mapping[str, object]) -> str | None: + if event.get("type") == "ping": + return None + self.finished = True + return _message_start_id(event) + + def _scan_encoded(self, data: bytes) -> str | None: + head: Final = self._head + data + boundaries: Final = tuple(match.end() for match in _SSE_FRAME_END_PATTERN.finditer(head)) + frames: Final = tuple(head[start:end] for start, end in zip((0, *boundaries[:-1]), boundaries)) + events: Final = (event for frame in frames if (event := _frame_event(frame)) is not None) + decider: Final = next((event for event in events if event.get("type") != "ping"), None) + if decider is not None: + return self._settled(decider) + self._head = head[boundaries[-1] :] if boundaries else head + if len(self._head) > _MAX_SCANNED_BYTES: + self.finished = True + return None + + +class MessageThreadOwnership(CustomLogger): + def __init__(self, message_thread_owner_cache: "InternalUsageCache") -> None: + self._owners: Final = message_thread_owner_cache + + async def async_pre_call_hook( + self, + user_api_key_dict: "UserAPIKeyAuth", + cache: "DualCache", + data: Mapping[str, object], + call_type: CallTypesLiteral, + ) -> dict[str, object] | None: # mutable-ok: the proxy swaps in a pre-call hook's result only when it is a dict + previous_message_ids: Final = _continued_message_ids(data) + if not previous_message_ids: + return None + bindings: Final = tuple( + [await self._binding(message_id, user_api_key_dict) for message_id in previous_message_ids] + ) + owner: Final = _owner(user_api_key_dict) + pass_through: Final = call_type == _PASS_THROUGH_CALL_TYPE + if not all(_may_continue(binding, owner, pass_through) for binding in bindings): + raise HTTPException(status_code=404, detail=message_thread_not_found_body(_UNOWNED_THREAD_MESSAGE)) + anthropic_beta: Final = next( + (binding.anthropic_beta for binding in bindings if binding is not None and binding.anthropic_beta), None + ) + if anthropic_beta is None or pass_through: + return None + return { + **data, + "extra_headers": _with_anthropic_beta(data.get("extra_headers"), anthropic_beta), + **_provider_headers_with_anthropic_beta(data.get("provider_specific_header"), anthropic_beta), + } + + async def async_post_call_success_hook( + self, + data: Mapping[str, object], + user_api_key_dict: "UserAPIKeyAuth", + response: LLMResponseTypes, + ) -> LLMResponseTypes: + message_id: Final = _response_message_id(response) if _sends_thread(data) else None + if message_id is not None: + await self._record_owner(message_id, user_api_key_dict, _sent_anthropic_beta(data)) + return response + + async def async_post_call_streaming_iterator_hook( # pyright: ignore[reportIncompatibleMethodOverride] # the proxy streams bytes, str and dict chunks here, not the base's ModelResponseStream + self, + user_api_key_dict: "UserAPIKeyAuth", + response: AsyncIterable[object], + request_data: Mapping[str, object], + ) -> AsyncGenerator[object, None]: + scanner: Final = _StreamedMessageIdScanner(finished=not _sends_thread(request_data)) + async for chunk in response: + yield chunk + if not scanner.finished and (message_id := scanner.scan(chunk)) is not None: + await self._record_owner(message_id, user_api_key_dict, _sent_anthropic_beta(request_data)) + + @with_service_target("message_thread_owners") + async def _binding(self, message_id: str, user_api_key_dict: "UserAPIKeyAuth") -> _ThreadBinding | None: + cached: Final[object] = await self._owners.async_get_cache( + key=_owner_key(message_id), litellm_parent_otel_span=user_api_key_dict.parent_otel_span + ) + return _as_binding(cached) + + @with_service_target("message_thread_owners") + async def _record_owner( + self, message_id: str, user_api_key_dict: "UserAPIKeyAuth", anthropic_beta: str | None + ) -> None: + if await self._binding(message_id, user_api_key_dict) is not None: + return + key: Final = _owner_key(message_id) + claimed: Final = _ThreadBinding(owner=_owner(user_api_key_dict), anthropic_beta=anthropic_beta).model_dump() + await self._owners.async_set_cache( + key=key, + value=await _first_claim(self._owners.dual_cache.redis_cache, key, claimed), + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + local_only=True, + ttl=_OWNER_TTL_SECONDS, + ) + + +async def _first_claim(store: _FirstClaimStore | None, key: str, claimed: Mapping[str, object]) -> object: + if store is None: + return claimed + try: + await store.async_set_cache(key, claimed, nx=True, ttl=_OWNER_TTL_SECONDS) + stored: Final = await store.async_get_cache(key) + except Exception: # noqa: BLE001 # a Redis outage keeps the binding in this worker, as DualCache's writes do + return claimed + return claimed if stored is None else stored diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 4468a724d2e..bf00bdfb5fb 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -65,6 +65,7 @@ from litellm.constants import ( LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED, LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL, MAX_TEAM_LIST_LIMIT, + MESSAGE_THREAD_MAX_TRACKED_OWNERS, PROXY_REJECTED_BEFORE_ROUTING_KEY, REDACTED_BY_LITELLM, REDIS_SPEND_LOGS_BUFFER_DEQUEUE_COUNT, @@ -1963,6 +1964,9 @@ class ProxyLogging: self.file_usage_cache: Final = InternalUsageCache( dual_cache=DualCache(in_memory_cache=InMemoryCache(max_size_in_memory=FILE_USAGE_MAX_TRACKED_COUNTERS)) ) + self.message_thread_owner_cache: Final = InternalUsageCache( + dual_cache=DualCache(in_memory_cache=InMemoryCache(max_size_in_memory=MESSAGE_THREAD_MAX_TRACKED_OWNERS)) + ) self.max_parallel_request_limiter = PROXY_MaxParallelRequestsHandler(self.internal_usage_cache) self.cache_control_check = PROXY_CacheControlCheck() self.alerting: list[str] | None = None @@ -2122,6 +2126,7 @@ class ProxyLogging: if redis_cache is not None: self.internal_usage_cache.dual_cache.redis_cache = redis_cache self.file_usage_cache.dual_cache.redis_cache = redis_cache + self.message_thread_owner_cache.dual_cache.redis_cache = redis_cache self.db_spend_update_writer.redis_update_buffer.redis_cache = redis_cache self.db_spend_update_writer.pod_lock_manager.redis_cache = redis_cache @@ -2141,6 +2146,7 @@ class ProxyLogging: continue available_args: Mapping[str, object] = { "internal_usage_cache": self.internal_usage_cache, + "message_thread_owner_cache": self.message_thread_owner_cache, "prisma_client": prisma_client, } passed_in_args = {name: value for name, value in available_args.items() if name in expected_args} diff --git a/litellm/router_rust/surface/health.py b/litellm/router_rust/surface/health.py index 80f7e148185..f2ac72a262a 100644 --- a/litellm/router_rust/surface/health.py +++ b/litellm/router_rust/surface/health.py @@ -279,6 +279,7 @@ class RustHealthCallbacks: is_caller_scoped_auth_failure, is_caller_timeout_408, is_failure_outside_selected_deployment, + is_message_thread_not_found, is_response_lookup_not_found, is_video_lookup_not_found, ) @@ -290,6 +291,7 @@ class RustHealthCallbacks: or (isinstance(exception, Exception) and is_background_response_poll_not_found(exception, litellm_params)) or is_video_lookup_not_found(exception, logged) or is_response_lookup_not_found(exception, logged) + or is_message_thread_not_found(exception) or is_caller_timeout_408(logged, status) # pyright: ignore[reportArgumentType] # the status as raised or is_caller_scoped_auth_failure(self._deployment_of(deployment_id), status) # pyright: ignore[reportArgumentType] # see above ) diff --git a/litellm/router_utils/cooldown_handlers.py b/litellm/router_utils/cooldown_handlers.py index 897f31840d3..190ef43a3e3 100644 --- a/litellm/router_utils/cooldown_handlers.py +++ b/litellm/router_utils/cooldown_handlers.py @@ -30,6 +30,7 @@ from litellm.litellm_core_utils.litellm_logging import mask_api_base_credentials from litellm.router_deprecated import renamed_router_param from litellm.router_utils.cooldown_cache import CooldownCacheValue from litellm.router_utils.cooldown_callbacks import router_cooldown_event_callback +from litellm.types.llms.anthropic import MESSAGE_THREAD_NOT_FOUND_ERROR_CODE from litellm.types.router import Deployment from litellm.types.utils import ( BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN, @@ -106,6 +107,12 @@ def is_video_lookup_not_found(exception: object, failure_kwargs: Mapping[str, ob return getattr(exception, "status_code", None) == 404 and failure_kwargs.get("call_type") in VIDEO_ID_CALL_TYPES +def is_message_thread_not_found(exception: object) -> bool: + """Whether a message-thread continue failed with Anthropic's 404 ``thread_not_found``: the caller's + ``previous_message_id`` is stale and the SDK replays the conversation, the deployment is healthy.""" + return getattr(exception, "status_code", None) == 404 and MESSAGE_THREAD_NOT_FOUND_ERROR_CODE in str(exception) + + # The calls on an existing response id. ``compact_responses`` is left out: it sends a model and input, # so its 404 can mean a missing model rather than a missing response. RESPONSE_ID_CALL_TYPES: Final = frozenset( diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 3abe64df3c6..244883859f0 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -448,6 +448,14 @@ class AnthropicCompaction(TypedDict, total=False): instructions: ReadOnly[str] +class AnthropicMessageThread(TypedDict, total=False): + """``thread`` body field of the message-threads beta: ``{"type": "create"}`` opens a server-side + thread, ``{"type": "continue", "previous_message_id": "msg_..."}`` appends only the new messages to it.""" + + type: Required[ReadOnly[Literal["create", "continue"]]] + previous_message_id: ReadOnly[str] + + class AnthropicMessagesRequestOptionalParams(TypedDict, total=False): max_tokens: int | None metadata: AnthropicMetadata | dict | None @@ -472,6 +480,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False): reasoning_effort: str | None safeguards: ReadOnly[list[dict[str, object]] | None] service_tier: ReadOnly[str | None] + thread: ReadOnly[AnthropicMessageThread | None] class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False): @@ -883,6 +892,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum): PER_TURN_CONTROL_2026_07_01 = "per-turn-control-2026-07-01" DANGEROUS_TOOL_USE_2026_09_03 = "dangerous-tool-use-2026-09-03" MCP_CLIENT_2025_04_04 = "mcp-client-2025-04-04" + MESSAGE_THREADS_2026_08_12 = "message-threads-2026-08-12" # Tool search beta header constant (for Anthropic direct API and Microsoft Foundry) @@ -910,6 +920,9 @@ ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24" ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER: Final = "mid-conversation-output-config-2026-07-01" ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER: Final = "thinking-display-updates-2026-08-18" + +MESSAGE_THREAD_NOT_FOUND_ERROR_CODE: Final = "thread_not_found" +MESSAGE_THREAD_UNSUPPORTED_ERROR_CODE: Final = "thread_unsupported_request" ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER: Final = "thinking-binding-controls-2026-08-01" ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER: Final = "mid-conversation-tool-changes-2026-07-01" diff --git a/tests/integration/messages_endpoint/test_message_threads_wire.py b/tests/integration/messages_endpoint/test_message_threads_wire.py new file mode 100644 index 00000000000..2ba3e627ea0 --- /dev/null +++ b/tests/integration/messages_endpoint/test_message_threads_wire.py @@ -0,0 +1,413 @@ +import json +import re +import uuid +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import pytest +from integration._support.client import Gateway, gateway_from_environment +from integration._support.process import owned_proxy +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +pytestmark: Final = pytest.mark.shares_worker_state + +ANTHROPIC_MODEL: Final = "threads-anthropic" +BRIDGED_MODEL: Final = "threads-bridged" +BRIDGED_DROP_PARAMS_MODEL: Final = "threads-bridged-drop-params" +PASSTHROUGH_MODEL: Final = "threads-messages-passthrough" +MESSAGE_THREADS_BETA: Final = "message-threads-2026-08-12" +EXPIRED_THREAD_MARKER: Final = "thread-expired" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_NONCE: Final = re.compile(rb"nonce-([0-9a-f]{32})") + + +def nonce() -> str: + return f"nonce-{uuid.uuid4().hex}" + + +def thread_not_found_reply() -> Reply: + return Reply( + status=404, + body=json.dumps( + { + "type": "error", + "error": { + "type": "not_found_error", + "message": "No thread state was found for the requested `previous_message_id`.", + "details": {"error_code": "thread_not_found"}, + }, + "request_id": "req_upstream", + } + ).encode(), + ) + + +def anthropic_message(identity: str) -> dict[str, JsonValue]: + return { + "id": f"msg_{identity}", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-8", + "content": [{"type": "text", "text": f"answered {identity}"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 3}, + } + + +def sse_event(event: str, data: Mapping[str, JsonValue]) -> bytes: + return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() + + +def streamed_anthropic_reply(identity: str) -> Reply: + message_start: Final = sse_event( + "message_start", {"type": "message_start", "message": {**anthropic_message(identity), "content": []}} + ) + return Reply( + chunks=( + message_start[:40], + message_start[40:], + sse_event( + "content_block_start", + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + ), + sse_event( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ok"}}, + ), + sse_event("content_block_stop", {"type": "content_block_stop", "index": 0}), + sse_event( + "message_delta", + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1}}, + ), + sse_event("message_stop", {"type": "message_stop"}), + ), + content_type="text/event-stream", + ) + + +def responses_reply(identity: str) -> Reply: + return Reply( + body=json.dumps( + { + "id": f"resp_{identity}", + "object": "response", + "created_at": 1789788253, + "status": "completed", + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "id": f"msg_{identity}", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": f"answered {identity}", "annotations": []}], + } + ], + "usage": {"input_tokens": 12, "output_tokens": 3, "total_tokens": 15}, + } + ).encode() + ) + + +def respond(request: Request) -> Reply: + if request.method != "POST": + return Reply(body=b'{"object":"list","data":[]}') + match: Final = _NONCE.search(request.body) + assert match is not None, request.body[:200] + identity: Final = match.group(1).decode() + if request.target.startswith("/openai"): + return responses_reply(identity) + if EXPIRED_THREAD_MARKER.encode() in request.body: + return thread_not_found_reply() + if b'"stream": true' in request.body or b'"stream":true' in request.body: + return streamed_anthropic_reply(identity) + return Reply(body=json.dumps(anthropic_message(identity)).encode()) + + +def write_config(directory: Path, wire_url: str) -> Path: + path: Final = directory / f"threads-{uuid.uuid4().hex}.json" + path.write_text( + json.dumps( + { + "model_list": [ + { + "model_name": ANTHROPIC_MODEL, + "litellm_params": { + "model": "anthropic/claude-opus-4-8", + "api_base": f"{wire_url}/anthropic", + "api_key": "synthetic-anthropic-key", + }, + }, + { + "model_name": BRIDGED_MODEL, + "litellm_params": { + "model": "openai/gpt-5.5", + "api_base": f"{wire_url}/openai", + "api_key": "synthetic-openai-key", + }, + }, + { + "model_name": BRIDGED_DROP_PARAMS_MODEL, + "litellm_params": { + "model": "openai/gpt-5.5", + "api_base": f"{wire_url}/openai", + "api_key": "synthetic-openai-key", + "drop_params": True, + }, + }, + { + "model_name": PASSTHROUGH_MODEL, + "litellm_params": { + "model": "hosted_vllm/qwen3-coder", + "api_base": f"{wire_url}/vllm", + "api_key": "synthetic-vllm-key", + }, + "model_info": {"supported_endpoints": ["/v1/messages"]}, + }, + ], + "general_settings": { + "master_key": "os.environ/LITELLM_MASTER_KEY", + "database_url": "os.environ/DATABASE_URL", + }, + } + ) + ) + return path + + +@dataclass(frozen=True, slots=True) +class Rig: + gateway: Gateway + wire: Wire + + def send( + self, + model: str, + request_nonce: str, + thread: Mapping[str, str], + *, + key: str | None = None, + text: str = "hi", + **fields: JsonValue, + ) -> tuple[int, bytes]: + response: Final = self.gateway.client.post( + "/v1/messages", + json={ + "model": model, + "max_tokens": 64, + "messages": [{"role": "user", "content": f"{text} {request_nonce}"}], + "thread": dict(thread), + **fields, + }, + headers={"Authorization": f"Bearer {self.gateway.key if key is None else key}"}, + timeout=120, + ) + return response.status_code, response.content + + def create(self, *, key: str | None = None, **fields: JsonValue) -> str: + request_nonce: Final = nonce() + status, raw = self.send(ANTHROPIC_MODEL, request_nonce, {"type": "create"}, key=key, **fields) + assert status == 200, raw[:500] + self.received(request_nonce) + return f"msg_{request_nonce.removeprefix('nonce-')}" + + def received(self, request_nonce: str) -> tuple[Request, ...]: + return tuple(request for request in self.wire.drain() if request_nonce.encode() in request.body) + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + directory: Final = tmp_path_factory.mktemp("threads") + with ( + gateway_from_environment() as shared, + wire_server(respond) as wire, + owned_proxy( + shared, + directory, + { + "LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS": "true", + "ANTHROPIC_API_BASE": f"{wire.url}/anthropic", + "ANTHROPIC_API_KEY": "synthetic-anthropic-key", + }, + config=write_config(directory, wire.url), + workers=1, + ) as gateway, + ): + yield Rig(gateway, wire) + + +def beta_header(headers: Mapping[str, str]) -> frozenset[str]: + return frozenset( + piece for key, value in headers.items() if key.lower() == "anthropic-beta" for piece in value.split(",") + ) # comprehension-ok: one header line may carry several comma-separated betas + + +def test_a_create_turn_reaches_anthropic_with_the_thread_field_and_its_beta(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(ANTHROPIC_MODEL, request_nonce, {"type": "create"}, system="be terse") + assert status == 200, raw[:500] + assert _JSON_OBJECT.validate_json(raw)["id"] == f"msg_{request_nonce.removeprefix('nonce-')}" + (upstream,) = rig.received(request_nonce) + body: Final = _JSON_OBJECT.validate_json(upstream.body) + assert body["thread"] == {"type": "create"} + assert body["system"] == "be terse" + assert MESSAGE_THREADS_BETA in beta_header(upstream.headers), upstream.headers + + +def test_a_continue_turn_reaches_anthropic_with_only_the_new_messages_and_the_same_beta(rig: Rig) -> None: + request_nonce: Final = nonce() + thread: Final = {"type": "continue", "previous_message_id": rig.create()} + status, raw = rig.send(ANTHROPIC_MODEL, request_nonce, thread) + assert status == 200, raw[:500] + (upstream,) = rig.received(request_nonce) + body: Final = _JSON_OBJECT.validate_json(upstream.body) + assert body["thread"] == thread + assert body["messages"] == [{"role": "user", "content": f"hi {request_nonce}"}] + assert "system" not in body and "tools" not in body + assert MESSAGE_THREADS_BETA in beta_header(upstream.headers), upstream.headers + + +def assert_thread_not_found(status: int, raw: bytes) -> None: + assert status == 404, raw[:500] + error: Final = _JSON_OBJECT.validate_json(raw) + detail: Final = error["error"] + assert isinstance(detail, dict), error + assert detail["type"] == "not_found_error" + assert detail["details"] == {"error_code": "thread_not_found"} + + +@pytest.mark.parametrize("model", [BRIDGED_MODEL, PASSTHROUGH_MODEL], ids=["translated", "messages-passthrough"]) +def test_a_create_turn_on_a_leg_without_threads_is_thread_unsupported_and_never_reaches_the_provider( + rig: Rig, model: str +) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(model, request_nonce, {"type": "create"}) + assert status == 400, raw[:500] + detail: Final = _JSON_OBJECT.validate_json(raw)["error"] + assert isinstance(detail, dict), raw[:500] + assert detail["type"] == "invalid_request_error" + assert detail["details"] == {"error_code": "thread_unsupported_request"} + assert rig.received(request_nonce) == () + + +def test_a_create_turn_on_a_translated_leg_is_served_without_the_thread_under_drop_params(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(BRIDGED_DROP_PARAMS_MODEL, request_nonce, {"type": "create"}) + assert status == 200, raw[:500] + (upstream,) = rig.received(request_nonce) + assert upstream.target.startswith("/openai"), upstream.target + assert "thread" not in _JSON_OBJECT.validate_json(upstream.body) + + +@pytest.mark.parametrize("model", [BRIDGED_MODEL, PASSTHROUGH_MODEL], ids=["translated", "messages-passthrough"]) +def test_a_continue_turn_on_a_leg_without_threads_is_thread_not_found_and_never_reaches_the_provider( + rig: Rig, model: str +) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(model, request_nonce, {"type": "continue", "previous_message_id": rig.create()}) + assert_thread_not_found(status, raw) + assert rig.received(request_nonce) == () + + +def test_another_key_cannot_continue_a_thread_and_never_reaches_anthropic(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + owner: Final = scenario.key() + intruder: Final = scenario.key() + thread: Final = {"type": "continue", "previous_message_id": rig.create(key=owner)} + intruder_nonce: Final = nonce() + assert_thread_not_found(*rig.send(ANTHROPIC_MODEL, intruder_nonce, thread, key=intruder)) + assert rig.received(intruder_nonce) == () + owner_nonce: Final = nonce() + status, raw = rig.send(ANTHROPIC_MODEL, owner_nonce, thread, key=owner) + assert status == 200, raw[:500] + assert len(rig.received(owner_nonce)) == 1 + + +def test_another_key_cannot_continue_a_thread_through_chat_completions(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + owner: Final = scenario.key() + intruder: Final = scenario.key() + thread: Final = {"type": "continue", "previous_message_id": rig.create(key=owner)} + intruder_nonce: Final = nonce() + response: Final = rig.gateway.client.post( + "/v1/chat/completions", + json={ + "model": ANTHROPIC_MODEL, + "max_tokens": 64, + "messages": [{"role": "user", "content": f"hi {intruder_nonce}"}], + "thread": thread, + }, + headers={"Authorization": f"Bearer {intruder}"}, + timeout=120, + ) + assert response.status_code == 404, response.content[:500] + assert rig.received(intruder_nonce) == () + + +ADVISOR_TOOL: Final = {"type": "advisor_20260301", "name": "advisor", "model": "claude-opus-4-8"} +ADVISOR_BETA: Final = "advisor-tool-2026-03-01" + + +@pytest.mark.parametrize("stream", [False, True], ids=["json", "streamed"]) +def test_a_continue_turn_resends_the_betas_its_create_turn_took_from_its_tools(rig: Rig, stream: bool) -> None: + create_nonce: Final = nonce() + status, raw = rig.send(ANTHROPIC_MODEL, create_nonce, {"type": "create"}, tools=[ADVISOR_TOOL], stream=stream) + assert status == 200, raw[:500] + (created,) = rig.received(create_nonce) + create_betas: Final = beta_header(created.headers) + assert {ADVISOR_BETA, MESSAGE_THREADS_BETA} <= create_betas, created.headers + continue_nonce: Final = nonce() + thread: Final = {"type": "continue", "previous_message_id": f"msg_{create_nonce.removeprefix('nonce-')}"} + status, raw = rig.send(ANTHROPIC_MODEL, continue_nonce, thread) + assert status == 200, raw[:500] + (continued,) = rig.received(continue_nonce) + assert "tools" not in _JSON_OBJECT.validate_json(continued.body) + assert beta_header(continued.headers) == create_betas, continued.headers + + +def pass_through_continue(rig: Rig, request_nonce: str, previous_message_id: str, key: str) -> int: + return rig.gateway.client.post( + "/anthropic/v1/messages", + json={ + "model": "claude-opus-4-8", + "max_tokens": 64, + "messages": [{"role": "user", "content": f"hi {request_nonce}"}], + "thread": {"type": "continue", "previous_message_id": previous_message_id}, + }, + headers={"Authorization": f"Bearer {key}"}, + timeout=120, + ).status_code + + +def test_another_key_cannot_continue_a_thread_through_the_anthropic_pass_through_route(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + owner: Final = scenario.key() + intruder: Final = scenario.key() + previous_message_id: Final = rig.create(key=owner) + intruder_nonce: Final = nonce() + assert pass_through_continue(rig, intruder_nonce, previous_message_id, intruder) == 404 + assert rig.received(intruder_nonce) == () + owner_nonce: Final = nonce() + assert pass_through_continue(rig, owner_nonce, previous_message_id, owner) == 200 + assert len(rig.received(owner_nonce)) == 1 + + +def test_a_streamed_create_turn_can_be_continued_by_its_key(rig: Rig) -> None: + previous_message_id: Final = rig.create(stream=True) + request_nonce: Final = nonce() + status, raw = rig.send( + ANTHROPIC_MODEL, request_nonce, {"type": "continue", "previous_message_id": previous_message_id} + ) + assert status == 200, raw[:500] + assert len(rig.received(request_nonce)) == 1 + + +def test_a_thread_not_found_from_anthropic_keeps_its_error_code_for_the_sdk_to_replay_on(rig: Rig) -> None: + request_nonce: Final = nonce() + thread: Final = {"type": "continue", "previous_message_id": rig.create()} + assert_thread_not_found(*rig.send(ANTHROPIC_MODEL, request_nonce, thread, text=EXPIRED_THREAD_MARKER)) + assert len(rig.received(request_nonce)) == 1 diff --git a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py index 210ab6717aa..0e0e1c8c53e 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -258,3 +258,48 @@ class TestAnthropicErrorSseFrame: "type": "error", "error": {"type": "api_error", "message": "upstream unavailable"}, } + + +THREAD_NOT_FOUND_UPSTREAM: Final = json.dumps( + { + "type": "error", + "error": { + "type": "not_found_error", + "message": "No thread state was found for the requested `previous_message_id`.", + "details": {"error_code": "thread_not_found"}, + }, + "request_id": "req_upstream", + } +) + + +class TestEmbeddedErrorDetails: + def test_a_wrapped_upstream_error_keeps_its_details_next_to_the_wrapped_message(self) -> None: + wrapped: Final = ( + f"litellm.NotFoundError: AnthropicException - {THREAD_NOT_FOUND_UPSTREAM}\n\n" + "LiteLLM: model group 'haiku' failed with the error above. No fallback was attempted." + ) + response: Final = AnthropicExceptionMapping.transform_to_anthropic_error(status_code=404, raw_message=wrapped) + assert response["error"]["details"] == {"error_code": "thread_not_found"} + assert response["error"]["type"] == "not_found_error" + assert response["error"]["message"] == wrapped + + def test_the_error_event_of_a_failed_stream_carries_the_same_details(self) -> None: + wrapped: Final = f"litellm.NotFoundError: AnthropicException - {THREAD_NOT_FOUND_UPSTREAM}" + frame: Final = anthropic_error_sse_frame(404, wrapped) + assert frame.error_response["error"]["details"] == {"error_code": "thread_not_found"} + + @pytest.mark.parametrize( + "raw_message", + ( + "plain text with a { brace and no json", + 'litellm.BadRequestError: OpenAIException - {"error": {"message": "bad", "type": "invalid_request_error"}}', + 'AnthropicException - {"type": "error", "error": {"type": "api_error", "message": "no details here"}}', + ), + ids=("no_json", "openai_shape", "anthropic_without_details"), + ) + def test_a_message_without_anthropic_details_gets_no_details_key(self, raw_message: str) -> None: + response: Final = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=400, raw_message=raw_message + ) + assert "details" not in response["error"] diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py index f6300b2e70c..ff150af324d 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py @@ -2801,6 +2801,85 @@ async def test_anthropic_messages_strict_tools_rejection_is_sent_again_without_s assert _JSON_BODY.validate_python(response)["id"] == "msg_strict_retry" +@pytest.mark.parametrize( + "model", + ("openai/gpt-5.5", "vertex_ai/claude-opus-4-8", "bedrock/anthropic.claude-opus-4-8-v1:0"), + ids=("chat_bridge", "vertex_native", "bedrock_native"), +) +def test_a_thread_continue_on_a_leg_without_message_threads_is_thread_not_found_before_any_upstream_call( + model: str, +) -> None: + from litellm.llms.anthropic.common_utils import AnthropicError + from litellm.llms.anthropic.messages.native.handler import anthropic_messages_handler + + with pytest.raises(AnthropicError) as raised: + anthropic_messages_handler( + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + model=model, + api_key="synthetic-key", + thread={"type": "continue", "previous_message_id": "msg_01previous"}, + ) + assert raised.value.status_code == 404 + error: Final = _JSON_BODY.validate_json(raised.value.message)["error"] + assert isinstance(error, dict) + assert error["type"] == "not_found_error" + assert error["details"] == {"error_code": "thread_not_found"} + + +@pytest.mark.parametrize( + "model", + ("openai/gpt-5.5", "vertex_ai/claude-opus-4-8", "bedrock/anthropic.claude-opus-4-8-v1:0"), + ids=("chat_bridge", "vertex_native", "bedrock_native"), +) +def test_a_thread_create_on_a_leg_without_message_threads_is_thread_unsupported_before_any_upstream_call( + model: str, +) -> None: + from litellm.llms.anthropic.common_utils import AnthropicError + from litellm.llms.anthropic.messages.native.handler import anthropic_messages_handler + + with pytest.raises(AnthropicError) as raised: + anthropic_messages_handler( + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + model=model, + api_key="synthetic-key", + thread={"type": "create"}, + ) + assert raised.value.status_code == 400 + error: Final = _JSON_BODY.validate_json(raised.value.message)["error"] + assert isinstance(error, dict) + assert error["type"] == "invalid_request_error" + assert error["details"] == {"error_code": "thread_unsupported_request"} + + +@pytest.mark.asyncio +async def test_a_thread_create_on_a_native_leg_without_message_threads_is_served_without_the_thread_under_drop_params( + local_beta_headers_config: None, +) -> None: + from litellm.llms.anthropic.messages.native import handler + + captured: Final[dict[str, object]] = {} + response: Final = await handler.anthropic_messages( + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + model="bedrock/us.anthropic.claude-sonnet-5", + custom_llm_provider="bedrock", + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + aws_region_name="us-east-1", + client=_upstream_answering_with([], captured), + thread={"type": "create"}, + drop_params=True, + ) + + body: Final = captured["body"] + assert isinstance(body, dict) + assert "thread" not in body + assert body["messages"] == [{"role": "user", "content": "hi"}] + assert _JSON_BODY.validate_python(response)["id"] == "msg_1" + + @pytest.mark.asyncio @pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) @pytest.mark.parametrize("stream", [False, True], ids=["complete", "stream"]) diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py index 8f86888728f..97d5b0550d4 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py @@ -17,6 +17,7 @@ from litellm.llms.anthropic.messages.native.transformation import ( AnthropicMessagesConfig, ) from litellm.llms.anthropic.wif import get_anthropic_wif_token +from litellm.types.llms.anthropic_tool_search import get_tool_search_beta_header from litellm.llms.deepseek.messages.transformation import DeepSeekAnthropicMessagesConfig from litellm.llms.minimax.messages.transformation import MinimaxMessagesConfig from litellm.llms.openai_like.messages.transformation import OpenAILikeAnthropicMessagesConfig @@ -723,6 +724,135 @@ def test_a_first_party_messages_request_never_fetches_the_remote_beta_headers_co assert not remote_config.called +MESSAGE_THREADS_BETA: Final = "message-threads-2026-08-12" + + +@pytest.mark.parametrize( + "thread", + ({"type": "create"}, {"type": "continue", "previous_message_id": "msg_01previous"}), + ids=("create", "continue"), +) +def test_messages_request_forwards_the_thread_field_to_anthropic(thread: dict[str, str]) -> None: + request: Final = AnthropicMessagesConfig().transform_anthropic_messages_request( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + model="claude-opus-4-8", + messages=[{"role": "user", "content": "what did I just say"}], + anthropic_messages_optional_request_params={"max_tokens": 64, "thread": dict(thread)}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request["thread"] == thread + assert request["messages"] == [{"role": "user", "content": "what did I just say"}] + + +def test_messages_headers_add_the_message_threads_beta_next_to_the_callers_betas() -> None: + headers: Final = AnthropicMessagesConfig()._update_headers_with_anthropic_beta( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + headers={"anthropic-beta": "context-1m-2025-08-07"}, + optional_params={"thread": {"type": "continue", "previous_message_id": "msg_01previous"}}, + messages=[], + ) + assert headers["anthropic-beta"] == f"context-1m-2025-08-07,{MESSAGE_THREADS_BETA}" + + +def test_messages_headers_add_no_message_threads_beta_without_a_thread() -> None: + headers: Final = AnthropicMessagesConfig()._update_headers_with_anthropic_beta( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + headers={}, optional_params={"max_tokens": 64}, messages=[] + ) + assert MESSAGE_THREADS_BETA not in headers.get("anthropic-beta", "") # pyright: ignore[reportUnknownMemberType] # bare-dict result + + +TOOL_SEARCH_TOOLS: Final = ( + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + {"type": "advisor_20260301", "name": "advisor", "model": "claude-opus-4-8"}, +) +TURN_WITH_OUTPUT_CONFIG: Final = ( + {"role": "user", "content": "be brief", "output_config": {"effort": "low"}}, + { + "role": "system", + "content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "tool_name": "lookup_record"}}], + }, +) + + +def test_a_thread_create_turn_takes_betas_from_its_tools_and_messages() -> None: + headers: Final = AnthropicMessagesConfig()._update_headers_with_anthropic_beta( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + headers={"anthropic-beta": "context-1m-2025-08-07"}, + optional_params={"thread": {"type": "create"}, "system": "be terse", "tools": list(TOOL_SEARCH_TOOLS)}, + messages=list(TURN_WITH_OUTPUT_CONFIG), + ) + assert set(headers["anthropic-beta"].split(",")) == { # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # bare-dict result + "context-1m-2025-08-07", + MESSAGE_THREADS_BETA, + get_tool_search_beta_header("anthropic"), + "advisor-tool-2026-03-01", + "per-turn-control-2026-07-01", + "mid-conversation-tool-changes-2026-07-01", + } + + +def test_a_thread_continue_turn_takes_no_betas_from_its_new_messages() -> None: + headers: Final = AnthropicMessagesConfig()._update_headers_with_anthropic_beta( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + headers={"anthropic-beta": "context-1m-2025-08-07"}, + optional_params={"thread": {"type": "continue", "previous_message_id": "msg_01p"}}, + messages=list(TURN_WITH_OUTPUT_CONFIG), + ) + assert headers["anthropic-beta"] == f"context-1m-2025-08-07,{MESSAGE_THREADS_BETA}" + + +def test_a_request_without_a_thread_still_takes_betas_from_its_tools_and_messages() -> None: + headers: Final = AnthropicMessagesConfig()._update_headers_with_anthropic_beta( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + headers={}, optional_params={"tools": list(TOOL_SEARCH_TOOLS)}, messages=list(TURN_WITH_OUTPUT_CONFIG) + ) + assert set(headers["anthropic-beta"].split(",")) >= { # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # bare-dict result + get_tool_search_beta_header("anthropic"), + "advisor-tool-2026-03-01", + "per-turn-control-2026-07-01", + "mid-conversation-tool-changes-2026-07-01", + } + + +def _advisor_turn() -> list[dict[str, object]]: + return [ + { + "role": "assistant", + "content": [ + {"type": "server_tool_use", "id": "srvtoolu_01", "name": "advisor", "input": {}}, + { + "type": "advisor_tool_result", + "tool_use_id": "srvtoolu_01", + "content": [{"type": "text", "text": "ok"}], + }, + {"type": "text", "text": "done"}, + ], + }, + {"role": "user", "content": "and now?"}, + ] + + +def test_a_thread_continue_turn_keeps_advisor_blocks_the_stored_tools_still_explain() -> None: + request: Final = AnthropicMessagesConfig().transform_anthropic_messages_request( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + model="claude-opus-4-8", + messages=_advisor_turn(), + anthropic_messages_optional_request_params={ + "max_tokens": 64, + "thread": {"type": "continue", "previous_message_id": "msg_01previous"}, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request["messages"][0]["content"] == _advisor_turn()[0]["content"] + + +def test_a_request_without_a_thread_still_strips_advisor_blocks_when_the_tool_is_absent() -> None: + request: Final = AnthropicMessagesConfig().transform_anthropic_messages_request( # pyright: ignore[reportUnknownMemberType] # bare-dict signature + model="claude-opus-4-8", + messages=_advisor_turn(), + anthropic_messages_optional_request_params={"max_tokens": 64}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request["messages"][0]["content"] == [{"type": "text", "text": "done"}] + + @pytest.mark.parametrize( "model, expected", [ diff --git a/tests/unit/llms/anthropic/messages/native/test_request_optional_param_utils.py b/tests/unit/llms/anthropic/messages/native/test_request_optional_param_utils.py index 8104c0cef56..910234fa3f1 100644 --- a/tests/unit/llms/anthropic/messages/native/test_request_optional_param_utils.py +++ b/tests/unit/llms/anthropic/messages/native/test_request_optional_param_utils.py @@ -137,6 +137,14 @@ def test_sampling_param_raises_clean_400_without_drop_params(monkeypatch): ) +def test_the_thread_field_survives_optional_param_filtering(): + thread: Final = {"type": "continue", "previous_message_id": "msg_01previous"} + result: Final = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param( + {"max_tokens": 64, "thread": thread} + ) + assert result == {"max_tokens": 64, "thread": thread} + + @pytest.mark.parametrize( ("model", "param", "value"), [ diff --git a/tests/unit/llms/bedrock/test_claude_platform_provider.py b/tests/unit/llms/bedrock/test_claude_platform_provider.py index 9142fe45752..b1c170a6f6f 100644 --- a/tests/unit/llms/bedrock/test_claude_platform_provider.py +++ b/tests/unit/llms/bedrock/test_claude_platform_provider.py @@ -927,3 +927,30 @@ def test_sigv4_no_duplicate_content_type_when_caller_sets_lowercase(): "Duplicate keys produce 'application/json, application/json' in the " "SigV4 canonical string and cause a 401." ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "thread", + ({"type": "create"}, {"type": "continue", "previous_message_id": "msg_01previous"}), + ids=("create", "continue"), +) +async def test_anthropic_messages_bedrock_claude_platform_forwards_a_message_thread(thread: dict[str, str]) -> None: + import litellm + + requests: list[dict] = [] + + await litellm.anthropic_messages( + model="bedrock/claude_platform/claude-sonnet-4-6", + messages=[{"role": "user", "content": "what did I just say"}], + max_tokens=10, + api_base="https://aws-external-anthropic.us-west-2.api.aws", + api_key="fake-platform-key", + workspace_id="wrkspc_test", + thread=dict(thread), + client=_async_gateway_client(requests, lambda url, headers, data: _anthropic_response(url)), + ) + + assert len(requests) == 1 + assert requests[0]["body"]["thread"] == thread + assert requests[0]["headers"]["anthropic-beta"] == "message-threads-2026-08-12" diff --git a/tests/unit/proxy/hooks/test_message_thread_ownership.py b/tests/unit/proxy/hooks/test_message_thread_ownership.py new file mode 100644 index 00000000000..e596e2958b9 --- /dev/null +++ b/tests/unit/proxy/hooks/test_message_thread_ownership.py @@ -0,0 +1,558 @@ +import asyncio +import datetime +import json +from collections.abc import AsyncIterator, Sequence +from typing import Final + +import httpx +import pytest +from fastapi import HTTPException +from pydantic import JsonValue, TypeAdapter + +from litellm.caching.caching import DualCache +from litellm.caching.redis_cache import RedisCache +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.hooks.message_thread_ownership import MessageThreadOwnership +from litellm.types.utils import CallTypesLiteral +from litellm.proxy.utils import InternalUsageCache, ProxyLogging + +OWNER: Final = UserAPIKeyAuth(token="hashed-owner-key") +INTRUDER: Final = UserAPIKeyAuth(token="hashed-intruder-key") +CREATE: Final = {"type": "create"} +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) + + +def continue_from(message_id: str) -> dict[str, object]: + return {"thread": {"type": "continue", "previous_message_id": message_id}, "messages": []} + + +def anthropic_message(message_id: str) -> dict[str, object]: + return {"id": message_id, "type": "message", "role": "assistant", "content": []} + + +def sse_frame(event: dict[str, object]) -> bytes: + return f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() + + +def message_start(message_id: str) -> dict[str, object]: + return {"type": "message_start", "message": anthropic_message(message_id)} + + +def sent_with(anthropic_beta: str, **data: object) -> dict[str, object]: + logging_obj: Final = Logging( + model="claude-opus-4-8", + messages=[], + stream=False, + call_type="anthropic_messages", + start_time=datetime.datetime.now(), + litellm_call_id="call", + function_id="function", + ) + sent: Final = httpx.Request("POST", "https://api.anthropic.com/v1/messages", headers={"anthropic-beta": anthropic_beta}) + logging_obj.model_call_details["httpx_response"] = httpx.Response(200, request=sent) + logging_obj.post_call(original_response="{}", additional_args={"complete_input_dict": {}}) + return {**data, "litellm_logging_obj": logging_obj} + + +def hook() -> MessageThreadOwnership: + return MessageThreadOwnership(message_thread_owner_cache=InternalUsageCache(DualCache())) + + +async def chunks(items: Sequence[object]) -> AsyncIterator[object]: + for item in items: + yield item + + +async def served( + ownership: MessageThreadOwnership, items: Sequence[object], request_data: dict[str, object] +) -> list[object]: + return [ + chunk + async for chunk in ownership.async_post_call_streaming_iterator_hook( + user_api_key_dict=OWNER, response=chunks(items), request_data=request_data + ) + ] + + +async def rejection( + ownership: MessageThreadOwnership, key: UserAPIKeyAuth, message_id: str, call_type: CallTypesLiteral +) -> HTTPException | None: + try: + await ownership.async_pre_call_hook( + user_api_key_dict=key, cache=DualCache(), data=continue_from(message_id), call_type=call_type + ) + except HTTPException as rejected: + return rejected + return None + + +async def allows( + ownership: MessageThreadOwnership, + key: UserAPIKeyAuth, + message_id: str, + call_type: CallTypesLiteral = "anthropic_messages", +) -> bool: + rejected: Final = await rejection(ownership, key, message_id, call_type) + if rejected is None: + return True + assert rejected.status_code == 404 + error: Final = _JSON_OBJECT.validate_json(rejected.detail)["error"] + assert isinstance(error, dict) + assert error["type"] == "not_found_error" + assert error["details"] == {"error_code": "thread_not_found"} + return False + + +@pytest.mark.asyncio +async def test_the_proxy_starts_with_a_hook_that_keeps_each_thread_with_its_key() -> None: + proxy_logging: Final = ProxyLogging(user_api_key_cache=DualCache()) + proxy_logging._add_proxy_hooks() + await proxy_logging.post_call_success_hook( + data={"thread": CREATE}, response=anthropic_message("msg_started"), user_api_key_dict=OWNER + ) + + with pytest.raises(HTTPException) as rejected: + await proxy_logging.pre_call_hook( + user_api_key_dict=INTRUDER, data=continue_from("msg_started"), call_type="anthropic_messages" + ) + assert rejected.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_only_the_key_that_created_a_thread_can_continue_it() -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message("msg_created") + ) + + assert await allows(ownership, OWNER, "msg_created") + assert not await allows(ownership, INTRUDER, "msg_created") + + +@pytest.mark.asyncio +async def test_without_a_key_only_the_signed_in_identity_that_created_a_thread_can_continue_it() -> None: + ownership: Final = hook() + alice: Final = UserAPIKeyAuth(user_id="alice", team_id="team-1") + bob: Final = UserAPIKeyAuth(user_id="bob", team_id="team-1") + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=alice, response=anthropic_message("msg_signed_in") + ) + + assert await allows(ownership, alice, "msg_signed_in") + assert not await allows(ownership, bob, "msg_signed_in") + + +@pytest.mark.asyncio +async def test_without_a_key_one_managed_agent_cannot_continue_another_agents_thread() -> None: + ownership: Final = hook() + planner: Final = UserAPIKeyAuth(agent_id="agent-planner") + coder: Final = UserAPIKeyAuth(agent_id="agent-coder") + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=planner, response=anthropic_message("msg_agent") + ) + + assert await allows(ownership, planner, "msg_agent") + assert not await allows(ownership, coder, "msg_agent") + + +@pytest.mark.parametrize("call_type", ("anthropic_messages", "acompletion", "aresponses")) +@pytest.mark.asyncio +async def test_a_continue_naming_a_message_this_proxy_never_served_in_a_thread_is_thread_not_found( + call_type: CallTypesLiteral, +) -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data={}, user_api_key_dict=OWNER, response=anthropic_message("msg_unthreaded") + ) + + assert not await allows(ownership, OWNER, "msg_unthreaded", call_type) + assert not await allows(ownership, OWNER, "msg_unknown", call_type) + assert not await allows(ownership, INTRUDER, "msg_unknown", call_type) + + +@pytest.mark.asyncio +async def test_each_continued_turn_hands_its_own_message_to_the_same_key() -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message("msg_first") + ) + await ownership.async_post_call_success_hook( + data=continue_from("msg_first"), user_api_key_dict=OWNER, response=anthropic_message("msg_second") + ) + + assert await allows(ownership, OWNER, "msg_second") + assert not await allows(ownership, INTRUDER, "msg_second") + + +@pytest.mark.asyncio +async def test_a_reply_served_again_to_another_key_stays_with_the_key_that_got_it_first() -> None: + ownership: Final = hook() + for key in (OWNER, INTRUDER): + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=key, response=anthropic_message("msg_cached") + ) + + assert await allows(ownership, OWNER, "msg_cached") + assert not await allows(ownership, INTRUDER, "msg_cached") + + +@pytest.mark.asyncio +async def test_a_worker_that_writes_after_another_worker_claimed_the_reply_keeps_the_first_owner( + router_redis: tuple[RedisCache, object], +) -> None: + redis_cache, _ = router_redis + late_write_started: Final = asyncio.Event() + owner_stored: Final = asyncio.Event() + + class WritesAfterTheOwner(RedisCache): + async def async_set_cache( + self, + key: str | None, + value: object, + **kwargs: object, # kwargs-ok: RedisCache write signature + ) -> None: + late_write_started.set() + await owner_stored.wait() + await super().async_set_cache(key, value, **kwargs) + + first: Final = MessageThreadOwnership( + message_thread_owner_cache=InternalUsageCache(DualCache(redis_cache=redis_cache)) + ) + late: Final = MessageThreadOwnership( + message_thread_owner_cache=InternalUsageCache( + DualCache(redis_cache=WritesAfterTheOwner(url="redis://router-unit.example:6379/0")) + ) + ) + + async def owner_claims() -> None: + await late_write_started.wait() + await first.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message("msg_raced") + ) + owner_stored.set() + + await asyncio.gather( + late.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=INTRUDER, response=anthropic_message("msg_raced") + ), + owner_claims(), + ) + + for worker in (first, late): + assert await allows(worker, OWNER, "msg_raced") + assert not await allows(worker, INTRUDER, "msg_raced") + + +@pytest.mark.asyncio +async def test_every_redis_command_for_a_thread_binding_is_traced_under_the_message_thread_owners_family( + router_redis: tuple[RedisCache, asyncio.Queue[tuple[tuple[object, ...], str | None, str | None]]], +) -> None: + redis_cache, commands = router_redis + creating_worker: Final = MessageThreadOwnership( + message_thread_owner_cache=InternalUsageCache(DualCache(redis_cache=redis_cache)) + ) + continuing_worker: Final = MessageThreadOwnership( + message_thread_owner_cache=InternalUsageCache(DualCache(redis_cache=redis_cache)) + ) + await creating_worker.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message("msg_traced") + ) + + assert await allows(continuing_worker, OWNER, "msg_traced") + sent: Final = tuple(commands.get_nowait() for _ in range(commands.qsize())) + thread_commands: Final = tuple((str(args[0]), target) for args, target, _ in sent if args[0] != "CLIENT") + assert {command for command, _ in thread_commands} == {"GET", "SET"} + assert {target for _, target in thread_commands} == {"message_thread_owners"} + + +@pytest.mark.parametrize("call_type", ("acompletion", "aresponses")) +@pytest.mark.asyncio +async def test_a_thread_continue_on_another_route_stays_with_the_key_that_created_the_thread( + call_type: CallTypesLiteral, +) -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message("msg_cross_route") + ) + + assert await allows(ownership, OWNER, "msg_cross_route", call_type) + assert not await allows(ownership, INTRUDER, "msg_cross_route", call_type) + + +@pytest.mark.parametrize("call_type", ("anthropic_messages", "acompletion", "aresponses")) +@pytest.mark.asyncio +async def test_a_continue_sent_in_extra_body_stays_with_the_key_that_created_the_thread( + call_type: CallTypesLiteral, +) -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message("msg_extra_body") + ) + in_extra_body: Final = {"messages": [], "extra_body": {"thread": continue_from("msg_extra_body")["thread"]}} + + assert ( + await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, cache=DualCache(), data=in_extra_body, call_type=call_type + ) + is None + ) + with pytest.raises(HTTPException) as rejected: + await ownership.async_pre_call_hook( + user_api_key_dict=INTRUDER, cache=DualCache(), data=in_extra_body, call_type=call_type + ) + assert rejected.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_an_owned_top_level_continue_does_not_let_another_keys_extra_body_continue_through() -> None: + ownership: Final = hook() + for key, message_id in ((OWNER, "msg_owner"), (INTRUDER, "msg_intruder")): + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=key, response=anthropic_message(message_id) + ) + mixed: Final = {**continue_from("msg_intruder"), "extra_body": {"thread": continue_from("msg_owner")["thread"]}} + + with pytest.raises(HTTPException) as rejected: + await ownership.async_pre_call_hook( + user_api_key_dict=INTRUDER, cache=DualCache(), data=mixed, call_type="acompletion" + ) + assert rejected.value.status_code == 404 + + +@pytest.mark.parametrize( + "create", + ( + pytest.param( + lambda ownership, data: ownership.async_post_call_success_hook( + data=data, user_api_key_dict=OWNER, response=anthropic_message("msg_body_create") + ), + id="reply", + ), + pytest.param( + lambda ownership, data: served( + ownership, (sse_frame(message_start("msg_body_create")), sse_frame({"type": "message_stop"})), data + ), + id="stream", + ), + ), +) +@pytest.mark.asyncio +async def test_a_thread_created_in_extra_body_stays_with_the_key_that_created_it(create) -> None: + ownership: Final = hook() + await create(ownership, {"messages": [], "extra_body": {"thread": CREATE}}) + + assert await allows(ownership, OWNER, "msg_body_create", "acompletion") + assert not await allows(ownership, INTRUDER, "msg_body_create", "acompletion") + + +@pytest.mark.asyncio +async def test_a_pass_through_continue_of_a_message_the_proxy_never_bound_is_left_to_the_upstream() -> None: + assert await allows(hook(), OWNER, "msg_unknown", "pass_through_endpoint") + assert await allows(hook(), INTRUDER, "msg_unknown", "pass_through_endpoint") + + +@pytest.mark.asyncio +async def test_a_pass_through_continue_of_another_keys_thread_is_thread_not_found() -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message("msg_bound") + ) + + assert await allows(ownership, OWNER, "msg_bound", "pass_through_endpoint") + assert not await allows(ownership, INTRUDER, "msg_bound", "pass_through_endpoint") + + +@pytest.mark.asyncio +async def test_a_continue_resends_the_betas_its_create_turn_sent_next_to_its_own_extra_headers() -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data=sent_with("advisor-tool-2026-03-01,message-threads-2026-08-12", thread=CREATE), + user_api_key_dict=OWNER, + response=anthropic_message("msg_advisor"), + ) + continued: Final = await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, + cache=DualCache(), + data={ + **continue_from("msg_advisor"), + "extra_headers": {"Anthropic-Beta": "context-1m-2025-08-07", "x-trace": "abc"}, + }, + call_type="anthropic_messages", + ) + + assert continued is not None + assert continued["extra_headers"] == { + "x-trace": "abc", + "anthropic-beta": "advisor-tool-2026-03-01,context-1m-2025-08-07,message-threads-2026-08-12", + } + assert continued["thread"] == continue_from("msg_advisor")["thread"] + + +@pytest.mark.asyncio +async def test_the_create_turns_betas_reach_every_later_turn_of_the_thread() -> None: + ownership: Final = hook() + items: Final = (sse_frame(message_start("msg_streamed_first")), sse_frame({"type": "message_stop"})) + await served(ownership, items, sent_with("advisor-tool-2026-03-01,message-threads-2026-08-12", thread=CREATE)) + second: Final = await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, cache=DualCache(), data=continue_from("msg_streamed_first"), call_type="anthropic_messages" + ) + assert second is not None + second_headers: Final = second["extra_headers"] + assert isinstance(second_headers, dict) + await ownership.async_post_call_success_hook( + data=sent_with(str(second_headers["anthropic-beta"]), **continue_from("msg_streamed_first")), + user_api_key_dict=OWNER, + response=anthropic_message("msg_second_turn"), + ) + third: Final = await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, cache=DualCache(), data=continue_from("msg_second_turn"), call_type="anthropic_messages" + ) + + assert third is not None + assert third["extra_headers"] == {"anthropic-beta": "advisor-tool-2026-03-01,message-threads-2026-08-12"} + + +@pytest.mark.parametrize("call_type", ("acompletion", "aresponses")) +@pytest.mark.asyncio +async def test_a_continue_on_chat_or_responses_resends_the_betas_its_create_turn_sent( + call_type: CallTypesLiteral, +) -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data=sent_with("advisor-tool-2026-03-01,message-threads-2026-08-12", thread=CREATE), + user_api_key_dict=OWNER, + response=anthropic_message("msg_other_route"), + ) + continued: Final = await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, cache=DualCache(), data=continue_from("msg_other_route"), call_type=call_type + ) + + assert continued is not None + assert continued["extra_headers"] == {"anthropic-beta": "advisor-tool-2026-03-01,message-threads-2026-08-12"} + + +CLIENT_BETAS: Final = {"custom_llm_provider": "anthropic,azure_ai", "extra_headers": {"anthropic-beta": "context-1m-2025-08-07"}} +CLIENT_VERSION: Final = {"custom_llm_provider": "anthropic", "extra_headers": {"anthropic-version": "2023-06-01"}} +CLIENT_BETAS_WITH_THE_CREATES: Final = { + "custom_llm_provider": "anthropic,azure_ai", + "extra_headers": {"anthropic-beta": "advisor-tool-2026-03-01,context-1m-2025-08-07,message-threads-2026-08-12"}, +} + + +@pytest.mark.parametrize( + ("client_headers", "expected"), + ( + (CLIENT_BETAS, CLIENT_BETAS_WITH_THE_CREATES), + ([CLIENT_BETAS, CLIENT_VERSION], (CLIENT_BETAS_WITH_THE_CREATES, CLIENT_VERSION)), + (CLIENT_VERSION, CLIENT_VERSION), + ), +) +@pytest.mark.parametrize("call_type", ("acompletion", "aresponses", "anthropic_messages")) +@pytest.mark.asyncio +async def test_a_continue_that_sends_its_own_betas_keeps_the_create_turns_betas_next_to_them( + call_type: CallTypesLiteral, client_headers: object, expected: object +) -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data=sent_with("advisor-tool-2026-03-01,message-threads-2026-08-12", thread=CREATE), + user_api_key_dict=OWNER, + response=anthropic_message("msg_client_betas"), + ) + continued: Final = await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, + cache=DualCache(), + data={**continue_from("msg_client_betas"), "provider_specific_header": client_headers}, + call_type=call_type, + ) + + assert continued is not None + assert continued["provider_specific_header"] == expected + assert continued["extra_headers"] == {"anthropic-beta": "advisor-tool-2026-03-01,message-threads-2026-08-12"} + + +@pytest.mark.asyncio +async def test_a_pass_through_continue_is_checked_but_left_unchanged() -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data=sent_with("advisor-tool-2026-03-01", thread=CREATE), + user_api_key_dict=OWNER, + response=anthropic_message("msg_other_route"), + ) + + assert ( + await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, + cache=DualCache(), + data=continue_from("msg_other_route"), + call_type="pass_through_endpoint", + ) + is None + ) + + +@pytest.mark.asyncio +async def test_thread_bindings_never_push_the_proxys_rate_limit_counters_out_of_memory() -> None: + proxy_logging: Final = ProxyLogging(user_api_key_cache=DualCache()) + ownership: Final = MessageThreadOwnership(message_thread_owner_cache=proxy_logging.message_thread_owner_cache) + await proxy_logging.internal_usage_cache.async_set_cache( + key="rpm-counter", value=1, litellm_parent_otel_span=None, ttl=60 + ) + for turn in range(250): + await ownership.async_post_call_success_hook( + data={"thread": CREATE}, user_api_key_dict=OWNER, response=anthropic_message(f"msg_{turn}") + ) + + assert await proxy_logging.internal_usage_cache.async_get_cache(key="rpm-counter", litellm_parent_otel_span=None) == 1 + assert await allows(ownership, OWNER, "msg_0") + + +@pytest.mark.parametrize( + "stream", + ( + pytest.param(lambda frame: (frame,), id="whole_frame"), + pytest.param(lambda frame: (frame[:25], frame[25:60], frame[60:]), id="torn_frame"), + pytest.param(lambda frame: (frame.decode()[:30], frame.decode()[30:]), id="torn_text_frame"), + pytest.param( + lambda frame: (sse_frame({"type": "ping"}) + frame[:30], frame[30:]), id="after_a_ping_in_one_chunk" + ), + ), +) +@pytest.mark.asyncio +async def test_a_streamed_thread_turn_hands_its_message_to_its_key_and_streams_unchanged(stream) -> None: + ownership: Final = hook() + items: Final = (*stream(sse_frame(message_start("msg_streamed"))), sse_frame({"type": "message_stop"})) + + assert await served(ownership, items, {"thread": CREATE}) == list(items) + assert await allows(ownership, OWNER, "msg_streamed") + assert not await allows(ownership, INTRUDER, "msg_streamed") + + +@pytest.mark.asyncio +async def test_a_streamed_thread_turn_of_event_dicts_hands_its_message_to_its_key() -> None: + ownership: Final = hook() + items: Final = (message_start("msg_dict_stream"), {"type": "message_stop"}) + + assert await served(ownership, items, {"thread": CREATE}) == list(items) + assert await allows(ownership, OWNER, "msg_dict_stream") + + +@pytest.mark.asyncio +async def test_a_stream_without_a_thread_hands_out_nothing() -> None: + ownership: Final = hook() + items: Final = (sse_frame(message_start("msg_plain_stream")), sse_frame({"type": "message_stop"})) + + assert await served(ownership, items, {}) == list(items) + assert not await allows(ownership, OWNER, "msg_plain_stream") + + +@pytest.mark.asyncio +async def test_a_message_start_after_another_event_is_not_read_as_the_threads_message() -> None: + ownership: Final = hook() + items: Final = ( + sse_frame({"type": "content_block_start", "index": 0}), + sse_frame(message_start("msg_late")), + ) + + assert await served(ownership, items, {"thread": CREATE}) == list(items) + assert not await allows(ownership, OWNER, "msg_late") diff --git a/tests/unit/proxy/utils/proxy_logging/test_pre_call_hook.py b/tests/unit/proxy/utils/proxy_logging/test_pre_call_hook.py index 26ee9f04d01..ff800b24912 100644 --- a/tests/unit/proxy/utils/proxy_logging/test_pre_call_hook.py +++ b/tests/unit/proxy/utils/proxy_logging/test_pre_call_hook.py @@ -454,6 +454,7 @@ def test_every_pre_call_customlogger_is_deliberately_classified(): "_PROXY_BatchRedisRequests", "_PROXY_SensitiveDataRoutingHandler", "ResponsesIDSecurity", + "MessageThreadOwnership", "SkillsInjectionHook", "_PROXY_LiteLLMManagedFiles", "_PROXY_LiteLLMManagedVectorStores", diff --git a/tests/unit/router_utils/test_cooldown_handlers.py b/tests/unit/router_utils/test_cooldown_handlers.py index e16a8e01d81..3a61ca48f26 100644 --- a/tests/unit/router_utils/test_cooldown_handlers.py +++ b/tests/unit/router_utils/test_cooldown_handlers.py @@ -3411,6 +3411,75 @@ def test_the_refusal_names_each_cooled_deployment_some_cooldown_state_routes_the assert {deployment["model_id"] for deployment in refusal.value.cooldown_deployments or ()} == reachable +@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") +class TestMessageThreadNotFoundCooldown: + THREAD_NOT_FOUND_BODY: Final = ( + '{"type":"error","error":{"type":"not_found_error","message":"No thread state was found for the ' + 'requested `previous_message_id`.","details":{"error_code":"thread_not_found"}}}' + ) + + @staticmethod + def _not_found(message: str) -> Exception: + return litellm.NotFoundError( + message=f"AnthropicException - {message}", + model="claude-opus-4-8", + llm_provider="anthropic", + ) + + @pytest.mark.asyncio + async def test_a_stale_thread_404_leaves_the_deployment_out_of_cooldown(self) -> None: + model_id: Final = "anthropic-threads" + router: Final = _make_router( + model_list=[ + { + "model_name": "claude", + "litellm_params": {"model": "anthropic/claude-opus-4-8", "api_key": "synthetic-key"}, + "model_info": {"id": model_id}, + } + ], + ) + + result: Final = router.deployment_callback_on_failure( + kwargs={ + "exception": self._not_found(self.THREAD_NOT_FOUND_BODY), + "litellm_params": {"model_info": {"id": model_id}}, + }, + completion_response=None, + start_time=0, + end_time=1, + ) + + assert result is False + assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == [] + + @pytest.mark.asyncio + async def test_a_404_without_the_thread_code_still_cools_the_deployment_down(self) -> None: + model_id: Final = "anthropic-missing-model" + router: Final = _make_router( + model_list=[ + { + "model_name": "claude", + "litellm_params": {"model": "anthropic/claude-opus-4-8", "api_key": "synthetic-key"}, + "model_info": {"id": model_id}, + } + ], + ) + + router.deployment_callback_on_failure( + kwargs={ + "exception": self._not_found( + '{"type":"error","error":{"type":"not_found_error","message":"model: x"}}' + ), + "litellm_params": {"model_info": {"id": model_id}}, + }, + completion_response=None, + start_time=0, + end_time=1, + ) + + assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == [model_id] + + @pytest.mark.parametrize( ("status_code", "call_type", "expected"), [ From 373fe799e723ed350c724ea2908d878527de2936 Mon Sep 17 00:00:00 2001 From: mika-lt <349045450+mika-lt@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:35:19 -0800 Subject: [PATCH 40/86] fix(router): send managed batch uploads to batch deployments when a chat deployment has a lower order (#50971) * fix(router): send managed batch uploads to batch deployments when a chat deployment has a lower order A purpose=batch upload on a mixed Azure chat and batch group was order-filtered before batch routing narrowed it, so the file landed only on the order-1 chat deployment and the batch create that followed got Azure's 400 invalid_deployment_type. The upload now runs under the same batch routing as the batch create. * fix(router): keep a failed batch upload's cooldown and order fallback on the batch deployments * fix(router): keep a batch upload off chat deployments while its batch deployment cools down * fix(router): keep a refused batch upload's retry off the chat deployment * fix(router): keep a refused batch upload's error and skip chat while the batch deployment is paused * test(router): pin the order fallback for a batch create on a group with no batch deployment * test(integration): expect an Azure batch upload on the batch deployment alone --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../crates/python-bridge/src/router/config.rs | 2 + litellm-rust/crates/router-core/src/call.rs | 10 +- litellm-rust/crates/router-core/src/host.rs | 4 + litellm-rust/crates/router-core/src/select.rs | 33 +++- .../crates/router-core/tests/call_loop.rs | 163 +++++++++++++++++ litellm/router_rust/core.py | 24 ++- litellm/router_rust/invoke/endpoints.py | 6 + .../test_batch_mode_deployments_wire.py | 3 +- tests/unit/test_router/test_router.py | 166 ++++++++++++++++++ 9 files changed, 398 insertions(+), 13 deletions(-) diff --git a/litellm-rust/crates/python-bridge/src/router/config.rs b/litellm-rust/crates/python-bridge/src/router/config.rs index de383a0ffa1..5e099b8841c 100644 --- a/litellm-rust/crates/python-bridge/src/router/config.rs +++ b/litellm-rust/crates/python-bridge/src/router/config.rs @@ -189,6 +189,7 @@ struct RequestSpec { route_handler: Option, decisions_media: Option, batch_create: Option, + batch_upload: Option, embedding_batch: Option, wildcard_request: Option, unrouted: Option, @@ -258,6 +259,7 @@ pub(crate) fn parse_request(value: Value, model: Option<&str>) -> Result = group_members(snapshot, order_group, listing_team) - .iter() + let members: Vec<_> = group_members(snapshot, order_group, listing_team) + .into_iter() .filter(|d| d.listed_for_team(listing_team)) + .collect(); + let batch_levels_only = + request.batch_create && members.iter().any(|d| d.is_batch_capable()); + let mut orders: Vec = members + .iter() + .filter(|d| !batch_levels_only || !d.cannot_run_batches()) .filter_map(|d| d.order()) .collect(); orders.sort_unstable(); diff --git a/litellm-rust/crates/router-core/src/host.rs b/litellm-rust/crates/router-core/src/host.rs index ae93288b0fa..66b99abbeda 100644 --- a/litellm-rust/crates/router-core/src/host.rs +++ b/litellm-rust/crates/router-core/src/host.rs @@ -87,6 +87,10 @@ pub struct RouterRequest { /// `model_info.mode: batch`, the Azure deployments that are not are skipped /// (`without_deployments_that_cannot_batch`). pub batch_create: bool, + /// A batch file upload: the batch create's narrowing also holds while those batch deployments + /// cool down, are paused, or fail their health check, so the file never lands on a deployment + /// that cannot run its batch. + pub batch_upload: bool, /// An embedding pick: the host keeps the candidates whose `max_embedding_batch_size` admits /// the input (`deployments_admitting_embedding_batch`). pub embedding_batch: bool, diff --git a/litellm-rust/crates/router-core/src/select.rs b/litellm-rust/crates/router-core/src/select.rs index 1cc32d23b57..43e42371248 100644 --- a/litellm-rust/crates/router-core/src/select.rs +++ b/litellm-rust/crates/router-core/src/select.rs @@ -232,6 +232,25 @@ fn filter_team_based( .collect()) } +/// `without_deployments_that_cannot_batch`: a batch create on a group the caller may use a healthy +/// batch deployment of skips the Azure deployments that cannot run batches. A batch upload skips +/// them while the caller may use any batch deployment of the group, healthy or not, and the retry +/// count reads the same list. +fn without_deployments_that_cannot_batch( + request: &RouterRequest, + all: &[Arc], + healthy: Vec>, +) -> Vec> { + let has_batch = |pool: &[Arc]| pool.iter().any(|d| d.is_batch_capable()); + if !request.batch_create || !(has_batch(&healthy) || (request.batch_upload && has_batch(all))) { + return healthy; + } + healthy + .into_iter() + .filter(|d| !d.cannot_run_batches()) + .collect() +} + impl Core { fn context<'a>( &'a self, @@ -482,6 +501,11 @@ impl Core { None }; if inputs.count_only { + let healthy = if resolution.kind.is_single() { + healthy + } else { + without_deployments_that_cannot_batch(request, &all, healthy) + }; return Ok(Candidates { name: name.to_owned(), model_group: resolution.group, @@ -646,13 +670,8 @@ impl Core { .await?; } - // `without_deployments_that_cannot_batch`: a batch create on a group the caller may use a - // batch deployment of skips the Azure deployments that cannot run batches. - if request.batch_create - && !resolution.kind.is_single() - && healthy.iter().any(|d| d.is_batch_capable()) - { - healthy.retain(|d| !d.cannot_run_batches()); + if !resolution.kind.is_single() { + healthy = without_deployments_that_cannot_batch(request, &all, healthy); } if let Some(pinned) = [ diff --git a/litellm-rust/crates/router-core/tests/call_loop.rs b/litellm-rust/crates/router-core/tests/call_loop.rs index fce612ebdc5..732eb13371b 100644 --- a/litellm-rust/crates/router-core/tests/call_loop.rs +++ b/litellm-rust/crates/router-core/tests/call_loop.rs @@ -1245,6 +1245,169 @@ async fn a_served_fallback_and_a_disabled_walk_report_no_failure(config: RouterC assert_eq!(fallbacks_failed(&host), vec![]); } +fn azure_chat_and_batch_tiers() -> RouterConfig { + serde_json::from_value(json!({ + "model_list": [ + {"model_name": "primary", "litellm_params": {"model": "azure/chat", "order": 1}, "model_info": {"id": "c1", "mode": "chat"}}, + {"model_name": "primary", "litellm_params": {"model": "azure/batch-a", "order": 2}, "model_info": {"id": "b2", "mode": "batch"}}, + {"model_name": "primary", "litellm_params": {"model": "azure/batch-b", "order": 3}, "model_info": {"id": "b3", "mode": "batch"}}, + ] + })) + .unwrap() +} + +fn azure_chat_tiers() -> RouterConfig { + serde_json::from_value(json!({ + "model_list": [ + {"model_name": "primary", "litellm_params": {"model": "azure/chat-a", "order": 1}, "model_info": {"id": "c1", "mode": "chat"}}, + {"model_name": "primary", "litellm_params": {"model": "azure/chat-b", "order": 2}, "model_info": {"id": "c2", "mode": "chat"}}, + ] + })) + .unwrap() +} + +#[rstest] +#[case::batch_create_moves_past_the_level_it_used(azure_chat_and_batch_tiers(), true, vec!["b2", "b3"])] +#[case::other_calls_walk_every_level(azure_chat_and_batch_tiers(), false, vec!["c1", "b2"])] +#[case::batch_create_without_a_batch_deployment_walks_every_level(azure_chat_tiers(), true, vec!["c1", "c2"])] +#[tokio::test] +async fn a_failed_order_level_falls_back_to_the_next_level_its_pick_can_use( + #[case] config: RouterConfig, + #[case] batch_create: bool, + #[case] expected: Vec<&str>, +) { + let host = Arc::new(ScriptedHost::failing("primary", vec![server_error()])); + let router = router(config, json!({"num_retries": 0}), host.clone()); + let request = RouterRequest { + batch_create, + ..request() + }; + router + .call(Op::from("acreate_batch"), request) + .await + .unwrap(); + assert_eq!(host.selected(), expected); +} + +fn azure_chat_then_batch() -> RouterConfig { + serde_json::from_value(json!({ + "model_list": [ + {"model_name": "primary", "litellm_params": {"model": "azure/chat", "order": 1}, "model_info": {"id": "c1", "mode": "chat"}}, + {"model_name": "primary", "litellm_params": {"model": "azure/batch", "order": 2}, "model_info": {"id": "b2", "mode": "batch"}}, + ] + })) + .unwrap() +} + +#[rstest] +#[case::a_batch_upload_finds_no_deployment(true, vec![])] +#[case::a_batch_create_falls_back_to_chat(false, vec!["c1"])] +#[tokio::test] +async fn while_the_batch_deployment_cools_down_a_batch_upload_finds_no_healthy_deployment( + #[case] batch_upload: bool, + #[case] expected: Vec<&str>, +) { + let host = Arc::new(ScriptedHost::failing( + "primary", + vec![failure(FailureKind::RateLimit, 429)], + )); + let router = router( + azure_chat_then_batch(), + json!({"num_retries": 0}), + host.clone(), + ); + let create = RouterRequest { + batch_create: true, + ..request() + }; + router + .call(Op::from("acreate_file"), create.clone()) + .await + .unwrap_err(); + let upload = RouterRequest { + batch_upload, + ..create + }; + let healthy: Vec = router + .healthy(&Op::from("acreate_file"), &upload) + .await + .map(|candidates| { + candidates + .healthy + .iter() + .map(|d| d.id.to_string()) + .collect() + }) + .unwrap_or_default(); + assert_eq!(host.selected(), vec!["b2"]); + assert_eq!(healthy, expected); +} + +#[rstest] +#[case::a_batch_upload_finds_no_deployment(true, vec![])] +#[case::a_batch_create_falls_back_to_chat(false, vec!["c1"])] +#[tokio::test] +async fn while_the_batch_deployment_is_paused_a_batch_upload_finds_no_healthy_deployment( + #[case] batch_upload: bool, + #[case] expected: Vec<&str>, +) { + let config: RouterConfig = serde_json::from_value(json!({ + "model_list": [ + {"model_name": "primary", "litellm_params": {"model": "azure/chat", "order": 1}, "model_info": {"id": "c1", "mode": "chat"}}, + {"model_name": "primary", "litellm_params": {"model": "azure/batch", "order": 2}, "model_info": {"id": "b2", "mode": "batch", "blocked": true}}, + ] + })) + .unwrap(); + let router = router(config, json!({}), Arc::new(ScriptedHost::default())); + let upload = RouterRequest { + batch_create: true, + batch_upload, + ..request() + }; + let healthy: Vec = router + .healthy(&Op::from("acreate_file"), &upload) + .await + .map(|candidates| { + candidates + .healthy + .iter() + .map(|d| d.id.to_string()) + .collect() + }) + .unwrap_or_default(); + assert_eq!(healthy, expected); +} + +#[rstest] +#[case::a_rate_limit(FailureKind::RateLimit, 429)] +#[case::a_wrong_key(FailureKind::Authentication, 401)] +#[tokio::test] +async fn a_batch_upload_the_batch_deployment_refuses_keeps_its_error_with_retries_on( + #[case] kind: FailureKind, + #[case] status: u16, +) { + let host = Arc::new(ScriptedHost::failing( + "primary", + (0..3).map(|_| failure(kind, status)).collect(), + )); + let router = router( + azure_chat_then_batch(), + json!({"num_retries": 2}), + host.clone(), + ); + let upload = RouterRequest { + batch_create: true, + batch_upload: true, + ..request() + }; + let error = router + .call(Op::from("acreate_file"), upload) + .await + .unwrap_err(); + assert_eq!(host.selected(), vec!["b2"]); + assert!(matches!(error, Error::Attempt(refused) if refused.kind == kind)); +} + #[tokio::test] async fn the_order_path_reports_no_failed_fallbacks() { let host = Arc::new(ScriptedHost::failing("primary", vec![server_error()])); diff --git a/litellm/router_rust/core.py b/litellm/router_rust/core.py index aef4db05ca9..69724f0b9fe 100644 --- a/litellm/router_rust/core.py +++ b/litellm/router_rust/core.py @@ -102,6 +102,7 @@ from litellm.router_rust.invoke.endpoints import ( endpoint_names, failover_fields, generic_plan, + is_batch_upload, log_refused_call, log_refused_call_sync, pinned_vector_store_file_deployment, @@ -1375,9 +1376,15 @@ class _EndpointPort: kind: str = "pick", specific_deployment: bool | None = None, messages: object = None, + batch_upload: bool = False, ) -> Mapping[str, object] | Sequence[Mapping[str, object]]: return await self._core.select( - model, request_kwargs, kind=kind, specific_deployment=specific_deployment, messages=messages + model, + request_kwargs, + kind=kind, + specific_deployment=specific_deployment, + messages=messages, + batch_upload=batch_upload, ) async def acompletion(self, *args: object, **kwargs: object) -> object: # kwargs-ok: endpoint payload @@ -2437,6 +2444,7 @@ class RustCore( messages: object = None, input: object = None, health_check_probe: bool = False, + batch_upload: bool = False, **_: object, # kwargs-ok: the RouterCore select options this core ignores ) -> Mapping[str, object] | Sequence[Mapping[str, object]]: """Pick a deployment for ``model`` without calling it (``pick``), list the deployments a @@ -2455,6 +2463,7 @@ class RustCore( messages, input, probe=kind == "healthy" and health_check_probe, + batch_upload=batch_upload, ) # A pick that makes no call holds no least-busy reservation (`select` on the Python core). with picks_reserve(False): @@ -2758,8 +2767,9 @@ class RustCore( **({"web_search": True} if web_search else {}), **({"route_handler": handler} if serves_route_capability(handler) else {}), **({"decisions_media": True} if decisions_media else {}), - # `batch_create_routing`: a batch create keeps to the deployments that can run batches. - **({"batch_create": True} if plan.kind == "batch" else {}), + # `batch_create_routing`: a batch create and its file upload keep to the deployments that can run batches. + **({"batch_create": True} if _routes_as_batch_create(plan, request) else {}), + **({"batch_upload": True} if plan.kind == "create_file" and is_batch_upload(request) else {}), # `deployments_admitting_embedding_batch`: an embedding keeps to the deployments admitting its input. **({"embedding_batch": True} if plan.kind == "embedding" else {}), } @@ -4045,6 +4055,7 @@ class _Selection: input: object, *, probe: bool = False, + batch_upload: bool = False, ) -> None: self._core: Final = core # `_async_get_available_deployment` reads the alias map first, every other pick its names. @@ -4076,6 +4087,9 @@ class _Selection: fields["endpoint"] = endpoint if probe: fields["health_check_probe"] = True + if batch_upload: + fields["batch_create"] = True + fields["batch_upload"] = True if kind in ("pick", "async_pass_through") or (kind == "sync_pick" and core._custom_strategy is not None): # pyright: ignore[reportPrivateUsage] # the selection serves its own core fields["pre_routes"] = True self._specific_deployment: Final = specific_deployment @@ -4289,6 +4303,10 @@ def _narrowing_fields(request: dict[str, object], kind: str) -> Mapping[str, obj return fields +def _routes_as_batch_create(plan: Plan, request: Mapping[str, object]) -> bool: + return plan.kind == "batch" or (plan.kind == "create_file" and is_batch_upload(request)) + + def _request_narrowing(request: Mapping[str, object]) -> Mapping[str, object]: """The narrowing keys a request carries, as the fields router-core reads them.""" from litellm.litellm_core_utils.routing_common_utils import ( diff --git a/litellm/router_rust/invoke/endpoints.py b/litellm/router_rust/invoke/endpoints.py index a5307ff8af9..fd3d055f707 100644 --- a/litellm/router_rust/invoke/endpoints.py +++ b/litellm/router_rust/invoke/endpoints.py @@ -147,6 +147,7 @@ class EndpointCore(Protocol): kind: str = "pick", specific_deployment: bool | None = None, messages: object = None, + batch_upload: bool = False, ) -> Mapping[str, object] | Sequence[Mapping[str, object]]: ... async def acompletion(self, *args: object, **kwargs: object) -> object: ... # kwargs-ok: endpoint payload @@ -677,6 +678,10 @@ _DIRECT_ENDPOINTS: Final = frozenset( ) +def is_batch_upload(request: Mapping[str, object]) -> bool: + return request.get("purpose") == "batch" + + def endpoint_names() -> frozenset[str]: """Every endpoint name ``RustEndpoints.member`` may serve: what ``dir()`` of a router lists.""" return frozenset( @@ -2313,6 +2318,7 @@ class RustEndpoints: kind="healthy", specific_deployment=bool(specific) or None, messages=_user_message(_PICK_TEXTS["create_file"]), + batch_upload=is_batch_upload(kwargs), ) deployments: Final = list(healthy) if isinstance(healthy, Sequence) else [healthy] diff --git a/tests/integration/routing/test_batch_mode_deployments_wire.py b/tests/integration/routing/test_batch_mode_deployments_wire.py index 7d702f499f5..648d3f2635f 100644 --- a/tests/integration/routing/test_batch_mode_deployments_wire.py +++ b/tests/integration/routing/test_batch_mode_deployments_wire.py @@ -249,7 +249,8 @@ def test_a_batch_create_on_a_mixed_group_reaches_the_deployment_its_provider_all with wire_server(_vendor) as wire, gateway.scenario() as scenario: group: Final = _group(scenario, wire, (chat, batch)) managed_file: Final = _managed_upload(gateway, group, gateway.key) - assert set(_keys_posted_to(wire.drain(), "/files")) == {chat.key, batch.key} + uploads_reach: Final = {batch.key} if creates_reach == "batch" else {chat.key, batch.key} + assert set(_keys_posted_to(wire.drain(), "/files")) == uploads_reach creates: Final = tuple(_create(gateway, managed_file, gateway.key) for _ in range(3)) diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index e84d187f937..ba928bfb8c3 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -7968,6 +7968,172 @@ async def test_acreate_batch_on_a_mixed_group_only_reaches_batch_deployments( assert batch_route.call_count == 20 +def _ordered(deployment: dict[str, object], order: int) -> dict[str, object]: + return {**deployment, "litellm_params": {**deployment["litellm_params"], "order": order}} + + +_AZURE_FILE_CREATED: Final = { + "id": "file-batch-input", + "object": "file", + "bytes": 1, + "created_at": 0, + "filename": "input.jsonl", + "purpose": "batch", + "status": "processed", +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("purpose", "uploaded_to"), [("batch", "batch-deployment"), ("fine-tune", "chat-deployment")]) +async def test_batch_file_upload_reaches_the_batch_deployment_when_the_chat_deployment_has_a_lower_order( + monkeypatch: pytest.MonkeyPatch, purpose: str, uploaded_to: str +): + from io import BytesIO + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + router: Final = litellm.Router( + model_list=[ + _ordered(_azure_sku_deployment("gpt-mixed", "chat-sku", "chat", id="chat-deployment"), 1), + _ordered(_azure_sku_deployment("gpt-mixed", "batch-sku", "batch", id="batch-deployment"), 2), + ], + num_retries=0, + ) + batch_row: Final = {"custom_id": "1", "method": "POST", "url": "/chat/completions", "body": {"model": "gpt-mixed"}} + + with respx.mock(assert_all_called=False) as respx_mock: + routes: Final = { + deployment_id: respx_mock.post(host=f"{sku_host}.openai.azure.com").mock( + return_value=httpx.Response(200, json=_AZURE_FILE_CREATED) + ) + for deployment_id, sku_host in (("chat-deployment", "chat-sku"), ("batch-deployment", "batch-sku")) + } + uploaded: Final = await router.acreate_file( + model="gpt-mixed", purpose=purpose, file=BytesIO(json.dumps(batch_row).encode()) + ) + + assert {deployment_id: route.call_count for deployment_id, route in routes.items()} == { + "chat-deployment": int(uploaded_to == "chat-deployment"), + "batch-deployment": int(uploaded_to == "batch-deployment"), + } + assert uploaded._hidden_params["model_file_id_mapping"] == {uploaded_to: "file-batch-input"} + + +_AZURE_CHAT_COMPLETION: Final = { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gpt-mixed", + "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "ok"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("status", "raised"), [(429, openai.RateLimitError), (500, openai.InternalServerError)]) +async def test_a_failed_batch_file_upload_tries_the_batch_deployment_once_and_leaves_chat_serving( + monkeypatch: pytest.MonkeyPatch, status: int, raised: type[Exception] +): + from io import BytesIO + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + router: Final = litellm.Router( + model_list=[ + _ordered(_azure_sku_deployment("gpt-mixed", "chat-sku", "chat", id="chat-deployment"), 1), + _ordered(_azure_sku_deployment("gpt-mixed", "batch-sku", "batch", id="batch-deployment"), 2), + ], + num_retries=0, + ) + batch_row: Final = {"custom_id": "1", "method": "POST", "url": "/chat/completions", "body": {"model": "gpt-mixed"}} + + with respx.mock(assert_all_called=False) as respx_mock: + batch_upload: Final = respx_mock.post(host="batch-sku.openai.azure.com").mock( + return_value=httpx.Response(status, json={"error": {"code": str(status), "message": "upload refused"}}) + ) + chat_completions: Final = respx_mock.post(host="chat-sku.openai.azure.com").mock( + return_value=httpx.Response(200, json=_AZURE_CHAT_COMPLETION) + ) + with pytest.raises(raised): + await router.acreate_file(model="gpt-mixed", purpose="batch", file=BytesIO(json.dumps(batch_row).encode())) + for _ in range(3): + await router.acompletion(model="gpt-mixed", messages=[{"role": "user", "content": "hi"}]) + + assert batch_upload.call_count == 1 + assert chat_completions.call_count == 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("status", "raised"), [(429, openai.RateLimitError), (401, openai.AuthenticationError)]) +async def test_a_batch_file_upload_the_batch_deployment_refuses_stays_off_chat_and_leaves_chat_serving( + monkeypatch: pytest.MonkeyPatch, status: int, raised: type[Exception] +): + from io import BytesIO + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + router: Final = litellm.Router( + model_list=[ + _ordered(_azure_sku_deployment("gpt-mixed", "chat-sku", "chat", id="chat-deployment"), 1), + _ordered(_azure_sku_deployment("gpt-mixed", "batch-sku", "batch", id="batch-deployment"), 2), + ], + num_retries=2, + ) + batch_row: Final = {"custom_id": "1", "method": "POST", "url": "/chat/completions", "body": {"model": "gpt-mixed"}} + + with respx.mock(assert_all_called=False) as respx_mock: + batch_upload: Final = respx_mock.post(host="batch-sku.openai.azure.com").mock( + return_value=httpx.Response(status, json={"error": {"code": str(status), "message": "upload refused"}}) + ) + chat_upload: Final = respx_mock.post(host="chat-sku.openai.azure.com", path__regex=r"/files").mock( + return_value=httpx.Response(200, json=_AZURE_FILE_CREATED) + ) + chat_completions: Final = respx_mock.post(host="chat-sku.openai.azure.com", path__regex=r"/chat/completions").mock( + return_value=httpx.Response(200, json=_AZURE_CHAT_COMPLETION) + ) + with pytest.raises(raised, match="upload refused"): + await router.acreate_file(model="gpt-mixed", purpose="batch", file=BytesIO(json.dumps(batch_row).encode())) + await asyncio.gather(*(task for task in asyncio.all_tasks() if task is not asyncio.current_task())) + for _ in range(3): + await router.acompletion(model="gpt-mixed", messages=[{"role": "user", "content": "hi"}]) + + assert (batch_upload.call_count, chat_upload.call_count, chat_completions.call_count) == (1, 0, 3) + + +@pytest.mark.asyncio +async def test_a_batch_file_upload_whose_batch_deployment_cools_down_after_the_pick_stays_off_chat( + monkeypatch: pytest.MonkeyPatch, +): + from io import BytesIO + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + router: Final = litellm.Router( + model_list=[ + _ordered(_azure_sku_deployment("gpt-mixed", "chat-sku", "chat", id="chat-deployment"), 1), + _ordered(_azure_sku_deployment("gpt-mixed", "batch-sku", "batch", id="batch-deployment"), 2), + ], + num_retries=0, + ) + + class _ConcurrentBatchFailure(CustomLogger): + async def async_pre_call_check(self, deployment, parent_otel_span): + _cool_down(router, "batch-deployment") + + batch_row: Final = {"custom_id": "1", "method": "POST", "url": "/chat/completions", "body": {"model": "gpt-mixed"}} + + with ( + patch.object(litellm, "callbacks", [_ConcurrentBatchFailure()]), # test-quality-ok: router reads this global + respx.mock(assert_all_called=False) as respx_mock, + ): + batch_upload: Final = respx_mock.post(host="batch-sku.openai.azure.com").mock( + return_value=httpx.Response(200, json=_AZURE_FILE_CREATED) + ) + chat_upload: Final = respx_mock.post(host="chat-sku.openai.azure.com").mock( + return_value=httpx.Response(200, json=_AZURE_FILE_CREATED) + ) + with pytest.raises(RouterRateLimitError, match="No deployments available"): + await router.acreate_file(model="gpt-mixed", purpose="batch", file=BytesIO(json.dumps(batch_row).encode())) + + assert (batch_upload.call_count, chat_upload.call_count) == (0, 0) + + def _azure_wildcard_deployment(sku_host: str, mode: str) -> dict[str, object]: return { "model_name": "mixed-wild/*", From 917fd1196680b812f7ead048bf8effa300ebcbb7 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 10 Nov 2026 18:36:55 -0800 Subject: [PATCH 41/86] fix(rate-limit): settle a fallback's tokens on the model group that served it (#51082) * fix(rate-limit): settle a fallback's tokens on the model group that served it The v3 limiter pinned post-call TPM settlement to the model reserved at admission, so when the router fell back to another model group the requested model's counter took the usage and the serving model's counter stayed empty. When the served model group differs from the admitted one, charge the served group and refund the abandoned group's per-model reservation. Same-model retries and alias reloads keep the admission target. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(rate-limit): refund an abandoned fallback reservation only on counters the served model does not settle Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(rate-limit): wait for the primary refund and the fallback charge before reading counters Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(rate-limit): only treat a limiter-admitted fallback target as the served model at settlement Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(rate-limit): drop unrelated formatting from the v3 limiter unit test file Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(rate-limit): remove helper copies duplicated while appending the fallback settlement tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../hooks/parallel_request_limiter_v3.py | 54 +++++-- .../test_fallback_settles_serving_model.py | 91 +++++++++++ .../hooks/test_parallel_request_limiter_v3.py | 141 ++++++++++++++++++ 3 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 tests/integration/routing/test_fallback_settles_serving_model.py diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index a3227bca235..14b9c48b0d6 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -500,6 +500,7 @@ class RequestRateLimiterStash: reserved_tokens: int = 0 reserved_model: RateLimitedModel | None = None reserved_scopes: frozenset[tuple[str, str]] = field(default_factory=frozenset) + fallback_models: frozenset[str] = field(default_factory=frozenset) ptu_ceiling: PTUTeamCeiling | None = None ptu_reserved_tokens: int = 0 ptu_reserved_window_identities: frozenset[ReservationWindowIdentity] = field(default_factory=frozenset) @@ -3786,6 +3787,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): ) -> bool: """Reserve the caller's per-model limits on a router fallback target. False when any is exhausted.""" data: Final = MappingProxyType({**request_data, "model": model}) + stash: Final = _request_stash.get() hidden_stash: Final = _request_stash.set(None) try: built: Final = await self._build_request_rate_limit_descriptors(user_api_key_dict, data, None) @@ -3794,10 +3796,16 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): descriptors: Final = [ descriptor for descriptor in built if descriptor["key"] in PER_MODEL_RATE_LIMIT_DESCRIPTOR_KEYS ] - if not descriptors: - return True - response: Final = await self.should_rate_limit(descriptors, parent_otel_span=user_api_key_dict.parent_otel_span) - return response["overall_code"] == "OK" + allowed: Final = ( + not descriptors + or (await self.should_rate_limit(descriptors, parent_otel_span=user_api_key_dict.parent_otel_span))[ + "overall_code" + ] + == "OK" + ) + if allowed and stash is not None: + stash.fallback_models = stash.fallback_models | {model} + return allowed @asynccontextmanager async def request_capacity( @@ -4720,12 +4728,21 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): reserved_tokens: Final = open_reservation.reserved_tokens if open_reservation is not None else 0 reserved_model: Final = stash.reserved_model if stash is not None else None reserved_scopes: Final[frozenset[tuple[str, str]]] = stash.reserved_scopes if stash is not None else frozenset() - # Reconciliation must target the same model-scoped counter that the - # pre-call reservation incremented. If a reservation was made, + served_model: Final = self._rate_limited_model(model_group) # ``reserved_model`` (resolved at admission, so an alias map reload - # mid-flight cannot move the charge) is authoritative; otherwise fall - # back to the router's ``model_group`` (the no-reservation charge path). - reconcile_model: Final = reserved_model if reserved_model is not None else self._rate_limited_model(model_group) + # mid-flight cannot move the charge) settles the request unless the + # router fell back to a group this limiter admitted as a fallback + # target: then the served group is charged and the abandoned group's + # per-model reservation is refunded. Client-supplied ``model_group`` + # metadata alone never moves the charge. + fell_back: Final = ( + stash is not None + and reserved_model is not None + and served_model is not None + and served_model.requested != reserved_model.requested + and served_model.requested in stash.fallback_models + ) + reconcile_model: Final = served_model if fell_back or reserved_model is None else reserved_model reconcile_models: Final = self._rate_limited_models(reconcile_model) pipeline_operations: Final[list[ReservationOperation]] = [] @@ -4764,6 +4781,17 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): ) ) charged_targets: Final = [target for target in targets if target not in key_owned_team_scopes] + abandoned_scopes: Final = ( + tuple( + sorted( + scope + for scope in reserved_scopes + if scope[0] in PER_MODEL_RATE_LIMIT_DESCRIPTOR_KEYS and scope not in charged_targets + ) + ) + if fell_back + else () + ) if reserved_tokens > 0 and total_tokens < reserved_tokens: verbose_proxy_logger.debug( "Releasing unused TPM budget on success: reserved=%s, actual=%s, release=%s", @@ -4779,6 +4807,14 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): reserved_tokens=reserved_tokens, ) ) + pipeline_operations.extend( + self._build_reservation_aware_tpm_ops( + targets=abandoned_scopes, + reserved_scopes=reserved_scopes, + actual_tokens=0, + reserved_tokens=reserved_tokens, + ) + ) call_type: Final[object] = kwargs.get("call_type") ptu_skips_socket_usage: Final = call_type == CallTypes.aresponses_websocket.value pipeline_operations.extend( diff --git a/tests/integration/routing/test_fallback_settles_serving_model.py b/tests/integration/routing/test_fallback_settles_serving_model.py new file mode 100644 index 00000000000..b9c32f7e91f --- /dev/null +++ b/tests/integration/routing/test_fallback_settles_serving_model.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import os +import time +import uuid +from collections.abc import Iterator +from contextlib import ExitStack +from dataclasses import dataclass +from typing import Final + +import pytest +from integration._support.client import Gateway, gateway_from_environment +from integration._support.process import OwnedState, graceful_stop_seconds, owned_proxy_process +from integration._support.wire import wire_server +from integration.routing.test_fallback_rate_limit_check import provider_error_reply +from integration.routing.test_per_model_rate_limit_fallbacks import ( + assert_served, + chat, + healthy_reply, + model_entry, + proxy_config, +) +from redis import Redis + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + +ADMISSION_PROBE_TOKENS: Final = 1 +SETTLEMENT_DEADLINE_SECONDS: Final = 15.0 + + +@dataclass(frozen=True, slots=True) +class Rig: + gateway: Gateway + primary: str + fallback: str + + +def key_model_tokens(group: str) -> int: + with Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), socket_timeout=2) as cache: + counters: Final = tuple(cache.scan_iter(match=f"*model_per_key:*:{group}}}:tokens")) + return sum(int(cache.get(counter) or 0) for counter in counters) + + +def settled_key_model_tokens(primary: str, fallback: str, total_tokens: int) -> tuple[int, int]: + deadline: Final = time.monotonic() + SETTLEMENT_DEADLINE_SECONDS + while True: + charged: Final = (key_model_tokens(primary), key_model_tokens(fallback)) + if (charged[0] == 0 and charged[1] >= total_tokens) or time.monotonic() >= deadline: + return charged + time.sleep(0.5) + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + suffix: Final = uuid.uuid4().hex[:10] + primary: Final = f"settle-primary-{suffix}" + fallback: Final = f"settle-fallback-{suffix}" + directory: Final = tmp_path_factory.mktemp("fallback-settles-serving-model") + with ExitStack() as stack: + rig_gateway: Final = stack.enter_context(gateway_from_environment()) + primary_wire: Final = stack.enter_context(wire_server(provider_error_reply)) + fallback_wire: Final = stack.enter_context(wire_server(healthy_reply)) + config: Final = proxy_config( + directory, + model_list=(model_entry(primary, primary_wire), model_entry(fallback, fallback_wire)), + fallbacks=({primary: [fallback]},), + general_settings={}, + ) + owned: Final = stack.enter_context( + owned_proxy_process( + rig_gateway, directory, {}, config=config, workers=2, state=OwnedState.SHARED_WITH_WORKER + ) + ) + yield Rig(owned.gateway, primary, fallback) + + +def test_provider_error_fallback_charges_its_tokens_to_the_model_that_served(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + key: Final = scenario.key(model_tpm_limit={rig.primary: 100_000, rig.fallback: 1_000}) + + response: Final = chat(rig.gateway, key, rig.primary, f"pmrl-{uuid.uuid4().hex}") + assert_served(response, rig.fallback, "served pmrl-") + total_tokens: Final = int(response.json()["usage"]["total_tokens"]) + assert total_tokens > 0, response.text + + primary_tokens, fallback_tokens = settled_key_model_tokens(rig.primary, rig.fallback, total_tokens) + assert primary_tokens == 0, (primary_tokens, fallback_tokens) + assert total_tokens <= fallback_tokens <= total_tokens + ADMISSION_PROBE_TOKENS, ( + primary_tokens, + fallback_tokens, + ) diff --git a/tests/unit/proxy/hooks/test_parallel_request_limiter_v3.py b/tests/unit/proxy/hooks/test_parallel_request_limiter_v3.py index a39d02a2788..e3a63d097ec 100644 --- a/tests/unit/proxy/hooks/test_parallel_request_limiter_v3.py +++ b/tests/unit/proxy/hooks/test_parallel_request_limiter_v3.py @@ -9941,3 +9941,144 @@ async def test_end_user_model_rpm_limit_is_shared_across_keys_and_isolated_per_c await _rpm_request(handler, cache, globex, "target") await _rpm_request(handler, cache, acme_via_key_one, "other-model") + + + +@pytest.mark.asyncio +async def test_success_tpm_accounting_moves_the_charge_to_the_model_group_that_served_a_fallback() -> None: + cache: Final = DualCache() + handler: Final = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache)) + key_metadata: Final = {"model_tpm_limit": {"primary": 1000, "fallback": 1000}} + auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-fallback-settle"), metadata=key_metadata) + + await handler.async_pre_call_hook( + user_api_key_dict=auth, + cache=cache, + data={"model": "primary", "messages": [{"role": "user", "content": "hello"}], "max_tokens": 10}, + call_type="acompletion", + ) + stash: Final = get_request_stash() + assert stash is not None + assert stash.reserved_model == RateLimitedModel(requested="primary", group="primary") + assert stash.reserved_tokens > 0 + assert await handler.async_fallback_target_allowed( + auth, "fallback", {"messages": [{"role": "user", "content": "hello"}]} + ) + + response: Final = ModelResponse( + id="fallback-settle", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="fallback", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + choices=[], + ) + kwargs: Final = { + "standard_logging_object": {"metadata": {"user_api_key_hash": auth.api_key}}, + "litellm_params": { + "metadata": { + "model_group": "fallback", + "original_model_group": "primary", + "user_api_key_metadata": key_metadata, + } + }, + "model": "fallback", + } + + ops: Final = handler._build_success_event_pipeline_operations( + kwargs=kwargs, response_obj=response, rate_limit_type="total" + ) + + charged: Final = {_increment_of(op).counter: _increment_of(op).amount for op in ops} + assert charged[_counter("model_per_key", f"{auth.api_key}:fallback")] == 150 + assert charged[_counter("model_per_key", f"{auth.api_key}:primary")] == -stash.reserved_tokens + + +@pytest.mark.asyncio +async def test_success_tpm_accounting_settles_an_alias_to_target_fallback_once_per_counter( + collision_limiter: tuple[DualCache, PROXY_MaxParallelRequestsHandler_v3, str, str], +) -> None: + cache, handler, alias, target = collision_limiter + key_metadata: Final = {"model_tpm_limit": {alias: 1000, target: 1000}} + auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-alias-fallback-settle"), metadata=key_metadata) + + await handler.async_pre_call_hook( + user_api_key_dict=auth, + cache=cache, + data={"model": alias, "messages": [{"role": "user", "content": "hello"}], "max_tokens": 10}, + call_type="acompletion", + ) + stash: Final = get_request_stash() + assert stash is not None + assert stash.reserved_model == RateLimitedModel(requested=alias, group=target) + assert stash.reserved_tokens > 0 + assert await handler.async_fallback_target_allowed( + auth, target, {"messages": [{"role": "user", "content": "hello"}]} + ) + + response: Final = ModelResponse( + id="alias-fallback-settle", + object="chat.completion", + created=int(datetime.now().timestamp()), + model=target, + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + choices=[], + ) + kwargs: Final = { + "standard_logging_object": {"metadata": {"user_api_key_hash": auth.api_key}}, + "litellm_params": { + "metadata": {"model_group": target, "original_model_group": alias, "user_api_key_metadata": key_metadata} + }, + "model": target, + } + + ops: Final = handler._build_success_event_pipeline_operations( + kwargs=kwargs, response_obj=response, rate_limit_type="total" + ) + + increments: Final = tuple(_increment_of(op) for op in ops) + target_counter: Final = _counter("model_per_key", f"{auth.api_key}:{target}") + alias_counter: Final = _counter("model_per_key", f"{auth.api_key}:{alias}") + assert [inc.amount for inc in increments if inc.counter == target_counter] == [150 - stash.reserved_tokens] + assert [inc.amount for inc in increments if inc.counter == alias_counter] == [] + + + +@pytest.mark.asyncio +async def test_success_tpm_accounting_ignores_a_client_supplied_model_group_without_a_router_fallback() -> None: + cache: Final = DualCache() + handler: Final = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache)) + key_metadata: Final = {"model_tpm_limit": {"primary": 1000}} + auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-client-model-group"), metadata=key_metadata) + + await handler.async_pre_call_hook( + user_api_key_dict=auth, + cache=cache, + data={"model": "primary", "messages": [{"role": "user", "content": "hello"}], "max_tokens": 10}, + call_type="acompletion", + ) + stash: Final = get_request_stash() + assert stash is not None + assert stash.reserved_tokens > 0 + + response: Final = ModelResponse( + id="client-model-group", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="primary", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + choices=[], + ) + kwargs: Final = { + "standard_logging_object": {"metadata": {"user_api_key_hash": auth.api_key}}, + "litellm_params": {"metadata": {"model_group": "uncapped", "user_api_key_metadata": key_metadata}}, + "model": "primary", + } + + ops: Final = handler._build_success_event_pipeline_operations( + kwargs=kwargs, response_obj=response, rate_limit_type="total" + ) + + charged: Final = {_increment_of(op).counter: _increment_of(op).amount for op in ops} + assert charged[_counter("model_per_key", f"{auth.api_key}:primary")] == 150 - stash.reserved_tokens + assert not any(":uncapped" in counter.scope.descriptor_value for counter in charged) From bb89296adf5dbf16b4fbc7f99fc5730b4933dd76 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 10 Nov 2026 18:43:06 -0800 Subject: [PATCH 42/86] fix(proxy): keep the upgrade notice off the request path and count area-labelled release notes (#51027) * fix(proxy): keep the upgrade notice off the request path and count area-labelled release notes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(proxy): regenerate dashboard API types and read the release cache through one typed helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): respond 502 from /get/latest_release_info when no release note entry is countable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): respond 502 when the release fetch failed and announce a release with no countable notes without counts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): re-ask for the latest release while the proxy is still fetching it and keep fetch diagnostics out of the 502 body Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep the known release on screen while the proxy refetches it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): answer 202 while the release is being fetched so the dashboard polls only then Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): name the release query options to stay inside the lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): return the 202 as a response value instead of mutating the injected response Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): fetch the release on the background client so its 502 never reaches the error handler Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../latest_release_endpoints.py | 56 ++++++-- .../test_latest_release_endpoints.py | 126 ++++++++++++++++-- .../useLatestReleaseInfo.test.ts | 76 +++++++++++ .../latestRelease/useLatestReleaseInfo.ts | 49 +++++-- .../NotificationsBell.test.tsx | 15 +++ .../NotificationsBell/NotificationsBell.tsx | 27 ++-- ui/litellm-dashboard/src/i18n/locales/en.json | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 20 ++- 8 files changed, 323 insertions(+), 47 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 4bb17fd290e..49f90997879 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -9,7 +9,8 @@ from types import MappingProxyType from typing import Annotated, Final, Literal, Protocol, TypeAlias import httpx -from fastapi import APIRouter, Depends +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from fastapi.responses import JSONResponse from pydantic import ValidationError from litellm._logging import verbose_proxy_logger @@ -29,11 +30,13 @@ LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60 LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info" _RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+(?:([A-Za-z]+)(?:\([^)]*\))?!?:\s)?\S") +_AREA_BULLET_PATTERN: Final = re.compile(r"^-\s+\*\*[^*]+\*\*\s+([A-Za-z]+)") _NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b") _CHANGELOG_PAGE_PATTERN: Final = re.compile(rf"{re.escape(CHANGELOG_URL)}v(\d+)\.(\d+)\.(\d+)/") _Bucket: TypeAlias = Literal["new_features", "bug_fixes", "other_updates"] _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) +_VERB_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"added": "new_features", "fixed": "bug_fixes"}) class LatestReleaseInfo(LiteLLMBaseModel): @@ -97,6 +100,9 @@ def _default_release_check_enabled() -> bool: def _bucket_for(line: str) -> _Bucket | None: if _NEW_CONTRIBUTOR_PATTERN.match(line) is not None: return None + area_match: Final = _AREA_BULLET_PATTERN.match(line) + if area_match is not None: + return _VERB_BUCKETS.get(area_match.group(1).lower(), "other_updates") match: Final = _RELEASE_BULLET_PATTERN.match(line) if match is None: return None @@ -105,7 +111,8 @@ def _bucket_for(line: str) -> _Bucket | None: def count_release_bullets(body: str) -> Mapping[_Bucket, int]: - """Bucket release-note bullets by conventional-commit type or ``other_updates``.""" + """Bucket release-note bullets by conventional-commit type (``* fix(x): ...``) or by the verb after the area label + (``- **Area:** Fixed ...``), falling back to ``other_updates``.""" return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None)) @@ -200,15 +207,20 @@ async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | L ) +def _cached_release(cache: InMemoryCache) -> LatestReleaseInfo | LatestReleaseUnavailable | None: + cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) + return cached if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)) else None + + async def get_latest_release_info( client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock ) -> LatestReleaseInfo | LatestReleaseUnavailable: - cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) - if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)): + cached: Final = _cached_release(cache) + if cached is not None: return cached async with fetch_lock: - cached_after_lock: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) - if isinstance(cached_after_lock, (LatestReleaseInfo, LatestReleaseUnavailable)): + cached_after_lock: Final = _cached_release(cache) + if cached_after_lock is not None: return cached_after_lock result: Final = await fetch_latest_release(client) ttl: Final = ( @@ -220,28 +232,46 @@ async def get_latest_release_info( return result +async def refresh_latest_release_info(client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock) -> None: + result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) + if isinstance(result, LatestReleaseUnavailable): + verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason) + + @router.get( "/get/latest_release_info", tags=["UI Settings"], dependencies=[Depends(user_api_key_auth)], response_model=LatestReleaseInfo | None, + responses={ + 202: {"description": "The release is being fetched in the background, so ask again shortly", "model": None}, + 502: {"description": "The changelog or GitHub could not be reached or answered with something unusable"}, + }, ) async def latest_release_info( + background_tasks: BackgroundTasks, client: Annotated[_AsyncGetClient, Depends(_default_client)], cache: Annotated[InMemoryCache, Depends(_default_cache)], fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)], release_check_enabled: Annotated[bool, Depends(_default_release_check_enabled)], -) -> LatestReleaseInfo | None: +) -> LatestReleaseInfo | JSONResponse | None: """ Newest stable release on the LiteLLM changelog, linked to its changelog page, with the PR count of its whole minor line (e.g. v1.105.0 + v1.105.1) from GitHub split into new features, bug fixes and other updates. - Returns null when the changelog or GitHub can't be reached so the dashboard upgrade banner simply doesn't render. + Never waits on the changelog or GitHub. It answers from the cache and, when the cache is empty or expired, responds + 202 with null and fetches after the response is sent, so a later call returns the release. Responds 502 once that + fetch has failed because the changelog or GitHub couldn't be reached or answered with something unusable, so the dashboard + upgrade notice simply doesn't render. The three counts are all zero when the release notes list no change in a + format this proxy can count. Set LITELLM_DISABLE_LATEST_RELEASE_CHECK=true to return null without making any outbound request. """ if not release_check_enabled: return None - result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) - if isinstance(result, LatestReleaseUnavailable): - verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason) - return None - return result + cached: Final = _cached_release(cache) + if isinstance(cached, LatestReleaseInfo): + return cached + if isinstance(cached, LatestReleaseUnavailable): + raise HTTPException(status_code=502, detail="Latest release info unavailable") + if not fetch_lock.locked(): + background_tasks.add_task(refresh_latest_release_info, client, cache, fetch_lock) + return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=None) diff --git a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py index b5a41c31810..e3d9e66c208 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py @@ -121,6 +121,53 @@ def _override_dependencies(client: _ByUrl, cache: InMemoryCache, role: LitellmUs app.dependency_overrides[_default_fetch_lock] = lambda: asyncio.Lock() +class _AsgiGet: + """One GET sent straight to the ASGI app, so the response body is readable while the app call is still running.""" + + def __init__(self, path: str) -> None: + self._chunks: list[bytes] = [] + self._sent = asyncio.Event() + self.task = asyncio.create_task( + app( + { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [(b"host", b"proxy")], + "client": ("127.0.0.1", 1), + "server": ("proxy", 80), + }, + self._receive, + self._send, + ) + ) + + async def _receive(self) -> dict[str, object]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def _send(self, message: dict[str, object]) -> None: + if message["type"] != "http.response.body": + return + body = message.get("body", b"") + assert isinstance(body, bytes) + self._chunks.append(body) + if not message.get("more_body", False): + self._sent.set() + + @property + def finished(self) -> bool: + return self.task.done() + + async def body(self) -> bytes: + await self._sent.wait() + return b"".join(self._chunks) + + @pytest.fixture def http_client(): yield TestClient(app) @@ -152,6 +199,18 @@ class TestCountReleaseBullets: == 0 ) + def test_buckets_area_labelled_bullets_by_their_leading_verb(self): + counts = count_release_bullets( + "### Changes\n\n" + "- **OpenAI:** Fixed empty `api_key` sending the admin key (#1)\n" + "- **AWS Bedrock:** fixed Converse `service_tier` (#2)\n" + "- **MCP:** Added per-server timeouts (#3)\n" + "- **Routing and fallbacks:** Changed the default cooldown (#4)\n" + "- **UI:** Moved the upgrade notice into the bell (#5)\n" + "- plain dash line that is not a change entry\n" + ) + assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (1, 2, 2) + def test_empty_body_yields_zero_counts(self): counts = count_release_bullets("") assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 0) @@ -387,19 +446,62 @@ class TestPatchReleaseAggregatesMinorLine: class TestLatestReleaseInfoEndpoint: - def test_returns_release_stats_for_authenticated_user(self, http_client): + def test_first_request_returns_null_and_the_next_returns_the_fetched_release(self, http_client): _override_dependencies(_minor_release(), _fresh_cache(), LitellmUserRoles.INTERNAL_USER) - response = http_client.get("/get/latest_release_info") - assert response.status_code == 200 - assert response.json() == EXPECTED_INFO + first = http_client.get("/get/latest_release_info") + assert (first.status_code, first.json()) == (202, None) + second = http_client.get("/get/latest_release_info") + assert (second.status_code, second.json()) == (200, EXPECTED_INFO) - def test_returns_null_when_the_changelog_is_unreachable(self, http_client): + @pytest.mark.asyncio + async def test_responds_while_the_changelog_fetch_is_still_hanging(self): + release = asyncio.Event() + + class _HangingClient(_ByUrl): + async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + self.calls.append((url, timeout)) + await release.wait() + raise httpx.ConnectTimeout("blackholed") + + client: Final = _HangingClient({}) + _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) + lock: Final = asyncio.Lock() + app.dependency_overrides[_default_fetch_lock] = lambda: lock + try: + requests: Final = [_AsgiGet("/get/latest_release_info") for _ in range(3)] + bodies: Final = [await asyncio.wait_for(request.body(), timeout=5) for request in requests] + still_fetching: Final = [request for request in requests if not request.finished] + release.set() + await asyncio.gather(*(request.task for request in requests)) + finally: + release.set() + for dependency in (user_api_key_auth, _default_client, _default_cache, _default_fetch_lock): + app.dependency_overrides.pop(dependency, None) + assert bodies == [b"null", b"null", b"null"] + assert len(still_fetching) == 1, "exactly one request should still be running the hanging fetch" + assert client.urls == [CHANGELOG_FEED_URL] + + def test_responds_502_once_the_changelog_fetch_has_failed(self, http_client): + client: Final = _ByUrl({CHANGELOG_FEED_URL: httpx.ConnectError("boom")}) + _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) + first: Final = http_client.get("/get/latest_release_info") + assert (first.status_code, first.json()) == (202, None) + later: Final = [http_client.get("/get/latest_release_info") for _ in range(2)] + assert [response.status_code for response in later] == [502, 502] + assert later[0].json() == {"detail": "Latest release info unavailable"} + assert len(client.calls) == 1 + + def test_release_with_no_countable_notes_is_still_announced_with_zero_counts(self, http_client): + uncountable: Final = _github_response(payload=_release("v1.102.0", "## Changes\n\n1. Reworked the router\n")) _override_dependencies( - _ByUrl({CHANGELOG_FEED_URL: httpx.ConnectError("boom")}), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN + _minor_release(**{_tag_url("v1.102.0"): uncountable}), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN + ) + http_client.get("/get/latest_release_info") + response: Final = http_client.get("/get/latest_release_info") + assert (response.status_code, response.json()) == ( + 200, + {**EXPECTED_INFO, "new_features": 0, "bug_fixes": 0, "other_updates": 0}, ) - response = http_client.get("/get/latest_release_info") - assert response.status_code == 200 - assert response.json() is None @pytest.mark.parametrize("name", ["LITELLM_DISABLE_LATEST_RELEASE_CHECK", "LITELLM_OFFLINE_MODE"]) @pytest.mark.parametrize( @@ -416,6 +518,8 @@ class TestLatestReleaseInfoEndpoint: monkeypatch.setenv(name, flag) client = _minor_release() _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) + first = http_client.get("/get/latest_release_info") + assert first.status_code == (200 if expected_body is None else 202) response = http_client.get("/get/latest_release_info") assert response.status_code == 200 assert response.json() == expected_body @@ -424,8 +528,8 @@ class TestLatestReleaseInfoEndpoint: def test_repeated_requests_reuse_cache(self, http_client): client = _minor_release() _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) - assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO - assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO + bodies = [http_client.get("/get/latest_release_info").json() for _ in range(3)] + assert bodies == [None, EXPECTED_INFO, EXPECTED_INFO] assert len(client.calls) == 2 def test_rejects_unauthenticated_requests(self, monkeypatch): diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts new file mode 100644 index 00000000000..ed76b5524be --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerErrorHandler } from "@/lib/http/runtime"; +import { + PENDING_RELEASE_POLL_MS, + fetchLatestRelease, + keepKnownRelease, + pendingReleasePollMs, +} from "./useLatestReleaseInfo"; + +const RELEASE = { version: "1.103.0", new_features: 1, bug_fixes: 2, other_updates: 3, release_url: "https://x" }; + +describe("pendingReleasePollMs", () => { + it("asks again soon while the proxy is still fetching the release", () => { + const state = { status: "success", data: { release: null, pending: true } }; + expect(pendingReleasePollMs(state)).toBe(PENDING_RELEASE_POLL_MS); + }); + + it("keeps asking while a known release is shown and the proxy is refetching it", () => { + const state = { status: "success", data: { release: RELEASE, pending: true } }; + expect(pendingReleasePollMs(state)).toBe(PENDING_RELEASE_POLL_MS); + }); + + it("stops once the release arrives", () => { + expect(pendingReleasePollMs({ status: "success", data: { release: RELEASE, pending: false } })).toBe(false); + }); + + it("stops once the proxy reports the fetch failed", () => { + expect(pendingReleasePollMs({ status: "error", data: { release: null, pending: true } })).toBe(false); + }); + + it("does not ask again when the release check is turned off", () => { + expect(pendingReleasePollMs({ status: "success", data: { release: null, pending: false } })).toBe(false); + }); +}); + +describe("keepKnownRelease", () => { + it("keeps showing the known release while the proxy refetches it", () => { + const kept = keepKnownRelease({ release: RELEASE, pending: false }, { release: null, pending: true }); + expect(kept).toEqual({ release: RELEASE, pending: true }); + }); + + it("takes a newer release over the known one", () => { + const newer = { release: { ...RELEASE, version: "1.104.0" }, pending: false }; + expect(keepKnownRelease({ release: RELEASE, pending: true }, newer)).toBe(newer); + }); + + it("drops the known release when the release check has been turned off", () => { + const off = { release: null, pending: false }; + expect(keepKnownRelease({ release: RELEASE, pending: false }, off)).toBe(off); + }); + + it("records a first answer as is", () => { + const first = { release: null, pending: true }; + expect(keepKnownRelease(undefined, first)).toBe(first); + }); +}); + +describe("fetchLatestRelease", () => { + afterEach(() => { + vi.unstubAllGlobals(); + registerErrorHandler(() => {}); + }); + + it("keeps a failed release fetch away from the dashboard error handler", async () => { + const onError = vi.fn(); + registerErrorHandler(onError); + const unavailable = new Response(JSON.stringify({ detail: "Latest release info unavailable" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(unavailable)); + + await expect(fetchLatestRelease()).rejects.toMatchObject({ status: 502 }); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts index 5186baa605c..7a3dcf92dac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts @@ -1,16 +1,41 @@ -import { $api } from "@/lib/http/api"; +import { useQuery } from "@tanstack/react-query"; +import { backgroundFetchClient } from "@/lib/http/api"; import type { components } from "@/lib/http/schema"; +import { createQueryKeys } from "../common/queryKeysFactory"; export type LatestReleaseInfo = components["schemas"]["LatestReleaseInfo"]; -export const useLatestReleaseInfo = (accessToken: string | null | undefined) => - $api.useQuery( - "get", - "/get/latest_release_info", - {}, - { - enabled: Boolean(accessToken), - staleTime: 60 * 60 * 1000, - retry: false, - }, - ); +export type LatestReleaseAnswer = { release: LatestReleaseInfo | null; pending: boolean }; + +export const PENDING_RELEASE_POLL_MS = 5 * 1000; + +export const latestReleaseKeys = createQueryKeys("latestRelease"); + +export const pendingReleasePollMs = (state: { status: string; data?: LatestReleaseAnswer }) => + state.status === "success" && state.data?.pending ? PENDING_RELEASE_POLL_MS : false; + +export const keepKnownRelease = (known: unknown, answer: unknown) => { + const previous = known as LatestReleaseAnswer | undefined; + const next = answer as LatestReleaseAnswer; + return next.pending && previous?.release ? { ...next, release: previous.release } : next; +}; + +export const fetchLatestRelease = async (): Promise => { + const { data, response } = await backgroundFetchClient.GET("/get/latest_release_info"); + return { release: data ?? null, pending: response.status === 202 }; +}; + +export const useLatestReleaseInfo = (accessToken: string | null | undefined) => { + const options = { + queryKey: latestReleaseKeys.all, + queryFn: fetchLatestRelease, + enabled: Boolean(accessToken), + staleTime: 60 * 60 * 1000, + retry: false, + refetchInterval: (query: { state: { status: string; data?: LatestReleaseAnswer } }) => + pendingReleasePollMs(query.state), + structuralSharing: keepKnownRelease, + }; + const { data, isError } = useQuery(options); + return { data: isError ? undefined : data?.release }; +}; diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx index 3b0eec6feec..3110d39a75d 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx @@ -75,6 +75,21 @@ describe("NotificationsBellView", () => { expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("1 new feature, 1 fix, and 1 other update"); }); + it("leaves the counts out when the release notes yielded none", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + await user.click(bell(2)); + const upgrade = announcement("LiteLLM v1.103.0 is available"); + expect(upgrade).toHaveTextContent("Your current version is v1.102.0"); + expect(upgrade).not.toHaveTextContent("0 new features"); + expect(upgrade).not.toHaveTextContent("other update"); + }); + it.each([ ["the running version is unknown", undefined, RELEASE], ["the latest release is unavailable", "1.102.0", null], diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index 05e3881373e..20cb15a552d 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -45,12 +45,22 @@ const autoRouterAnnouncement = (): Announcement => ({ href: AUTO_ROUTER_DOCS_URL, }); -const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string => - [ - i18n.t("shell.notifications.release.newFeatures", { count: new_features }), - i18n.t("shell.notifications.release.fixes", { count: bug_fixes }), - i18n.t("shell.notifications.release.otherUpdates", { count: other_updates }), - ].join(", "); +const describeUpgrade = ( + currentVersion: string, + { new_features, bug_fixes, other_updates }: LatestReleaseInfo, +): string => { + if (new_features + bug_fixes + other_updates === 0) { + return i18n.t("shell.notifications.upgrade.descriptionWithoutCounts", { current: currentVersion }); + } + return i18n.t("shell.notifications.upgrade.description", { + summary: [ + i18n.t("shell.notifications.release.newFeatures", { count: new_features }), + i18n.t("shell.notifications.release.fixes", { count: bug_fixes }), + i18n.t("shell.notifications.release.otherUpdates", { count: other_updates }), + ].join(", "), + current: currentVersion, + }); +}; const upgradeAnnouncement = ( currentVersion: string | null | undefined, @@ -62,10 +72,7 @@ const upgradeAnnouncement = ( return { readKey: `litellm:upgradeBannerDismissed:${latestRelease.version}`, title: i18n.t("shell.notifications.upgrade.title", { version: latestRelease.version }), - description: i18n.t("shell.notifications.upgrade.description", { - summary: describeRelease(latestRelease), - current: currentVersion, - }), + description: describeUpgrade(currentVersion, latestRelease), linkLabel: i18n.t("shell.notifications.upgrade.link"), href: latestRelease.release_url, }; diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 113c77e1916..25bf2817e27 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -8208,6 +8208,7 @@ "upgrade": { "title": "LiteLLM v{{version}} is available", "description": "{{summary}}. Your current version is v{{current}}", + "descriptionWithoutCounts": "Your current version is v{{current}}", "link": "View release notes" }, "announcement": { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4f946a4be89..f5f75e65c83 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -6077,7 +6077,11 @@ export interface paths { * Latest Release Info * @description Newest stable release on the LiteLLM changelog, linked to its changelog page, with the PR count of its whole * minor line (e.g. v1.105.0 + v1.105.1) from GitHub split into new features, bug fixes and other updates. - * Returns null when the changelog or GitHub can't be reached so the dashboard upgrade banner simply doesn't render. + * Never waits on the changelog or GitHub. It answers from the cache and, when the cache is empty or expired, responds + * 202 with null and fetches after the response is sent, so a later call returns the release. Responds 502 once that + * fetch has failed because the changelog or GitHub couldn't be reached or answered with something unusable, so the dashboard + * upgrade notice simply doesn't render. The three counts are all zero when the release notes list no change in a + * format this proxy can count. * Set LITELLM_DISABLE_LATEST_RELEASE_CHECK=true to return null without making any outbound request. */ get: operations["latest_release_info_get_latest_release_info_get"]; @@ -63074,6 +63078,20 @@ export interface operations { "application/json": components["schemas"]["LatestReleaseInfo"] | null; }; }; + /** @description The release is being fetched in the background, so ask again shortly */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The changelog or GitHub could not be reached or answered with something unusable */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; get_mcp_semantic_filter_settings_get_mcp_semantic_filter_settings_get: { From 461b56b14b2a024ff73e59f56c01db169dfb902f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:50:55 +0000 Subject: [PATCH 43/86] fix(vertex_ai): bill gemini image text, thinking and priority tokens at their own rates (#51017) * fix(vertex_ai): bill gemini image text, thinking and priority tokens at their own rates Co-authored-by: kerry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(images): bill unitemized output as text when no images return Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cost_calculator): price image calls only by the tier the provider served --------- Co-authored-by: kenneth Co-authored-by: kerry Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 9 + .../litellm_core_utils/llm_cost_calc/utils.py | 42 +++-- .../llms/gemini/image_usage_transformation.py | 138 +++++++++++---- .../vertex_and_google_ai_studio_gemini.py | 17 +- .../image_generation/cost_calculator.py | 2 + .../vertex_gemini_transformation.py | 42 ++--- .../test_vertex_gemini_image_token_spend.py | 158 ++++++++++++++++++ .../llm_cost_calc/test_llm_cost_calc_utils.py | 44 +++++ .../test_gemini_image_edit_transformation.py | 4 +- ..._gemini_image_generation_transformation.py | 6 +- .../gemini/test_image_usage_transformation.py | 67 +++++++- ...tex_ai_image_generation_cost_calculator.py | 72 ++++++++ ...rtex_ai_image_generation_transformation.py | 92 ++++++++++ tests/unit/test_cost_calculator.py | 30 ++++ 14 files changed, 628 insertions(+), 95 deletions(-) create mode 100644 tests/integration/spend/test_vertex_gemini_image_token_spend.py diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 53137b73f8b..5b4a1d30ff0 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1237,6 +1237,14 @@ def _normalize_service_tier(service_tier: object) -> str | None: _BASE_PRICING_SERVICE_TIERS: Final[frozenset[str]] = frozenset({"default", "standard"}) +def _served_image_service_tier(completion_response: object, usage_object: Usage | None) -> str | None: + """Image requests never send a tier upstream, so only the tier the provider reports can price them.""" + return _resolve_billable_service_tier( + requested=None, + served=_served_service_tier(completion_response, usage_object), + ) + + def _resolve_billable_service_tier(requested: object, served: object) -> str | None: """Served tier wins when it names a priced tier or explicitly says base pricing; otherwise the request decides.""" served_lower: Final = served.lower() if isinstance(served, str) else None @@ -2043,6 +2051,7 @@ def completion_cost( call_type=call_type, model_info=_deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id), vertex_location=vertex_location, + service_tier=_served_image_service_tier(completion_response, cost_per_token_usage_object), ) elif call_type in _VIDEO_CALL_TYPES: ### VIDEO GENERATION COST CALCULATION ### diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 2eb204d151b..1f6a66f699a 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -2107,6 +2107,7 @@ def calculate_image_response_cost_from_usage( custom_llm_provider: str, model_info: ModelInfo | None = None, vertex_location: str | None = None, + service_tier: str | None = None, ) -> float | None: """ Calculate image generation cost from usage metadata when available. @@ -2154,28 +2155,22 @@ def calculate_image_response_cost_from_usage( else completion_tokens_details_attr ) - if output_tokens_details is None: - completion_tokens_details = CompletionTokensDetailsWrapper( - text_tokens=0, - image_tokens=completion_tokens, - reasoning_tokens=0, - audio_tokens=0, - ) - else: - text_tokens = _get_token_detail_value(output_tokens_details, "text_tokens") or 0 - image_tokens: Final = _get_token_detail_value(output_tokens_details, "image_tokens") or 0 - audio_tokens: Final = _get_token_detail_value(output_tokens_details, "audio_tokens") or 0 - reasoning_tokens: Final = _get_token_detail_value(output_tokens_details, "reasoning_tokens") or 0 - known_output_tokens: Final = text_tokens + image_tokens + audio_tokens + reasoning_tokens - if completion_tokens > known_output_tokens: - text_tokens += completion_tokens - known_output_tokens - - completion_tokens_details = CompletionTokensDetailsWrapper( - text_tokens=text_tokens, - image_tokens=image_tokens, - reasoning_tokens=reasoning_tokens, - audio_tokens=audio_tokens, - ) + text_tokens: Final = _get_token_detail_value(output_tokens_details, "text_tokens") or 0 + image_tokens: Final = _get_token_detail_value(output_tokens_details, "image_tokens") or 0 + audio_tokens: Final = _get_token_detail_value(output_tokens_details, "audio_tokens") or 0 + reasoning_tokens: Final = _get_token_detail_value(output_tokens_details, "reasoning_tokens") or 0 + has_output_modalities: Final = any( + _get_token_detail_value(output_tokens_details, modality) is not None + for modality in ("text_tokens", "image_tokens", "audio_tokens") + ) + unitemized_tokens: Final = max(completion_tokens - text_tokens - image_tokens - audio_tokens - reasoning_tokens, 0) + unitemized_image_tokens: Final = unitemized_tokens if not has_output_modalities and image_response.data else 0 + completion_tokens_details: Final = CompletionTokensDetailsWrapper( + text_tokens=text_tokens + unitemized_tokens - unitemized_image_tokens, + image_tokens=image_tokens + unitemized_image_tokens, + reasoning_tokens=reasoning_tokens, + audio_tokens=audio_tokens, + ) normalized_usage: Final = Usage( prompt_tokens=prompt_tokens, @@ -2191,6 +2186,7 @@ def calculate_image_response_cost_from_usage( custom_llm_provider=custom_llm_provider, model_info=model_info, vertex_location=vertex_location, + service_tier=service_tier, ) return prompt_cost + completion_cost @@ -2298,6 +2294,7 @@ class CostCalculatorUtils: call_type: str | None = None, model_info: ModelInfo | None = None, vertex_location: str | None = None, + service_tier: str | None = None, ) -> float: """ Route the image generation cost calculator based on the custom_llm_provider @@ -2354,6 +2351,7 @@ class CostCalculatorUtils: image_response=completion_response, model_info=pricing, vertex_location=vertex_location, + service_tier=service_tier, ) elif custom_llm_provider == litellm.LlmProviders.BEDROCK.value: if isinstance(completion_response, ImageResponse): diff --git a/litellm/llms/gemini/image_usage_transformation.py b/litellm/llms/gemini/image_usage_transformation.py index 3ef403f79ee..3ea8e445a47 100644 --- a/litellm/llms/gemini/image_usage_transformation.py +++ b/litellm/llms/gemini/image_usage_transformation.py @@ -1,67 +1,131 @@ +from collections.abc import Mapping from typing import Final from pydantic import TypeAdapter +from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig from litellm.types.utils import ImageUsage, ImageUsageInputTokensDetails +_TOKEN_COUNT_ADAPTER: Final = TypeAdapter(int) +_TOKEN_DETAILS_LIST_ADAPTER: Final[TypeAdapter[list[object]]] = TypeAdapter(list[object]) +_TOKEN_DETAILS_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) -def _get_token_count(details: dict) -> int: + +def _get_token_count(details: Mapping[str, object]) -> int: raw_token_count: Final = details.get("tokenCount", details.get("token_count", 0)) return raw_token_count if isinstance(raw_token_count, int) else 0 -def _get_modality_token_details(usage_metadata: dict, *details_keys: str) -> list: - for details_key in details_keys: - details = usage_metadata.get(details_key) - if isinstance(details, list): - return details - return [] +def _get_usage_token_count(usage_metadata: Mapping[str, object], key: str) -> int: + return _TOKEN_COUNT_ADAPTER.validate_python(usage_metadata.get(key, 0)) -def _sum_modality_token_details(usage_metadata: dict, *details_keys: str) -> ImageUsageInputTokensDetails: - tokens_details: Final = ImageUsageInputTokensDetails( - image_tokens=0, - text_tokens=0, +def _get_modality_token_details( + usage_metadata: Mapping[str, object], *details_keys: str +) -> tuple[Mapping[str, object], ...]: + details: Final[list[object]] = next( + ( + _TOKEN_DETAILS_LIST_ADAPTER.validate_python(usage_metadata.get(details_key)) + for details_key in details_keys + if isinstance(usage_metadata.get(details_key), list) + ), + [], + ) + return tuple( + _TOKEN_DETAILS_MAPPING_ADAPTER.validate_python(detail) for detail in details if isinstance(detail, Mapping) ) - for details in _get_modality_token_details(usage_metadata, *details_keys): - if isinstance(details, dict): - modality = str(details.get("modality", "")).upper() - token_count = _get_token_count(details) - if modality == "TEXT": - tokens_details.text_tokens += token_count - elif modality == "IMAGE": - tokens_details.image_tokens += token_count - return tokens_details +def _sum_modality_token_details( + token_details: tuple[Mapping[str, object], ...], +) -> ImageUsageInputTokensDetails: + return ImageUsageInputTokensDetails( + image_tokens=sum( + _get_token_count(details) + for details in token_details + if str(details.get("modality", "")).upper() == "IMAGE" + ), + text_tokens=sum( + _get_token_count(details) for details in token_details if str(details.get("modality", "")).upper() == "TEXT" + ), + ) -def transform_gemini_image_usage(usage_metadata: dict[str, object]) -> ImageUsage: +def _output_modality_token_details( + candidate_tokens: int, + reasoning_tokens: int, + candidate_tokens_are_inclusive: bool, + modality_tokens: ImageUsageInputTokensDetails, +) -> ImageUsageInputTokensDetails: + non_reasoning_candidate_tokens: Final = max( + candidate_tokens - (reasoning_tokens if candidate_tokens_are_inclusive else 0), + 0, + ) + known_modality_tokens: Final = modality_tokens.text_tokens + modality_tokens.image_tokens + text_tokens: Final = ( + max( + modality_tokens.text_tokens - (known_modality_tokens - non_reasoning_candidate_tokens), + 0, + ) + if known_modality_tokens > non_reasoning_candidate_tokens + else modality_tokens.text_tokens + non_reasoning_candidate_tokens - known_modality_tokens + ) + return ImageUsageInputTokensDetails(image_tokens=modality_tokens.image_tokens, text_tokens=text_tokens) + + +def transform_gemini_image_usage(usage_metadata: Mapping[str, object]) -> ImageUsage: """ Transform Gemini usageMetadata to ImageUsage format. """ - input_tokens_details = _sum_modality_token_details(usage_metadata, "promptTokensDetails", "prompt_tokens_details") - output_tokens: Final = TypeAdapter(int).validate_python(usage_metadata.get("candidatesTokenCount", 0)) - output_tokens_details: Final = _sum_modality_token_details( + validated_counts: Final = ImageUsage.model_validate( + { + "input_tokens": usage_metadata.get("promptTokenCount", 0), + "input_tokens_details": {"image_tokens": 0, "text_tokens": 0}, + "output_tokens": usage_metadata.get("candidatesTokenCount", 0), + "total_tokens": usage_metadata.get("totalTokenCount", 0), + } + ) + prompt_tokens: Final = validated_counts.input_tokens + candidate_tokens: Final = validated_counts.output_tokens + reasoning_tokens: Final = _get_usage_token_count(usage_metadata, "thoughtsTokenCount") + candidate_tokens_are_inclusive: Final = VertexGeminiConfig.is_candidate_token_count_inclusive(usage_metadata) + completion_tokens: Final = candidate_tokens + (0 if candidate_tokens_are_inclusive else reasoning_tokens) + input_tokens_details: Final = _sum_modality_token_details( + _get_modality_token_details(usage_metadata, "promptTokensDetails", "prompt_tokens_details") + ) + candidate_token_details: Final = _get_modality_token_details( usage_metadata, "candidatesTokensDetails", "candidates_tokens_details" ) + modality_tokens: Final = _sum_modality_token_details(candidate_token_details) + output_modality_tokens: Final = ( + _output_modality_token_details( + candidate_tokens, + reasoning_tokens, + candidate_tokens_are_inclusive, + modality_tokens, + ) + if candidate_token_details + else None + ) - if not _get_modality_token_details(usage_metadata, "candidatesTokensDetails", "candidates_tokens_details"): - output_tokens_details.image_tokens = output_tokens - else: - known_output_tokens: Final = output_tokens_details.text_tokens + output_tokens_details.image_tokens - if output_tokens > known_output_tokens: - output_tokens_details.text_tokens += output_tokens - known_output_tokens + output_tokens_details: Final[dict[str, int]] = { + **( + {"image_tokens": output_modality_tokens.image_tokens, "text_tokens": output_modality_tokens.text_tokens} + if output_modality_tokens is not None + else {} + ), + **({"reasoning_tokens": reasoning_tokens} if "thoughtsTokenCount" in usage_metadata else {}), + } usage_payload: Final[dict[str, object]] = { - "input_tokens": usage_metadata.get("promptTokenCount", 0), + "input_tokens": prompt_tokens, "input_tokens_details": input_tokens_details, - "output_tokens": output_tokens, - "total_tokens": usage_metadata.get("totalTokenCount", 0), - "prompt_tokens": usage_metadata.get("promptTokenCount", 0), + "output_tokens": completion_tokens, + "total_tokens": validated_counts.total_tokens, + "prompt_tokens": prompt_tokens, "prompt_tokens_details": input_tokens_details.model_dump(), - "completion_tokens": output_tokens, - "completion_tokens_details": output_tokens_details.model_dump(), - "output_tokens_details": output_tokens_details.model_dump(), + "completion_tokens": completion_tokens, + "completion_tokens_details": {**output_tokens_details}, + "output_tokens_details": {**output_tokens_details}, } return ImageUsage.model_validate(usage_payload) diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 80244cfc3eb..b5267f9681a 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -2442,7 +2442,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): return model_response @staticmethod - def is_candidate_token_count_inclusive(usage_metadata: UsageMetadata) -> bool: + def is_candidate_token_count_inclusive(usage_metadata: Mapping[str, object]) -> bool: """ Check if the candidate token count is inclusive of the thinking token count @@ -2452,12 +2452,17 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): Addresses - https://github.com/BerriAI/litellm/pull/10141#discussion_r2052272035 """ - non_thinking_tokens: Final = ( - usage_metadata.get("promptTokenCount", 0) - + usage_metadata.get("candidatesTokenCount", 0) - + usage_metadata.get("toolUsePromptTokenCount", 0) + prompt_tokens: Final = usage_metadata.get("promptTokenCount", 0) + candidate_tokens: Final = usage_metadata.get("candidatesTokenCount", 0) + tool_use_prompt_tokens: Final = usage_metadata.get("toolUsePromptTokenCount", 0) + total_tokens: Final = usage_metadata.get("totalTokenCount", 0) + return ( + isinstance(prompt_tokens, int) + and isinstance(candidate_tokens, int) + and isinstance(tool_use_prompt_tokens, int) + and isinstance(total_tokens, int) + and prompt_tokens + candidate_tokens + tool_use_prompt_tokens == total_tokens ) - return non_thinking_tokens == usage_metadata.get("totalTokenCount", 0) @staticmethod def _response_has_search_grounding( diff --git a/litellm/llms/vertex_ai/image_generation/cost_calculator.py b/litellm/llms/vertex_ai/image_generation/cost_calculator.py index df8629aac46..6334c8646b6 100644 --- a/litellm/llms/vertex_ai/image_generation/cost_calculator.py +++ b/litellm/llms/vertex_ai/image_generation/cost_calculator.py @@ -18,6 +18,7 @@ def cost_calculator( image_response: ImageResponse, model_info: ModelInfo | None = None, vertex_location: str | None = None, + service_tier: str | None = None, ) -> float: """ Vertex AI Image Generation Cost Calculator @@ -40,6 +41,7 @@ def cost_calculator( custom_llm_provider="vertex_ai", model_info=_model_info, vertex_location=vertex_location, + service_tier=service_tier, ) if token_based_cost is not None: return token_based_cost + web_search_cost diff --git a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py index e1049ef8ece..252054d7330 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py @@ -14,6 +14,7 @@ from litellm.llms.gemini.common_utils import ( get_gemini_image_web_search_requests, map_gemini_image_tools_params, ) +from litellm.llms.gemini.image_usage_transformation import transform_gemini_image_usage from litellm.llms.vertex_ai.common_utils import get_vertex_base_url, map_vertex_image_size from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str @@ -21,8 +22,6 @@ from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import ( ImageObject, ImageResponse, - ImageUsage, - ImageUsageInputTokensDetails, ) if TYPE_CHECKING: @@ -262,27 +261,6 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): return request_body - def _transform_image_usage(self, usage: dict) -> ImageUsage: - input_tokens_details: Final = ImageUsageInputTokensDetails( - image_tokens=0, - text_tokens=0, - ) - tokens_details: Final = usage.get("promptTokensDetails", []) - for details in tokens_details: - if isinstance(details, dict) and (modality := details.get("modality")): - token_count = details.get("tokenCount", 0) - if modality == "TEXT": - input_tokens_details.text_tokens += token_count - elif modality == "IMAGE": - input_tokens_details.image_tokens += token_count - - return ImageUsage( - input_tokens=usage.get("promptTokenCount", 0), - input_tokens_details=input_tokens_details, - output_tokens=usage.get("candidatesTokenCount", 0), - total_tokens=usage.get("totalTokenCount", 0), - ) - def transform_image_generation_response( self, model: str, @@ -334,7 +312,23 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): ) if usage_metadata := response_object.get("usageMetadata", None): - model_response.usage = self._transform_image_usage(_JSON_DICT.validate_python(usage_metadata)) + normalized_usage_metadata: Final = _JSON_DICT.validate_python(usage_metadata) + model_response.usage = transform_gemini_image_usage(normalized_usage_metadata) + traffic_type: Final = normalized_usage_metadata.get("trafficType") + if isinstance(traffic_type, str) and traffic_type: + hidden_params: Final = model_response.hidden_params + provider_specific_fields: Final[dict[str, object]] = ( + _JSON_DICT.validate_python(hidden_params.get("provider_specific_fields")) + if isinstance(hidden_params.get("provider_specific_fields"), Mapping) + else {} + ) + model_response.hidden_params = { # rebind-ok: transformation fills the caller-owned response metadata + **hidden_params, + "provider_specific_fields": { + **provider_specific_fields, + "traffic_type": traffic_type, + }, + } web_search_requests: Final = get_gemini_image_web_search_requests(response_object) if web_search_requests and model_response.usage is not None: diff --git a/tests/integration/spend/test_vertex_gemini_image_token_spend.py b/tests/integration/spend/test_vertex_gemini_image_token_spend.py new file mode 100644 index 00000000000..65864fcba61 --- /dev/null +++ b/tests/integration/spend/test_vertex_gemini_image_token_spend.py @@ -0,0 +1,158 @@ +import json +from collections.abc import Callable +from hashlib import sha256 +from typing import Final + +import pytest +from integration._support.client import Gateway, Scenario, eventually +from integration._support.database import read_rows +from integration._support.vertex import service_account_json +from integration._support.wire import Reply, Request, Wire, wire_server + +_PROJECT: Final = "scripted-project" +_LOCATION: Final = "global" +_BACKEND: Final = "gemini-scripted-image-tokens" +_MODEL_PATH: Final = f"/v1/projects/{_PROJECT}/locations/{_LOCATION}/publishers/google/models" +_PROMPT: Final = "scripted image token spend" +_PNG_B64: Final = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" +_PROMPT_TOKENS: Final = 12 +_TEXT_OUTPUT_TOKENS: Final = 10 +_IMAGE_OUTPUT_TOKENS: Final = 1290 +_THOUGHTS_TOKENS: Final = 40 +_INPUT_RATE: Final = 2e-6 +_TEXT_OUTPUT_RATE: Final = 1e-5 +_IMAGE_OUTPUT_RATE: Final = 4e-5 +_PRIORITY_INPUT_RATE: Final = 4e-6 +_PRIORITY_OUTPUT_RATE: Final = 2e-5 +_PRIORITY_IMAGE_RATE: Final = 8e-5 +_EXPECTED_STANDARD_SPEND: Final = ( + _PROMPT_TOKENS * _INPUT_RATE + + (_TEXT_OUTPUT_TOKENS + _THOUGHTS_TOKENS) * _TEXT_OUTPUT_RATE + + _IMAGE_OUTPUT_TOKENS * _IMAGE_OUTPUT_RATE +) +_EXPECTED_PRIORITY_SPEND: Final = ( + _PROMPT_TOKENS * _PRIORITY_INPUT_RATE + + (_TEXT_OUTPUT_TOKENS + _THOUGHTS_TOKENS) * _PRIORITY_OUTPUT_RATE + + _IMAGE_OUTPUT_TOKENS * _PRIORITY_IMAGE_RATE +) + + +def _image_reply(traffic_type: str | None, text_only: bool, breakdown: bool) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + assert (request.method, request.target) == ( + "POST", + f"{_MODEL_PATH}/{_BACKEND}:generateContent", + ), (request.method, request.target) + assert _PROMPT in request.body.decode(), request.body + usage_metadata: Final[dict[str, object]] = { + "promptTokenCount": _PROMPT_TOKENS, + "candidatesTokenCount": 25 if text_only else _TEXT_OUTPUT_TOKENS + _IMAGE_OUTPUT_TOKENS, + "thoughtsTokenCount": 0 if text_only else _THOUGHTS_TOKENS, + "totalTokenCount": _PROMPT_TOKENS + + (25 if text_only else _TEXT_OUTPUT_TOKENS + _IMAGE_OUTPUT_TOKENS + _THOUGHTS_TOKENS), + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": _PROMPT_TOKENS}], + **( + { + "candidatesTokensDetails": [ + {"modality": "TEXT", "tokenCount": 25 if text_only else _TEXT_OUTPUT_TOKENS}, + *([] if text_only else [{"modality": "IMAGE", "tokenCount": _IMAGE_OUTPUT_TOKENS}]), + ] + } + if breakdown + else {} + ), + **({"trafficType": traffic_type} if traffic_type is not None else {}), + } + return Reply( + body=json.dumps( + { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + {"text": "No image generated"} + if text_only + else {"inlineData": {"mimeType": "image/png", "data": _PNG_B64}}, + ], + }, + "finishReason": "NO_IMAGE" if text_only else "STOP", + } + ], + "usageMetadata": usage_metadata, + } + ).encode() + ) + + return respond + + +def _deployment(gateway: Gateway, scenario: Scenario, wire: Wire) -> str: + return scenario.model( + model=f"vertex_ai/{_BACKEND}", + api_base=f"{wire.url}{_MODEL_PATH}/{_BACKEND}:generateContent", + api_key=None, + vertex_project=_PROJECT, + vertex_location=_LOCATION, + vertex_credentials=service_account_json(_PROJECT, gateway.upstream_url.rstrip("/")), + input_cost_per_token=_INPUT_RATE, + output_cost_per_token=_TEXT_OUTPUT_RATE, + output_cost_per_image_token=_IMAGE_OUTPUT_RATE, + input_cost_per_token_priority=_PRIORITY_INPUT_RATE, + output_cost_per_token_priority=_PRIORITY_OUTPUT_RATE, + output_cost_per_image_token_priority=_PRIORITY_IMAGE_RATE, + ) + + +def _spend_for_key(key: str) -> float: + rows: Final = eventually( + lambda: read_rows( + 'SELECT spend FROM "LiteLLM_SpendLogs" WHERE api_key=%s', + (sha256(key.encode()).hexdigest(),), + ), + lambda found: len(found) == 1, + seconds=70, + ) + return float(str(rows[0]["spend"])) + + +@pytest.mark.timeout(120) +@pytest.mark.parametrize( + ("traffic_type", "text_only", "breakdown", "expected_spend"), + [ + pytest.param(None, False, True, _EXPECTED_STANDARD_SPEND, id="standard"), + pytest.param("ON_DEMAND_PRIORITY", False, True, _EXPECTED_PRIORITY_SPEND, id="on-demand-priority"), + pytest.param(None, True, True, _PROMPT_TOKENS * _INPUT_RATE + 25 * _TEXT_OUTPUT_RATE, id="text-only"), + pytest.param( + None, True, False, _PROMPT_TOKENS * _INPUT_RATE + 25 * _TEXT_OUTPUT_RATE, id="text-only-no-breakdown" + ), + pytest.param( + None, + False, + False, + _PROMPT_TOKENS * _INPUT_RATE + + _THOUGHTS_TOKENS * _TEXT_OUTPUT_RATE + + (_TEXT_OUTPUT_TOKENS + _IMAGE_OUTPUT_TOKENS) * _IMAGE_OUTPUT_RATE, + id="image-no-breakdown", + ), + ], +) +def test_vertex_gemini_image_spend_prices_text_thinking_and_served_tier( + gateway: Gateway, + traffic_type: str | None, + text_only: bool, + breakdown: bool, + expected_spend: float, +) -> None: + with wire_server(_image_reply(traffic_type, text_only, breakdown)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(gateway, scenario, wire) + key: Final = scenario.key(models=[model]) + response: Final = gateway.request( + "POST", + "/v1/images/generations", + {"model": model, "prompt": _PROMPT}, + key=key, + ) + + assert response.status_code == 200, response.text + assert _spend_for_key(key) == pytest.approx(expected_spend) diff --git a/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 0aa76deb9d4..cb9d87622e2 100644 --- a/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/unit/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -19,6 +19,7 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import ( apply_off_peak_pricing, apply_provider_cache_read_default, calculate_cache_writing_cost, + calculate_image_response_cost_from_usage, generic_cost_per_token, get_billed_token_rates, get_regional_uplift_multiplier, @@ -27,6 +28,7 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import ( from litellm.llms.gemini.image_generation.cost_calculator import ( cost_calculator as gemini_image_generation_cost_calculator, ) +from litellm.llms.gemini.image_usage_transformation import transform_gemini_image_usage from litellm.llms.vertex_ai.image_generation.cost_calculator import ( cost_calculator as vertex_image_generation_cost_calculator, ) @@ -3511,6 +3513,48 @@ def test_token_type_cost_breakdown_applies_anthropic_geo_multiplier(_local_model assert text_output_cost + geo.reasoning_cost == pytest.approx(completion_cost) +@pytest.mark.parametrize("has_image", [False, True]) +@pytest.mark.parametrize("thoughts", [0, 40]) +@pytest.mark.parametrize("gemini_usage", [False, True]) +@pytest.mark.parametrize("explicit_breakdown", [False, True]) +def test_image_response_unitemized_output_uses_returned_images( + has_image: bool, thoughts: int, gemini_usage: bool, explicit_breakdown: bool +) -> None: + prices: Final[ModelInfo] = { + "input_cost_per_token": 2e-6, + "output_cost_per_token": 3e-6, + "output_cost_per_image_token": 7e-5, + } + response: Final = ImageResponse( + data=[ImageObject(b64_json="x")] if has_image else [], + usage=transform_gemini_image_usage( + { + "promptTokenCount": 5, + "candidatesTokenCount": 25, + "thoughtsTokenCount": thoughts, + "totalTokenCount": 30 + thoughts, + **({"candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 25}]} if explicit_breakdown else {}), + } + ) + if gemini_usage + else ImageUsage( + input_tokens=5, + input_tokens_details=ImageUsageInputTokensDetails(text_tokens=5, image_tokens=0), + output_tokens=25 + thoughts, + total_tokens=30 + thoughts, + **( + {"output_tokens_details": {"text_tokens": 25, "image_tokens": 0, "reasoning_tokens": thoughts}} + if explicit_breakdown + else {"output_tokens_details": {"reasoning_tokens": thoughts}} + ), + ), + ) + expected_output_rate: Final = 7e-5 if has_image and not explicit_breakdown else 3e-6 + assert calculate_image_response_cost_from_usage( + model="gemini-image-test", image_response=response, custom_llm_provider="gemini", model_info=prices + ) == pytest.approx(5 * 2e-6 + 25 * expected_output_rate + thoughts * 3e-6) + + @pytest.mark.parametrize("details_as_dict", [True, False]) def test_image_response_input_image_tokens_priced_at_image_rate(details_as_dict): """ diff --git a/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py b/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py index c21dec9b6bb..34826fa0571 100644 --- a/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py +++ b/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py @@ -234,12 +234,12 @@ class TestGeminiImageEditTransformation: assert usage["prompt_tokens"] == 35 assert usage["completion_tokens"] == 1716 assert usage["prompt_tokens_details"]["image_tokens"] == 5 - assert usage["completion_tokens_details"]["image_tokens"] == 1716 + assert usage["completion_tokens_details"] == {} logging_usage = StandardLoggingPayloadSetup.get_usage_as_dict( response_obj=image_response.model_dump() ) - assert logging_usage["completion_tokens_details"]["image_tokens"] == 1716 + assert all(value is None for value in logging_usage["completion_tokens_details"].values()) @pytest.mark.parametrize( ("body", "error", "message"), diff --git a/tests/unit/llms/gemini/test_gemini_image_generation_transformation.py b/tests/unit/llms/gemini/test_gemini_image_generation_transformation.py index 138aafcbfcd..5d5edc2a49c 100644 --- a/tests/unit/llms/gemini/test_gemini_image_generation_transformation.py +++ b/tests/unit/llms/gemini/test_gemini_image_generation_transformation.py @@ -294,7 +294,7 @@ def test_gemini_image_generation_preserves_tool_config_side_effect(): } -def test_gemini_image_generation_usage_without_output_details_treats_output_as_image(): +def test_gemini_image_generation_preserves_unspecified_output_modality(): config = GoogleImageGenConfig() raw_response = httpx.Response( status_code=200, @@ -334,8 +334,8 @@ def test_gemini_image_generation_usage_without_output_details_treats_output_as_i ) usage = result.model_dump()["usage"] - assert usage["completion_tokens_details"]["text_tokens"] == 0 - assert usage["completion_tokens_details"]["image_tokens"] == 1716 + assert usage["completion_tokens_details"] == {} + assert usage["output_tokens"] == 1716 def test_gemini_image_generation_response_tracks_web_search_requests(): diff --git a/tests/unit/llms/gemini/test_image_usage_transformation.py b/tests/unit/llms/gemini/test_image_usage_transformation.py index f2f1c3d999f..cb64ddb61ac 100644 --- a/tests/unit/llms/gemini/test_image_usage_transformation.py +++ b/tests/unit/llms/gemini/test_image_usage_transformation.py @@ -11,7 +11,6 @@ PROMPT_DETAILS: Final = [{"modality": "TEXT", "tokenCount": 30}, {"modality": "I @pytest.mark.parametrize( ("candidates_details", "expected_output_details"), [ - ({}, {"image_tokens": 1716, "text_tokens": 0}), ( {"candidatesTokensDetails": [{"modality": "IMAGE", "tokenCount": 1120}]}, {"image_tokens": 1120, "text_tokens": 596}, @@ -48,6 +47,72 @@ def test_transform_gemini_image_usage_reports_image_and_chat_style_counts( } +def test_transform_gemini_image_usage_adds_exclusive_thoughts_to_output_tokens() -> None: + usage: Final = transform_gemini_image_usage( + { + "promptTokenCount": 12, + "candidatesTokenCount": 1300, + "thoughtsTokenCount": 40, + "totalTokenCount": 1352, + "candidatesTokensDetails": [ + {"modality": "TEXT", "tokenCount": 10}, + {"modality": "IMAGE", "tokenCount": 1290}, + ], + } + ) + + assert usage.output_tokens == 1340 + assert usage.completion_tokens == 1340 + assert usage.completion_tokens_details == { + "text_tokens": 10, + "image_tokens": 1290, + "reasoning_tokens": 40, + } + assert usage.output_tokens_details == usage.completion_tokens_details + + +def test_transform_gemini_image_usage_does_not_double_count_inclusive_thoughts() -> None: + usage: Final = transform_gemini_image_usage( + { + "promptTokenCount": 12, + "candidatesTokenCount": 1300, + "thoughtsTokenCount": 40, + "totalTokenCount": 1312, + "candidatesTokensDetails": [ + {"modality": "TEXT", "tokenCount": 10}, + {"modality": "IMAGE", "tokenCount": 1250}, + ], + } + ) + + assert usage.output_tokens == 1300 + assert usage.completion_tokens == 1300 + assert usage.completion_tokens_details == { + "text_tokens": 10, + "image_tokens": 1250, + "reasoning_tokens": 40, + } + assert usage.output_tokens_details == usage.completion_tokens_details + + +@pytest.mark.parametrize("thoughts", [0, 40]) +def test_transform_gemini_image_usage_preserves_unspecified_output_modality(thoughts: int) -> None: + usage: Final = transform_gemini_image_usage( + { + "promptTokenCount": 12, + "candidatesTokenCount": 1300, + "thoughtsTokenCount": thoughts, + "totalTokenCount": 1312 + thoughts, + } + ) + + assert usage.output_tokens == 1300 + thoughts + assert usage.completion_tokens_details == { + "reasoning_tokens": thoughts, + } + assert usage.output_tokens_details == usage.completion_tokens_details + + @pytest.mark.parametrize("count_field", ["promptTokenCount", "totalTokenCount"]) def test_transform_gemini_image_usage_rejects_a_count_that_is_not_a_number(count_field: str): with pytest.raises(ValidationError): diff --git a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py index 65d6e97f8e5..c1c4c5010ed 100644 --- a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py +++ b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_cost_calculator.py @@ -19,6 +19,7 @@ from litellm.types.utils import ( ImageResponse, ImageUsage, ImageUsageInputTokensDetails, + ModelInfo, PromptTokensDetailsWrapper, Usage, ) @@ -148,3 +149,74 @@ def test_vertex_image_generation_cost_no_web_search_when_absent(monkeypatch): ) assert cost_zero == cost_none + + +@pytest.mark.parametrize("priority_image_rate", [None, 8e-5]) +def test_vertex_image_generation_cost_prices_token_details_by_service_tier(priority_image_rate: float | None) -> None: + model: Final = "gemini-scripted-image-tokens" + model_info: Final[ModelInfo] = { + "key": model, + "max_tokens": 1000, + "max_input_tokens": 1000, + "max_output_tokens": 1000, + "input_cost_per_token": 2e-6, + "output_cost_per_token": 1e-5, + "output_cost_per_image_token": 4e-5, + "input_cost_per_token_priority": 4e-6, + "output_cost_per_token_priority": 2e-5, + "output_cost_per_image_token_priority": priority_image_rate, + "litellm_provider": "vertex_ai", + "mode": "image_generation", + "supported_openai_params": None, + } + usage: Final = ImageUsage.model_validate( + { + "input_tokens": 12, + "input_tokens_details": {"text_tokens": 12, "image_tokens": 0}, + "output_tokens": 1340, + "total_tokens": 1352, + "completion_tokens_details": { + "text_tokens": 10, + "image_tokens": 1290, + "reasoning_tokens": 40, + }, + "output_tokens_details": { + "text_tokens": 10, + "image_tokens": 1290, + "reasoning_tokens": 40, + }, + } + ) + image_response: Final = ImageResponse( + data=[ImageObject(b64_json="synthetic-image")], + usage=usage, + ) + standard_cost: Final = vertex_image_generation_cost_calculator( + model=model, + image_response=image_response, + model_info=model_info, + vertex_location="global", + ) + priority_cost: Final = vertex_image_generation_cost_calculator( + model=model, + image_response=image_response, + model_info=model_info, + vertex_location="global", + service_tier="priority", + ) + + expected_standard_cost: Final = 12 * 2e-6 + (10 + 40) * 1e-5 + 1290 * 4e-5 + expected_priority_cost: Final = ( + 12 * 4e-6 + (10 + 40) * 2e-5 + 1290 * (4e-5 if priority_image_rate is None else priority_image_rate) + ) + assert standard_cost == pytest.approx(expected_standard_cost) + assert priority_cost == pytest.approx(expected_priority_cost) + + +def test_vertex_image_generation_without_usage_keeps_flat_image_price() -> None: + cost: Final = vertex_image_generation_cost_calculator( + model="gemini-scripted-image-tokens", + image_response=ImageResponse(data=[ImageObject(b64_json="image")]), + model_info={"output_cost_per_image": 0.07}, + ) + assert cost == pytest.approx(0.07) diff --git a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py index 10ce24e969c..e1eadb89328 100644 --- a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py +++ b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py @@ -15,6 +15,7 @@ from litellm.llms.gemini.image_generation.transformation import GoogleImageGenCo from litellm.llms.vertex_ai.image_generation import ( get_vertex_ai_image_generation_config, ) +from litellm.llms.vertex_ai.image_generation.cost_calculator import cost_calculator from litellm.llms.vertex_ai.image_generation.vertex_gemini_transformation import ( VertexAIGeminiImageGenerationConfig, ) @@ -410,6 +411,97 @@ class TestVertexAIGeminiImageGenerationConfig: assert result.usage.output_tokens == 17 assert result.usage.total_tokens == 110 + def test_transform_text_only_no_image_response_bills_text_tokens(self) -> None: + result: Final = self.config.transform_image_generation_response( + model="gemini-3.1-flash-image", + raw_response=httpx.Response( + 200, + json={ + "candidates": [ + {"content": {"parts": [{"text": "No image generated"}]}, "finishReason": "NO_IMAGE"} + ], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 25, + "totalTokenCount": 30, + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 25}], + }, + }, + ), + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert result.data == [] + assert cost_calculator( + model="gemini-3.1-flash-image", + image_response=result, + model_info={ + "input_cost_per_token": 5e-7, + "output_cost_per_token": 3e-6, + "output_cost_per_image_token": 6e-5, + }, + vertex_location="global", + ) == pytest.approx(5 * 5e-7 + 25 * 3e-6) + + def test_transform_image_generation_response_preserves_usage_details_and_traffic_type(self): + mock_response: Final = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "candidates": [ + { + "content": { + "parts": [ + { + "inlineData": { + "mimeType": "image/png", + "data": "base64_encoded_image_data", + } + } + ] + } + } + ], + "usageMetadata": { + "promptTokenCount": 12, + "candidatesTokenCount": 1300, + "thoughtsTokenCount": 40, + "totalTokenCount": 1352, + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12}], + "candidatesTokensDetails": [ + {"modality": "TEXT", "tokenCount": 10}, + {"modality": "IMAGE", "tokenCount": 1290}, + ], + "trafficType": "ON_DEMAND_PRIORITY", + }, + } + mock_response.headers = {} + + model_response: Final = ImageResponse() + result: Final = self.config.transform_image_generation_response( + model="gemini-3.1-flash-image", + raw_response=mock_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.usage.output_tokens == 1340 + assert result.usage.input_tokens_details.model_dump() == {"text_tokens": 12, "image_tokens": 0} + assert result.usage.completion_tokens_details == { + "text_tokens": 10, + "image_tokens": 1290, + "reasoning_tokens": 40, + } + assert result.usage.output_tokens_details == result.usage.completion_tokens_details + assert result._hidden_params["provider_specific_fields"] == {"traffic_type": "ON_DEMAND_PRIORITY"} + def test_transform_image_generation_response_multiple_images(self): """Test response transformation with multiple images""" mock_response = MagicMock(spec=httpx.Response) diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py index 07b6111aa67..b991f492bdd 100644 --- a/tests/unit/test_cost_calculator.py +++ b/tests/unit/test_cost_calculator.py @@ -2503,6 +2503,36 @@ def test_vertex_gemini_flash_image_generation_regional_costs_uplift_over_global( assert image_cost("us-central1") == pytest.approx(global_cost * 1.10, rel=1e-9) +@pytest.mark.parametrize("requested_tier", ["flex", "priority"]) +def test_vertex_gemini_image_generation_bills_only_the_tier_vertex_served( + _local_model_cost_map: None, requested_tier: str +) -> None: + """Image requests never send service_tier upstream, so a request tier must not change the bill + when Vertex reports no trafficType, while a served ON_DEMAND_PRIORITY still bills priority rates.""" + usage: Final = ImageUsage( + input_tokens=1000, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=1000), + output_tokens=1120, + total_tokens=2120, + ) + + def image_cost(traffic_type: str | None, service_tier: str | None) -> float: + response: Final = ImageResponse(data=[ImageObject(b64_json="img")], usage=usage) + response.hidden_params["provider_specific_fields"] = {"traffic_type": traffic_type} if traffic_type else {} + return completion_cost( + completion_response=response, + model="vertex_ai/gemini-3.1-flash-image", + call_type="image_generation", + optional_params={"service_tier": service_tier} if service_tier else {}, + ) + + standard_cost: Final = image_cost(None, None) + assert standard_cost > 0 + assert image_cost(None, requested_tier) == standard_cost + assert image_cost("ON_DEMAND", requested_tier) == standard_cost + assert image_cost("ON_DEMAND_PRIORITY", requested_tier) > standard_cost + + def test_vertex_uplift_composes_with_above_128k_pricing(monkeypatch): """The regional-endpoint uplift multiplies whatever rate the request priced at, including the above-128k dynamic rates, so a synthetic model carrying both keys From cb024047ac189b26fcf4a1f47a9f588c40eebef9 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:32:19 +0000 Subject: [PATCH 44/86] test(spend): expect token billing for Vertex image edits with usage (#51109) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../integration/spend/test_vertex_image_regional_uplift.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/spend/test_vertex_image_regional_uplift.py b/tests/integration/spend/test_vertex_image_regional_uplift.py index dfd52d28f1f..8cb116bd347 100644 --- a/tests/integration/spend/test_vertex_image_regional_uplift.py +++ b/tests/integration/spend/test_vertex_image_regional_uplift.py @@ -242,7 +242,7 @@ def test_image_generation_bills_the_deployment_location( [ pytest.param("global", False, _GLOBAL_IMAGE_COST, id="global-edit"), pytest.param(_REGION, False, _REGIONAL_IMAGE_COST, id="regional-edit"), - pytest.param(_REGION, True, _REGIONAL_IMAGE_COST, id="regional-edit-usage-is-per-image"), + pytest.param(_REGION, True, _REGIONAL_TOKEN_COST, id="regional-edit-usage-bills-tokens"), ], ) def test_image_edit_bills_the_deployment_location( @@ -489,8 +489,8 @@ def _burst_calls(global_model: str, regional_model: str) -> tuple[_Call, ...]: kinds: Final = ( ("/v1/images/generations", global_model, _GLOBAL_TOKEN_COST), ("/v1/images/generations", regional_model, _REGIONAL_TOKEN_COST), - ("/v1/images/edits", global_model, _GLOBAL_IMAGE_COST), - ("/v1/images/edits", regional_model, _REGIONAL_IMAGE_COST), + ("/v1/images/edits", global_model, _GLOBAL_TOKEN_COST), + ("/v1/images/edits", regional_model, _REGIONAL_TOKEN_COST), ) return tuple( _Call(call_id=uuid.uuid4().hex, route=route, model=model, expected=expected) From 054ed65cdc4b1b1252eb5a4d0bf12885f4f664d7 Mon Sep 17 00:00:00 2001 From: paveln83 <349044532+paveln83@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:38:52 -0800 Subject: [PATCH 45/86] fix(reasoning): log the GPT-5 reasoning_effort drop warning only when the provider gets no effort (#50995) * fix(reasoning): log the GPT-5 reasoning_effort drop warning only when the provider gets no effort * fix(proxy): count an extra_body effort as sent and pass the drop holder through ChatGPT and Copilot * fix(proxy): pass the drop holder to get_optional_params under the internal _litellm_ prefix * fix(openai): keep the reasoning effort drop warning when extra_body sends another level The held warning now stays quiet only when the request sends the level the gate dropped, so a top-level xhigh dropped while extra_body sends none still logs. The Copilot test case writes the access-token file the current authenticator reads. * fix(openai): keep the reasoning effort drop warning when a request fails before reaching the provider The drop warning holder now lives for the whole completion call, and completion's exception handler prints whatever it still holds, since a request that fails after mapping never reached the provider. Each flush empties the holder, so a provider error after dispatch does not print the warning a second time * fix(openai): warn when extra_body's reasoning_effort carries no effort level on the Responses bridge --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/_logging.py | 7 +- .../chat/gpt_reasoning_transformation.py | 3 + litellm/llms/azure_ai/chat/transformation.py | 4 +- litellm/llms/chatgpt/chat/transformation.py | 13 +- .../chat/gpt_reasoning_transformation.py | 46 +++- litellm/llms/openai/openai.py | 5 +- litellm/main.py | 124 ++++++--- litellm/utils.py | 70 +++-- tests/unit/test_main.py | 255 ++++++++++++++++++ 9 files changed, 457 insertions(+), 70 deletions(-) diff --git a/litellm/_logging.py b/litellm/_logging.py index 6a38eea2b12..e5c8d7b6b77 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -11,7 +11,7 @@ from collections.abc import Iterator, Mapping from datetime import datetime from logging import Formatter from types import FrameType -from typing import Final, TextIO +from typing import Final, Protocol, TextIO from urllib.parse import unquote from pydantic import TypeAdapter @@ -936,6 +936,11 @@ else: handler.setFormatter(formatter) + +class WarningLogger(Protocol): + def warning(self, msg: str, /, *args: object) -> None: ... + + verbose_proxy_logger = logging.getLogger("LiteLLM Proxy") # Malformed virtual key rejections log through this child; LevelRoutingStreamHandler # writes its WARNING records to stdout. It has no handler or level of its own. diff --git a/litellm/llms/azure/chat/gpt_reasoning_transformation.py b/litellm/llms/azure/chat/gpt_reasoning_transformation.py index 29305866e23..428813f771c 100644 --- a/litellm/llms/azure/chat/gpt_reasoning_transformation.py +++ b/litellm/llms/azure/chat/gpt_reasoning_transformation.py @@ -4,6 +4,7 @@ from collections.abc import Collection, Mapping from typing import ClassVar, Final import litellm +from litellm._logging import WarningLogger, verbose_logger from litellm.llms.openai.chat.gpt_reasoning_transformation import ( OpenAIGPT5Config, is_gpt_reasoning_series_name, @@ -90,6 +91,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): api_version: str = "", *, declared_reasoning_efforts: Collection[str] | None = None, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict: return OpenAIGPT5Config.map_openai_params( self, @@ -98,6 +100,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): model=model, drop_params=drop_params, declared_reasoning_efforts=declared_reasoning_efforts, + reasoning_drop_logger=reasoning_drop_logger, ) def transform_request( diff --git a/litellm/llms/azure_ai/chat/transformation.py b/litellm/llms/azure_ai/chat/transformation.py index 93ff6d8170a..9c87050fde4 100644 --- a/litellm/llms/azure_ai/chat/transformation.py +++ b/litellm/llms/azure_ai/chat/transformation.py @@ -9,7 +9,7 @@ from httpx import Response from pydantic import TypeAdapter, ValidationError import litellm -from litellm._logging import verbose_logger +from litellm._logging import WarningLogger, verbose_logger from litellm.integrations.anthropic_cache_control_hook import ( AnthropicCacheControlHook, declared_prompt_cache_breakpoint, @@ -203,6 +203,7 @@ class AzureAIStudioConfig(OpenAIConfig): drop_params: bool, *, declared_reasoning_efforts: Collection[str] | None = None, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict[str, object]: # mutable-ok: OpenAIConfig.map_openai_params signature if not azureAIGPT5Config.is_model_gpt_5_model(model): return super().map_openai_params( @@ -217,6 +218,7 @@ class AzureAIStudioConfig(OpenAIConfig): model=model, drop_params=drop_params, declared_reasoning_efforts=declared_reasoning_efforts, + reasoning_drop_logger=reasoning_drop_logger, ) def _supports_stop_reason(self, model: str) -> bool: diff --git a/litellm/llms/chatgpt/chat/transformation.py b/litellm/llms/chatgpt/chat/transformation.py index e8f057b4d3d..9e1e752d8b8 100644 --- a/litellm/llms/chatgpt/chat/transformation.py +++ b/litellm/llms/chatgpt/chat/transformation.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from typing import Any, Final +from litellm._logging import WarningLogger, verbose_logger from litellm.exceptions import AuthenticationError from litellm.llms.openai.openai import OpenAIConfig from litellm.types.llms.openai import AllMessageValues @@ -118,11 +119,15 @@ class ChatGPTConfig(OpenAIConfig): def map_openai_params( self, - non_default_params: dict, - optional_params: dict, + non_default_params: dict[str, object], + optional_params: dict[str, object], model: str, drop_params: bool, - ) -> dict: - optional_params = super().map_openai_params(non_default_params, optional_params, model, drop_params) + *, + reasoning_drop_logger: WarningLogger = verbose_logger, + ) -> dict[str, object]: + optional_params = super().map_openai_params( + non_default_params, optional_params, model, drop_params, reasoning_drop_logger=reasoning_drop_logger + ) optional_params.setdefault("stream", False) return optional_params diff --git a/litellm/llms/openai/chat/gpt_reasoning_transformation.py b/litellm/llms/openai/chat/gpt_reasoning_transformation.py index 8c9551d01c0..ad90dcf9655 100644 --- a/litellm/llms/openai/chat/gpt_reasoning_transformation.py +++ b/litellm/llms/openai/chat/gpt_reasoning_transformation.py @@ -1,11 +1,12 @@ """Support for OpenAI gpt-5 model family.""" import re -from collections.abc import Collection +from collections.abc import Collection, Mapping +from dataclasses import dataclass from typing import ClassVar, Final import litellm -from litellm._logging import verbose_logger +from litellm._logging import WarningLogger, verbose_logger from litellm.utils import ( declared_value_factory, has_catalog_entry, @@ -37,6 +38,44 @@ def refused_reasoning_effort_warning(*, suggest_declared_levels: bool, suggest_b ) +def _effort_level(sent_effort: object) -> str | None: + match sent_effort: + case str(level) | {"effort": str(level)}: + return level + case _: + return None + + +def _sent_effort_level(sent_params: Mapping[str, object]) -> str | None: + match sent_params: + case {"extra_body": {"reasoning_effort": sent_effort}}: + return _effort_level(sent_effort) + case {"reasoning_effort": sent_effort}: + return _effort_level(sent_effort) + case _: + return None + + +@dataclass(slots=True) +class DeferredReasoningDropWarnings: + logger: WarningLogger + held: tuple[tuple[str, tuple[object, ...]], ...] = () + + def warning(self, msg: str, /, *args: object) -> None: + self.held = (*self.held, (msg, args)) + + def log_unless_dropped_effort_sent(self, sent_params: Mapping[str, object]) -> None: + sent_effort: Final = _sent_effort_level(sent_params) + held: Final = self.held + self.held = () + for msg, args in held: + match args: + case ("reasoning_effort", dropped_effort, *_) if dropped_effort == sent_effort: + continue + case _: + self.logger.warning(msg, *args) + + def _catalogue_declares_default_effort() -> bool: """Whether the loaded cost map carries default_reasoning_effort for ANY entry. @@ -330,6 +369,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig): drop_params: bool, *, declared_reasoning_efforts: Collection[str] | None = None, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict: if self.is_model_gpt_5_search_model(model): if "max_tokens" in non_default_params: @@ -364,7 +404,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig): and self.refuses_reasoning_effort_level(model, requested_effort) ): if litellm.drop_params or drop_params: - verbose_logger.warning( + reasoning_drop_logger.warning( refused_reasoning_effort_warning( suggest_declared_levels=declared_reasoning_efforts is not None, suggest_base_model=self.LOOKS_UP_BASE_MODEL diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 495ebf594d8..55442dfb1cf 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -27,7 +27,7 @@ from typing_extensions import overload import litellm from litellm import LlmProviders -from litellm._logging import verbose_logger +from litellm._logging import WarningLogger, verbose_logger from litellm.constants import DEFAULT_MAX_RETRIES from litellm.exceptions import UnsupportedParamsError from litellm.files.types import FileContentStreamingResult @@ -260,6 +260,8 @@ class OpenAIConfig(BaseConfig): optional_params: dict, model: str, drop_params: bool, + *, + reasoning_drop_logger: WarningLogger = verbose_logger, ) -> dict: """ """ if openaiOSeriesConfig.is_model_o_series_model(model=model): @@ -275,6 +277,7 @@ class OpenAIConfig(BaseConfig): optional_params=optional_params, model=model, drop_params=drop_params, + reasoning_drop_logger=reasoning_drop_logger, ) elif litellm.openAIGPTAudioConfig.is_model_gpt_audio_model(model=model): return litellm.openAIGPTAudioConfig.map_openai_params( diff --git a/litellm/main.py b/litellm/main.py index ce71a41d516..2d6cd18162b 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -128,6 +128,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( ) from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header from litellm.litellm_core_utils.reasoning_effort_capability import ( + RequestedReasoningEffort, model_group_from_metadata, reasoning_effort_needs_allowing, resolve_chat_reasoning, @@ -154,7 +155,10 @@ from litellm.llms.bedrock.common_utils import ( from litellm.llms.bedrock_mantle.chat.claude_transformation import bedrock_mantle_chat_config from litellm.llms.cohere.common_utils import CohereModelInfo from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, http2_enabled -from litellm.llms.openai.chat.gpt_reasoning_transformation import OpenAIGPT5Config +from litellm.llms.openai.chat.gpt_reasoning_transformation import ( + DeferredReasoningDropWarnings, + OpenAIGPT5Config, +) from litellm.llms.openai.common_utils import get_openai_credentials from litellm.llms.openai_like.json_loader import JSONProviderRegistry from litellm.llms.vertex_ai.common_utils import ( @@ -225,7 +229,7 @@ from litellm.utils import ( token_counter as token_counter, # noqa: PLC0414 # public re-export ) -from ._logging import verbose_logger +from ._logging import WarningLogger, verbose_logger from .caching.caching import disable_cache, enable_cache, update_cache from .litellm_core_utils.core_helpers import ( as_str_mapping, @@ -5478,6 +5482,51 @@ def _complete_langflow(ctx: CompletionDispatchContext) -> _CompletionDispatchRes ) +def _responses_bridge_optional_params( + optional_params: dict[str, object], # mutable-ok: completion's optional_params, which the Responses bridge takes + summary_alias: object, + requested: RequestedReasoningEffort, + *, + custom_llm_provider: str, + model: str, + base_model: str | None, + api_base: str | None, + deployment_model_info: Mapping[str, object] | None, + drop_params: bool, + additional_drop_params: object, +) -> dict[str, object]: # mutable-ok: completion's optional_params, which the Responses bridge takes + if isinstance(requested, dict): + from litellm.responses.utils import ( + RefusedReasoningFields, + bridge_reasoning, + raise_refused_reasoning_fields, + ) + + bridged_reasoning: Final = bridge_reasoning( + requested=requested, + effort_kept="reasoning_effort" in optional_params, + summary_alias=summary_alias, + model=model, + base_model=base_model, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + deployment_model_info=deployment_model_info, + drop_params=drop_params, + additional_drop_params=additional_drop_params, + ) + if isinstance(bridged_reasoning, RefusedReasoningFields): + raise_refused_reasoning_fields(bridged_reasoning, model, custom_llm_provider) + return {**optional_params, "reasoning_effort": bridged_reasoning} + if summary_alias is None: + return optional_params + kept_effort: Final = optional_params.get("reasoning_effort", requested) + if isinstance(kept_effort, dict): + return {**optional_params, "reasoning_effort": {**kept_effort, "summary": summary_alias}} + if kept_effort is None: + return {**optional_params, "reasoning_effort": {"summary": summary_alias}} + return {**optional_params, "reasoning_effort": {"effort": kept_effort, "summary": summary_alias}} + + def _complete_databricks_agent(ctx: CompletionDispatchContext) -> _CompletionDispatchResult: from litellm.llms.databricks.agent.transformation import DatabricksAgentConfig @@ -5830,6 +5879,7 @@ def completion( merge_with_first_system=True, ) + reasoning_drop_warnings: Final = DeferredReasoningDropWarnings(logger=verbose_logger) try: if base_url is not None: api_base = base_url @@ -6085,14 +6135,22 @@ def completion( mapping_args: Final = MappingProxyType({**optional_param_args, "reasoning_effort": provider_reasoning_effort}) mapping_special_params: Final = MappingProxyType(dict(provider_params)) - def optional_params_for_model_info(deployment_info: Mapping[str, object] | None) -> dict[str, object]: - return get_optional_params(**mapping_args, model_info=deployment_info, **mapping_special_params) + def optional_params_for_model_info( + deployment_info: Mapping[str, object] | None, + reasoning_drop_logger: WarningLogger = verbose_logger, + ) -> dict[str, object]: + return get_optional_params( + **mapping_args, + model_info=deployment_info, + _litellm_reasoning_drop_logger=reasoning_drop_logger, + **mapping_special_params, + ) validated_model_info: Final = ( _MODEL_INFO_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else None ) deployment_base_model: Final = base_model if isinstance(base_model, str) else None - optional_params = optional_params_for_model_info(validated_model_info) + optional_params = optional_params_for_model_info(validated_model_info, reasoning_drop_warnings) fakes_stream: Final = partial( _completion_fakes_stream, model, @@ -6289,6 +6347,7 @@ def completion( ) if mock_response is not None or mock_tool_calls or mock_timeout: kwargs.pop("mock_timeout", None) # remove for any fallbacks triggered + reasoning_drop_warnings.log_unless_dropped_effort_sent(optional_params) return mock_completion( model, messages, @@ -6337,41 +6396,19 @@ def completion( from litellm.completion_extras import responses_api_bridge optional_params, rs_val = strip_reasoning_summary_aliases_from_optional_params(optional_params) - - if isinstance(resolved_reasoning_effort, dict): - from litellm.responses.utils import ( - RefusedReasoningFields, - bridge_reasoning, - raise_refused_reasoning_fields, - ) - - bridged_reasoning: Final = bridge_reasoning( - requested=resolved_reasoning_effort, - effort_kept="reasoning_effort" in optional_params, - summary_alias=rs_val, - model=model, - base_model=base_model if isinstance(base_model, str) else None, - custom_llm_provider=custom_llm_provider, - api_base=api_base, - deployment_model_info=typed_deployment_model_info, - drop_params=drop_unsupported_reasoning, - additional_drop_params=routing_kwargs.get("additional_drop_params"), - ) - if isinstance(bridged_reasoning, RefusedReasoningFields): - raise_refused_reasoning_fields(bridged_reasoning, model, custom_llm_provider) - optional_params["reasoning_effort"] = bridged_reasoning - elif rs_val is not None: - eff: Final = optional_params.get("reasoning_effort", resolved_reasoning_effort) - if isinstance(eff, dict): - optional_params["reasoning_effort"] = {**eff, "summary": rs_val} - elif eff is not None: - optional_params["reasoning_effort"] = { - "effort": eff, - "summary": rs_val, - } - else: - optional_params["reasoning_effort"] = {"summary": rs_val} - + bridge_optional_params: Final = _responses_bridge_optional_params( + optional_params, + rs_val, + resolved_reasoning_effort, + custom_llm_provider=custom_llm_provider, + model=model, + base_model=deployment_base_model, + api_base=api_base, + deployment_model_info=typed_deployment_model_info, + drop_params=drop_unsupported_reasoning, + additional_drop_params=routing_kwargs.get("additional_drop_params"), + ) + reasoning_drop_warnings.log_unless_dropped_effort_sent(bridge_optional_params) return responses_api_bridge.completion( # pyright: ignore[reportReturnType] # bridge returns a coroutine on the acompletion path; awaited by the async caller model=model, messages=messages, @@ -6381,7 +6418,7 @@ def completion( api_base=api_base, acompletion=acompletion, logging_obj=logging, - optional_params=optional_params, + optional_params=bridge_optional_params, litellm_params=litellm_params, timeout=timeout, client=client, # pass AsyncOpenAI, OpenAI client @@ -6390,7 +6427,9 @@ def completion( stream=stream, cache=kwargs.get("cache"), ) - elif (custom_llm_provider == "openai" and OpenAIGPT5Config.is_model_gpt_5_model(model)) or ( + + reasoning_drop_warnings.log_unless_dropped_effort_sent(optional_params) + if (custom_llm_provider == "openai" and OpenAIGPT5Config.is_model_gpt_5_model(model)) or ( custom_llm_provider == "azure" and litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(_azure_detection_model) ): @@ -6656,6 +6695,7 @@ def completion( raise LiteLLMUnknownProvider(model=model, custom_llm_provider=custom_llm_provider) return response except Exception as e: + reasoning_drop_warnings.log_unless_dropped_effort_sent(MappingProxyType({})) ## Map to OpenAI Exception raise exception_type( model=model, diff --git a/litellm/utils.py b/litellm/utils.py index c6ade2603c4..156247897c3 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -516,7 +516,7 @@ from litellm.secret_managers.main import ( resolve_api_key_oidc_reference, ) -from ._logging import is_debugging_on, verbose_logger +from ._logging import WarningLogger, is_debugging_on, verbose_logger from .caching.caching import ( AzureBlobCache, Cache, @@ -5441,6 +5441,42 @@ def _declared_reasoning_efforts(model_info: Mapping[str, object] | None) -> tupl return deployment_reasoning_effort_levels(model_info) or () +def _map_params_with_provider_config( + provider_config: BaseConfig, + *, + custom_llm_provider: str, + non_default_params: dict[str, object], # mutable-ok: map_openai_params takes the caller's dicts + optional_params: dict[str, object], # mutable-ok: map_openai_params fills and returns the caller's dict + model: str, + drop_params: bool, + model_info: Mapping[str, object] | None, + reasoning_drop_logger: WarningLogger, +) -> dict[str, object]: # mutable-ok: get_optional_params keeps building on the mapped params + if custom_llm_provider == "azure_ai" and isinstance(provider_config, litellm.AzureAIStudioConfig): + return provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + declared_reasoning_efforts=_declared_reasoning_efforts(model_info), + reasoning_drop_logger=reasoning_drop_logger, + ) + if isinstance(provider_config, (litellm.ChatGPTConfig, litellm.GithubCopilotConfig)): + return provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + reasoning_drop_logger=reasoning_drop_logger, + ) + return provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + ) + + _DROPPED_REASONING_PARAMS: Final = ("reasoning_effort", "thinking") _DROP_UNREAD_OUTPUT_CONFIG_WARNING: Final = ( @@ -5530,6 +5566,7 @@ def get_optional_params( prompt_cache_key: str | None = None, base_model: str | None = None, model_info: Mapping[str, object] | None = None, + _litellm_reasoning_drop_logger: WarningLogger = verbose_logger, **kwargs: object, ): drop_params = normalize_drop_params(drop_params) # rebind-ok: config and DB deployments pass "true" as a string @@ -5541,6 +5578,7 @@ def get_optional_params( # OpenAI param. passed_params.pop("base_model", None) passed_params.pop("model_info", None) + passed_params.pop("_litellm_reasoning_drop_logger", None) provider_config: BaseConfig | None = None if custom_llm_provider is not None and custom_llm_provider in [provider.value for provider in LlmProviders]: provider_config = ProviderConfigManager.get_provider_chat_config( @@ -6035,6 +6073,7 @@ def get_optional_params( model=model, drop_params=bool(drop_params), declared_reasoning_efforts=_declared_reasoning_efforts(model_info), + reasoning_drop_logger=_litellm_reasoning_drop_logger, ) if litellm.OpenAIGPT5Config.is_model_gpt_5_model(model=model) else litellm.OpenAIConfig().map_openai_params( @@ -6067,6 +6106,7 @@ def get_optional_params( model=_azure_detection_model, drop_params=bool(drop_params), declared_reasoning_efforts=_declared_reasoning_efforts(model_info), + reasoning_drop_logger=_litellm_reasoning_drop_logger, ) else: verbose_logger.debug( @@ -6088,21 +6128,15 @@ def get_optional_params( drop_params=bool(drop_params), ) elif provider_config is not None: - optional_params = ( - provider_config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=model, - drop_params=bool(drop_params), - declared_reasoning_efforts=_declared_reasoning_efforts(model_info), - ) - if custom_llm_provider == "azure_ai" and isinstance(provider_config, litellm.AzureAIStudioConfig) - else provider_config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=model, - drop_params=bool(drop_params), - ) + optional_params = _map_params_with_provider_config( + provider_config, + custom_llm_provider=custom_llm_provider, + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=bool(drop_params), + model_info=model_info, + reasoning_drop_logger=_litellm_reasoning_drop_logger, ) else: # assume passing in params for openai-like api optional_params = litellm.OpenAILikeChatConfig().map_openai_params( @@ -11734,8 +11768,8 @@ def reasoning_effort_with_summary_alias( def strip_reasoning_summary_aliases_from_optional_params( - optional_params: dict, -) -> tuple[dict, object | None]: + optional_params: Mapping[str, object], +) -> tuple[dict[str, object], object | None]: """Copy optional_params; remove reasoningSummary aliases from top-level and extra_body.""" op: Final = dict(optional_params) rs_val = op.pop("reasoningSummary", None) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index c4879587038..577d4b4b11d 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -2451,6 +2451,261 @@ def test_openai_responses_bridge_drops_a_reasoning_effort_dict_for_a_model_witho assert "reasoning" not in request_body +_AZURE_TEST_API_BASE: Final = "https://example.openai.azure.com" +_UNLISTED_AZURE_GPT_5_MODEL: Final = "azure/gpt-5.6-litellm-unlisted" +_FUNCTION_TOOL: Final = {"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}} + + +@dataclass(frozen=True, slots=True) +class AzureUpstreamRoutes: + chat: respx.Route + responses: respx.Route + + def sent_reasoning_effort(self) -> object: + if self.responses.called: + return json.loads(self.responses.calls[0].request.content).get("reasoning", {}).get("effort") + return json.loads(self.chat.calls[0].request.content).get("reasoning_effort") + + +@pytest.fixture +def azure_upstream_routes( + local_model_cost_map: None, + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + openai_api_response: dict[str, object], +) -> AzureUpstreamRoutes: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.delenv("LITELLM_REASONING_AUTO_SUMMARY", raising=False) + return AzureUpstreamRoutes( + chat=respx_mock.post(url__regex=r".*/chat/completions.*").respond(json=openai_api_response), + responses=respx_mock.post(url__regex=r".*/responses.*").respond( + json={ + "id": "resp_test", + "object": "response", + "created_at": 1734366691, + "status": "completed", + "model": "gpt-5.6-litellm-unlisted", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Done.", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + ), + ) + + +@pytest.mark.parametrize( + ("request_params", "expected_sent_effort"), + [ + pytest.param({"reasoning_effort": "none", "reasoningSummary": "auto"}, "none", id="summary-alias"), + pytest.param( + {"reasoning_effort": "none", "extra_body": {"reasoningSummary": "auto"}}, + "none", + id="summary-alias-in-extra-body", + ), + pytest.param( + {"reasoning_effort": {"effort": "none", "summary": "auto"}, "tools": [_FUNCTION_TOOL]}, + None, + id="dict-with-summary-and-tool", + ), + pytest.param( + {"reasoning_effort": {"effort": "none"}, "reasoningSummary": "auto"}, None, id="dict-with-summary-alias" + ), + pytest.param({"reasoning_effort": "none"}, None, id="chat"), + pytest.param({"reasoning_effort": "none", "tools": [_FUNCTION_TOOL]}, None, id="bridged-without-summary"), + pytest.param({"reasoning_effort": {"effort": "none", "summary": "auto"}}, None, id="dict-with-summary-on-chat"), + pytest.param( + {"reasoning_effort": "none", "extra_body": {"reasoning_effort": "none"}}, + "none", + id="effort-in-extra-body", + ), + pytest.param( + {"reasoning_effort": "none", "tools": [_FUNCTION_TOOL], "extra_body": {"reasoning_effort": "none"}}, + "none", + id="effort-in-extra-body-bridged", + ), + pytest.param( + { + "reasoning_effort": "none", + "tools": [_FUNCTION_TOOL], + "extra_body": {"reasoning_effort": {"effort": "none", "summary": "auto"}}, + }, + "none", + id="effort-dict-in-extra-body-bridged", + ), + pytest.param( + { + "reasoning_effort": "none", + "reasoningSummary": "auto", + "extra_body": {"reasoning_effort": {"summary": "detailed"}}, + }, + None, + id="summary-only-dict-in-extra-body-bridged", + ), + pytest.param( + {"reasoning_effort": "none", "extra_body": {"reasoning_effort": "low"}}, + "low", + id="other-effort-in-extra-body", + ), + pytest.param( + {"reasoning_effort": "none", "tools": [_FUNCTION_TOOL], "extra_body": {"reasoning_effort": "low"}}, + "low", + id="other-effort-in-extra-body-bridged", + ), + ], +) +def test_reasoning_effort_drop_warning_fires_unless_the_provider_gets_the_dropped_effort( + request_params: dict[str, object], + expected_sent_effort: str | None, + azure_upstream_routes: AzureUpstreamRoutes, + caplog: pytest.LogCaptureFixture, +): + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + api_version="2025-04-01-preview", + drop_params=True, + **request_params, + ) + + sent_effort: Final = azure_upstream_routes.sent_reasoning_effort() + drop_warnings: Final = [r.getMessage() for r in caplog.records if "Dropping reasoning_effort" in r.getMessage()] + assert sent_effort == expected_sent_effort + assert len(drop_warnings) == (0 if sent_effort == "none" else 1) + + +@pytest.mark.parametrize( + "request_params", + [ + pytest.param( + {"provider_affinity_header": "x-session-affinity", "litellm_session_id": "a\nb"}, + id="rejected-before-dispatch", + ), + pytest.param({}, id="rejected-by-the-provider"), + ], +) +def test_reasoning_effort_drop_warning_fires_once_when_the_request_fails( + request_params: dict[str, object], + azure_upstream_routes: AzureUpstreamRoutes, + caplog: pytest.LogCaptureFixture, +): + azure_upstream_routes.chat.respond(400, json={"error": {"message": "refused", "type": "invalid_request_error"}}) + with caplog.at_level(logging.WARNING, logger="LiteLLM"), pytest.raises(litellm.BadRequestError): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + api_version="2025-04-01-preview", + drop_params=True, + reasoning_effort="none", + **request_params, + ) + + assert len([r for r in caplog.records if "Dropping reasoning_effort" in r.getMessage()]) == 1 + + +@pytest.fixture +def github_copilot_access_token(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "access-token").write_text("token") + + +@pytest.mark.parametrize( + ("model", "api_base", "refused_effort", "model_info"), + [ + pytest.param("openai/gpt-5.6-litellm-unlisted", "https://api.openai.com/v1", "xhigh", None, id="openai"), + pytest.param( + "azure_ai/gpt-5.6-litellm-unlisted", + "https://example.services.ai.azure.com/openai/v1", + "xhigh", + None, + id="azure_ai", + ), + pytest.param( + "chatgpt/gpt-5.5", + "https://chatgpt.example.com/backend-api/codex", + "minimal", + {"reasoning_effort_levels": ["minimal", "low", "medium", "high"]}, + id="chatgpt-declared-level", + ), + pytest.param( + "github_copilot/gpt-5.5", + "https://api.githubcopilot.com", + "minimal", + {"reasoning_effort_levels": ["minimal", "low", "medium", "high"]}, + id="github-copilot-declared-level", + ), + ], +) +def test_reasoning_effort_drop_warning_stays_quiet_when_the_summary_alias_restores_the_effort( + model: str, + api_base: str, + refused_effort: str, + model_info: dict[str, object] | None, + azure_upstream_routes: AzureUpstreamRoutes, + github_copilot_access_token: None, + caplog: pytest.LogCaptureFixture, +): + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + litellm.completion( + model=model, + messages=[{"role": "user", "content": "hello"}], + api_base=api_base, + api_key="fake-key", + drop_params=True, + reasoning_effort=refused_effort, + reasoningSummary="auto", + model_info=model_info, + ) + + assert azure_upstream_routes.sent_reasoning_effort() == refused_effort + assert not [r for r in caplog.records if "Dropping reasoning_effort" in r.getMessage()] + + +def test_a_request_param_named_like_the_drop_holder_still_reaches_the_provider( + azure_upstream_routes: AzureUpstreamRoutes, +): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + api_version="2025-04-01-preview", + drop_params=True, + reasoning_drop_logger="sent by the caller", + ) + + sent_body: Final = json.loads(azure_upstream_routes.chat.calls[0].request.content) + assert sent_body["reasoning_drop_logger"] == "sent by the caller" + + +def test_mock_response_keeps_the_reasoning_effort_drop_warning( + local_model_cost_map: None, caplog: pytest.LogCaptureFixture +): + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + litellm.completion( + model=_UNLISTED_AZURE_GPT_5_MODEL, + messages=[{"role": "user", "content": "hello"}], + api_base=_AZURE_TEST_API_BASE, + api_key="fake-key", + reasoning_effort="none", + drop_params=True, + mock_response="hi", + ) + + assert any("Dropping reasoning_effort='none'" in r.getMessage() for r in caplog.records) + + @pytest.mark.parametrize( "drop_params, expected_reasoning", [ From 478d9bf5d230419884efb9abb64d94ab071337dd Mon Sep 17 00:00:00 2001 From: shrey Date: Tue, 10 Nov 2026 19:50:44 -0800 Subject: [PATCH 46/86] test(mcp): read the comma grant through real credential storage (#51065) --- .../mcp_server/test_discoverable_endpoints.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py b/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py index 47d24a9626f..64413c4a74a 100644 --- a/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py +++ b/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py @@ -5,6 +5,7 @@ import json import time from base64 import urlsafe_b64encode from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from typing import TYPE_CHECKING, Final, Literal from unittest.mock import AsyncMock, MagicMock, patch @@ -15375,6 +15376,25 @@ _PUBLISHED_SLACK_METADATA: Final = { } +def _in_memory_credentials_table(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + from litellm.proxy import proxy_server + + monkeypatch.setenv("LITELLM_SALT_KEY", "connect-check-test-salt") + prisma: Final = MagicMock() + rows: Final = prisma.db.litellm_mcpusercredentials + + async def upsert(where: "Mapping[str, Mapping[str, str]]", data: "Mapping[str, Mapping[str, str]]") -> None: + key: Final = where["user_id_server_id"] + rows.find_unique.return_value = SimpleNamespace( + credential_b64=data["create"]["credential_b64"], user_id=key["user_id"], server_id=key["server_id"] + ) + + rows.find_unique = AsyncMock(return_value=None) + rows.upsert = AsyncMock(side_effect=upsert) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + return prisma + + @pytest.mark.asyncio @pytest.mark.parametrize( "server_update", @@ -15391,9 +15411,10 @@ async def test_connect_reads_a_comma_grant_against_the_server_scopes( from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager server: Final = _cimd_oauth_server().model_copy(update=server_update) - credential: Final = {**_stored_grant(), "scopes": ["channels:read,chat:write,files:read"]} - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock()) - monkeypatch.setattr(db, "get_user_oauth_credential", AsyncMock(return_value=credential)) + prisma: Final = _in_memory_credentials_table(monkeypatch) + await db.store_user_oauth_credential( + prisma, "owner", server.server_id, "vendor-token", scopes=["channels:read,chat:write,files:read"] + ) assert await endpoints._vendor_credential_state("owner", server.server_id, ("channels:read",)) == "absent" monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server) assert await endpoints._vendor_credential_state("owner", server.server_id, ("channels:read",)) == "present" From 63d39b41fd4cfa3b9aa0c47f3ea04756e4054e98 Mon Sep 17 00:00:00 2001 From: quietjules <349048734+quietjules@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:21:39 -0800 Subject: [PATCH 47/86] fix(proxy): keep a request's cache opt-in when its key has cache metadata (#50999) * fix(proxy): merge a key's cache controls into the request's cache settings instead of replacing them * fix(proxy): keep a key with no-cache out of opt-in caching A key whose cache metadata sets no-cache now drops the caller's use-cache, so on a default_off proxy its responses stay out of the shared cache as before the merge change. default_on proxies ignore use-cache, so they are unchanged * fix(proxy): keep base handling of non-dict key cache metadata Key cache metadata that is not a dict, such as false or a string, wipes the request's cache settings as before the merge change, so those keys stay out of opt-in caching * fix(proxy): keep a key with use-cache false out of opt-in caching * fix(proxy): only keep request cache settings on an allowed opt-in --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/models/verification_token.py | 2 +- litellm/proxy/litellm_pre_call_utils.py | 41 ++++- .../unit/proxy/test_litellm_pre_call_utils.py | 155 ++++++++++++++++++ 3 files changed, 190 insertions(+), 8 deletions(-) diff --git a/litellm/models/verification_token.py b/litellm/models/verification_token.py index 89048c56a9f..4b804ebde1c 100644 --- a/litellm/models/verification_token.py +++ b/litellm/models/verification_token.py @@ -35,7 +35,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): tpd_limit: int | None = None budget_duration: str | None = None budget_reset_at: datetime | None = None - allowed_cache_controls: list | None = [] + allowed_cache_controls: list[object] | None = [] allowed_routes: list | None = [] key_type: str | None = None permissions: dict = {} diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 447b41f9cfd..0b2ba93af71 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -2217,15 +2217,41 @@ class LiteLLMProxyRequestSetup: return data @staticmethod - def add_key_level_controls(key_metadata: dict | None, data: dict, _metadata_variable_name: str): + def _request_cache_kept_for_key( + request_cache: Mapping[str, object], + key_cache: Mapping[str, object], + allowed_cache_controls: Sequence[object] | None, + ) -> Mapping[str, object]: + key_opts_out: Final = ( + key_cache.get("no-cache", False) is not False or key_cache.get("use-cache", True) is not True + ) + request_is_allowed: Final = not allowed_cache_controls or all( + control in allowed_cache_controls for control in request_cache + ) + if key_opts_out or request_cache.get("use-cache") is not True or not request_is_allowed: + return MappingProxyType({}) + return {k: v for k, v in request_cache.items() if not (k == "s-max-age" and "s-maxage" in key_cache)} + + @staticmethod + def add_key_level_controls( + key_metadata: dict | None, + data: dict, + _metadata_variable_name: str, + allowed_cache_controls: Sequence[object] | None = None, + ): if key_metadata is None: return data - if "cache" in key_metadata: - data["cache"] = {} - if isinstance(key_metadata["cache"], dict): - for k, v in key_metadata["cache"].items(): - if k in SupportedCacheControls: - data["cache"][k] = v + if "cache" in key_metadata and not isinstance(key_metadata["cache"], dict): + data["cache"] = {} # rebind-ok: data is an out-param + elif "cache" in key_metadata: + request_cache: Final = _DECODED_JSON.validate_python(data["cache"]) if "cache" in data else None + key_cache: Final = _request_fields(_DECODED_JSON.validate_python(key_metadata["cache"])) + data["cache"] = { # rebind-ok: data is an out-param + **LiteLLMProxyRequestSetup._request_cache_kept_for_key( + _request_fields(request_cache), key_cache, allowed_cache_controls + ), + **{k: v for k, v in key_cache.items() if k in SupportedCacheControls}, + } ## KEY-LEVEL SPEND LOGS / TAGS if "tags" in key_metadata and key_metadata["tags"] is not None: @@ -2967,6 +2993,7 @@ async def add_litellm_data_to_request( key_metadata=key_metadata, data=data, _metadata_variable_name=_metadata_variable_name, + allowed_cache_controls=user_api_key_dict.allowed_cache_controls, ) data[_metadata_variable_name] = LiteLLMProxyRequestSetup.add_team_and_project_level_controls( user_api_key_dict=user_api_key_dict, diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py index f4a836b4b4b..f5757a0ff18 100644 --- a/tests/unit/proxy/test_litellm_pre_call_utils.py +++ b/tests/unit/proxy/test_litellm_pre_call_utils.py @@ -17,6 +17,7 @@ from pydantic import ValidationError as PydanticValidationError from starlette.datastructures import Headers import litellm +from litellm.caching.caching import Cache, CacheMode from litellm.constants import ( CALLER_SESSION_ID_METADATA_KEY, COMPLEXITY_ROUTER_CHILD_SESSION_ID_METADATA_KEY, @@ -64,6 +65,7 @@ from litellm.proxy.litellm_pre_call_utils import ( resolve_tenant_otel_destinations, ) from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload +from litellm.types.caching import LiteLLMCacheType from litellm.types.litellm_params import AGENTIC_LOOP_KWARG_NAMES from litellm.types.utils import CredentialItem from tests.unit.proxy.router_entry_point_stubs import route_entry_points_to_stubs @@ -8379,6 +8381,159 @@ async def test_key_level_callback_vars_survive_the_strip(): assert updated["dd_site"] == "us5.datadoghq.com" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "key_cache, request_cache, expected_cache, uses_cache_when_default_off", + [ + pytest.param( + {"ttl": 600}, + {"use-cache": True, "namespace": "team-a"}, + {"use-cache": True, "namespace": "team-a", "ttl": 600}, + True, + id="key-control-added-to-request-opt-in", + ), + pytest.param( + {"no-store": True}, + {"use-cache": True, "no-store": False}, + {"use-cache": True, "no-store": True}, + True, + id="key-control-wins-over-request", + ), + pytest.param( + {"use-cache": True, "namespace": "key-ns"}, + None, + {}, + False, + id="unsupported-key-controls-ignored", + ), + pytest.param(None, {"use-cache": True}, {}, False, id="non-dict-key-cache-still-wipes-request"), + pytest.param( + {"no-cache": True}, + {"use-cache": True, "ttl": 60}, + {"no-cache": True}, + False, + id="key-no-cache-keeps-the-key-out-of-opt-in-caching", + ), + pytest.param( + {"use-cache": False}, + {"use-cache": True, "ttl": 60}, + {}, + False, + id="key-use-cache-false-keeps-the-key-out-of-opt-in-caching", + ), + pytest.param( + {"use-cache": True}, + {"use-cache": True}, + {"use-cache": True}, + True, + id="key-use-cache-true-leaves-the-request-opt-in", + ), + pytest.param( + {"no-cache": "true"}, + {"use-cache": True}, + {"no-cache": "true"}, + False, + id="key-non-boolean-no-cache-keeps-the-key-out-of-opt-in-caching", + ), + pytest.param( + {"no-cache": False}, + {"use-cache": True}, + {"use-cache": True, "no-cache": False}, + True, + id="key-no-cache-false-leaves-the-request-opt-in", + ), + pytest.param( + {"ttl": 600}, + {"no-cache": True, "namespace": "team-a"}, + {"ttl": 600}, + False, + id="request-without-opt-in-is-still-replaced", + ), + pytest.param( + {"ttl": 600}, + {"use-cache": "true", "no-store": True}, + {"ttl": 600}, + False, + id="request-non-boolean-use-cache-is-still-replaced", + ), + pytest.param( + {"s-maxage": 1}, + {"use-cache": True, "s-max-age": 100000}, + {"use-cache": True, "s-maxage": 1}, + True, + id="key-s-maxage-overrides-the-request-s-max-age-alias", + ), + pytest.param( + {"ttl": 600}, + {"use-cache": True, "s-max-age": 5}, + {"use-cache": True, "s-max-age": 5, "ttl": 600}, + True, + id="request-s-max-age-kept-when-the-key-has-no-s-maxage", + ), + ], +) +async def test_key_cache_controls_merge_into_request_cache_controls( + key_cache: dict[str, object] | None, + request_cache: dict[str, object] | None, + expected_cache: dict[str, object], + uses_cache_when_default_off: bool, +): + key: Final = UserAPIKeyAuth(api_key="hashed-key", metadata={"cache": key_cache}) + data: Final = { + "model": "gpt-4.1-mini", + "messages": [{"role": "user", "content": "hello"}], + **({} if request_cache is None else {"cache": request_cache}), + } + + updated: Final = await add_litellm_data_to_request( + data=data, + request=_callback_credential_request_mock(), + user_api_key_dict=key, + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + + assert updated["cache"] == expected_cache + opt_in_cache: Final = Cache(type=LiteLLMCacheType.LOCAL, mode=CacheMode.default_off) + assert opt_in_cache.should_use_cache(**updated) is uses_cache_when_default_off + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "allowed_cache_controls, request_cache, expected_cache", + [ + pytest.param(["no-store"], {"use-cache": True}, {"ttl": 600}, id="opt-in-not-allowed-is-replaced"), + pytest.param( + ["use-cache"], {"use-cache": True, "no-store": True}, {"ttl": 600}, id="any-disallowed-control-is-replaced" + ), + pytest.param(["use-cache"], {"use-cache": True}, {"use-cache": True, "ttl": 600}, id="allowed-opt-in-is-kept"), + pytest.param([], {"use-cache": True}, {"use-cache": True, "ttl": 600}, id="empty-list-allows-the-opt-in"), + ], +) +async def test_key_cache_metadata_only_keeps_request_controls_the_key_allows( + allowed_cache_controls: list[str], + request_cache: dict[str, object], + expected_cache: dict[str, object], +): + key: Final = UserAPIKeyAuth( + api_key="hashed-key", + metadata={"cache": {"ttl": 600}}, + allowed_cache_controls=allowed_cache_controls, + ) + + updated: Final = await add_litellm_data_to_request( + data={"model": "gpt-4.1-mini", "messages": [{"role": "user", "content": "hello"}], "cache": request_cache}, + request=_callback_credential_request_mock(), + user_api_key_dict=key, + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + + assert updated["cache"] == expected_cache + + class TestPromotedTraceControlFields: """LIT-5137: caller metadata trace fields must reach litellm_metadata.""" From 83c47061e142178c41a956efafa7f38cb61d5272 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:33:52 +0000 Subject: [PATCH 48/86] feat(replicate): add google/gemini-4-carbon from the Replicate model page (#51125) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 19 +++++++++++++++++++ model_prices_and_context_window.json | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 092987ce69e..ced7e002ba4 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -100030,5 +100030,24 @@ "supports_tool_choice": true, "supports_vision": true, "thinking_always_on": true + }, + "replicate/google/gemini-4-carbon": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 1e-05, + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/google/gemini-4-carbon", + "supports_audio_input": true, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_video_input": true, + "supports_vision": true } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 092987ce69e..ced7e002ba4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -100030,5 +100030,24 @@ "supports_tool_choice": true, "supports_vision": true, "thinking_always_on": true + }, + "replicate/google/gemini-4-carbon": { + "input_cost_per_token": 2e-06, + "litellm_provider": "replicate", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 1e-05, + "output_cost_per_token": 1e-05, + "source": "https://replicate.com/google/gemini-4-carbon", + "supports_audio_input": true, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_video_input": true, + "supports_vision": true } } From 27e5c3ba75bb9bb4e1ba05d772a5e226112771cf Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Tue, 10 Nov 2026 20:51:34 -0800 Subject: [PATCH 49/86] fix(mcp)!: forward the LiteLLM key when the admin configures it (#50604) * fix(mcp)!: forward the LiteLLM key when the admin configures it Restores forwarding of the caller's LiteLLM key through explicit channels (Extra Headers listing X-LiteLLM-API-Key or Authorization, x-mcp-{alias}-authorization and x-mcp-auth) that #45401 and #46932 started stripping. Implicit Authorization forwarding stays scrubbed for true_passthrough and unlisted servers, gateway session tokens and bridge envelopes stay scrubbed, and guardrail and JWT signer bearer reads now skip the admission key * test(mcp): serve signer introspection from an in-process transport * fix(mcp): copy oauth2 headers so a listed key never reaches a later server * test(mcp): drive the shared-header regression through the real MCP client --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- .../mcp_jwt_signer/mcp_jwt_signer.py | 10 +- .../mcp_server/auth/user_api_key_auth_mcp.py | 47 +- .../proxy/mcp_server/mcp_server_manager.py | 16 +- litellm/proxy/mcp_server/operations.py | 2 +- litellm/proxy/mcp_server/rest_endpoints.py | 1 - tests/integration/mcp/test_mcp_credentials.py | 47 +- .../proxy/guardrails/test_mcp_jwt_signer.py | 48 +- .../auth/test_user_api_key_auth_mcp.py | 476 +++--------------- .../mcp_server/test_mcp_server_manager.py | 137 ++++- .../unit/proxy/mcp_server/test_operations.py | 7 +- .../proxy/mcp_server/test_rest_endpoints.py | 16 +- .../mcp/test_aresponses_api_with_mcp.py | 7 +- 12 files changed, 304 insertions(+), 510 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py index 9cb929dec92..9463023854e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py +++ b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py @@ -941,15 +941,13 @@ async def inject_mcp_jwt_headers_for_upstream( if signer is None or user_api_key_dict is None: return merged - normalized_raw: Final = {k.lower(): v for k, v in (raw_headers or {}).items()} - incoming_bearer_token: str | None = None - auth_hdr: Final = normalized_raw.get("authorization", "") - if auth_hdr.lower().startswith("bearer "): - incoming_bearer_token = auth_hdr[len("bearer ") :] + from litellm.proxy.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler + + incoming_bearer_token: Final = MCPRequestHandler.guardrail_bearer_from_raw_headers(raw_headers, user_api_key_dict) hook_data: Final = { "mcp_tool_name": "" if for_list_tools else mcp_tool_name, - "incoming_bearer_token": incoming_bearer_token, + "incoming_bearer_token": incoming_bearer_token.get_secret_value() if incoming_bearer_token else None, "extra_headers": merged, } call_type: Final[CallTypesLiteral] = "list_mcp_tools" if for_list_tools else "call_mcp_tool" diff --git a/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py index be24b1630b0..bcb979d720b 100644 --- a/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py @@ -919,7 +919,6 @@ class MCPRequestHandler: mcp_auth_header=mcp_auth_header, mcp_server_auth_headers=mcp_server_auth_headers, admitted_credential=admitted_credential, - preserve_jwt_subject=bool(validated_user_api_key_auth.jwt_claims), ) return ( @@ -959,49 +958,29 @@ class MCPRequestHandler: mcp_server_auth_headers: dict[str, dict[str, str]] | None, *, admitted_credential: str | None, - preserve_jwt_subject: bool = False, ) -> tuple[dict[str, str] | None, dict[str, str], str | None, dict[str, dict[str, str]] | None]: - """Remove gateway-shaped credentials and the caller's admitted credential from EVERY egress - header context: top-level ``Authorization`` (oauth2 + raw), the deprecated ``x-mcp-auth``, and - per-server ``x-mcp-{alias}-authorization``. Preserve the raw ``x-litellm-api-key`` admission - header; an admitted subject's top-level Authorization is otherwise dropped unconditionally.""" + """Remove gateway-shaped credentials from every egress header context, and the caller's admitted + credential from the implicitly forwarded top-level ``Authorization``. Raw headers (only forwarded + when a server lists them in ``extra_headers``), ``x-mcp-auth`` and ``x-mcp-{alias}-authorization`` + keep the caller's LiteLLM key, since the admin or caller chose to send it there.""" + gateway_shaped: Final = MCPRequestHandler._is_gateway_admission_credential - def cred(value: str | None) -> bool: - return MCPRequestHandler._is_gateway_admission_credential( - value - ) or MCPRequestHandler._is_caller_admission_key(value, admitted_credential) - - # 1. Top-level Authorization → oauth2_headers. authz: Final = oauth2_headers.get("Authorization") if oauth2_headers else None - preserve_subject: Final = bool( - preserve_jwt_subject - and not admitted - and authz - and not _normalize_caller_admission_credential(authz).startswith("sk-") - and not MCPRequestHandler._is_gateway_admission_credential(authz) - and MCPRequestHandler._is_caller_admission_key(authz, admitted_credential) - ) - if admitted or cred(authz): + if admitted or gateway_shaped(authz) or MCPRequestHandler._is_caller_admission_key(authz, admitted_credential): oauth2_headers = None - # 2. raw_headers: drop the admitted subject's Authorization, and ANY header whose value is a - # gateway credential (covers x-mcp-auth and x-mcp-{alias}-authorization in their raw form). raw_headers = { k: v for k, v in raw_headers.items() - if k.lower() == "x-litellm-api-key" - or (preserve_subject and k.lower() == "authorization") - or not ((admitted and k.lower() == "authorization") or cred(v)) + if not ((admitted and k.lower() == "authorization") or gateway_shaped(v)) } - # 3. Deprecated x-mcp-auth value. - if cred(mcp_auth_header): + if gateway_shaped(mcp_auth_header): mcp_auth_header = None - # 4. Per-server x-mcp-{alias}-authorization values (drop the value, then any now-empty server dict). if mcp_server_auth_headers: stripped: Final = { - alias: {h: val for h, val in hdrs.items() if not cred(val)} + alias: {h: val for h, val in hdrs.items() if not gateway_shaped(val)} for alias, hdrs in mcp_server_auth_headers.items() } mcp_server_auth_headers = {alias: hdrs for alias, hdrs in stripped.items() if hdrs} @@ -1892,6 +1871,14 @@ class MCPRequestHandler: return None return bearer + @staticmethod + def guardrail_bearer_from_raw_headers( + raw_headers: Mapping[str, str] | None, auth: UserAPIKeyAuth | None + ) -> SecretStr | None: + if auth is None: + return MCPRequestHandler.get_incoming_bearer_token(raw_headers or {}) + return MCPRequestHandler.get_guardrail_bearer_token(Headers(headers=dict(raw_headers or {})), auth) + @staticmethod def get_oauth2_headers_from_headers(headers: Headers) -> dict[str, str]: """ diff --git a/litellm/proxy/mcp_server/mcp_server_manager.py b/litellm/proxy/mcp_server/mcp_server_manager.py index 013336e1d5d..cc02fc43ef3 100644 --- a/litellm/proxy/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/mcp_server/mcp_server_manager.py @@ -1206,8 +1206,10 @@ def should_strip_caller_authorization( admission credential — a virtual key, an IdP JWT, or an SSO / OIDC / session token whose ``api_key`` is ``None`` — and must never reach the upstream, so it is stripped regardless of the ``api_key`` value. - - **Every other auth-enabled mode**: strip when ``Authorization`` is the - credential admission validated. + - **true_passthrough**: strip when ``Authorization`` is the credential + admission validated. + - **Every other mode**: forward. The caller's ``Authorization`` only reaches + the upstream because the admin listed it in ``extra_headers``. """ if ( user_api_key_auth is not None @@ -1233,6 +1235,8 @@ def should_strip_caller_authorization( mcp_server.auth_type == MCPAuth.oauth2 and mcp_server.delegate_auth_to_upstream ) if not (mcp_server.is_oauth_passthrough or is_delegated_oauth): + if not mcp_server.is_client_forwarded_token: + return False from litellm.proxy.auth.user_api_key_auth import is_no_auth_dev_mode from litellm.proxy.proxy_server import ( general_settings, # pyright: ignore[reportUnknownVariableType] # legacy global is declared as a bare dict @@ -1403,7 +1407,7 @@ def _openapi_forwarded_extra_headers( ) forwarded: Final[dict[str, str]] = {} for header_name in mcp_server.extra_headers: - if not isinstance(header_name, str) or header_name.lower() == "x-litellm-api-key": + if not isinstance(header_name, str): continue if skip_caller_authorization and header_name.lower() == "authorization": continue @@ -6241,7 +6245,7 @@ class MCPServerManager: ) caller_bearer: Final = ( - MCPRequestHandler.get_incoming_bearer_token(raw_headers or {}) + MCPRequestHandler.guardrail_bearer_from_raw_headers(raw_headers, user_api_key_auth) if incoming_bearer_token is ... else incoming_bearer_token ) @@ -6640,7 +6644,7 @@ class MCPServerManager: # For M2M OAuth servers, Authorization must come from token fetch. extra_headers = None else: - extra_headers = oauth2_headers + extra_headers = dict(oauth2_headers) if oauth2_headers else None # Migrated authorization_code: the v2 resolver injects the stored per-user # token, so drop the caller-forwarded Authorization (apply-if-absent would # otherwise let it shadow the resolved token). Delegate keeps it. Centralized @@ -6673,7 +6677,7 @@ class MCPServerManager: ) for header in mcp_server.extra_headers: - if not isinstance(header, str) or header.lower() == "x-litellm-api-key": + if not isinstance(header, str): continue if header.lower() == "authorization" and strip_caller_authorization: continue diff --git a/litellm/proxy/mcp_server/operations.py b/litellm/proxy/mcp_server/operations.py index dfc45417814..8422d516d8c 100644 --- a/litellm/proxy/mcp_server/operations.py +++ b/litellm/proxy/mcp_server/operations.py @@ -1080,7 +1080,7 @@ def _prepare_mcp_server_headers( ) for header in server.extra_headers: - if not isinstance(header, str) or header.lower() == "x-litellm-api-key": + if not isinstance(header, str): continue if header.lower() == "authorization" and (strip_caller_authorization or withhold_forwarded_authorization): continue diff --git a/litellm/proxy/mcp_server/rest_endpoints.py b/litellm/proxy/mcp_server/rest_endpoints.py index 2737b567985..815c1d6270c 100644 --- a/litellm/proxy/mcp_server/rest_endpoints.py +++ b/litellm/proxy/mcp_server/rest_endpoints.py @@ -627,7 +627,6 @@ if MCP_AVAILABLE: ) = MCPRequestHandler.scrub_gateway_admission_credentials( admitted=False, admitted_credential=admitted_credential, - preserve_jwt_subject=bool(user_api_key_dict.jwt_claims), oauth2_headers=oauth2_headers_from_request, raw_headers=dict(headers), mcp_auth_header=MCPRequestHandler.get_mcp_auth_header_from_headers(headers), diff --git a/tests/integration/mcp/test_mcp_credentials.py b/tests/integration/mcp/test_mcp_credentials.py index 08dd5234b68..d21e6fea28e 100644 --- a/tests/integration/mcp/test_mcp_credentials.py +++ b/tests/integration/mcp/test_mcp_credentials.py @@ -155,11 +155,11 @@ def test_caller_headers_for_other_servers_and_unknown_headers_never_reach_the_pe @pytest.mark.parametrize( - ("auth_type", "per_server_header"), - (("none", True), ("true_passthrough", False)), + ("auth_type", "per_server_header", "forwarded"), + (("none", True, True), ("true_passthrough", False, False)), ) -def test_callers_own_litellm_key_never_reaches_the_peer_over_rest( - gateway: Gateway, auth_type: str, per_server_header: bool +def test_callers_litellm_key_reaches_the_peer_only_through_a_per_server_header( + gateway: Gateway, auth_type: str, per_server_header: bool, forwarded: bool ) -> None: with mcp_peer() as peer, gateway.scenario() as scenario: alias: Final = "cred" + uuid.uuid4().hex[:8] @@ -178,19 +178,14 @@ def test_callers_own_litellm_key_never_reaches_the_peer_over_rest( observed: Final = peer.drain() calls: Final = tool_calls(observed) assert len(calls) == 1, calls - header_sets: Final = tuple( - TypeAdapter(dict[bytes, bytes]).validate_python(request["headers"]) for request in observed - ) - assert all(all(key.encode() not in value for value in headers.values()) for headers in header_sets), ( - "caller key appeared in the recorded MCP header set" - ) + assert (_header(calls[0], b"authorization") == f"Bearer {key}".encode()) is forwarded @pytest.mark.parametrize("entry", ("server_mcp", "rest")) def test_empty_primary_header_does_not_expose_authorization_key(gateway: Gateway, entry: EntryPoint) -> None: with mcp_peer() as peer, gateway.scenario() as scenario: alias: Final = "cred" + uuid.uuid4().hex[:8] - identity: Final = register_mcp(scenario, peer, alias, extra_headers=["x-upstream-token", "x-tenant"]) + identity: Final = register_mcp(scenario, peer, alias, extra_headers=["x-tenant"]) key: Final = scenario.key(object_permission={"mcp_servers": [identity]}) caller: Final = McpCaller( gateway, @@ -200,8 +195,6 @@ def test_empty_primary_header_does_not_expose_authorization_key(gateway: Gateway headers={ "x-litellm-api-key": "", "Authorization": f"Bearer {key}", - f"x-mcp-{alias}-authorization": f"Bearer {key}", - "x-upstream-token": key, "x-tenant": "tenant-control", }, ) @@ -216,40 +209,40 @@ def test_empty_primary_header_does_not_expose_authorization_key(gateway: Gateway TypeAdapter(dict[bytes, bytes]).validate_python(request["headers"]) for request in observed ) assert all(all(key.encode() not in value for value in headers.values()) for headers in header_sets), ( - "empty primary header prevented scrubbing the admitted Authorization key" + "admission key reached the peer through a header the server does not list" ) @pytest.mark.parametrize("entry", ENTRY_POINTS) -def test_extra_headers_cannot_forward_gateway_admission_key(gateway: Gateway, entry: EntryPoint) -> None: +def test_extra_headers_forward_listed_admission_key(gateway: Gateway, entry: EntryPoint) -> None: with mcp_peer() as peer, gateway.scenario() as scenario: alias: Final = "cred" + uuid.uuid4().hex[:8] identity: Final = register_mcp( - scenario, peer, alias, extra_headers=["X-LiteLLM-API-Key", "x-tenant"], + scenario, + peer, + alias, + extra_headers=["X-LiteLLM-API-Key", "x-tenant"], ) key: Final = scenario.key(object_permission={"mcp_servers": [identity]}) caller: Final = McpCaller(gateway, key, entry, alias, headers={"x-tenant": "tenant-control"}) peer.drain() outcome: Final = caller.call(f"{alias}-add", ADD, identity if entry != "server_mcp" else None) assert outcome.ok, outcome.raw - observed: Final = peer.drain() - calls: Final = tool_calls(observed) + calls: Final = tool_calls(peer.drain()) assert len(calls) == 1, calls assert _header(calls[0], b"x-tenant") == b"tenant-control" - header_sets: Final = tuple( - TypeAdapter(dict[bytes, bytes]).validate_python(request["headers"]) for request in observed - ) - assert all(all(key.encode() not in value for value in headers.values()) for headers in header_sets), ( - "gateway admission key reached the upstream through Extra Headers" - ) + assert key.encode() in (_header(calls[0], b"x-litellm-api-key") or b"") @pytest.mark.parametrize("entry", ("server_mcp", "rest")) -def test_openapi_extra_headers_cannot_forward_gateway_admission_key(gateway: Gateway, entry: EntryPoint) -> None: +def test_openapi_extra_headers_forward_listed_admission_key(gateway: Gateway, entry: EntryPoint) -> None: with openapi_peer() as peer, gateway.scenario() as scenario: alias: Final = "cred" + uuid.uuid4().hex[:8] identity: Final = register_mcp( - scenario, peer, alias, extra_headers=["X-LiteLLM-API-Key", "x-tenant"], + scenario, + peer, + alias, + extra_headers=["X-LiteLLM-API-Key", "x-tenant"], ) key: Final = scenario.key(object_permission={"mcp_servers": [identity]}) caller: Final = McpCaller(gateway, key, entry, alias, headers={"x-tenant": "tenant-control"}) @@ -260,7 +253,7 @@ def test_openapi_extra_headers_cannot_forward_gateway_admission_key(gateway: Gat assert [(request["method"], request["path"]) for request in observed] == [("GET", "/pets/7")] headers: Final = TypeAdapter(dict[bytes, bytes]).validate_python(observed[0]["headers"]) assert headers.get(b"x-tenant") == b"tenant-control" - assert all(key.encode() not in value for value in headers.values()), "gateway admission key reached OpenAPI" + assert key.encode() in headers.get(b"x-litellm-api-key", b"") def test_server_scoped_caller_header_reaches_only_its_server(gateway: Gateway) -> None: diff --git a/tests/unit/proxy/guardrails/test_mcp_jwt_signer.py b/tests/unit/proxy/guardrails/test_mcp_jwt_signer.py index a7b24169398..8b7a420c739 100644 --- a/tests/unit/proxy/guardrails/test_mcp_jwt_signer.py +++ b/tests/unit/proxy/guardrails/test_mcp_jwt_signer.py @@ -11,7 +11,7 @@ Tests cover: import base64 import time -from typing import Any, Dict, Optional +from typing import Any, Dict, Final, Optional from unittest.mock import AsyncMock, MagicMock, patch import jwt @@ -1292,3 +1292,49 @@ async def test_inject_mcp_jwt_signs_for_tool_call_path(): scopes = set(decoded["scope"].split()) assert "mcp:tools/call" in scopes assert "mcp:tools/search_web:call" in scopes + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("raw_headers", "introspected"), + [ + ({"Authorization": "Bearer sk-admission-key"}, False), + ({"x-litellm-api-key": "sk-admission-key", "Authorization": "Bearer upstream-opaque"}, True), + ], +) +async def test_inject_mcp_jwt_never_introspects_the_admission_key( + raw_headers: dict[str, str], introspected: bool +) -> None: + import httpx + from fastapi import HTTPException + + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + from litellm.proxy._types import UserAPIKeyAuth, hash_token + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + inject_mcp_jwt_headers_for_upstream, + ) + + _make_signer( + access_token_discovery_uri="https://idp.example.com/.well-known/openid-configuration", + token_introspection_endpoint="https://idp.example.com/introspect", + ) + inactive_introspection: Final = AsyncHTTPHandler( + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"active": False})) + ) + + async def sign() -> dict[str, str]: + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", + return_value=inactive_introspection, + ): + return await inject_mcp_jwt_headers_for_upstream( + user_api_key_dict=UserAPIKeyAuth(api_key=hash_token("sk-admission-key"), user_id="alice"), + raw_headers=raw_headers, + for_list_tools=True, + ) + + if introspected: + with pytest.raises(HTTPException): + await sign() + else: + assert (await sign())["Authorization"].startswith("Bearer ") diff --git a/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py index 7e74e62894f..ea54d456307 100644 --- a/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -10141,444 +10141,100 @@ class TestSessionBearerEgressScrub: assert "authorization" not in {k.lower() for k in raw} assert per_server == {"github": {"Authorization": "Bearer gh_injected_upstream"}} - @pytest.mark.parametrize( - "context", - ["per_server", "mcp_auth", "oauth2_authorization", "raw_authorization"], - ) @pytest.mark.parametrize( "caller_value", - ["sk-caller-admission-key-123", "Bearer sk-caller-admission-key-123"], + [ + "sk-caller-admission-key-123", + "Bearer sk-caller-admission-key-123", + "BEARER sk-caller-admission-key-123", + "Bearer sk-caller-admission-key-123", + "Basic sk-caller-admission-key-123", + ], ) - async def test_scrub_removes_caller_admission_key_from_each_egress_context( - self, - context: Literal["per_server", "mcp_auth", "oauth2_authorization", "raw_authorization"], - caller_value: str, - ) -> None: + async def test_scrub_drops_caller_key_only_from_implicit_authorization(self, caller_value: str) -> None: caller_key: Final = "sk-caller-admission-key-123" - upstream_token: Final = "Bearer real-upstream-token" - oauth2_headers: Final = {"Authorization": caller_value if context == "oauth2_authorization" else upstream_token} raw_headers: Final = { "x-litellm-api-key": f"Bearer {caller_key}", - "authorization": caller_value if context == "raw_authorization" else upstream_token, - "x-upstream-token": upstream_token, - } - mcp_auth_header: Final = caller_value if context == "mcp_auth" else upstream_token - mcp_server_auth_headers: Final = { - "echo_srv": { - "Authorization": caller_value if context == "per_server" else upstream_token, - } + "authorization": caller_value, + "x-tenant": caller_value, } oauth2, raw, mcp_auth, per_server = MCPRequestHandler.scrub_gateway_admission_credentials( admitted=False, - oauth2_headers=oauth2_headers, + oauth2_headers={"Authorization": caller_value}, raw_headers=raw_headers, - mcp_auth_header=mcp_auth_header, - mcp_server_auth_headers=mcp_server_auth_headers, + mcp_auth_header=caller_value, + mcp_server_auth_headers={"echo_srv": {"Authorization": caller_value}}, admitted_credential=caller_key, ) - assert raw["x-litellm-api-key"] == f"Bearer {caller_key}" - assert raw["x-upstream-token"] == upstream_token - if context == "raw_authorization": - assert "authorization" not in raw - else: - assert raw["authorization"] == upstream_token - if context == "oauth2_authorization": - assert oauth2 is None - else: - assert oauth2 == {"Authorization": upstream_token} - if context == "mcp_auth": - assert mcp_auth is None - else: - assert mcp_auth == upstream_token - if context == "per_server": - assert not per_server - else: - assert per_server == {"echo_srv": {"Authorization": upstream_token}} + assert oauth2 is None + assert raw == raw_headers + assert mcp_auth == caller_value + assert per_server == {"echo_srv": {"Authorization": caller_value}} - async def test_scrub_keeps_non_ascii_per_server_token(self) -> None: - upstream_token: Final = "Bearer t\u00f6ken" - _oauth2, _raw, _mcp_auth, per_server = MCPRequestHandler.scrub_gateway_admission_credentials( + async def test_scrub_keeps_distinct_upstream_authorization(self) -> None: + upstream_token: Final = "Bearer real-upstream-token" + oauth2, _raw, _mcp_auth, _per_server = MCPRequestHandler.scrub_gateway_admission_credentials( admitted=False, - admitted_credential="sk-caller-admission-key-123", - oauth2_headers=None, + oauth2_headers={"Authorization": upstream_token}, raw_headers={}, mcp_auth_header=None, - mcp_server_auth_headers={"echo_srv": {"Authorization": upstream_token}}, + mcp_server_auth_headers=None, + admitted_credential="sk-caller-admission-key-123", ) - assert per_server == {"echo_srv": {"Authorization": upstream_token}} - - async def test_scrub_keeps_caller_key_when_admission_credential_is_missing(self) -> None: - caller_key: Final = "Bearer sk-caller-admission-key-123" - oauth2, raw, mcp_auth, per_server = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": caller_key}, - raw_headers={"x-litellm-api-key": caller_key, "authorization": caller_key}, - mcp_auth_header=caller_key, - mcp_server_auth_headers={"echo_srv": {"Authorization": caller_key}}, - admitted_credential=None, - ) - - assert oauth2 == {"Authorization": caller_key} - assert raw == {"x-litellm-api-key": caller_key, "authorization": caller_key} - assert mcp_auth == caller_key - assert per_server == {"echo_srv": {"Authorization": caller_key}} + assert oauth2 == {"Authorization": upstream_token} @pytest.mark.parametrize( - "admission_value,per_server_value", - ( - ("Bearer sk-caller-admission-key-123", "BEARER sk-caller-admission-key-123"), - ("Bearer sk-caller-admission-key-123", "Bearer sk-caller-admission-key-123"), - ("Bearer sk-caller-admission-key-123", "Basic sk-caller-admission-key-123"), - ("Basic sk-caller-admission-key-123", "sk-caller-admission-key-123"), - ("Bearer sk-caller-admission-key-123", "bearer sk-caller-admission-key-123"), - ), + ("headers", "custom_key_header_name", "expected"), + [ + ( + { + "x-litellm-api-key": "Bearer sk-caller", + "authorization": "Bearer upstream", + SpecialHeaders.azure_authorization.value: "provider", + }, + None, + "sk-caller", + ), + ( + {"authorization": "Bearer sk-caller", SpecialHeaders.azure_authorization.value: "provider"}, + None, + "sk-caller", + ), + ( + {"x-custom-api-key": "Bearer sk-caller", "x-litellm-api-key": "Bearer standard"}, + "x-custom-api-key", + "sk-caller", + ), + ({"x-litellm-api-key": "", "authorization": "sk-caller"}, None, "sk-caller"), + ({"authorization": "", "api-key": "sk-caller"}, None, "sk-caller"), + ( + { + SpecialHeaders.azure_authorization.value: "azure-key", + SpecialHeaders.anthropic_authorization.value: "anthropic-key", + SpecialHeaders.google_ai_studio_authorization.value: "google-key", + SpecialHeaders.azure_apim_authorization.value: "apim-key", + }, + None, + "azure-key", + ), + ({SpecialHeaders.anthropic_authorization.value: "anthropic-key"}, None, "anthropic-key"), + ({SpecialHeaders.google_ai_studio_authorization.value: "google-key"}, None, "google-key"), + ({SpecialHeaders.azure_apim_authorization.value: "apim-key"}, None, "apim-key"), + ], ) - async def test_caller_admission_credential_scrubs_bearer_variants_and_basic( - self, - admission_value: str, - per_server_value: str, + async def test_caller_admission_credential_matches_admission_header_precedence( + self, headers: dict[str, str], custom_key_header_name: str | None, expected: str ) -> None: - upstream_authorization: Final = "Bearer unrelated-upstream-token" - headers: Final = Headers( - { - "x-litellm-api-key": admission_value, - "authorization": upstream_authorization, - "x-mcp-auth": per_server_value, - "x-mcp-echo_srv-authorization": per_server_value, - "x-upstream-token": upstream_authorization, - } - ) - user_api_key_auth: Final = UserAPIKeyAuth(api_key="stored-key-hash") admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, - user_api_key_auth, - custom_key_header_name=None, - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": upstream_authorization}, - raw_headers=dict(headers), - mcp_auth_header=per_server_value, - mcp_server_auth_headers={ - "echo_srv": {"Authorization": per_server_value}, - "other_srv": {"Authorization": upstream_authorization}, - }, - admitted_credential=admitted_credential, - ) - - assert result == ( - {"Authorization": upstream_authorization}, - { - "x-litellm-api-key": admission_value, - "authorization": upstream_authorization, - "x-upstream-token": upstream_authorization, - }, - None, - {"other_srv": {"Authorization": upstream_authorization}}, - ) - - @pytest.mark.parametrize( - ("empty_header", "admission_header"), - (("x-litellm-api-key", "authorization"), ("authorization", "api-key")), - ) - async def test_empty_admission_header_falls_back_to_authenticated_key( - self, empty_header: str, admission_header: str - ) -> None: - caller_key: Final = "sk-caller-admission-key-123" - headers: Final = Headers({empty_header: "", admission_header: caller_key}) - credential: Final = MCPRequestHandler.caller_admission_credential( - headers, UserAPIKeyAuth(api_key="stored-key-hash"), custom_key_header_name=None - ) - assert credential == caller_key - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": f"Bearer {caller_key}"}, - raw_headers={**dict(headers), "x-upstream-token": caller_key, "x-tenant": "tenant-control"}, - mcp_auth_header=caller_key, - mcp_server_auth_headers={"echo": {"Authorization": caller_key}}, - admitted_credential=credential, - ) - assert result == (None, {empty_header: "", "x-tenant": "tenant-control"}, None, {}) - - @pytest.mark.parametrize("claims", [None, {"sub": "caller"}]) - @pytest.mark.parametrize("bearer", ["validated-jwt", "sk-admission-key"]) - async def test_scrubbing_preserves_only_validated_jwt_admission( - self, claims: dict[str, str] | None, bearer: str - ) -> None: - headers: Final = Headers({"Authorization": f"Bearer {bearer}"}) - auth: Final = UserAPIKeyAuth(api_key="stored-key-hash", jwt_claims=claims) - credential: Final = MCPRequestHandler.caller_admission_credential(headers, auth, custom_key_header_name=None) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": f"Bearer {bearer}"}, - raw_headers={**dict(headers), "x-mcp-echo-authorization": f"Bearer {bearer}"}, - mcp_auth_header=bearer, - mcp_server_auth_headers={"echo": {"Authorization": f"Bearer {bearer}"}}, - admitted_credential=credential, - preserve_jwt_subject=bool(claims), - ) - expected: Final = {"Authorization": f"Bearer {bearer}"} if claims and not bearer.startswith("sk-") else None - assert result == (None, dict(headers) if expected else {}, None, {}) - - async def test_caller_admission_credential_prefers_x_litellm_header(self) -> None: - caller_key: Final = "sk-caller-admission-key-123" - upstream_authorization: Final = "Bearer unrelated-upstream-token" - provider_key: Final = "provider-key-not-admitted" - headers: Final = Headers( - { - "x-litellm-api-key": f"Bearer {caller_key}", - "authorization": upstream_authorization, - SpecialHeaders.azure_authorization.value: provider_key, - "x-mcp-auth": f"Bearer {caller_key}", - "x-mcp-echo_srv-authorization": f"Bearer {caller_key}", - } - ) - admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, + Headers(headers), UserAPIKeyAuth(api_key="stored-key-hash"), - custom_key_header_name=None, - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": upstream_authorization}, - raw_headers=dict(headers), - mcp_auth_header=f"Bearer {caller_key}", - mcp_server_auth_headers={ - "echo_srv": {"Authorization": f"Bearer {caller_key}"}, - "other_srv": {"Authorization": upstream_authorization}, - }, - admitted_credential=admitted_credential, + custom_key_header_name=custom_key_header_name, ) - assert result == ( - {"Authorization": upstream_authorization}, - { - "x-litellm-api-key": f"Bearer {caller_key}", - "authorization": upstream_authorization, - "api-key": provider_key, - }, - None, - {"other_srv": {"Authorization": upstream_authorization}}, - ) - - async def test_caller_admission_credential_prefers_authorization_before_provider_headers(self) -> None: - caller_key: Final = "sk-caller-admission-key-123" - provider_key: Final = "provider-key-not-admitted" - headers: Final = Headers( - { - "authorization": f"Bearer {caller_key}", - SpecialHeaders.azure_authorization.value: provider_key, - "x-mcp-auth": f"Bearer {caller_key}", - "x-mcp-echo_srv-authorization": f"Bearer {caller_key}", - } - ) - admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, - UserAPIKeyAuth(api_key="stored-key-hash"), - custom_key_header_name=None, - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": f"Bearer {caller_key}"}, - raw_headers=dict(headers), - mcp_auth_header=f"Bearer {caller_key}", - mcp_server_auth_headers={"echo_srv": {"Authorization": f"Bearer {caller_key}"}}, - admitted_credential=admitted_credential, - ) - - assert result == ( - None, - {"api-key": provider_key}, - None, - {}, - ) - - async def test_caller_admission_credential_uses_configured_custom_header(self) -> None: - caller_key: Final = "sk-caller-admission-key-123" - standard_key: Final = "Bearer standard-key-not-admitted" - upstream_authorization: Final = "Bearer unrelated-upstream-token" - headers: Final = Headers( - { - "x-custom-api-key": f"Bearer {caller_key}", - "x-litellm-api-key": standard_key, - "authorization": upstream_authorization, - "x-mcp-auth": f"Bearer {caller_key}", - "x-mcp-echo_srv-authorization": f"Bearer {caller_key}", - } - ) - admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, - UserAPIKeyAuth(api_key="stored-key-hash"), - custom_key_header_name="x-custom-api-key", - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": upstream_authorization}, - raw_headers=dict(headers), - mcp_auth_header=f"Bearer {caller_key}", - mcp_server_auth_headers={"echo_srv": {"Authorization": f"Bearer {caller_key}"}}, - admitted_credential=admitted_credential, - ) - - assert result == ( - {"Authorization": upstream_authorization}, - { - "x-litellm-api-key": standard_key, - "authorization": upstream_authorization, - }, - None, - {}, - ) - - async def test_caller_admission_credential_falls_back_to_standard_headers_when_custom_header_is_absent( - self, - ) -> None: - caller_value: Final = "Bearer sk-caller-admission-key-123" - headers: Final = Headers( - { - "x-litellm-api-key": caller_value, - "authorization": caller_value, - "x-mcp-auth": caller_value, - "x-mcp-echo_srv-authorization": caller_value, - } - ) - admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, - UserAPIKeyAuth(api_key="stored-key-hash"), - custom_key_header_name="x-custom-api-key", - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": caller_value}, - raw_headers=dict(headers), - mcp_auth_header=caller_value, - mcp_server_auth_headers={"echo_srv": {"Authorization": caller_value}}, - admitted_credential=admitted_credential, - ) - - assert admitted_credential == "sk-caller-admission-key-123" - assert result == ( - None, - {"x-litellm-api-key": caller_value}, - None, - {}, - ) - - async def test_caller_admission_credential_uses_master_key_alias_as_admission_gate(self) -> None: - master_key: Final = "sk-" + "1234" - headers: Final = Headers( - { - "x-litellm-api-key": f"Bearer {master_key}", - "authorization": f"Bearer {master_key}", - "x-mcp-auth": f"Bearer {master_key}", - "x-mcp-echo_srv-authorization": f"Bearer {master_key}", - } - ) - admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, - UserAPIKeyAuth(api_key="litellm_proxy_master_key"), - custom_key_header_name=None, - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers={"Authorization": f"Bearer {master_key}"}, - raw_headers=dict(headers), - mcp_auth_header=f"Bearer {master_key}", - mcp_server_auth_headers={"echo_srv": {"Authorization": f"Bearer {master_key}"}}, - admitted_credential=admitted_credential, - ) - - assert result == ( - None, - {"x-litellm-api-key": f"Bearer {master_key}"}, - None, - {}, - ) - - @pytest.mark.parametrize( - "provider_header_name", - ( - SpecialHeaders.azure_authorization.value, - SpecialHeaders.anthropic_authorization.value, - SpecialHeaders.google_ai_studio_authorization.value, - SpecialHeaders.azure_apim_authorization.value, - ), - ) - async def test_caller_admission_credential_scrubs_provider_header( - self, - provider_header_name: str, - ) -> None: - provider_key: Final = "provider-admission-key" - headers: Final = Headers( - { - provider_header_name: provider_key, - "x-mcp-auth": f"Bearer {provider_key}", - "x-mcp-echo_srv-authorization": f"Bearer {provider_key}", - "x-upstream-token": "Bearer unrelated-upstream-token", - } - ) - admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, - UserAPIKeyAuth(api_key="stored-key-hash"), - custom_key_header_name=None, - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers=None, - raw_headers=dict(headers), - mcp_auth_header=f"Bearer {provider_key}", - mcp_server_auth_headers={ - "echo_srv": {"Authorization": f"Bearer {provider_key}"}, - "other_srv": {"Authorization": "Bearer unrelated-upstream-token"}, - }, - admitted_credential=admitted_credential, - ) - - assert result == ( - None, - {"x-upstream-token": "Bearer unrelated-upstream-token"}, - None, - {"other_srv": {"Authorization": "Bearer unrelated-upstream-token"}}, - ) - - async def test_caller_admission_credential_follows_provider_header_precedence(self) -> None: - azure_key: Final = "azure-admission-key" - headers: Final = Headers( - { - SpecialHeaders.azure_authorization.value: azure_key, - SpecialHeaders.anthropic_authorization.value: "anthropic-not-admitted", - SpecialHeaders.google_ai_studio_authorization.value: "google-not-admitted", - SpecialHeaders.azure_apim_authorization.value: "apim-not-admitted", - "x-mcp-echo_srv-authorization": f"Bearer {azure_key}", - } - ) - admitted_credential: Final = MCPRequestHandler.caller_admission_credential( - headers, - UserAPIKeyAuth(api_key="stored-key-hash"), - custom_key_header_name=None, - ) - result: Final = MCPRequestHandler.scrub_gateway_admission_credentials( - admitted=False, - oauth2_headers=None, - raw_headers=dict(headers), - mcp_auth_header=f"Bearer {azure_key}", - mcp_server_auth_headers={"echo_srv": {"Authorization": f"Bearer {azure_key}"}}, - admitted_credential=admitted_credential, - ) - - assert result == ( - None, - { - "x-api-key": "anthropic-not-admitted", - "x-goog-api-key": "google-not-admitted", - "ocp-apim-subscription-key": "apim-not-admitted", - }, - None, - {}, - ) + assert admitted_credential == expected async def test_caller_admission_credential_returns_none_without_admitted_api_key(self) -> None: caller_value: Final = "Bearer sk-caller-admission-key-123" diff --git a/tests/unit/proxy/mcp_server/test_mcp_server_manager.py b/tests/unit/proxy/mcp_server/test_mcp_server_manager.py index 13e4704bd90..f7cbf7be8f7 100644 --- a/tests/unit/proxy/mcp_server/test_mcp_server_manager.py +++ b/tests/unit/proxy/mcp_server/test_mcp_server_manager.py @@ -321,7 +321,7 @@ def test_validated_admission_jwt_is_not_forwarded_as_an_extra_header(auth_type: "auth_type", [MCPAuth.none, MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic, MCPAuth.true_passthrough], ) -def test_admission_authorization_is_not_forwarded_for_any_auth_type( +def test_listed_admission_authorization_forwards_except_for_true_passthrough( auth_type: MCPAuth, monkeypatch: pytest.MonkeyPatch ) -> None: from litellm.proxy import proxy_server @@ -341,7 +341,11 @@ def test_admission_authorization_is_not_forwarded_for_any_auth_type( "Authorization": "Bearer sk-" + "1234", "x-tenant": "tenant-control", } - expected_without_admission_key: Final = {"x-tenant": "tenant-control"} + expected_without_admission_key: Final = ( + {"x-tenant": "tenant-control"} + if auth_type == MCPAuth.true_passthrough + else {"x-tenant": "tenant-control", "Authorization": "Bearer sk-" + "1234"} + ) admission_upstream_headers: Final = _prepare_mcp_server_headers( server, mcp_auth_header=None, @@ -414,12 +418,13 @@ def test_no_master_key_preserves_caller_authorization_unless_auth_is_enabled( raw_headers=raw_headers, user_api_key_auth=user, )[1] - assert auth_enabled_headers == {"x-tenant": "t"} - assert _openapi_forwarded_extra_headers(server, raw_headers, user) == {"x-tenant": "t"} + expected_auth_enabled: Final = {"x-tenant": "t"} if auth_type == MCPAuth.true_passthrough else expected_forwarded + assert auth_enabled_headers == expected_auth_enabled + assert _openapi_forwarded_extra_headers(server, raw_headers, user) == expected_auth_enabled @pytest.mark.parametrize("auth_type", [MCPAuth.none, MCPAuth.bearer_token, MCPAuth.true_passthrough]) -def test_custom_auth_is_stripped_in_no_auth_dev_mode( +def test_custom_auth_is_stripped_in_no_auth_dev_mode_for_true_passthrough( auth_type: MCPAuth, monkeypatch: pytest.MonkeyPatch ) -> None: from litellm.proxy import proxy_server @@ -438,7 +443,11 @@ def test_custom_auth_is_stripped_in_no_auth_dev_mode( raw_headers: Final = {"Authorization": "Bearer custom-tok", "x-tenant": "t"} user: Final = UserAPIKeyAuth(api_key="custom-tok") user.authenticated_by_custom_auth = True - expected_headers: Final = {"x-tenant": "t"} + expected_headers: Final = ( + {"x-tenant": "t"} + if auth_type == MCPAuth.true_passthrough + else {"x-tenant": "t", "Authorization": "Bearer custom-tok"} + ) forwarded_headers: Final = _prepare_mcp_server_headers( server, mcp_auth_header=None, @@ -502,6 +511,11 @@ def test_custom_admission_header_controls_authorization_forwarding( extra_headers=["Authorization", "x-tenant"], ) user: Final = UserAPIKeyAuth(api_key="hashed-key") + expected_headers: Final = ( + expected + if auth_type == MCPAuth.true_passthrough + else {"x-tenant": "t", "Authorization": raw_headers["Authorization"]} + ) upstream_headers: Final = _prepare_mcp_server_headers( server, @@ -511,8 +525,8 @@ def test_custom_admission_header_controls_authorization_forwarding( raw_headers=raw_headers, user_api_key_auth=user, )[1] - assert upstream_headers == expected - assert _openapi_forwarded_extra_headers(server, raw_headers, user) == expected + assert upstream_headers == expected_headers + assert _openapi_forwarded_extra_headers(server, raw_headers, user) == expected_headers class TestMCPServerManager: @@ -3856,7 +3870,7 @@ class TestMCPServerManager: assert captured_extra_headers == {"x-request-id": "req-123"} @pytest.mark.asyncio - async def test_call_regular_mcp_tool_never_forwards_admission_key_for_none_auth( + async def test_call_regular_mcp_tool_forwards_listed_admission_key_for_none_auth( self, monkeypatch: pytest.MonkeyPatch ) -> None: from litellm.proxy import proxy_server @@ -3907,7 +3921,76 @@ class TestMCPServerManager: assert capture_extra_headers.await_args is not None captured_extra_headers: Final = capture_extra_headers.await_args.args[0] - assert captured_extra_headers == {"x-tenant": "tenant-control"} + assert captured_extra_headers == {"Authorization": "Bearer sk-" + "1234", "x-tenant": "tenant-control"} + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_listed_key_does_not_reach_a_later_server( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from mcp.types import JSONRPCRequest + + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "master_key", "test-master-key") + tool_call_headers: Final[dict[str, httpx2.Headers]] = {} + + async def respond(request: httpx2.Request) -> httpx2.Response: + if request.method == "DELETE": + return httpx2.Response(200) + payload: Final = _JSONRPC_ADAPTER.validate_json(request.content) + if not isinstance(payload, JSONRPCRequest): + return httpx2.Response(202) + if payload.method == "tools/call": + tool_call_headers[request.url.host] = request.headers + result: Final[dict[str, object]] = {"content": [], "isError": False} + else: + result = { + "protocolVersion": "2025-03-26", + "serverInfo": {"name": "upstream", "version": "1"}, + "capabilities": {"tools": {}}, + } + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": result}) + + manager: Final = MCPServerManager(upstream_transport=httpx2.MockTransport(respond)) + listing_server: Final = MCPServer( + server_id="lists-key", + name="lists-key", + url="https://lists-key.example/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + extra_headers=["X-LiteLLM-API-Key"], + ) + later_server: Final = listing_server.model_copy( + update={ + "server_id": "no-key", + "name": "no-key", + "url": "https://no-key.example/mcp", + "extra_headers": None, + } + ) + oauth2_headers: Final = {"Authorization": "Bearer upstream-token"} + raw_headers: Final = { + "x-litellm-api-key": "sk-" + "1234", + "Authorization": "Bearer upstream-token", + } + + for server in (listing_server, later_server): + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="tool", + arguments={}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + proxy_logging_obj=None, + user_api_key_auth=UserAPIKeyAuth(api_key="hashed-key"), + ) + + assert tool_call_headers["lists-key.example"].get("x-litellm-api-key") == "sk-" + "1234" + assert "x-litellm-api-key" not in tool_call_headers["no-key.example"] @pytest.mark.asyncio async def test_call_regular_mcp_tool_v2_authz_code_drops_caller_authorization( @@ -21576,7 +21659,7 @@ async def test_repeated_stale_discovery_uses_current_callers_endpoint(endpoint: @pytest.mark.parametrize("header_name", ("x-litellm-api-key", "X-LiteLLM-API-Key")) -def test_openapi_extra_headers_exclude_gateway_admission_key(header_name: str) -> None: +def test_openapi_extra_headers_forward_listed_admission_key(header_name: str) -> None: caller_key: Final = "Bearer sk-admission-only" raw_headers: Final = {"x-litellm-api-key": caller_key, "x-tenant": "tenant-control"} server: Final = MCPServer( @@ -21593,8 +21676,7 @@ def test_openapi_extra_headers_exclude_gateway_admission_key(header_name: str) - None, raw_headers, UserAPIKeyAuth(api_key="sk-admission-only"), - ) == (None, {"X-Tenant": "tenant-control"}, None) - assert raw_headers == {"x-litellm-api-key": caller_key, "x-tenant": "tenant-control"} + ) == (None, {header_name: caller_key, "X-Tenant": "tenant-control"}, None) @pytest.mark.asyncio @@ -21900,3 +21982,32 @@ def test_id_jag_listed_tool_metadata_is_isolated_between_identity_assertions() - manager.record_listed_tools(server, (first,), callers[0]) manager.record_listed_tools(server, (second,), callers[1]) assert tuple(manager.get_listed_tool(server, "list_notes", caller) for caller in callers) == (first, second, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("raw_headers", "expected_bearer"), + [ + ({"authorization": "Bearer sk-admission-key"}, None), + ({"x-litellm-api-key": "sk-admission-key", "authorization": "Bearer upstream-token"}, "upstream-token"), + ], +) +async def test_pre_call_guardrail_bearer_never_carries_the_admission_key( + raw_headers: dict[str, str], expected_bearer: str | None +) -> None: + from litellm.proxy._types import hash_token + + proxy_logging_obj: Final = _permissive_proxy_logging() + + await MCPServerManager().pre_call_tool_check( + name="tool", + arguments={}, + server_name="guarded", + user_api_key_auth=UserAPIKeyAuth(api_key=hash_token("sk-admission-key")), + proxy_logging_obj=proxy_logging_obj, + server=MCPServer(server_id="guarded", name="guarded", transport=MCPTransport.stdio), + raw_headers=raw_headers, + ) + + hook_kwargs: Final = proxy_logging_obj.create_mcp_request_object_from_kwargs.call_args.args[0] + assert hook_kwargs["incoming_bearer_token"] == expected_bearer diff --git a/tests/unit/proxy/mcp_server/test_operations.py b/tests/unit/proxy/mcp_server/test_operations.py index c482f588521..df2502c6be7 100644 --- a/tests/unit/proxy/mcp_server/test_operations.py +++ b/tests/unit/proxy/mcp_server/test_operations.py @@ -140,7 +140,7 @@ async def test_mapped_caller_bearer_reaches_agent_evaluation( mcp_auth_header=None, mcp_server_auth_headers=None, ) - assert "authorization" not in raw + assert oauth is None provider_requests: Final[asyncio.Queue[str]] = asyncio.Queue() def provider(exchange: httpx.Request) -> httpx.Response: @@ -1917,7 +1917,7 @@ async def test_initial_tool_listing_preserves_legacy_error_fallback( @pytest.mark.parametrize("header_name", ("x-litellm-api-key", "X-LiteLLM-API-Key")) -def test_discovery_extra_headers_exclude_gateway_admission_key(header_name: str) -> None: +def test_discovery_extra_headers_forward_listed_admission_key(header_name: str) -> None: caller_key: Final = "Bearer sk-admission-only" raw_headers: Final = {"x-litellm-api-key": caller_key, "x-tenant": "tenant-control"} server: Final = MCPServer( @@ -1937,8 +1937,7 @@ def test_discovery_extra_headers_exclude_gateway_admission_key(header_name: str) UserAPIKeyAuth(api_key="sk-admission-only"), ) assert auth_header is None - assert extra_headers == {"X-Tenant": "tenant-control"} - assert raw_headers == {"x-litellm-api-key": caller_key, "x-tenant": "tenant-control"} + assert extra_headers == {header_name: caller_key, "X-Tenant": "tenant-control"} @pytest.mark.asyncio diff --git a/tests/unit/proxy/mcp_server/test_rest_endpoints.py b/tests/unit/proxy/mcp_server/test_rest_endpoints.py index fdb1ff3cd09..b7eb68cae03 100644 --- a/tests/unit/proxy/mcp_server/test_rest_endpoints.py +++ b/tests/unit/proxy/mcp_server/test_rest_endpoints.py @@ -2764,7 +2764,7 @@ class TestCallToolRestAPI: assert resolved_server is target_server assert billed.team_id == "team-b" - async def test_extract_mcp_headers_scrubs_admitted_caller_credential(self) -> None: + async def test_extract_mcp_headers_scrubs_caller_key_only_from_implicit_authorization(self) -> None: caller_key: Final = "sk-rest-caller-admission-key-123" upstream_token: Final = "Bearer unrelated-upstream-token" request: Final = _build_request( @@ -2783,9 +2783,14 @@ class TestCallToolRestAPI: assert extracted_headers == ( None, - {"upstream": {"Authorization": upstream_token}}, + { + "echo_srv": {"Authorization": f"Bearer {caller_key}"}, + "upstream": {"Authorization": upstream_token}, + }, { "x-litellm-api-key": f"Bearer {caller_key}", + "authorization": f"Bearer {caller_key}", + "x-mcp-echo_srv-authorization": f"Bearer {caller_key}", "x-mcp-upstream-authorization": upstream_token, }, None, @@ -5097,12 +5102,5 @@ async def test_rest_guardrail_bearer_excludes_admission_key( assert result.is_error is False assert observed.qsize() == 1 assert observed.get_nowait() == expected - if claims is None: - list_headers: Final = rest_endpoints._extract_mcp_headers_from_request( - _build_request(headers), - auth, - )[2] - authorization: Final = list_headers.get("authorization", "") - assert (authorization.removeprefix("Bearer ") or None) == expected finally: global_mcp_tool_registry.unregister_tools_with_prefix("guarded-") diff --git a/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py b/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py index 24f5173d6a3..d9d61b86590 100644 --- a/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py +++ b/tests/unit/responses/mcp/test_aresponses_api_with_mcp.py @@ -1066,7 +1066,7 @@ async def test_mcp_tool_execution_events_creation(): @pytest.mark.asyncio -async def test_aresponses_mcp_call_does_not_forward_admission_authorization( +async def test_aresponses_mcp_call_forwards_listed_admission_authorization( monkeypatch: pytest.MonkeyPatch, ) -> None: import importlib @@ -1165,7 +1165,10 @@ async def test_aresponses_mcp_call_does_not_forward_admission_authorization( assert mcp_client.call_tool.await_args is not None assert manager.create_mcp_client.await_args is not None - assert manager.create_mcp_client.await_args.kwargs["extra_headers"] == {"x-tenant": "tenant-a"} + assert manager.create_mcp_client.await_args.kwargs["extra_headers"] == { + "Authorization": "Bearer sk-" + "1234", + "x-tenant": "tenant-a", + } @pytest.mark.asyncio From e35eec2bbe10138fd76e5b629590967e995ec934 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:51:57 +0000 Subject: [PATCH 50/86] feat(azure): add azure_ai/mistral-large-4-latest from the Azure Models API, priced from Mistral's model page (#51047) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...model_prices_and_context_window_backup.json | 18 ++++++++++++++++++ model_prices_and_context_window.json | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index ced7e002ba4..b5829c7cade 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -100049,5 +100049,23 @@ "supports_tool_choice": false, "supports_video_input": true, "supports_vision": true + }, + "azure_ai/mistral-large-4-latest": { + "deprecation_date": "2027-10-27", + "deprecation_source": "https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CognitiveServices/locations/eastus/models?api-version=2024-10-01", + "input_cost_per_token": 7e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 2.1e-06, + "source": "https://docs.mistral.ai/models/mistral-large-4-0", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index ced7e002ba4..b5829c7cade 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -100049,5 +100049,23 @@ "supports_tool_choice": false, "supports_video_input": true, "supports_vision": true + }, + "azure_ai/mistral-large-4-latest": { + "deprecation_date": "2027-10-27", + "deprecation_source": "https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CognitiveServices/locations/eastus/models?api-version=2024-10-01", + "input_cost_per_token": 7e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 2.1e-06, + "source": "https://docs.mistral.ai/models/mistral-large-4-0", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true } } From f8a60328b47b430c8f2e49fb652e88a20e429f64 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:05:29 -0800 Subject: [PATCH 51/86] ci(image-scan): build Rust with a fast ci cargo profile instead of fat-LTO release (#51075) * ci: speed up image scans with an opt-in Rust profile Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: separate cached Rust wheels by maturin profile Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/image-scan.yml | 4 ++-- Dockerfile | 1 + litellm-rust/Cargo.toml | 6 ++++++ packaging/litellm-proxy/pyproject.toml | 9 +++++++++ pyproject.toml | 1 + 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml index 6e13b49eb15..54051104cb9 100644 --- a/.github/workflows/image-scan.yml +++ b/.github/workflows/image-scan.yml @@ -60,7 +60,7 @@ jobs: persist-credentials: false - name: Build the image - run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci -t "$IMAGE" . + run: docker build --build-arg MATURIN_PEP517_ARGS="--profile ci" --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci -t "$IMAGE" . - name: Save the image for the verify matrix run: docker save "$IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-image.tar.zst" @@ -73,7 +73,7 @@ jobs: compression-level: 0 - name: Build the Admin MCP schema image - run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci --target builder -t "$ADMIN_MCP_SCHEMA_IMAGE" . + run: docker build --build-arg MATURIN_PEP517_ARGS="--profile ci" --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci --target builder -t "$ADMIN_MCP_SCHEMA_IMAGE" . - name: Save the Admin MCP schema image for the verify matrix run: docker save "$ADMIN_MCP_SCHEMA_IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-admin-mcp-schema.tar.zst" diff --git a/Dockerfile b/Dockerfile index 205f0cc981d..0e9337e658b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,6 +102,7 @@ COPY --from=ui-builder /ui/out/. litellm/proxy/_experimental/out/ RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.sh && ./docker/build_admin_ui.sh # Install project and workspace packages (fast - deps already cached) +ARG MATURIN_PEP517_ARGS="" RUN uv build --package litellm-proxy --wheel --out-dir /wheels RUN uv sync --frozen --no-install-package litellm-proxy --no-default-groups --no-editable \ --extra proxy \ diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index cc72cd01dc0..a00a8ec2446 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -161,3 +161,9 @@ panic = "unwind" debug = false incremental = false strip = "symbols" + +[profile.ci] +inherits = "release" +opt-level = 0 +lto = false +codegen-units = 256 diff --git a/packaging/litellm-proxy/pyproject.toml b/packaging/litellm-proxy/pyproject.toml index 172f40a7228..6b99c77df7a 100644 --- a/packaging/litellm-proxy/pyproject.toml +++ b/packaging/litellm-proxy/pyproject.toml @@ -20,3 +20,12 @@ include = [ { path = ".cargo/config.toml", format = "sdist" }, { path = "model_prices_and_context_window.json", format = "sdist" }, ] + +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "setup.py" }, + { file = "setup.cfg" }, + { dir = "src" }, + { env = "MATURIN_PEP517_ARGS" }, +] diff --git a/pyproject.toml b/pyproject.toml index 6145db57902..e80ba2d5d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -368,6 +368,7 @@ exclude = [ concurrent-builds = 1 cache-keys = [ { file = "pyproject.toml" }, + { env = "MATURIN_PEP517_ARGS" }, { file = "rust-toolchain.toml" }, { file = ".cargo/config.toml" }, { file = "litellm-rust/Cargo.lock" }, From 19c48218ae78b81349bc68d10b96f34cdf5e35da Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:05:41 -0800 Subject: [PATCH 52/86] test: deflake proxy metrics and shutdown drain (#51078) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-unit.yml | 19 ++++++++++++++----- .../db/test_log_db_metrics_service_spans.py | 12 ++++++------ .../unit/responses/test_streaming_iterator.py | 17 ++++++++++------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index d3f4821a05b..f0b51c6f1fb 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -633,23 +633,32 @@ jobs: job-timeout-minutes: 60 dist: loadscope - - shard: proxy-infra + - shard: proxy-infra-db-spend test-path: |- tests/unit/proxy/db --ignore=tests/unit/proxy/db/db_transaction_queue/test_e2e_pod_lock_manager.py --ignore=tests/unit/proxy/db/test_update_daily_tag_spend.py - tests/unit/proxy/middleware - --ignore=tests/unit/proxy/middleware/test_request_size_limit_middleware.py tests/unit/proxy/spend_tracking --ignore=tests/unit/proxy/spend_tracking/test_search_api_logging.py - tests/unit/proxy/pass_through_endpoints - tests/unit/proxy/mcp_server/test_tool_registry.py tests/unit/proxy/common_utils --ignore=tests/unit/proxy/common_utils/test_cache_aware_routing.py --ignore=tests/unit/proxy/common_utils/test_check_batch_cost.py --ignore=tests/unit/proxy/common_utils/test_check_responses_cost.py --ignore=tests/unit/proxy/common_utils/test_proxy_encrypt_decrypt.py --ignore=tests/unit/proxy/common_utils/test_realtime_cache.py + python-version: "3.12" + workers: 4 + reruns: 0 + timeout-minutes: 20 + job-timeout-minutes: 60 + dist: loadscope + + - shard: proxy-infra-routing + test-path: |- + tests/unit/proxy/middleware + --ignore=tests/unit/proxy/middleware/test_request_size_limit_middleware.py + tests/unit/proxy/pass_through_endpoints + tests/unit/proxy/mcp_server/test_tool_registry.py tests/unit/proxy/enterprise_billing tests/unit/proxy/types_utils tests/unit/proxy/logging_endpoints diff --git a/tests/unit/proxy/db/test_log_db_metrics_service_spans.py b/tests/unit/proxy/db/test_log_db_metrics_service_spans.py index a7fd830b948..7de9b6fac43 100644 --- a/tests/unit/proxy/db/test_log_db_metrics_service_spans.py +++ b/tests/unit/proxy/db/test_log_db_metrics_service_spans.py @@ -18,12 +18,12 @@ from prisma.errors import ClientNotConnectedError from pydantic import TypeAdapter import litellm -from litellm._service_logger import ServiceTypes +from litellm._service_logger import ServiceLogging, ServiceTypes from litellm.integrations.datadog.datadog import DataDogLogger from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig from litellm.proxy.db.log_db_metrics import log_db_metrics from litellm.proxy.db.prisma_client import _PrismaDrainTracker, _TrackedPrismaEngine -from litellm.proxy.proxy_server import proxy_logging_obj +from litellm.proxy import proxy_server from litellm.integrations.prometheus_services import PrometheusServicesLogger from prometheus_client import REGISTRY @@ -80,10 +80,10 @@ def rig(monkeypatch: pytest.MonkeyPatch) -> _Rig: with patch("asyncio.create_task", side_effect=_discard_periodic_flush): datadog: Final = DataDogLogger() monkeypatch.setattr(litellm, "service_callback", [otel, datadog, "prometheus_system"]) - monkeypatch.setattr(proxy_logging_obj.service_logging_obj, "dd_logger", datadog, raising=False) - monkeypatch.setattr( - proxy_logging_obj.service_logging_obj, "prometheusServicesLogger", PrometheusServicesLogger(), raising=False - ) + service_logging: Final = ServiceLogging() + service_logging.dd_logger = datadog + service_logging.prometheusServicesLogger = PrometheusServicesLogger() + monkeypatch.setattr(proxy_server.proxy_logging_obj, "service_logging_obj", service_logging) return _Rig(exporter, provider, datadog) diff --git a/tests/unit/responses/test_streaming_iterator.py b/tests/unit/responses/test_streaming_iterator.py index 3fb9edc9459..11f4e78619e 100644 --- a/tests/unit/responses/test_streaming_iterator.py +++ b/tests/unit/responses/test_streaming_iterator.py @@ -2743,14 +2743,17 @@ async def test_shutdown_drain_waits_for_a_pending_logging_task(): @pytest.mark.asyncio async def test_shutdown_drain_gives_up_on_a_stuck_logging_task_at_its_timeout(): - stuck: Final = streaming_module.spawn_logging_task( - asyncio.get_running_loop(), asyncio.Event().wait(), task_name="stuck" - ) + loop: Final = asyncio.get_running_loop() + with patch.object(loop, "time", return_value=0.0): + stuck: Final = streaming_module.spawn_logging_task(loop, asyncio.Event().wait(), task_name="stuck") - await asyncio.wait_for(streaming_module.drain_logging_tasks(timeout=0.05), timeout=1) - - assert not stuck.done() - stuck.cancel() + try: + await streaming_module.drain_logging_tasks(timeout=0) + assert not stuck.done() + finally: + stuck.cancel() + with suppress(asyncio.CancelledError): + await stuck async def _drain_held_until_released(release: asyncio.Event) -> bool: From 407ce3da2f05eed8fdf560271b4c7baeec30aee0 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 05:18:02 +0000 Subject: [PATCH 53/86] chore(cost-map): add 3 fal_ai rows priced from the fal pricing API and model page (third fal-ai sync) (#51126) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 38 +++++++++++++++++++ model_prices_and_context_window.json | 38 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b5829c7cade..733ff3bb965 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -100067,5 +100067,43 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true + }, + "fal_ai/fal-ai/recraft-20b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default realistic_image style, from the fal pricing API; vector styles cost 2x and have no catalog field; the fal.ai page for this id redirects to fal-ai/recraft/v2/text-to-image, which rounds the rate to $0.022"}, + "mode": "image_generation", + "output_cost_per_image": 0.0219, + "source": "https://api.fal.ai/v1/models/pricing?endpoint_id=fal-ai/recraft-20b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/stable-diffusion-v3-medium/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://api.fal.ai/v1/models/pricing?endpoint_id=fal-ai/stable-diffusion-v3-medium/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/pixverse/lipsync": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video; when audio_url is not provided the text is spoken with TTS at $0.24 per 100 characters, which is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/pixverse/lipsync", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b5829c7cade..733ff3bb965 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -100067,5 +100067,43 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true + }, + "fal_ai/fal-ai/recraft-20b": { + "litellm_provider": "fal_ai", + "metadata": {"notes":"per image at the default realistic_image style, from the fal pricing API; vector styles cost 2x and have no catalog field; the fal.ai page for this id redirects to fal-ai/recraft/v2/text-to-image, which rounds the rate to $0.022"}, + "mode": "image_generation", + "output_cost_per_image": 0.0219, + "source": "https://api.fal.ai/v1/models/pricing?endpoint_id=fal-ai/recraft-20b", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/stable-diffusion-v3-medium/image-to-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "source": "https://api.fal.ai/v1/models/pricing?endpoint_id=fal-ai/stable-diffusion-v3-medium/image-to-image", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/pixverse/lipsync": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of output video; when audio_url is not provided the text is spoken with TTS at $0.24 per 100 characters, which is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.04, + "source": "https://fal.ai/models/fal-ai/pixverse/lipsync", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] } } From 7ecb9ca70494815ab8579ef3665735f1f32aabc1 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 05:36:28 +0000 Subject: [PATCH 54/86] fix(bedrock): govcloud eol dates from the lifecycle table, gpt-5.6 cyber govcloud mantle row and daybreak blue input limit (#51095) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 63 ++++++++++++++----- model_prices_and_context_window.json | 63 ++++++++++++++----- 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 733ff3bb965..3210f3e080b 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -15776,8 +15776,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "successor_model": "bedrock/us-gov-east-1/anthropic.claude-sonnet-5-5", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", @@ -15808,8 +15808,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "supports_tool_search": true, @@ -15969,8 +15969,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "successor_model": "bedrock/us-gov-west-1/anthropic.claude-sonnet-5-5", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", @@ -16001,8 +16001,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "supports_tool_search": true, @@ -16026,8 +16026,8 @@ "token_count_model": {"model":"anthropic.claude-sonnet-4-5-20250929-v1:0","litellm_provider":"bedrock"} }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { - "deprecation_date": "2027-03-30", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-70b-instruct.md", + "deprecation_date": "2027-05-09", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 2.65e-06, "litellm_provider": "bedrock", "max_input_tokens": 8000, @@ -16038,8 +16038,8 @@ "supports_pdf_input": true }, "bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0": { - "deprecation_date": "2027-03-30", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-8b-instruct.md", + "deprecation_date": "2027-05-09", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 8000, @@ -50861,8 +50861,8 @@ "cache_creation_input_token_cost_above_200k_tokens": 9e-06, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.44e-05, "cache_read_input_token_cost_above_200k_tokens": 7.2e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "successor_model": "us-gov.anthropic.claude-sonnet-5-5", "litellm_provider": "bedrock_converse", "supports_tool_search": true, @@ -61526,7 +61526,7 @@ "output_cost_per_token": 2.2e-05, "output_cost_per_token_above_272k_tokens": 3.3e-05, "litellm_provider": "bedrock_mantle", - "max_input_tokens": 1050000, + "max_input_tokens": 922000, "max_output_tokens": 128000, "mode": "responses", "search_context_cost_per_query": {"search_context_size_high":0.012,"search_context_size_low":0.012,"search_context_size_medium":0.012}, @@ -100105,5 +100105,38 @@ "supported_output_modalities": [ "video" ] + }, + "bedrock_mantle/us-gov-east-1/openai.gpt-5.6-cyber": { + "cache_creation_input_token_cost": 2.109375e-05, + "cache_read_input_token_cost": 1.6875e-06, + "input_cost_per_token": 1.6875e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.00010125, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-56-cyber.html", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false, + "supports_xhigh_reasoning_effort": true, + "use_openai_responses_path": true } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 733ff3bb965..3210f3e080b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -15776,8 +15776,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "successor_model": "bedrock/us-gov-east-1/anthropic.claude-sonnet-5-5", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", @@ -15808,8 +15808,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "supports_tool_search": true, @@ -15969,8 +15969,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "successor_model": "bedrock/us-gov-west-1/anthropic.claude-sonnet-5-5", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", @@ -16001,8 +16001,8 @@ "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "supports_tool_search": true, @@ -16026,8 +16026,8 @@ "token_count_model": {"model":"anthropic.claude-sonnet-4-5-20250929-v1:0","litellm_provider":"bedrock"} }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { - "deprecation_date": "2027-03-30", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-70b-instruct.md", + "deprecation_date": "2027-05-09", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 2.65e-06, "litellm_provider": "bedrock", "max_input_tokens": 8000, @@ -16038,8 +16038,8 @@ "supports_pdf_input": true }, "bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0": { - "deprecation_date": "2027-03-30", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-8b-instruct.md", + "deprecation_date": "2027-05-09", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 8000, @@ -50861,8 +50861,8 @@ "cache_creation_input_token_cost_above_200k_tokens": 9e-06, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.44e-05, "cache_read_input_token_cost_above_200k_tokens": 7.2e-07, - "deprecation_date": "2027-04-08", - "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-4-5.md", + "deprecation_date": "2027-04-27", + "deprecation_source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html", "successor_model": "us-gov.anthropic.claude-sonnet-5-5", "litellm_provider": "bedrock_converse", "supports_tool_search": true, @@ -61526,7 +61526,7 @@ "output_cost_per_token": 2.2e-05, "output_cost_per_token_above_272k_tokens": 3.3e-05, "litellm_provider": "bedrock_mantle", - "max_input_tokens": 1050000, + "max_input_tokens": 922000, "max_output_tokens": 128000, "mode": "responses", "search_context_cost_per_query": {"search_context_size_high":0.012,"search_context_size_low":0.012,"search_context_size_medium":0.012}, @@ -100105,5 +100105,38 @@ "supported_output_modalities": [ "video" ] + }, + "bedrock_mantle/us-gov-east-1/openai.gpt-5.6-cyber": { + "cache_creation_input_token_cost": 2.109375e-05, + "cache_read_input_token_cost": 1.6875e-06, + "input_cost_per_token": 1.6875e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.00010125, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-56-cyber.html", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false, + "supports_xhigh_reasoning_effort": true, + "use_openai_responses_path": true } } From 60103ca01785e3d26d699f4b514c56c8a981f2db Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:03:33 -0800 Subject: [PATCH 55/86] feat(helm): single chart with monolith mode and shared image, remove litellm-helm (#43330) helm/litellm becomes the only chart. Every container runs the one shared image with args only, monolith.enabled renders a single proxy Deployment, the retired chart's KEDA, extraResources, generated master key, helm test, minReadySeconds and rollout strategy settings are ported, and the bundled PostgreSQL and Redis subcharts are removed Resolves LIT-8880 Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .circleci/config.yml | 31 +- .github/workflows/test-unit.yml | 21 +- .gitignore | 2 +- Makefile | 2 +- docker-entrypoint.sh | 37 +- helm/litellm-helm/.helmignore | 23 - helm/litellm-helm/Chart.lock | 9 - helm/litellm-helm/Chart.yaml | 41 -- helm/litellm-helm/README.md | 266 ------- .../litellm-helm/charts/postgresql-14.3.1.tgz | Bin 72928 -> 0 bytes helm/litellm-helm/charts/redis-18.19.1.tgz | Bin 87594 -> 0 bytes helm/litellm-helm/ci/test-values.yaml | 15 - helm/litellm-helm/templates/NOTES.txt | 23 - helm/litellm-helm/templates/_helpers.tpl | 399 ----------- .../templates/configmap-litellm.yaml | 22 - helm/litellm-helm/templates/deployment.yaml | 258 ------- .../templates/extra-resources.yaml | 6 - helm/litellm-helm/templates/hpa.yaml | 64 -- helm/litellm-helm/templates/ingress.yaml | 78 -- helm/litellm-helm/templates/keda.yaml | 58 -- .../templates/migrations-job.yaml | 122 ---- .../templates/poddisruptionbudget.yaml | 33 - .../templates/secret-dbcredentials.yaml | 13 - .../templates/secret-masterkey.yaml | 12 - .../templates/service-metrics.yaml | 17 - helm/litellm-helm/templates/service.yaml | 22 - .../templates/serviceaccount.yaml | 13 - .../templates/servicemonitor.yaml | 39 - .../templates/tests/test-connection.yaml | 25 - .../templates/tests/test-env-vars.yaml | 43 -- .../templates/tests/test-servicemonitor.yaml | 152 ---- .../tests/billing_metrics_tests.yaml | 100 --- .../tests/bundled_db_images_tests.yaml | 94 --- helm/litellm-helm/tests/collector_tests.yaml | 272 ------- .../tests/connection_pool_tests.yaml | 112 --- .../tests/coordination_redis_tests.yaml | 143 ---- .../tests/database_auth_tests.yaml | 288 -------- .../deployment_command_args_labels_tests.yaml | 68 -- helm/litellm-helm/tests/deployment_tests.yaml | 520 -------------- helm/litellm-helm/tests/hpa_tests.yaml | 144 ---- helm/litellm-helm/tests/ingress_tests.yaml | 45 -- helm/litellm-helm/tests/keda_tests.yaml | 106 --- .../tests/lens_connection_tests.yaml | 128 ---- .../tests/masterkey-secret_tests.yaml | 71 -- .../tests/metrics_server_tests.yaml | 106 --- .../tests/migrations-job_tests.yaml | 432 ----------- helm/litellm-helm/tests/pdb_tests.yaml | 45 -- helm/litellm-helm/tests/service_tests.yaml | 116 --- helm/litellm-helm/values.yaml | 676 ------------------ helm/litellm/.helmignore | 20 + helm/litellm/Chart.yaml | 6 +- helm/litellm/README.md | 174 ++++- helm/litellm/ci/postgres.yaml | 58 ++ helm/litellm/ci/test-values.yaml | 34 + helm/litellm/templates/NOTES.txt | 43 +- helm/litellm/templates/_helpers.tpl | 172 ++++- helm/litellm/templates/_workload.tpl | 515 +++++++++++++ .../litellm/templates/backend/deployment.yaml | 31 +- helm/litellm/templates/backend/hpa.yaml | 2 +- .../backend/poddisruptionbudget.yaml | 1 + helm/litellm/templates/backend/service.yaml | 9 +- helm/litellm/templates/extra-resources.yaml | 4 + helm/litellm/templates/gateway/configmap.yaml | 5 +- .../litellm/templates/gateway/deployment.yaml | 247 +------ helm/litellm/templates/gateway/hpa.yaml | 66 +- helm/litellm/templates/gateway/keda.yaml | 3 + .../gateway/poddisruptionbudget.yaml | 1 + .../templates/gateway/service-metrics.yaml | 19 +- helm/litellm/templates/gateway/service.yaml | 19 +- .../templates/gateway/servicemonitor.yaml | 29 +- helm/litellm/templates/ingress.yaml | 42 +- .../templates/liteadmin.yaml | 2 +- helm/litellm/templates/migrations-job.yaml | 41 +- .../templates/monolith/deployment.yaml | 3 + helm/litellm/templates/monolith/hpa.yaml | 3 + helm/litellm/templates/monolith/keda.yaml | 3 + .../monolith/poddisruptionbudget.yaml | 7 + .../templates/monolith/service-metrics.yaml | 3 + helm/litellm/templates/monolith/service.yaml | 3 + .../templates/monolith/servicemonitor.yaml | 3 + helm/litellm/templates/secret-masterkey.yaml | 22 + helm/litellm/templates/serviceaccount.yaml | 6 +- .../templates/tests/test-connection.yaml | 53 ++ helm/litellm/templates/ui/deployment.yaml | 23 +- helm/litellm/templates/ui/hpa.yaml | 2 +- .../templates/ui/poddisruptionbudget.yaml | 1 + helm/litellm/templates/ui/service.yaml | 9 +- helm/litellm/tests/collector_tests.yaml | 10 +- helm/litellm/tests/component_hosts_tests.yaml | 12 +- helm/litellm/tests/extra_resources_tests.yaml | 45 ++ .../tests/hpa_workload_metrics_tests.yaml | 44 ++ .../litellm/tests/ingress_monolith_tests.yaml | 164 +++++ helm/litellm/tests/keda_tests.yaml | 266 +++++++ helm/litellm/tests/liteadmin_tests.yaml | 149 ++++ .../litellm/tests/masterkey_secret_tests.yaml | 156 ++++ helm/litellm/tests/metrics_server_tests.yaml | 10 +- helm/litellm/tests/migration_job_tests.yaml | 75 +- helm/litellm/tests/monolith_tests.yaml | 539 ++++++++++++++ .../tests/no_bundled_datastores_tests.yaml | 54 ++ helm/litellm/tests/redis_env_tests.yaml | 4 +- .../litellm/tests/rollout_strategy_tests.yaml | 36 + helm/litellm/tests/shared_image_tests.yaml | 264 +++++++ helm/litellm/values.schema.json | 183 +++++ helm/litellm/values.yaml | 229 ++++-- scripts/tpm_headline_test.sh | 2 +- tests/unit/proxy/test_proxy_cli.py | 24 +- tests/unit/test_docker_entrypoint.py | 144 ++++ 107 files changed, 3637 insertions(+), 5769 deletions(-) delete mode 100644 helm/litellm-helm/.helmignore delete mode 100644 helm/litellm-helm/Chart.lock delete mode 100644 helm/litellm-helm/Chart.yaml delete mode 100644 helm/litellm-helm/README.md delete mode 100644 helm/litellm-helm/charts/postgresql-14.3.1.tgz delete mode 100644 helm/litellm-helm/charts/redis-18.19.1.tgz delete mode 100644 helm/litellm-helm/ci/test-values.yaml delete mode 100644 helm/litellm-helm/templates/NOTES.txt delete mode 100644 helm/litellm-helm/templates/_helpers.tpl delete mode 100644 helm/litellm-helm/templates/configmap-litellm.yaml delete mode 100644 helm/litellm-helm/templates/deployment.yaml delete mode 100644 helm/litellm-helm/templates/extra-resources.yaml delete mode 100644 helm/litellm-helm/templates/hpa.yaml delete mode 100644 helm/litellm-helm/templates/ingress.yaml delete mode 100644 helm/litellm-helm/templates/keda.yaml delete mode 100644 helm/litellm-helm/templates/migrations-job.yaml delete mode 100644 helm/litellm-helm/templates/poddisruptionbudget.yaml delete mode 100644 helm/litellm-helm/templates/secret-dbcredentials.yaml delete mode 100644 helm/litellm-helm/templates/secret-masterkey.yaml delete mode 100644 helm/litellm-helm/templates/service-metrics.yaml delete mode 100644 helm/litellm-helm/templates/service.yaml delete mode 100644 helm/litellm-helm/templates/serviceaccount.yaml delete mode 100644 helm/litellm-helm/templates/servicemonitor.yaml delete mode 100644 helm/litellm-helm/templates/tests/test-connection.yaml delete mode 100644 helm/litellm-helm/templates/tests/test-env-vars.yaml delete mode 100644 helm/litellm-helm/templates/tests/test-servicemonitor.yaml delete mode 100644 helm/litellm-helm/tests/billing_metrics_tests.yaml delete mode 100644 helm/litellm-helm/tests/bundled_db_images_tests.yaml delete mode 100644 helm/litellm-helm/tests/collector_tests.yaml delete mode 100644 helm/litellm-helm/tests/connection_pool_tests.yaml delete mode 100644 helm/litellm-helm/tests/coordination_redis_tests.yaml delete mode 100644 helm/litellm-helm/tests/database_auth_tests.yaml delete mode 100644 helm/litellm-helm/tests/deployment_command_args_labels_tests.yaml delete mode 100644 helm/litellm-helm/tests/deployment_tests.yaml delete mode 100644 helm/litellm-helm/tests/hpa_tests.yaml delete mode 100644 helm/litellm-helm/tests/ingress_tests.yaml delete mode 100644 helm/litellm-helm/tests/keda_tests.yaml delete mode 100644 helm/litellm-helm/tests/lens_connection_tests.yaml delete mode 100644 helm/litellm-helm/tests/masterkey-secret_tests.yaml delete mode 100644 helm/litellm-helm/tests/metrics_server_tests.yaml delete mode 100644 helm/litellm-helm/tests/migrations-job_tests.yaml delete mode 100644 helm/litellm-helm/tests/pdb_tests.yaml delete mode 100644 helm/litellm-helm/tests/service_tests.yaml delete mode 100644 helm/litellm-helm/values.yaml create mode 100644 helm/litellm/.helmignore create mode 100644 helm/litellm/ci/postgres.yaml create mode 100644 helm/litellm/ci/test-values.yaml create mode 100644 helm/litellm/templates/_workload.tpl create mode 100644 helm/litellm/templates/extra-resources.yaml create mode 100644 helm/litellm/templates/gateway/keda.yaml rename helm/{litellm-helm => litellm}/templates/liteadmin.yaml (96%) create mode 100644 helm/litellm/templates/monolith/deployment.yaml create mode 100644 helm/litellm/templates/monolith/hpa.yaml create mode 100644 helm/litellm/templates/monolith/keda.yaml create mode 100644 helm/litellm/templates/monolith/poddisruptionbudget.yaml create mode 100644 helm/litellm/templates/monolith/service-metrics.yaml create mode 100644 helm/litellm/templates/monolith/service.yaml create mode 100644 helm/litellm/templates/monolith/servicemonitor.yaml create mode 100644 helm/litellm/templates/secret-masterkey.yaml create mode 100644 helm/litellm/templates/tests/test-connection.yaml create mode 100644 helm/litellm/tests/extra_resources_tests.yaml create mode 100644 helm/litellm/tests/ingress_monolith_tests.yaml create mode 100644 helm/litellm/tests/keda_tests.yaml create mode 100644 helm/litellm/tests/liteadmin_tests.yaml create mode 100644 helm/litellm/tests/masterkey_secret_tests.yaml create mode 100644 helm/litellm/tests/monolith_tests.yaml create mode 100644 helm/litellm/tests/no_bundled_datastores_tests.yaml create mode 100644 helm/litellm/tests/shared_image_tests.yaml create mode 100644 helm/litellm/values.schema.json diff --git a/.circleci/config.yml b/.circleci/config.yml index cf1a7b94fe5..15129f1e5e3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -531,31 +531,36 @@ jobs: IMAGE_TAG=${CIRCLE_SHA1:-ci} kind load docker-image litellm-ci:${IMAGE_TAG} --name litellm-test - # Run helm lint - run: name: Run helm lint command: | - helm lint ./helm/litellm-helm + helm lint ./helm/litellm -f ./helm/litellm/ci/test-values.yaml + + - run: + name: Start PostgreSQL for the chart + command: | + kubectl apply -f ./helm/litellm/ci/postgres.yaml + kubectl rollout status deployment/litellm-ci-postgres --timeout=3m - # Run helm tests - run: name: Run helm tests command: | IMAGE_TAG=${CIRCLE_SHA1:-ci} - helm install litellm ./helm/litellm-helm -f ./helm/litellm-helm/ci/test-values.yaml \ + helm install litellm ./helm/litellm -f ./helm/litellm/ci/test-values.yaml \ --set image.repository=litellm-ci \ --set image.tag=${IMAGE_TAG} \ - --set image.pullPolicy=Never - if ! kubectl rollout status deployment/litellm --timeout=180s; then - kubectl describe pods -l app.kubernetes.io/name=litellm - kubectl logs -l app.kubernetes.io/name=litellm --tail=200 - exit 1 - fi + --set image.pullPolicy=Never \ + --wait --wait-for-jobs --timeout 10m || { + kubectl get pods -o wide + kubectl describe pods -l app.kubernetes.io/instance=litellm + kubectl logs -l app.kubernetes.io/component=proxy --tail=200 || true + kubectl logs -l app.kubernetes.io/component=migrations --tail=200 || true + exit 1 + } - echo "Printing pod logs..." - kubectl logs $(kubectl get pods -l app.kubernetes.io/name=litellm -o jsonpath="{.items[0].metadata.name}") + echo "Printing proxy pod logs..." + kubectl logs -l app.kubernetes.io/component=proxy --tail=200 - # Run the helm tests helm test litellm --logs # Cleanup diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index f0b51c6f1fb..f8b93b5cc11 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -1353,17 +1353,16 @@ jobs: - name: Run unit tests run: | - for chart in helm/litellm-helm helm/litellm; do - declared="$(grep -h '^suite:' "$chart"/tests/*.yaml | wc -l | tr -d '[:space:]')" - output="$(mktemp)" - helm unittest -f 'tests/*.yaml' "$chart" | tee "$output" - executed="$(sed -n 's/^Test Suites:.*[[:space:]]\([0-9][0-9]*\) total$/\1/p' "$output")" - if [ "$declared" != "$executed" ]; then - echo "::error::$chart declares $declared test suites but helm-unittest ran $executed. Suites are being skipped silently, so their assertions never execute." - exit 1 - fi - echo "$chart: all $declared declared test suites ran" - done + chart=helm/litellm + declared="$(grep -h '^suite:' "$chart"/tests/*.yaml | wc -l | tr -d '[:space:]')" + output="$(mktemp)" + helm unittest -f 'tests/*.yaml' "$chart" | tee "$output" + executed="$(sed -n 's/^Test Suites:.*[[:space:]]\([0-9][0-9]*\) total$/\1/p' "$output")" + if [ "$declared" != "$executed" ]; then + echo "::error::$chart declares $declared test suites but helm-unittest ran $executed. Suites are being skipped silently, so their assertions never execute." + exit 1 + fi + echo "$chart: all $declared declared test suites ran" openai-sdk-compat: name: OpenAI ${{ matrix.openai-version }} compatibility needs: rust-bridge diff --git a/.gitignore b/.gitignore index b08ee92d6af..1b4785d193a 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,7 @@ ui/litellm-dashboard/next-env.d.ts *.tsbuildinfo ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json -helm/litellm-helm/*.tgz +helm/litellm/charts/*.tgz helm/*.tgz litellm/proxy/vertex_key.json **/.vim/ diff --git a/Makefile b/Makefile index 48df3c8e215..c256d273d8a 100644 --- a/Makefile +++ b/Makefile @@ -341,7 +341,7 @@ test-integration: install-test-deps $(UV_RUN) pytest tests/ test-unit-helm: install-helm-unittest - helm unittest -f 'tests/*.yaml' helm/litellm-helm + helm unittest -f 'tests/*.yaml' helm/litellm # LLM Translation testing targets test-llm-translation: install-test-deps diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 7bcb62253a7..a48aad33324 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -16,8 +16,11 @@ # --admin-agent the bundled LiteAdmin Slack agent (litellm-admin-agent --web ARGS) # # gateway and backend get their host and port defaults first, so ARGS such as -# --port 8080 override them. An unknown first word is executed as-is (docker run -# sh), through ddtrace-run like every component when USE_DDTRACE=true. PgBouncer is not a component: LITELLM_PGBOUNCER_ENABLED=true +# --port 8080 override them. With no DATABASE_URL, proxy assembles DATABASE_URL +# and DATABASE_URL_READ_REPLICA from DATABASE_HOST and the other discrete +# variables the way gateway and backend do. An unknown first word is executed +# as-is (docker run sh), through ddtrace-run like every component when +# USE_DDTRACE=true. PgBouncer is not a component: LITELLM_PGBOUNCER_ENABLED=true # starts it inside proxy and gateway. Components that write Prometheus samples # start with an empty PROMETHEUS_MULTIPROC_DIR; metrics, collector and raw # readers keep the workers' files, other raw commands clear stale samples. @@ -60,7 +63,35 @@ case "$component" in raw) ;; proxy) - set -- litellm "$@" + if { [ -z "${DATABASE_URL:-}" ] && [ -n "${DATABASE_HOST:-}" ]; } || + { [ -z "${DATABASE_URL_READ_REPLICA:-}" ] && [ -n "${DATABASE_HOST_READ_REPLICA:-}" ]; }; then + set -- python -c 'import os, sys +from litellm.proxy.db.db_url_settings import ( + DatabaseURLSettings, + add_missing_query_params, + connection_params_from_url, + idle_lifetime_params, + translate_libpq_ssl_params, +) +if os.environ.get("USE_AWS_KMS") == "True": + from litellm.secret_managers.aws_secret_manager import decrypt_env_var + os.environ.update(decrypt_env_var()) +settings = DatabaseURLSettings.from_env() +settings.apply_writer_url_to_env() +reader = settings.build_reader_url() +if reader: + os.environ["DATABASE_URL_READ_REPLICA"] = translate_libpq_ssl_params( + add_missing_query_params(reader, connection_params_from_url(os.environ.get("DATABASE_URL", ""))) + ) +if settings.max_idle_connection_lifetime is not None: + lifetime = idle_lifetime_params(settings.max_idle_connection_lifetime) + for name in ("DATABASE_URL", "DATABASE_URL_READ_REPLICA"): + if os.environ.get(name): + os.environ[name] = add_missing_query_params(os.environ[name], lifetime) +os.execvp("litellm", ["litellm", *sys.argv[1:]])' "$@" + else + set -- litellm "$@" + fi ;; gateway) set -- python -m gateway.launch --workers "${NUM_WORKERS:-1}" --host 0.0.0.0 --port 4000 "$@" diff --git a/helm/litellm-helm/.helmignore b/helm/litellm-helm/.helmignore deleted file mode 100644 index 0e8a0eb36f4..00000000000 --- a/helm/litellm-helm/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/helm/litellm-helm/Chart.lock b/helm/litellm-helm/Chart.lock deleted file mode 100644 index 1e63f4f9eca..00000000000 --- a/helm/litellm-helm/Chart.lock +++ /dev/null @@ -1,9 +0,0 @@ -dependencies: -- name: postgresql - repository: oci://registry-1.docker.io/bitnamicharts - version: 14.3.1 -- name: redis - repository: oci://registry-1.docker.io/bitnamicharts - version: 18.19.1 -digest: sha256:38962e231f6596b93f82a8412bbe4cf5de696caecf5775dfbbd163383eb1c009 -generated: '2026-10-30T02:13:11.505726000Z' diff --git a/helm/litellm-helm/Chart.yaml b/helm/litellm-helm/Chart.yaml deleted file mode 100644 index 059889cb667..00000000000 --- a/helm/litellm-helm/Chart.yaml +++ /dev/null @@ -1,41 +0,0 @@ -apiVersion: v2 - -# We can't call ourselves just "litellm" because then we couldn't publish to the -# same OCI repository as the "litellm" OCI image -name: litellm-helm -description: Call all LLM APIs using the OpenAI format - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.1.4 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: v1.85.1 - -annotations: - org.opencontainers.image.source: "https://github.com/BerriAI/litellm" - org.opencontainers.image.url: "https://docs.litellm.ai/" - -dependencies: - - name: "postgresql" - version: "14.3.1" - repository: oci://registry-1.docker.io/bitnamicharts - condition: db.deployStandalone - - name: redis - version: "18.19.1" - repository: oci://registry-1.docker.io/bitnamicharts - condition: redis.enabled diff --git a/helm/litellm-helm/README.md b/helm/litellm-helm/README.md deleted file mode 100644 index bc6e29632f1..00000000000 --- a/helm/litellm-helm/README.md +++ /dev/null @@ -1,266 +0,0 @@ -# Helm Chart for LiteLLM - -> [!IMPORTANT] -> This is community maintained, Please make an issue if you run into a bug -> We recommend using [Docker or Kubernetes for production deployments](https://docs.litellm.ai/docs/proxy/prod) - -## Prerequisites - -- Kubernetes 1.21+ -- Helm 3.8.0+ - -If `db.deployStandalone` is used: - -- PV provisioner support in the underlying infrastructure - -If `db.useStackgresOperator` is used (not yet implemented): - -- The Stackgres Operator must already be installed in the Kubernetes Cluster. This chart will **not** install the operator if it is missing. - -## Parameters - -### LiteLLM Proxy Deployment Settings - -| Name | Description | Value | -| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -| `replicaCount` | The number of LiteLLM Proxy pods to be deployed | `1` | -| `masterkeySecretName` | The name of the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use the generated secret name. | N/A | -| `masterkeySecretKey` | The key within the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use `masterkey` as the key. | N/A | -| `masterkey` | The Master API Key for LiteLLM. If not specified, a random key in the `sk-...` format is generated on first install and reused on upgrades. | N/A | -| `environmentSecrets` | An optional array of Secret object names. The keys and values in these secrets will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | -| `environmentConfigMaps` | An optional array of ConfigMap object names. The keys and values in these configmaps will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | -| `image.repository` | LiteLLM Proxy image repository | `ghcr.io/berriai/litellm` | -| `image.pullPolicy` | LiteLLM Proxy image pull policy | `IfNotPresent` | -| `image.tag` | Overrides the image tag whose default the latest version of LiteLLM at the time this chart was published. | `""` | -| `imagePullSecrets` | Registry credentials for the LiteLLM and initContainer images. | `[]` | -| `serviceAccount.create` | Whether or not to create a Kubernetes Service Account for this deployment. The default is `false` because LiteLLM has no need to access the Kubernetes API. | `false` | -| `service.type` | Kubernetes Service type (e.g. `LoadBalancer`, `ClusterIP`, etc.) | `ClusterIP` | -| `service.port` | TCP port that the Kubernetes Service will listen on. Also the TCP port within the Pod that the proxy will listen on. | `4000` | -| `livenessProbe.*` | Liveness probe settings for the LiteLLM container (`path`, `periodSeconds`, `timeoutSeconds`, thresholds, and initial delay). | See `values.yaml` | -| `readinessProbe.*` | Readiness probe settings for the LiteLLM container (`path`, `periodSeconds`, `timeoutSeconds`, thresholds, and initial delay). | See `values.yaml` | -| `startupProbe.*` | Startup probe settings for the LiteLLM container (`path`, `periodSeconds`, `timeoutSeconds`, thresholds, and initial delay). | See `values.yaml` | -| `resources.*` | CPU/memory requests and limits for the LiteLLM container. Unset by default; production deployments should set 1 CPU and 4Gi of memory per worker. | `{}` | -| `service.loadBalancerClass` | Optional LoadBalancer implementation class (only used when `service.type` is `LoadBalancer`) | `""` | -| `ingress.labels` | Additional labels for the Ingress resource | `{}` | -| `ingress.*` | See [values.yaml](./values.yaml) for example settings | N/A | -| `proxyConfigMap.create` | When `true`, render a ConfigMap from `.Values.proxy_config` and mount it. | `true` | -| `proxyConfigMap.name` | When `create=false`, name of the existing ConfigMap to mount. | `""` | -| `proxyConfigMap.key` | Key in the ConfigMap that contains the proxy config file. | `"config.yaml"` | -| `proxy_config.*` | See [values.yaml](./values.yaml) for default settings. Rendered into the ConfigMap’s `config.yaml` only when `proxyConfigMap.create=true`. See [example_config_yaml](../../../litellm/proxy/example_config_yaml/) for configuration examples. | `N/A` | -| `extraContainers[]` | An array of additional containers to be deployed as sidecars alongside the LiteLLM Proxy. | -| `pdb.enabled` | Enable a PodDisruptionBudget for the LiteLLM proxy Deployment | `false` | -| `pdb.minAvailable` | Minimum number/percentage of pods that must be available during **voluntary** disruptions (choose **one** of minAvailable/maxUnavailable) | `null` | -| `pdb.maxUnavailable` | Maximum number/percentage of pods that can be unavailable during **voluntary** disruptions (choose **one** of minAvailable/maxUnavailable) | `null` | -| `pdb.annotations` | Extra metadata annotations to add to the PDB | `{}` | -| `pdb.labels` | Extra metadata labels to add to the PDB | `{}` | - -| `billingMetrics.enabled` | Enable enterprise billable-request metering. Requires an enterprise license. | `false` | -| `billingMetrics.endpoint` | Collector that the billable-request counter is pushed to. | `https://telemetry.litellm.ai` | -| `billingMetrics.tokenUrl` | Cognito OAuth2 client-credentials token endpoint. | `https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token` | -| `billingMetrics.secretName` | Name of an existing Secret with `client-id` and `client-secret` keys. | `litellm-billing-metrics` | -| `billingMetrics.exportIntervalMs` | How often the counter is pushed, in milliseconds. The proxy defaults to `60000` when unset. | `""` | - -#### Example `proxy_config` ConfigMap from values (default): - -``` -proxyConfigMap: - create: true - key: "config.yaml" - -proxy_config: - general_settings: - master_key: os.environ/PROXY_MASTER_KEY - model_list: [] -``` - -The chart ships no models, so add them under `proxy_config.model_list` or from the Admin UI with `store_model_in_db: true` - -``` -proxy_config: - model_list: - - model_name: gpt-5.6 - litellm_params: - model: openai/gpt-5.6 - api_key: os.environ/OPENAI_API_KEY -``` - -#### Example using existing `proxyConfigMap` instead of creating it: - -``` -proxyConfigMap: - create: false - name: my-litellm-config - key: config.yaml - -# proxy_config is ignored in this mode -``` - -#### Example `environmentSecrets` Secret - -``` -apiVersion: v1 -kind: Secret -metadata: - name: litellm-envsecrets -data: - AZURE_OPENAI_API_KEY: TXlTZWN1cmVLM3k= -type: Opaque -``` - -#### Enterprise billable-request metering - -Enterprise licenses meter billable requests by pushing a counter to LiteLLM's collector with Cognito bearer authentication. Create a Secret with `client-id` and `client-secret` keys, then enable the chart values: - -``` -kubectl create secret generic litellm-billing-metrics --from-literal=client-id=... --from-literal=client-secret=... -``` - -``` -billingMetrics: - enabled: true -``` - -The endpoint and token URL must use HTTPS. The chart requires both URLs and -the Secret name when billing metrics are enabled - -### Database Settings - -| Name | Description | Value | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `db.useExisting` | Use an existing Postgres database. A Kubernetes Secret object must exist that contains credentials for connecting to the database. An example secret object definition is provided below. | `false` | -| `db.endpoint` | If `db.useExisting` is `true`, this is the IP, Hostname or Service Name of the Postgres server to connect to. | `localhost` | -| `db.database` | If `db.useExisting` is `true`, the name of the existing database to connect to. | `litellm` | -| `db.url` | If `db.useExisting` is `true`, the connection url of the existing database to connect to can be overwritten with this value. | `postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME)` | -| `db.secret.name` | If `db.useExisting` is `true`, the name of the Kubernetes Secret that contains credentials. | `postgres` | -| `db.secret.usernameKey` | If `db.useExisting` is `true`, the name of the key within the Kubernetes Secret that holds the username for authenticating with the Postgres instance. | `username` | -| `db.secret.passwordKey` | If `db.useExisting` is `true`, the name of the key within the Kubernetes Secret that holds the password associates with the above user. | `password` | -| `db.username` | If `db.useExisting` is `true`, the database user as a plain value. When set, `db.secret.usernameKey` is not read. | `""` | -| `db.port` | If `db.useExisting` is `true` with token auth, the Postgres port used to build the connection URL. Password auth takes the port from `db.url`. | `5432` | -| `db.schema` | With token auth, the Postgres schema holding the LiteLLM tables, rendered as `DATABASE_SCHEMA` when the tables are not in `public`. Ignored with password auth, where the schema stays in `db.url` as `?schema=`. | `""` | -| `db.useIAMAuth` | If `db.useExisting` is `true` and `db.deployStandalone` is `false`, authenticate with an AWS RDS IAM token instead of a password. See [Token auth](#token-auth-to-an-existing-postgres-rds-iam-or-microsoft-entra-id). | `false` | -| `db.useAzureEntraAuth` | If `db.useExisting` is `true` and `db.deployStandalone` is `false`, authenticate with a Microsoft Entra ID token instead of a password. Mutually exclusive with `db.useIAMAuth`. | `false` | -| `db.useStackgresOperator` | Not yet implemented. | `false` | -| `db.deployStandalone` | Deploy a standalone, single instance deployment of Postgres, using the Bitnami postgresql chart. This is useful for getting started but doesn't provide HA or (by default) data backups. | `true` | -| `postgresql.*` | If `db.deployStandalone` is `true`, configuration passed to the Bitnami postgresql chart. See the [Bitnami Documentation](https://github.com/bitnami/charts/tree/main/bitnami/postgresql) for full configuration details. See [values.yaml](./values.yaml) for the default configuration. | See [values.yaml](./values.yaml) | -| `postgresql.auth.*` | If `db.deployStandalone` is `true`, care should be taken to ensure the default `password` and `postgres-password` values are **NOT** used. | `NoTaGrEaTpAsSwOrD` | -| `postgresql.image.*` | If `db.deployStandalone` is `true`, the image for the bundled Postgres. Pinned to a `docker.io/bitnamilegacy` build because Bitnami retired the versioned tags under `docker.io/bitnami`. | `bitnamilegacy/postgresql:16.2.0-debian-12-r6` | -| `redis.image.*` | If `redis.enabled` is `true`, the image for the bundled Redis. Pinned to a `docker.io/bitnamilegacy` build for the same reason. | `bitnamilegacy/redis:7.2.4-debian-12-r9` | - -#### Bundled Postgres image - -Bitnami removed the versioned tags from `docker.io/bitnami` and republished the archived builds under `docker.io/bitnamilegacy`, so the image defaults that ship inside the `postgresql` and `redis` subcharts no longer pull. The chart pins both to the `bitnamilegacy` copies of the exact builds those subchart versions were released with, which keeps the on-disk data directory layout unchanged for existing installs. - -Keep `postgresql.image.tag` pinned. `docker.io/bitnami/postgresql` still publishes a floating `latest`, and pointing the bundled Postgres at a different major version starts the server against a data directory it cannot read (`database files are incompatible with server`). There is no in-place way back, so crossing a major version means dumping the database with the old image and restoring it into the new one. The chart refuses to render when the tag is empty or `latest`. - -Those images no longer receive updates. For anything beyond getting started, run Postgres outside the chart and point at it with `db.useExisting`. - -#### Example Postgres `db.useExisting` Secret - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: postgres -data: - # Password for the "postgres" user - postgres-password: - username: litellm - password: -type: Opaque -``` - -#### Token auth to an existing Postgres (RDS IAM or Microsoft Entra ID) - -When the database accepts short-lived tokens instead of a password (AWS RDS or Aurora with IAM database authentication, or Azure Database for PostgreSQL with Microsoft Entra ID), there is no password to put in a Secret. Turn on `db.useIAMAuth` or `db.useAzureEntraAuth` and the chart stops referencing `db.secret.passwordKey` and `db.url`. It sets `IAM_TOKEN_DB_AUTH` (or `AZURE_POSTGRESQL_AUTH`) and emits `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_USERNAME` and `DATABASE_NAME` for the proxy, the migrations job and the collector sidecar. Each of them mints its own token at startup and builds `DATABASE_URL` from those parts, and the in-container pgbouncer (`db.connectionPool`) refreshes the token before it expires. - -```yaml -db: - useExisting: true - deployStandalone: false - useIAMAuth: true - endpoint: litellm.cluster-abc123.us-west-1.rds.amazonaws.com - database: litellm - username: litellm_iam # the role granted rds_iam; no db.secret needed -``` - -The pod needs cloud credentials for the token: on EKS use IRSA or Pod Identity on the chart's `serviceAccount` (and `migrationJob.serviceAccountName` when the Helm hook runs before the ServiceAccount exists), on AKS use workload identity. Set `AWS_RDS_REGION` under `envVars` when the RDS hostname is not a standard regional endpoint (the token must be signed for the region the database lives in). TLS options that would otherwise go in `db.url` are set as `DATABASE_SSLMODE` and `DATABASE_SSLROOTCERT` under `envVars`. Leaving `db.username` empty reads the user from `db.secret.usernameKey` as before. - -#### Examples for `environmentSecrets` and `environemntConfigMaps` - -```yaml -# Use config map for not-secret configuration data -apiVersion: v1 -kind: ConfigMap -metadata: - name: litellm-env-configmap -data: - SOME_KEY: someValue - ANOTHER_KEY: anotherValue -``` - -```yaml -# Use secrets for things which are actually secret like API keys, credentials, etc -# Base64 encode the values stored in a Kubernetes Secret: $ pbpaste | base64 | pbcopy -# The --decode flag is convenient: $ pbpaste | base64 --decode - -apiVersion: v1 -kind: Secret -metadata: - name: litellm-env-secret -type: Opaque -data: - SOME_PASSWORD: cDZbUGVXeU5e0ZW # base64 encoded - ANOTHER_PASSWORD: AAZbUGVXeU5e0ZB # base64 encoded -``` - -Source: [GitHub Gist from troyharvey](https://gist.github.com/troyharvey/4506472732157221e04c6b15e3b3f094) - -### Migration Job Settings - -The migration job supports both ArgoCD and Helm hooks to ensure database migrations run at the appropriate time during deployments. - -| Name | Description | Value | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------- | -| `migrationJob.enabled` | Enable or disable the schema migration Job | `true` | -| `migrationJob.backoffLimit` | Backoff limit for Job restarts | `4` | -| `migrationJob.ttlSecondsAfterFinished` | TTL for completed migration jobs | `120` | -| `migrationJob.annotations` | Additional annotations for the migration job pod | `{}` | -| `migrationJob.extraContainers` | Additional containers to run alongside the migration job | `[]` | -| `migrationJob.hooks.argocd.enabled` | Enable ArgoCD hooks for the migration job (uses PreSync hook with BeforeHookCreation delete policy) | `true` | -| `migrationJob.hooks.helm.enabled` | Enable Helm hooks for the migration job (uses pre-install,pre-upgrade hooks with before-hook-creation delete policy) | `false` | -| `migrationJob.hooks.helm.weight` | Helm hook execution order (lower weights executed first). Optional - defaults to "1" if not specified. | N/A | - -## Accessing the Admin UI - -When browsing to the URL published per the settings in `ingress.*`, you will -be prompted for **Admin Configuration**. The **Proxy Endpoint** is the internal -(from the `litellm` pod's perspective) URL published by the `-litellm` -Kubernetes Service. If the deployment uses the default settings for this -service, the **Proxy Endpoint** should be set to `http://-litellm:4000`. - -The **Proxy Key** is the value specified for `masterkey` or, if a `masterkey` -was not provided to the helm command line, the `masterkey` is a randomly -generated string in the `sk-...` format stored in the `-litellm-masterkey` Kubernetes Secret. -The key is generated once on the first install; later `helm upgrade` runs reuse the -value already in that Secret, so upgrading never rotates the master key. - -```bash -kubectl -n litellm get secret -litellm-masterkey -o jsonpath="{.data.masterkey}" -``` - -## Admin UI Limitations - -At the time of writing, the Admin UI is unable to add models. This is because -it would need to update the `config.yaml` file which is a exposed ConfigMap, and -therefore, read-only. This is a limitation of this helm chart, not the Admin UI -itself. - -## Connect Lens - -This chart does not deploy Lens. Deploy Lens from [its own chart](https://github.com/BerriAI/lens/tree/main/helm/lens), then set `lens.enabled: true`, `lens.url`, `lens.publicUrl`, `lens.gatewaySecret.name` and `lens.serviceTokenSecret.name`. The secret keys default to `gateway-secret` and `service-token`. Set `lens.service.name` when that Lens runs in this release's namespace and the chart ingress should route `/lens-ingest` to it. The old `lensWorker` values are no longer read, and enabling them fails the render - -## Shutdown accounting - -`GRACEFUL_SHUTDOWN_TIMEOUT` defaults to 30 seconds. Pending success callbacks share the time remaining since SIGTERM or the first `/health/drain` call. Callback flushing reserves the existing 10-second teardown headroom for cancellation, tracked accounting tasks and persistence. Exhausting this budget can discard callbacks, which is logged - -Set `terminationGracePeriodSeconds` to at least `GRACEFUL_SHUTDOWN_TIMEOUT + 10`, allowing additional time for preStop hooks and database writes. This is not a hard shutdown bound or a guarantee of persistence. Database retries retain their own limits, and cancellation-resistant callbacks can remain alive. The existing preStop hook can also be followed by Uvicorn's separate connection drain, as described in `values.yaml` diff --git a/helm/litellm-helm/charts/postgresql-14.3.1.tgz b/helm/litellm-helm/charts/postgresql-14.3.1.tgz deleted file mode 100644 index e8e2fac0fdaf8d5614921bfa3b7cddeeb088b3f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72928 zcmV)4K+3-#iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0POwyb{jXcD2&hFdJ6og%&|0EOB82QQ!h>G1f)tD~bA#|N*D|8#Kp{P^J2 zpTNO}=_vgqj6?dTgSBzx8~2rbU=$G!IVKV7Z2{mzj;4ezdf)_dIED;u0f6UlidYZ) zz6AgP9*E!^^#EZWo1rk24|Sm)2#9}&Xcv?HG3F7R<9$=EcX;FlXpCXx9Ugh~@TCLI z$f1Wm#DsE0o4^(-^bWd*FS-W~j^k1szctdFmFyk9>>jyL1>qs~QN#pO`Z4rp$UEvD zY{3}+j3~n->Vf;CEf~k?PX%QwKrxB}6!{pj-j<=uC-XUpwg8|gCJb}Qf8yib{ys%h z%s5?mhus2|z8J4;3jhwMvhzHk-*_p+dqcoc_e1x?R)CmKam?kTp=#vl|DJ;#{hjRs z%m9o4iBSX?NvMwi8UKoW?okv-+>9TW8to<`sfE!ZCTmEef&3F z9UUEfIC_5Ejic$-9a=mP8p!^$Br5x{<)sYT@_>3rQ5<4l@^8xnewZ*J;&X^24snF2 zB@I7cJV1)}z**#Xw*W|Jn6+Sv`79aB1{hJ*Qc#d^)p-(bW9@xT5!x5jl#GC_RE7?Z zyU)9aTc2BHefw+#J5>9Ub+KUUgqSe|d25 z{qf81|Ks4WcW}`8rn9O(*7(1NVS?BrEC6c9|IzaohsQ7S5#Te(0iR(89&i`}a*rs*fq0IrYI^`jewYL}ngUxt?E#-e6Fg0X z0munRn5sEdc%}SqZEtU>-@+mm)JeFyOjla`*II=cf={F#Q>y`B^qZ`07C)v&4|F<} zV24Qkl*ii+YzJ?{)85+D@Dif{U@|Ysk=i9fTK+iS%dBUZWmld z;X=R+z{3nh;C(A5z88yehWPRg7P9nY?ad+k49&Gx*H6~!a-17$y!<1|^|Y@lui~`M z6(6IDtVeONlo`<0BbNm0Mb=J(9)P@9Tg4R#Rqx67VjvD7pAb4{ zX3q5On&})_5AP~Q5u4!&w}~d^p`oN zrX3T*NW2eF9FoOcSOlDaIlPk=*gS1nh`F_==myC?YXBd`-ADlv;y#ExxiG&3h$=_4r*fM%vK~u8c)(iJ@MMi&|Du zDbE=Yp+C~fBTfZZI1xyNDhwByt*(3nU1`I1CrKDe1B#=$=ph7lq-AeBo04Y~$u)q) zu`9^1k>%(x083~QuUgX~(Bxs|Q0Y!TA%a+&VFN7<>WzTp6z!Rs-H`Y&v>>7noWc`1 zL_lBI^~wU`6ly0r6YWrANR1PlJv&Hibk5^WIh+)O#v-z z0gBRfk4Do26Bsg7N00$F(0@6}gjW#b6XX0ZYkW3kh{`>^k;OuU;yR>|P8sNne^x># zsvBH5n&1fYg`|o z#BRE>{=1$8df@u>&DrSY`fskk**zI<^+7Dyg zFqmciwkM`VhWK8IQ-;H7fr2;*!yyT=zbH~6-ho)YEUjn!BcmAkc(Sl-DVviO8;r)u zG^;QH>VcZx0h^H|48&N~8bj^rO4qgV*`$8zoI|^0uS;&Gn(f;Brmyj08$;001T(LT z;){BHqGU*p#AkhJEIbqRXPu83cqTMFhd=zu}MGy3SwR69}9aH29CZoVpkIZ<_`mzUXoC@VttTueZ1z@JF+G8bV zw!E)NJFiS1&lAQ)$IM}rNEpi15EvPiGj|Mq)NR*54%wY-opJ$sF$ui1t^1-Ss&QUS z@jc|oGZL_`;Gceg^Efn0;lP(s9khTJFBeuMvTfZ=1nRbSw;AGM{pax+@+cwPGwVi= zMBW5b#yy%8_4OJUr3?s^&@&6tC&eWes(FN4W}Tg>9<@T3Q@sg|5GxhXXADmx0MiAw z?D;B3WfxrW8KMuEp*^|k;RrzAM=Ucz#-UJ7#uz z6nIkZZ4){wrsN(6C`d;^xho<^QfuF;0h@DUvzoR|`?NPm+9Ua& zSDe<{IZ-kn zWw2%YLRQ$i_<_&!Y{j_1fz{pLNuush@PaQn@>fj9cw<>2l0)#cT@ zkvF)yxH!8>M_RrMdZ0rl6QN?u+0g?Bl?)VJpubDKVVscGbGc&bf_Dr}l2F+n!99$8 z>3YjbX`?<}S>z!1zWZX^JBas2#9EocJFGvc1V90!hl4P+3)BFfvFRcsfT4qG|il! zVrmb`RBEg;SSOhI)!<0N*)jm*spOt$h=AnQ6t$F|l=~#As912Kh`Ifn2R48lirSJv z`n!Up%M|LWHeBH70x<@sLX)d6l8O-BqfoARXON=I!!2#n5+>miR zmauSyK&)Qwm>nKGk^3tQ@!c|bH+YWzMIr>!FUGVL5XA^*9L{5=)_eh$+h>-fWKLaI z8l2~D&t|VOwwiF5JfAc5P+(&ZAW8{U7&5kKNLJI#9)`3Z3)75dh%_a0{%~1A2i%LI zC#S>f(?S2{^h6a;Xb8@%+-114AFZ;!PyYN zAfRHwOQ=*|W?`eqk)^u%%5o=H=n|nu<@6Ed>&SJM>IJ|_0e})4Ya5HoFfudFs2QPA)|uVTKo)luTTpFkT5}p7COFcyhTU??t5`a@b#hi=$w9xHt-0z)hDZ zD}aw5Dy2rX3S1My4b#xnY*rv)H>CqwvKtqKUePZqOXE=qig+mg3i&Ck5I+>x!c}$i z+)!lQLeA-uyihg!oKrkbII88j#xlX7FbP({^IBG|8Ltj8Ap0h_`MEy>a4OccJSXf< z@03GTsP$S|b)tHr7eI2n1VpWj#QXn{OoxsGv;f+lp*b?XSGjm*?+rxJS)(#Oa<$N4 zY7Aor0u)o^Lym&~_y76-7g)zbX4GgU=d3bdgi=Q^CwgfQZq7#lMLu1`x*c1hUaxE` zQR%icnv$9Ydu&ezN)PEDYQ&fyQuwVO@q?-3cCYhPb&q)@*{Xl=P%j~C_B_o@pp^5w z>vJ_^WmJWh4H*g{d+6`%pS}HkVT1j>Cz|)qeMSHKE;yT{j-p%af!FGe2ENQk)AB$Y7j64o|SNQkMtZ0!+b#gn=}0whHBYplmNw)GWm^ z5_VHCq$ngXILBiODMk#a$3hIma}cI4cI5W3$B+QTk$AWl3vG~oSyfbRF%Y%T}%`6!%FDFywSR))lAk>Tcw_6N|aMLnP5LPVw(dp1R$7j^2Qt&0_K~H3Y$YQ zyU5*56iz&brx6MijfiqQ5mQgLRq6n$!X`8|q9dut2RNPyef7%>NrAUcEGR|)j45QY z98gtJ2uuoBWwXi*C64lPOnTIDNY4*D6$el>~ip+(db-v z7z5)4py;;*DK5)UNoezL318J;yD!zeHAj3#0_IFzZfnXLOvALb;*05L>0osle~s>m z_9dNTmW3i^0Mco)BrIr1@f^}cE-olmPu+p8A9f4dUiwA0t+s=v=HVWgLngykLIeZx zx7+Qe`ct2SRz*vZIr^uJa2XEhmXa$GfTLek*eGORAm3lWcm;1G6Emrwb2_@+X=5++ zrfEq$eLIGOrsin3lWR`rwD8JpkOCP`z1bMbc4V!Plf$--wI~qU1HXGF>O%UMrOhYp zKALEe13MCL&{ zP3B^LOiwM!=lN5Erp@ZD3T?F`4i)7pvdRXTou8poR&vfH2R9@27Z?hoJ&Wtp(H)_g z4vlxnqJmQxF{oz2kW6j#dBDx2sO&5ZoL~u1$Of61hU%9Ff}t{S${Gl2#-d3CAO|wo z35*Fbhe(X&u55QzCH)P+d~qvq-l{iUHZ!?@_( z(X`ufj@oEM;Nz!OY{&*qZV0t(q#8Om#pA~B%kjN0AvVR_y7RP^t^c&47wiawL(E;+ zvc4IL80PpM*-?YLC2jLz1Re;z1281fw6bw>N4tU1WgN&I4y$awoOF#)<_1X`3ntd7 zACNB{i!)R%*=aOFi=k`WwiS5oD7|)gixFT((;}d4A9MWU8B6WdY*S*4OlW-+jMEol za#T(aeR4#B&O}qrX~3t1vGg#Ky{9YvCu#|jD>cK@8G!c?hdTL^hW$5^D9{m7Q*;_Q zTVnsDajQ@r?9#pj=~|c|D8nP9Br993j6xI0R61d5cBHF%Ye}q8Rr6N*YaMRN`PJd4 z++8BP7PtQWR+x)7GX&(9EHFVu*=n5HbLrq{16*!e-AEav%LA9E>Lt7iw<`cvBht}f z;jT0GC_Ihse}RP_X^W8{uokKw@BNp2BCc48k@4)45~~4 z>!WmWb~?6vm7>kkXCTD)C_;?w0g7ON#aEp~4pN@P`aIYsjvr*t4Nfmb>1lMHLbtzd z0kH_kvDCmBiXwS`sPht3Ec{bOw16*WL=3|d6vD*_`6LRMTpC~)fZ!}T9(W<2=G)%`DnnO)`7m9)Y?^>VK++ba^PA>>v8~FO6qbzTS{pGsIs=xXO$-R`ug>-LPif?EG4AM zRyucP{(*K-IS=J7UX}<&ozYho8Hut@vLZO`nW;j3?douiJEz%~LGA)Ec!KR{ zHj`(;Y%MCAR_@EUv}NtH!WoJCa~Q!Xl1Z8qd45*sbFl2(ig`m9l&6PPSb`PaVcr7h z`-BEMe3C>cO(CGF?ZDh2g@{CG4@58`dw|Vh818{N3UD&t10keSvkw2ILdx2oIUX3@aO`k`-GC_e68ojT89Tm z#}~N3J9|t8z~tTmI{D5f7J(*vl6-_d@bb|T%NfH?{8KPF3~jzmTQeA)H)$|~(G=+v z4MGUyhS-8}GzU|eSjm?qOrnOC(Ak3dJXB(JCm0i^r#Y5|pigK?z(t6te6JI4(&fL**ST&sYT(U3xh@F~#>d zL{k~u+<+x3yJTAs46dSZA*g$u4fp0&mDXDhx3oBhhVWD7Lv_hTGxn;Q%i;H7thQ}1 zV%>m}SVmj{G6D7*y|EYLrR1y?eay8nj5DpiCJErM@zlH=IQyW|y(KZgMLMw)eo_JN@(Xj@IiA zPfa<&+8*d!tFSJe{iobUlS>4WM0}*OPxgHocY8zbP^8aB>o-8Z0$jeskv((Ofhmhn zHRxxA@qUOQLry@2*`SI^z)a|sETn+6Sx@L}n#_w37j_|zru#4mJQQ(?kqp)m(-Pvo zClE020rQ#s>S)Y%%#F)XNZ!tYYMCkQDzP+24I67>#VB=zH1Yrku)*& zY&a;;A)al7hO^Vx1L@wmvaq3T}m&I-Fz%*Z|Fj4 zB8cKye?}%;#ed8o4c;&5BVhgv1xbjbsfX@Wu+cu0d648-j!1y^x23%=e;ax{7zG}T zI8L9~Y;xLP9pNe%%5h6>*|`3IEGsag2LzRsZX$stFG7#e4)Je+VDT_VZAB z)y+;o$@Dx;e7K_jv@kRp2DUzlRXH#0)hoh19TryAmxc6EGVlWPXR#*Ov;3FJX6mbmQujEIzYi>^v3<|HN| znJz}LSl$ds#5ff&MN8Bhpk4v{o%GL&2{CO2V5$^Uv4abeWTA6#PNJ#Vo$>;i;+l2A zX?ZegMdo!|ex$zPeZg1pk7wHCDVjV#qkMn6_{^ISIZ3jNZxuYL8|24{)A@9LFK@QcT$b%b~5Z1su^ij-;Hvq0mP|XOB7EYc4m*B7_2!-9|$} zU@CyfRPIX-t)eLHZ(GE9Wa5fR9o?0!P2vD@G~yI;G+h)75o21}3n``xOM{^eP)b6f z-rmWlP0)2=pkU;-6s;q6tMbw(5f?awU|4wfw-rK9w+?5Kh;D0omaTMcH%vKY>?f5P zS~~XC+2#v%{J13Xu=4(g+B*h=0PD+W@9m0h_Gs|HNj>+youLiafvcLqfvkXOteI@5A?L>6S%DQE92%jb z7fA12F^5U4Tbn6&@kmFVopY=Q4v(H6r+Up0Ud23N?hG;p1k-#-Ucp~ z0v7|~rN?9nEF33nF(x0Zt3SPA^Wf<$ z=Jr~o==p9!EPc_ZAEX@+f)f%w<8ogdQk?NoBHy(L&!hZh-9MX$65iMz5znLiw(HS(eSxRlDMJDwCB-KY{W2 zN!}_32r8-EIwuo#;gnc`!7#LGW7T9S@JkRUN7Y%RsQSCiy#G(BuuMLA^@b+;z=j~% z7F<+z;Lir?th}wuuPikj+Zr}~BYLf*XppYQ+;{4|^cHBZZ1KG+LrI8w0(`_#6s&~d z^g}G#CYM%KXryCA;3_Wwbdp7}usn;Z$`rg8`H$x|#NoUN1!yG$rz?xVqEZA-*OLHF zzV-i-{cq+Ai~DV*K=t-;sDXW)yIbX3( z*?Tr@fZFypL+4zoeJ^1ly{^V5opWk-mIq#A!jOAl*q;%?^j(Q##2*lf!2f=JaBy(o zJwJN&^3{JfkRFi$6_Nv|XmdU_1FOC7uf*Kb&-S+kkEQ!B&zh8`GWJxuzDxNVbH zJ4ssgEk24QPm$!|I5+JS+mfoSC4^)opj?e3phQ=zW6C(}=i z5$Fhw*AY6iGmtWPHcZFX*~EG%Z}DfyPh@TjJ%rNW^z3Rzs&Z|a&=u*nC&6YF#yzGy zfuV>_~U5!mONDlnZG{DyO@lN{YqofsoAN+Rg-y(Jj4?A~@hajxJ4{J;g3Gv}4}-(pl$TDu4N?!R+jYyL zaLQa*h%)KCsIn8S0#&x6RU{wBUxHC4jkVFL$ob*VgJm5`4qlV>D};DnnuKcj^FVKN zxf;T%S2G*hxMaZL5b~LHLd8%!X>_JjGk#?$6abiI!7S4lcDmf|eUr`v*P zmW)%9Su$4Ig{YKC7WHENqN6PhP<`75jzEC!?MxZ!AdRpOhd)a>HG6G&6H$#eML5uy%m-4oR7)()8=wh}P_PGVplZwSf7_4#)2gH0haIm|R;|<7 z0}nIo&&&n73v1L!(%? z%Y&~$;=4Or8Iqppd5EKRNj^a#;HvsvAIUIGV6`)smBWZx3Y=2^L!Tx}VrpE_GxWqUT1vaPIT%}LuT zsoGTLwiaYnt%ndgS>tUxH{;6{5)_@>sL$$ zx13x*MozeD1gj^8tA@EwcDQP&>!ygShWv-m6X)8HPoFHV1piZKj4L7G>C(pKjDx1! zaV7fVQM1R{>+UZke_U?T`J$7^mD8GLlB<6Gm8O%cXwbKua$i(VIj5J_Of1&~YXjNk znow*Y#attb)$`1?WYD){bISk1@&jRfhtd@Cfh zYr==kCvbwb}7pnOrtSo@>^9HA(WEMmJ>2%bM6O zS6&_L%7l64NdCwv^NJy?kCr#DWPf_8%z0I$ZY+JCGm6?K&~x?AI$88e(f?cI(Q|mX zZZf@cHh%f(^oruWPENgAS^jdf>bW-ZQ>WIe!Tv43-e<_K*S0@CUy{8#dHt4Y?~jye zts&E1lOTLcxA#X7f>!DFYDD1)a_+Uk{*@%&vsPm(q~3E4^Nr@;D+T=(CE+Wj|Lf1h zSK6#EARS*RT}y8mFP)XI6z#Xvd<_ixx6RMjC@$ZU^ld0fUzOY~o2jo>H`M0pE2ZFD z!oKwr_O)T`zgyP6Ql4xubzk`~0PU0aS-!G0()YPrVXbobWr)|$;#ZCSky80pBK+d> z`BhT>1t;~Zrg^E%emRNzmfP>?a{E>G;L|4fs}`h9` zDqFY7%zxLZviU>i{wwF<<`V#BI(wBAfXh?)W#j=|jqcBv4Dd(j-zBjf4kCB`WW=T_ zf#>=-r5x@Q-(whpkW8@;Ll7kM82FS#zml=xqDkA=O+o4Ci8*pM1|vOs7hsw|3L}mX zOJOAHSksikc?JhF5D^X_U=fVjjBp_E1Tt1c$lL>@!TuQfcjAjYaDI>I0*qxFC73nG zsA#G8D;cx>Ed|s~Zm1FE#*1i=!>=5lK!>7f5<(g-#N=BT7Mi{JajcQ)w`mMn>ftiI zl6Gwx%B8fd{?P-S{}Vv0aM!>W>#_e424OXPQ)J7m-^7m+lOhLM@I0YZNP=~mx*SEJ ztny;!pZ3(IYCYZw#32g)i;US@ER3MVIi6!)gS@f5H)cjU&uv`nYR_{2pnGA|#3mRq8DJMZXPkP`g(#d=3@inI{7#2#%nRY-)^j~DWuXOp+NLJ2gupF9| za~dp(X4$+3D>18>+dy}M-XAMZZcye5X-;rZOmR?~RwLU|sAvP=q2go)b^!H;4HeSWQsDcIT5VUeWPNQQ@7#%7)SeTmj3qq^lK-c-ijp`3`5{~ zeu!WMJP%Ji>F;8|Q@hmn7<#cVR^^5*6-vqTeoF{P9t=Yd2Vylo!H9|)5%FTk*aM*f z@MJ3VAHMrId~@>dVt9LUcKvBT!|8puBadD@`;R~W&HMA*`*Yy^`K|Zoi)Xvtcq+;? zFh{!r^QNbIT<-_WL9$(!d2rUd7Jkbd;I;<>b>C_$DR&5MGloZYE|AHn92vXq#N>=h zJc~x?G&72s_A0UiF6Z)FB|l*O(&S6d5m-fSnkBHVKtrBD$(5(f6qrg-b+*7ZJ+;w{ zfd;3Q4zQSvPfMtkB2atY?IeMzGhQbAJG7tV69wuQS*k#{?onCYG+ki2&s5xKFE}A& zx!eZuP({l)dlm18W&YLo`r?w}oh3i04YH6#=>2X+hP{6{{CNQ83FFo&3^0dLB2TgC z+@rZ2;W0^cn60&UU%m_7qSdCqvURv@Dpq@ycL>ED8QXeKZ~!QplY6}t&One*@g0I0 z3g_Avtiy>hiMrq%!Fvr0&11e$Ezsp81eH=%&JAMBIR0d-aW0L^w64XjufVQ#a(=IO zwcXy$@Aqzc$5-#0wb}8_cSY&uZHta@H*f8|+dPJno!auf+BV%^w^!TH@utDfrfr*+ zT9%JMows&s18!Pj$O1>?KUtVX7J8whMJ!xSB0p>o_zc*$&%>ybclZjMggUhOCyV`wyrtG%_2|nB-njV?uL~S+& zh)P<=IFcsOjyz0nMeKyc&raIz+LS#m^btaJMkGbjBJk1v6h%UtqrfWW!5Ht~A9jxp z_P3Skty$vTA9{1dq3UKaaRrD&95M@a`qkNUxAzD2j^q&0IaWto+`YmrQTi-*x-~1& z6R%a9Ef77RoSjlw$%DL7CknKeS<7&o-(}umyGop6f+H?|aRQ!YOKa`*d*}pjbXRZr0ATPGMO+o?&QcJWkVOVMw8}aaJbar#4QSTGxu^9@(WoR&9H5Kd* z$CX5IIBgACl-saRpxBzShk6yg&LD=nf zyI>e1$PkDK7c!5n5wfR_F7Q2NjhX!W5)OycR>a2XIr!EDiz;P_sq0XvE4$coIGC!G z9wwisO8_H@JrEH84$&?q`ni#g9xy94BV%eq4^DgF@W>0$7{karJo4zFy52-}LpoE% zR(^o9$tB@(;g-1@N)D*Q2*{(3Vi2b}26V&I)u&;EStz6n1hZn$a*&=g>u$!uA=;x8 zCBzipL+LtDOx!PB5pHh-tK*y#m-EH7uKE*Nr~;SJjgPadrlduFWg5` zLvZRQX(;oR-l$O5s&CgSR9vgzj=QSN?pc$i(Jd|`{TUeDc@I(pfSD_rpf2_TJ*CJdCenm9F1~HgzR_DiN@s#bml|8G$ z+L*SyO_FjeA9+oN@@&(qEp)vX>W(da zG$~y(o_>fC#VV|0cPpREMi8+kU!pI_1h@&+OHy;E0wzJE+p3kQN~EkiNy1PTcCsp_7hLBO(dFo_ z%~US~BAHA>uf0MA)~SLd(r5Xc_GT%Gwzt7Rot(6j{|J*tX8th^!z>RXAd*WFQimfG zhQLqXGteKNt(qf0-~Oe}i%LQ;#Frn< zSl4NobT0YPQ1C?9CF%n8G4y+u$d=@d7kE_m6@B92`p5nt%g2&tfX*(umhY^LVKBxz zcFfI_W~2-_n@Vn(G<%|Q?IAkOmzWv?%!3fKl*Nh^v8u>l8KuK0msvTeb*xMwnueI^ zXI96=EH?e}i_X9$Y#{Xn+Od^7FdzKVzrH-XeA6qwQ^EEzMZQ_@SqF^o54%T)!VDGT zm+pZig7*-I+Lt6>zerz;&W>4J>}z|%xj>v^pXH{Uxq9kqP5m%SJH{d901D%GE*hOM zc`%#uY~HQ$M;T~sy-=3k9cBnmHNrX6r$##$V=satYvY+78 w!%% zb{ypoi9V4-$)&||BeiOJl@F;1)i_MjyEQiLA1a7K<|sPo9=_-vIL@NuZ_c8paTetS z!Y85HWngmEN=caBr;@!Y@fumaicF1pVtgeL=8LrVZ%yxSPeM3lWlQ@EPdZyYz|cBt zlfBQUoK0IHXIrC^Pvj1;o$6DY8@l*Lnj@e`Q!hdfo(3yJ)@11P1W#oUnwQN-x~Vpu zX=~%@tvPpO&DU~Ys|Pxx)AQ584G6jclsQsCehkh*aPw>z+~g`-l-x4>FRR)vI6WVof&*}Rc_JtfZ*Rv7F}J?H zzPhLY0%QsoD!m<&c42Jd2<$vFw)DZXJ&^u56h92BpY3*RpNz{gkfm33bH)OYFqv1i zbKd{)^t_XQ8zAOW94p6L=h9kC7@9jSj;x`TnBMf?y*X{bG{XNXU=o~=kUQyW$dQjK zX;(SpqWhmAH5p#4Gy&iqQmmpSvKw{EClAN!01TmZ282a|my%GGco^7iFwacB+~J$s z)Bhe`UEiEu-=3U}`ahnZ-kzMk?!P;~xxF~Oxjq|=Qf5mJa1V6Es;=W?rZvD*%~#KZ z7I!#)#xodYihI*{Q#L~0tkBc2UhLD}4v$Gd&95%(is zJlTkU?K>x146kq3O@99CCI?1+>obW)kL6sLO*2S{?@@#p+mp@3kxCvNn7i>se~nSg z?bx>7T!Yg~QFKO>cL(!q~spt-;ZVTwKbN7k5Ph>5CZay?{qkgUhQs3-zGoy&jNEiTpOd0U>$tqy= z0rU*)DA8%%8^CS>_hFR+De1W$P@(#Znu=rxDQ^_xt zUH9$!!kfdkob-{nEJkl^Kkwv^%J0k@Rm62My^_w);wW39JKrPzMAz6?8< zmXlz+YBTIc>0J(73&~v$U`wf84roh>EdbpD&a<4fW>!$CxN6C4C$EPU@~Y2SwG~+9 zxj1)>1HA|4S`0E&*Lc90bErb4sj(a(4!8#A-?gjTHSV0oUk14gumStX9+MikGftPm zYy+#tYhRAUx^uG~Rj&+8u`HfT=Sg>04OXIb<9P#rJ^cPvD@V!~6MvY^B42dWVS&sv z*0A)=R~lhhY|FQ}!iu=U(F#{oq_dB6g+#qpNcRp~-6@e*1a;Wgy(wWc3AxS5Jx|_N zM;;;%#~xJK=Iv(IUAT~MSw6G5*)&tz9Qg#~Q?lpe9FIvL7(hu91s)~iELGvpTBn%I z=7vuj<%r$rHYv#Sjkm*f(wkliH%?>%#)%9c@&e3c+CRw%BaC_BnWi(R(+ zAcO0C3S-;fUo4_-DC&It2)c@?-TJZd>62De7U5U=!XGmfFGz&Bx{a-JMCvjP15=mE z2O#F~T$?FW8i%6IVpcK~>xAA#a&1=DX7a2cD&6mvxxLl5Tv%3t!X0hKq!&(IBNYdc zf^ae$rzo1onxRVO9lM7iI5S@$*XM!_N3v`Blz;@~8}`=p=M#zuv-h^lB~NB5lov#t z5I>QjpX!$D7shi~x*}iQ$--s3P zXYPWM2lZ5_V!}`W7|Bakc4>oB8Ylqcg(dAad6lVFs(5aWrdopsM~uVBN9lP>JC{oh zt8)evrwF+QR7odnJA0|FE&(laEmt&)B1y7L+jp(LD>pVv?}n7&htlw2CTD`tD>iNm zQpsH(73kIDYm0~r`$-E@a5P0M&xZoo_7u%XM5xZzZ33L^XI7Rrmy$p6G1q43;bw+2q?!%U>rUGJ8`h zKY}VK=xRzMz6TiDQ#Y+E(|J!FXqVN*SPoE(e_@Q6Fnlt**A!QrK9> zl4xu)1nB$R?Z#D2yKbrcv#Ppmn#brWljg2$X#b5t#*rE_F%R7>aRQt{F` zgsudrbuPR^fJqmfI`Gda#5&1?a|fnMM9i%#-^^gk>;w~s4bn*L5D7!_Z8AkVdALA+ z4yRcm0MLh+_CP$nrCE{N5Kcd8_&*3cG^RM>6VUlHGofM~u!AE%OacTtrDqK~pt~z9 z(@$U@Jb!s`0RGd>12hrS!{OY16qenk$xYCnDi zjnsorpPUF<20}QU3cGc@5C8;UzEdjX)2EIoJtGO-VOc&Y-GF31hho+2+&q%evpitE zVQpgF|MczirGB#Ey02VrGNJk$c7J6g+7OO|gM)*Y$H(&T!NEc9@8Q9#=gLnk z(b0?JgIC9YIyiiBc=+;9;9x`eYd;C&kpAglZCv@reI=idTL5&vQ+?j)fsQaBdi(pL z_dP8MgiiO>*X_=pD9#r#5@nTlLOsiF8SC`GM>+mGb`G-isa2nGileElubyKb$`_U% za$uw-Cxp&BYDY1BW~oyFqn~pmzqgX03DfDlrQzPI$^6rvraR%YGJ37{y7cxFK0^^V z`qEi-)DvC8Hu+o`K$;ISHpqEz5j7?xL@-0+Ql_^v|Isny{2v_vgE@|b%|YEtc?Qa~ zk^q7<>!+USD}Z&?`OpW<(4NfD#*w+vJxv)G68}!S)aB`oA`upo(#M~&5-IATCaQ`p zIoZm|muZo)4_D#OiDf!MKw%V+IXFD<#99^lV%-D7cm|_njwtqlA_(C0$mDYEP9vvR zYHv24bd-JyuC9f4#D(Q%PufolI3G%73|{%%8(VjI4##YHkm zuHxk7Vv#;>X8?68Tje~T;UGX!t^oja`!=UfSLNlS zNzP~B@s|D9m=)H&ZEdWJD${`Bf2}CwgtWd& z_@N_Xn@irki!c{(-{4LI|I%IDrEIfnbKLwotzKm^W?{*N@QT%}xU_afcjbt#gC*ao z-R;8jdI6~G*XF);nR%fC0d)G(VnIg`4ys;DX*fDK{=TAs5s3@jaSD9O4M;yuJ|iy% z!*?5E#6eX7!-#o~4@)Q=O9i(XHf7Rn4W&$-pQxOg&FV_Ka@ShEScsRk53*Yct0=$` zN7KAUODT}IIi?`K^eLv0yJ($>W|f|+;z_O68Z}#M7!-1*x`{?j*552gs<0rg z^Z8MmW*6f%eqx4Y>Li_f(%=mpI@Chs-4<<$|39_!x?29fEDG)N>gIIRaUGwSFmU{d1>GkOB>e4Rovlx3M z5@q_sVOfcO99xC9{_+=-yJIXbnU^tK4>PRq2~~ObF#}^Htoi`OAz7dR{N*oOTOU7q z!pt|3MgfM?h%k=*3le0zjh{Yk-K2oTB72NblmWsK$SQ)ls0sREsLzOj8N5fjhDw7U z$QVhJr%HTJVUl}9A1LM=MeLM%D)?_f67T@kG!US1 zGIdC^DH9J{QpGX&-(r-2tJmP>?I{>uo%|>8Jg`qkh>&K#Z^6Sx1Za&LyJtZ zVZvt`>Q89Fy3)04DEJ92gsmaH4nM0AvBL%7bm0OhknX-UPr(lQ&0$c=mCo+2JD@zn z6#>B{5$OFOb3%dKmoYNP{w6DyYQ}v+i|XXD_(nYCnOBXgP6rmE>#v81}2ouWlL}* z)Dtz~(0cGzqlqb=L%PWA`gg1JRF~cRCNR9KE?!Vqmh8(rO9Wpdm0K;HR?%;n7+9+g zcjYJv>bW_{K`lq){;MTK6V4h zVfW+5irRUN*a6*7pFVZK@A8WIm&d>hb{+0lvg6}Nhv5Q9HDA79j)=*t6+`sAxCv4E z_%S`HW{K6;#ZF-6&TJ3Lnz0+DZWpXG-GEx5&B?D;EW+}V$~fKBq)A?dJC|9s+@N<-6-Fi9`sOj|3hc^ zCKH^3EOo#?v|GXRgq>AT9AVq0kr0AIaEIVdaCdiicM0w~KyV1|?(XjHZiBnKyUp@_ z`|rutR_z@2VRv=c`#jfm-!maKa3?U~3+I0{`J+6YVEl+~yA#qz1mE4?&=u<4*-53h zl6-*9;@=vJ!f#Z>?Lq0u=Te0N^4lc}nCO{dea$V=6{1gh`~T|L=BTZqfh%oH+VQ1- z1&t7zp$|3f&*2?4hiPZIXz1a&!L;FoT{d!wz!a%%5FfRd=hdx7Wz7v$0_sr>@CTVb z=6;jU%5jo}U{U#qC0V~u znwTzyt2k<|eCp(Ow)(RzvW5#+H0lZJoF(k0Q zX0rWI4Jh7-*y^b<81GoK(PBAIEvJ*4W^H!WF!iLls{~6QuWLZ@3IdNj+edrDg?u=5%C6K&n)b}znnT?^!MoBQ18JGIixtonEKZI zdRG*nc?w}V+E4WQ8Di3nog4=!F)7^-mouMiHms1O$Df$R@gwD`0erX#7J-YTwEW#3 zX89tk7rOvz_7Ycnv>c=!i@2Fx3rukcdPwMYCFBO4<1UQxvnjJ-n;}e+ZRu8~uE*X^ z+q-T91V%>a6YZ&VGlkGY4^$z-oR049!4ZR}&(FDhV{E>sq|*dY<0!bJ`+b?$4DT_4 z?@nIVV;m_zOGPli6oH!dz~+qifqZKs=7LvlgO>c;#(p3%O6bgAt`xje+F}lfE=I?L zgT~)qc{b9Cf95LMDM&G?+v;q&7Z%TaScfc zaeh9jTuPnoIw5EZ`4G)ASCQMZq!MMj^@imLB+#w5jP*=in>Ur)|Ngm2(~ifSlvBkv zIL^II5X|9+M>YAYt>=&G{!%ScjM!}&g;XE1_&hGhBuku@f0at(F1g&y4;qB4`lf&T zwe5(@vABtrq0q+`2f$I$ABj*i`>wWpjgjTgT6~2!gw4!ou(_wV9P40Fug}p(!_|mL zjyHr1NQhmTAkVnz;pjKO3Qml>;EnT8?=&V-OdhX2#>+Pjf%|o(JBUwEN(?0r$H&xj>K$!GT4B=rw>nHq!fZ87#UJTnq9YaCd z9y=I#sAsSq4z=PNxD&sF94KnM2%C=puwqHlr%B?k7L4(2r0+u`Auz&zv7B{R6iST0 zVIf^q|H+UC-Pe*&^h~)E0}D2rP}5Xtr`_7iVujvHoIWOtVv;Z58rJmhDpIGo#@>&T zic{{Aa{ioNh4Tjd-lfTkjvM_30K;b>QIK0;yEStwG?(psxw3w&Q5ecvf195$)Ymgl zzH-75tC)3rlxYxwPIB0s@_v2=W$9pCeqSJ=%EqmT9p}1b8g5vG4BU9r{ac@4USH!m zU1HK(`{qtdjWSrgn?z@5?NDJP?TcQeOOmC~$XS%9AEE9Bb1W-6&y55tF1R$(Jb^Z+ zmhtW)`(s)=86O>Uespm3Jm3J=;Fs+h`#|fN^~HD2nMGTTMc5i;-@88t8xQTW7gaS= zONRo9z;1d5xqWo2Z5asWWRH{fQI)u4wO`h{_KOQD=vF5xHR+jEVmgNO$${T5(UQp< zX(i2dG<>V-!%#IB@1`|iUXmx;nN5XM zrHL8$tE;L5ViQl}lCs2kcVU6S3aBF*jY9Q^Qs5+i>>RU@HAy)_s?DHtm^((9dS8gA zkV2;UJhj7yP+@&%ltU_9<%wY!;(+~T7qpPG%#ymIhx-OgOIB>!a*td<3z!n4j0geSD+1$?ZD0gDC6FT1@{2 zJAyZqxCyR2R}$)9I%}(|r`x%4x;{zWy&pUXTkkOzGX~RX1rYxpa;N#3l-`6->|eZo zg4WPcxV}Ec{4)lCE58-B(?efZ?(Af|v_K^~uS^p`_u2zonf*6X?a!v66(xVisvdvz zD<#R%h7w=`&Ts;gYAP;YhI@)Az5%=+{r;@bI{oMj*^yyB{_j3u9Zk$zarYdjDBnul z8GQ7W#0&D>dEZX9h$~mydt^x(l{qoB$pSmX3-? zwOXcsazmL`m3U;-bm0(dm`u{b0fZFe<>@LX!T!lKy4-I%)?_tsOV0T4)2yoZgD3DS$n=t4)x$&UPX=O`~>2*9tTrOx6Cq`GF+#5pGijnt?KVVk!3`}Gml&}DONT}(*twk>*OOll<`RpC*|oRPaHbY=lX zqV0F^-G8@!_Qmoo&Ae#3(E5%ECaAgBYELULuq*vu7mayTeq zqkz93rLr?4v<$s)s%c&GsuZoF^TIVW;uZcDL6}LE8NG-d;gS-EUu8dqpPE0>%2+`! z`r9xCwN0Voo%@>H)|ZNVz?lCbArlR6|gvf(Q-Ie;X+Kb&- zt*O*f2ITD6o>Wq{07^3lR)u(9Dz9ZKM*+0=#O)u>pC~RBd0B~76m(|?0azfEk9B^4 z8y5us)AY35E4#URawkB<$V{&Mou0caNDZXfEVK1$Sh$jCF7Wy6vZ>Jy(;h=SI^%blIxN1vZuK+K!6_MykG$snu1eCTNBweB|_ zf{pU&7e47>I+-+<8;KJl64wA~r45>i?$c(RV79%K4on|z;Fn%zi#MpmL;}ijc z$BQ*)P8E|`L;V))4~%E06jI^(HU|GvrYSSi^9TjM6|Ap;tPtk(2G{>SC*#-F&q0;6aw|je`-YS!+rblVnc{ z<8e#yGPuuqd4G+LOSVO#GUL;D){tx5aK-G`|ZbD55Mx1Qim@qN?ZrrKBZ2Q zI@(ErK2aq+-=Hc;&t=|Srywn-QfrIB%`b7mRkmtup$%R9AEApd1(Wd(Rj=AkKT%S% zmFM$bMg1=dDr<#01SEGo6yi!YDDF~3D0grB4kHWWBo|djqI;-t+s3Q99*PpoheQqk zDzrXSj;F5`Eknyi`9Pcb`~r-34jS#l8JhhkTv75(aa2e2Ojfd35Z!c8=tG}$&UCOE zL>!Cp7>oloDzA(g=8~h6^cIn;^8oCnK~rNSM-HKUW?(Y2`@I)PKXrq5$0cyUipG*O z^8PQLROh>qOC?)H;tQ7p3$GyK4~Iap5emhe!qQz+I#!o!yibtS5W6RY`ij**_{2cF zBd_ET9NYh+%xLP7fl#*N3~wnjrT+zvlZ*V4q0pT5Vb0?Anzlh|a4i>eUw$#)x_5p;lqKoLfKD$;ZuKnOifOo`b}H&fsHF z+>u&{H_kOvFhE1`%u06WeH0<@nXro^JGRN5zWE|@TPScjlZPk&b~e_ zF3cQsJA(QBMV=kwye4bBny?*;mKp?I@CI<=rc`y*jY_vhBu|0p6(_4+eTdO_A|>8G zG4sYXeJQKw*5bUN77Km>A>T~NaKuY%J|JYyz^ciXjlvjh;lwd3i_~m;JrV9<4aZEiV!`fVIPwe;AHvavb3Q-5bllIzu{0$w!Ah2z%cdbS>C^e- zYE6%BZS(vIo|DLlGKrvGI;N^17KL}136luqLfAjxrtsPLN0!h)vCo=Mr&!%%>w_tJ zx4KEyG7(2aE=WT)HC`j!-gVI!)$^05O$}~MpF-mT2xIhbie)UI4|>}5`p7w8fs7($ zb$;WOq_3oorH}nfViq-Qy`5B=ETh>c3(aFaju=iJ!mEgEvJusv9ji>+CiK6Tt=Ol2 zEwkJlG1<;XO>Nc$0X9D6y|1b}pYDo>KC=HFg@}i|yjM@_cRn|P?$ELku0YFcW39YI z=JKtRBkF5akB{&-E>>|v(*7K475?R{tbV8S46AnG z?t8xJFlY}Tb-{n{uY3$+dXM(F)jL~`>G5{;{QScHc16XP-T>0kr3yO!Hb@*BzH!%4 zSJC>9etHbAdCPf{oUd?CA9wier1$V)L7@McpNqpYF&gCcaC*G<3<#eNUINI9XTous zCb$(l$0ldB(d#eUP!dP8q$Fvd+8EfVw#8^WCw{#_9GIr+1Eic~_w!bdfF`spB6}x^;lL=`7 zku6d{NYQsUAn_1}u1S#m-3#p^=g+CAtldD`g}^wK-K!~Ow6%NAb;(M%VrW)al<+FB$h4`Ow6@ZnYN zRWIII3I|lg&{`jj7i}LSRrV~sFPs8=a97d}0r{W&)T^N3>Fy2C8)>%q0_c#Q8x<@L zP}Q&*)aB*iBbu#u^&vY9z~=IWJ8plFS(11l*35uwqr-%WIN4fwBJt5W0W|3m8O(o9 zkN0XbRZ)?+AE z#nsc1UAdjutlWGl#KsJ$l-M}(l+~iSRbi>-MQLTj#F@YN5*nQh_{+Sy1MdEb4istY zP|ZBf)>J(5v=S^ijeMkH51_G%XpgVzMv3qt;Ccz^9bq$3qyTai)k{AsBe9!q7`a}s1Xt4 z$JxTW1#5;OR$RzD((UGif~qPh@ykZebBe23lHC?{4Z?!^_JAd6QVQsLd)gSG2yPRRu0XTu$3c*fccS#XR*(HBue|o z{p}~iQVL0pC^g7U&fo*OkGt+z1-q#McK1U(aAs|C5p?}+B-MyXMAL#Q4mClwHO>snudYEJ%C#XhSxSXr`|wdD}gLk_BW03O+}9sw73H$ zM!MlLVi;;F?aJY$ePw*X=-#sR{<&FQANXo9Su5e~+QwSJ`be(N^N4-^>tffB>J5IU zpN4YBV~Bpi51<=|UJ$_j`V`pw@Kt@txA$3{R0T(;i>?i&^=!{_@f!H+*TvGu`}ctF zw^(PsPH-BdQ-Z=@4K|}_3jXhBlUNGN=g+4)Xg~trdG0UW*CY+X*cbS+J#Ni4&+Ak{ zNM&}uxs_2}5b*1-D~#sa`7V+5ep^+X%)J8Iz&G)A&4)zWtcEjtJO`1h3zr0sxuW@q z#_}W=S)T)CtbAM^anc=4(<2Am0P5`u33SoA<^x`9OVzWTwEi}z#qiGFWNUU=-Y$A; zgB{==OT9#IKC7$PFQGdc;xj|sQR<+b8Rc5)q%@(aNwe4^R=a1=8Ux+w1ptvVjg(l} znk=BC5rl*k9i`tjaRtIB^d1LYlV;1Vf~@tg<{D1S$Bhlg<*T8cV^MaaYq8vSX$^wQ zs+Uq2&e@y7b(k)E<3bCWO!PGi;#P7_{e@c*W0msr)my8r^= zN8b-K!_WCMb+NaZa!u%iAL);Ogq`|rO=^JfYp{7dZ{o=AR`J8haJ4Eq;S61 zuFf}Nn-)}UIuCPEvub%4*lw~>Il0?}1ZS+UTy^Q}s$WT5zhN{)tpHx6*7k=&{?1zG zeo^#`mh3N=YMc9H=--Fai`F4tQWnHX1u7*mgVHUU!KwS&^N5Z|%15;mx`ZPp1Z))b z&?~JB*K(D7w^k1?wP*(GTVtmrLoIy88VGEI-EqgsEu$qRiXs$cP|#fA_t17jx#>zN z>q=c!6%`yze1p`6&`>=J#eQI;-WjRoUza&!l!MPCeRqPPb}8qMmUd`Ihg0ON^uqa1 zL=~*_KM_@t+KxY@?e)r!uDZ2KuUZhc8|rknS{`xf0c9{+rtdH$!r7FR2YC;hmCkH< zDry|Kh_LTy<>T*AuZ2r^C2K~(c~AR>H4V#`RdBg=M9Z~!W>oEU4I4qnux#n&+$gjv zrXI}SPsoxUlL|gMYPfXkM+cXO2(BeNu5XvtSoC}}2NZp=%{Ek1&>3+UhAXg?(3@p} zTdb-T>-MOYH{qoap=s@fY)F^_Z1OQxB zJ*n*JWvrePrf2-`-?ZLwVK#w;6w&DR>ep~+8 z_S;Z7kX$|Kc%2NokeZd2EwRn-!P-G>-y9<_d|nr5NJ=l823*z@T;I;#>3h)JZNe?h_4T?|GwB} zGHVx#W|bU|dL;+Bu$*X+ax=8k2K-J(*>oTX3A_QN#E`;rZM$7ZPnV;BJ%gPjfJdLA zoa(ZlhAbL)!9T}a`ZeXZJq8sn!LdmIf815|maA9y{O_u!u3v4dXO&A#LHy$x^FkF# zcA^_HcL{k-5C=e5&jVH|vU7^_>p4-H)IBZ+nTJ(o(W7%C_SLFMv`PUPZHTdI(@z1B`ip0awkEAxrq>xJ?M*v@QJ`rg<|( zF@Kj={QP4aR^mL??2oyiDJTf8ugc5?hmIPlt#1|?GPti!&D89Hxt8v%Z5~cOT(8|n z&Hd$P>1*R|nEY9K>OYgAxYP@aETgZE%J1ATi<>FTCy!6*vS1VF&a^T!{ntD7$=@^@EBKKkG-Mx`!bqX@dUW%veOI(`iO= zK6y$A{50SN8f4$Tf8|ZyXlz^)=+)WXm+g2fLsxfGPbX95j<_*Q^T${e%1!C(T3_ag}!5x zp-3+KO&q2CMNE}}ZD)n5iu!crbocUc@p!Ox=KgQhU@4*Mg?%%=Q1TT$9;`EAbdnGu z*`1=8|DwttNpy808~aaNXD3geH9|x?hBwS{hi^kCZtMgdNx?SuH%)+#qSRW`>*PHX-rkjzXQ*JK*|oT5{nw*$+Sl?Ln>s4VFj)<$PYZh= zwhqPdShc(bh*_(NB~NpzvNLrX)(-ZOq()XTD?Z!#D${=<5nXum{~5q5(~a$qx9ni{ zY;I`x@XH0*NgI?h)f}(i3;6%s7G&x@0(kg4>Cos{b1_0)1So!`-k^PFw5>~V%sf<~ zVz7FlF+B+ket~WYN>*SwecX8PFzxq-5FV&t39qbeWL1Yp|Jo{KYu|T?3?p~isZo8e zL#}eQ-^0N!EAYC0YzQh+a(-i0;Tr|gdYZ^uLpM(D4kNoc>HUs%LDc4qq1N>Dd42r~ zg)t@O!F<-RXm54e{_`h`M`sLe+_@M%h|6QZ2&PCLG#6aJxmr0Gg>`p8L%ZIk{E;UG zl{@urvE-7wTbb(W>UIVwTQ@I z$==`eL!!2%os&@VpPOf;dFfj3%Js#`!`J|9V(78Nlo2G}G;*a5w@M5%M`u^5o7{r< zWMX7ItPFqVrwzUckmgCp?K$*T5X6e8s8WYqVI&MNU42FWV?j8(KX0$;SUs&?^z`a& zQ+&pDOaXrQ&-38)AIrOTWlxUZGQy}z%9{&c8?4CR^6~Nc-pLi9>0KQ6AZyJfNE2~m zbj5-!@~c#GQUcCbLiY_%+@fS6$JHb&TQ{qTwQj7YqR~?xd~=)g6*Fx0_VMt1_a0D1 zpB$5l*8BbVU$Iil^k{=D%TW4fdwe~vpsTbM-SyeaxXyX=-%nWpI`#LM{+hHD8~ee* zhB=Y3?f3Kh+r!7*^V`Q=XO`1$P8)8@g-R|=jy8eFUMej!(x}bHYI*rUzij;ndE4n} z-;PWD>_&mi(!p7HB{X;{SQz-Jn-rn&a?6tI@RIi8{|L@>9MJ427yCVS*bgd%25OF4 z3t~(yB|epPTIdjE;bImI)Bn75JtmBxIaGB_4SKaiQ4C0tM4*g+)BBdN{i9=Az#d%_DZ6=2i9PCX!mdA59yWtF+GZ`R zNFAokkKbP4_(kksT8%xJ1-sWK45o6mjuckRc~dvPI|ETnf6A6A^l>Cv-Vj3C$x1`5 zid)%86ulU)BWm4rfwivM73Ei_$b@j zFH E*5LoGc%dF%b{Xp0#klrfi-BO9{4`CUkm*#kbq4vX)bWBtccVm71$1eDD$IM ziIupX-#@mR@=`5M*J@}`*=D`7l11`4-7sA2%QZkv?5i>D+9jlScucgj1fHmydTGDv z{HZldPN=l=evvFn6zdbgrS^!h7wp@SnF}kc(n0};g&`?xUu98?f9)O=tB!4M112l+ ze=iP8crIkHc*-#501SY^Yel>we~&jtrX7NF-adoww?Ia}(nipmvjAROsYh5mYZJE8 zQOaV(sa6w?Yw_@D;!Nag);SVt4rM%4-^vt{)f4&iWlv%1{Q5`Q$QI2JN3+6mE1~3$ zKoUJ+y~>W|VPX?tuK}mcB_k!;J_n-49uP3Uri=h8Hrpns^Vjb;M%FESnn$h_pc5-b zLR+H|R!WdJ%FS@t>rP<7hG2EYGrxm%No(vsQyg`jx{i?v;Iej)FVhy>x_#_4xWDOx z=CZZdUAa*KB3%!E<>W(i;uGTH+9hV*Uu3~?iFsHy+GGq zpZuIiLkq+FFM;E>?1XYmQc_@Bj0 zzZZR7pzy9;WFimr_0`EWklLrn39q;=5uC^a9ZT(+>jz|j_jt{DnQ$vM=5XvyYf~yW z$F6JP3~W3_sQ`@RwxXYeUdJd9=*8#^7r-K<$dGM9m6xY zW{%X$J+$+^p@%4`SIAV6KK8VBaWr?5&iU!t3Q!QIXqwinDwB7-#p`&_g}67wj9WT< z)+U=ew^Pv5+<@*WI+dIMXg3l6lILk+=FG8qA1!8kG^vLCc!{V_`ypd3-7>k6qun~y z_Akd&N2{}ARJAK*!k>v@I&XGNGbpW`Z!%qBlxpjVhf2q1IX#Cl^e@qZaL3l4G$76d zP2>R;|2`Mv5MS8vuzk>Q0#J-@tZQ_X@HuXTPErs?mT+G#0S|U+K3pnzm5jmoggcB2 znTUx#h|X?FejIiKMwLD~mjY0x8(xNct-IK(Qdn4jS>W{klX_K81LAgg=41Sn+oY>OL*L!x&5~rgKSN)U5cAg0y5EYfLUZ;VVnsxz{N8daCqZ-;p~Q84AcCOfqoU4m~QeM8Y?2W(qdmWDILdtNn1PVlue>W z6TN6}iPj8-IvabN~*g{1S7J;|AEwN1J!mZCl_;r36hG#*H$ zzAE}R?rz_vI(-E)vffW~gW9B(#Z0#IH9DIPI?$A0td}*QH;$TNn`D?sEKs{2ob=oN zo5%+pWhlz`{V*&jseNGcC43p)GhNACnuvDi0|K^`R*MA2y>!&(aOhab)O(% zAgESJj*WjJ778I7%G*M66cXPX6HD)(lHk2YZW;m^<%Y2?A9VHvps$k?=A&dwW06t# z`FQYt{tkcp`OzPVf58uP^!qU6>ht~c;|!nf?ZG0C2qzZ;2@b~~_?zuuZrk)~Uv9On z)isNqOrCJKWMK3TOK5A)d0ssUd-CCxaZ6F|`6k>mO)xYXtbiaG%0ugU=C^X1n4Z5J z$411ngt=Z&zYHA}Sk^~53<$p`l&+dcMqSnfihRlr{$M^fagzm7T=OF4Mnfl*a#1i~ zt9{QO1-Jk80REC+CJ*=;I{rFV$9ww)a39!*bi|fsYxuhRoR8Kj0viva>H^f2`V$WB zm2eBlq&l>JqVNun(w0N9{>l(5Kyw1qCDEu%i4E;kefzxc`5y=b0(-BO-eJq0Y5-m;zg)|YKo zM5z>7O=cwW2KhDzYBi4#Z}#D26>c3g#?~={itKKK)-4^06O~Th+LO^<`18Sn2+YQG zz4ZL8PmQU<-4N)XBW`~q*<$0shpIbjmuSdahVksFW8*JSjTNPPSMlC-@Xs z`pWX~%GW*RVCd$}?Yi273dWnt1k&w7jXDNn=XVl^lz=2?BVz~-S z;O4ROz6YS?GW%!&~a1QtSKCEcKKRY8nk_tCMB~TxyqX+H z5=2q*Hw|5fxGtI8ldzSO?do-Q>d9{!W}JhVUj+ZrB6>p;N%GDn&;iYGjS-F`L2&VJ!)j9wh$r_u*c zR32Eg1Es=sc_BfHdbJV#!dcsj3}NIYX;qx@PlEpPg*%(bF1-FXx2w&LHE=9@)vdBJ zX?mGW1?q*M0vJs941t>p>_5zjV}McQkf3yD&jHU(IJd32N&sLex$c7CX2i+xL)2O z(|A1A3T#zDy~bL8zIJz@9(UYz6GrQyUjVN3I=%G1o@AE&hrVl2^#l3G(4rf=wiZqG z3pZzakswy~+)Be`KJLjMEB!BVBY?)WV-BW@R=~VRmre=Av|SvvhmWCf=nR+ZFT!-^MJTLeeOFzUO&HX zoZHWvusGXF%T^C74m&o9h(*Y%i(yXH+=2Dvdfr2tDJdKpgLrFdbS{xpSVdcZnXMJN z-~kQ!L?cKf6x7&`U?Z|TU-np9?p31X@E8hYR<$VUWWgn-ZP%ZsY-u`d=d0^wu2?z@mC2V-gWx!;1qQP*TPl5%D>f`{8Vm2R_-!YNpiD0Z_$W^ zJ)Pag;>&W;(eLEF*zb}vo-KAb{wv;(?3E@D)mY^PWGU9n(hLWVIYT(Am!m2t)8iP{=x zVqi&GYZyXTDxP{{XB6DKxF{x5(K0N!uw=B2&PKvqXI8&V!ex|5TmL?l-vx z)R&;*2|sfv`cT!q%wk8(YSkZ4yE@vOR9v|MHAkusEi|*jwD)_&2mgFf;@+IkS%f>bjxH>r0_n(#w2)3dU{M0T^@3UiXB5&ok09}l?F-#$kQqN@{MmMnL3Yt1w8;w z5f3NX@!=@_=5Q@fOhfN*ar5n6}eJlcp5+APnB7>-`{lRaQQoBX>Wrk1txkR-Y4Gd(}wgpG%#jfgQjU)%8K? zvU{=R_Pv`HcbbLulsWpAf%b8}wqhQ~op#qa%aRY~-Il)tKV0X*`qABF+@2EUC;Vn> zGnU?JXkIMTq5M=(ePF!<^}4;L_Z%u~^L$-OAzTt&!}{1Uq<^YE_f9P+FY%*3OmvUJ-#*UgtsvUK61Zu3tgCGB} zaw9`WE&Vu>jo4_|?n<;M7Z>JASl4k4`4Jh%mtx;q+t6R;n7bG!pTX*X!=OihjKzd- zr=XzwhX;2}!nstdHZRmZnvtYJjc@^Jna;3A?ido7D|9|%iB2_aj8d8uoW}a`yiVk! zDzyUtItgZ$2{h`BC9A|{pGxUE%b7DKd2z3G_R5uZntKVI9;@MV=AAOBzpj4zb_I`N zs@`8X-doLmn>JD2tX`Ip>UBxu@NrygvHNEN-KLEMwnppA7!bUvTB46oc*NeBpqhTQkKu_ z4PqH+Z=9r-d%btyR&r^fGzgl$oIVqs1t}DMyR&>&Dv2x!G!#$+lMc1 zj*`=Izs!4f!5AugRJB^54;>56o0)DeYTB-m?-S;ueqZg=+ex(Q9zV;~-JUuY~jxvL#P-VW)S)^hjBKVoI1lXx*DKJGh-EjHp8{K`6 zjnzcUQW;37JXMNnSg>e@OqFw=jirIa0GVZ5R{=2 zTIMJ$Z-wZh!L@O}aoULx3LT>mYB^kKd_1YA1Yp8hywt$8&){@is@fG-&=*<@dlcV_ z>%Cm5t6t4C13+&rbq@7!@TfUxpC4C=&ZQ$VaYJr=hBM(ffNg~;$-6(KWdih+>nDk| zVJx5=@qR%U@dk}|#fZUwm!p1xzI$A`tVw4Qq+-{wTE_QeFKcbaO%hvLbq3O~q=f|ly-Cn?+1D0ciBT`e>+fv(8II0m$69GYq zeJ;C8d^IM0kyT2p4_fTkd(r%TxzSfhFBTo!N9XuA=OIb6>D}Qajs`V{oh9+I`XMJ6 z$HEUdUDu06b(a))uX6|t`0;J|nWuEtudNxgzEzvt-J)Q~Rr;+c14+fXDYst+*n2~bZw9-i z5!MfHLJ`uM11CC?o}t3RpRDnTwwun=M+>%#$^uV?InbWxJD3)O$3|=FOXoeZuu?*E zNLdU)qq}vAtM{5pK>*&Gt3mZ{6;eliu4+nSdvy+-}#W>Io`(!oEWjxK_VCAT{RoKiElU6)C_@U9!B4T$^qDqI) zCk~UsIWLK(2r2JWUvoY7K3kpUEFK9wc4UMg)GZ~rhSB)nyU0}-Z-+@` zaEhC43k4D`{?MfBF7E0(_Qw32lYNKFTHo$HpMRl$XnT*WuaI?fS5g;-0gKY%KuU@q zA4dXwc>4ldKeXwXm~Uc|p3S(>2-{iV5_qZBlLB4L_fm`>>6~uYqG zf;AmioIYQm)=e;t&u%(6uXOY>vexyyTRy!Xb}9l;T|N9R;UY|JR2yjwqyARXbP!Hv zu8ZpuT&VGF;MI7xmZd5z#>%F_C&q9vy}%O{Clj_eAF*f4b7f9{JscopTIsqa+$yEi z*PjqUse7NFiujy0BWnPK7|I+|*CjV3TkflK92at0(i!p3J)^Xbbz9iAx zyMvdb-Sk4QL72n#8r->!xxezwsh9RDR*jdBb#JXsPM_m1lSh{?lf`%G)5VyXTT^xz zwtoh$!RqPFo>DU7ECR9ZYEly>IeEKVEA>rIC%F-6jk@lW@4D&ka!;`}un>`$9Xx*} zug@5^kuqk`t~bTQVy#cF@?x;qTuvVJU39kUXtj(ZG|LZkBN;b-?qct3zDG|TqZZwh!8nBMfWPc6 zPmD*LIW_LZ7W7A#wk%UpwfBcnA*hsmmW1`-kr`OwR`)@%7WmnEYEA>pC3XT-q4zBm*^D zh8#H~-K0LjSf#eY8?z-*83daEp+90P`YV!Nmn2aXmk(x9)ZG?u(#7g`TeHoR=1~vk ziQek)^3`UXM3Wi%ZJa->?S{X)3z|e&PCNh9d@V9pR&U2>pj{4!h3_GU9f=ahr9!R`pNiVxZ5bm51b| zLntSMCOzca?AX%ydx(0Y!QrY-mr+%YHmkH*%+=yuT55L9bi^SEUD~wu;e2K4*E*}f zXx60FoJOA&E?W;+mulzo($*TD1*FZWn5k#g=G30)I~ccgG3l+~F8(@_*jra`ayuH6 zVEA-pweLTybJ;^O^=uBnUE3vW-sI^SW>wnHVRkh~;vT4X_U$OSB;N5MK&xhpC>HdY z@(sCJds6eiNpvC{*SPl*KWV;r7af}Fz*k6P#Cmjll%tvlpl76M)~ZkPs(sBVM#XAw z2D#c@r=Zo(U!6`h;QYIEm~{30fxY`!-y&`x@Sk7Vqtb?$-Md(uu00+oq(ykD5`R4v zn#!PqsO8I*(jHXrMERbOTh{HQtZaOzsnqLEe2P;G8qS=6mU{DDz1nitZj%)FB#jOo zX|kpn4KHrXHrObu{{uySuk!lol6FTg?nhK<#!!4)z^`pKZYc0ZSu^$hw)#??n}0Dg zh|GHQ|Kc4gX-TN&6ZfQh|4-iWOv%xIai#57HFL6Xsd7}Xw(%qR-6(&2wY`e35YB5q zxy~>Xss4`G5TCh_=`q$DN9s&}Dl>OzL^m#)G1m&U*{khYb*1$#;zc>7#k#8nBKQ72 z_@a)1!Fi0bqeQxsn>M^$)HGmRs;dHjzqob+=;H*n_s)9mP`IUMQF5U1sl_zoDgV~e z4^j~eUahY3gY1g3%zZyQ8~b+VpwT`$9EzBZPqW}^E37;}6<2>$L}7(~p)(@Itld)U zZ&nm8-aM(|UAt4-^PZpGv4?Y08$v)sFR%4xgyM0(pM^l(y&UVFzjwVL&emPRA|bB+ zXBJD8%q#n)pViI9L0j`aF7`^ciA39Ci>>PKX`on*v@ER2*@28t^@ z$nm7+f<%c&%SF%QsH*KQg^APXIg29btg0f3TyEWtt*22f8a6^4)}{*8_&na-8(!UA zW@TVD@HbXVaHI|s*LI3R|sqAZIV9^hg)>!9!^m>TU|A9%0 zm-Rg~QabfZs*w8P5Gbnj(}G#=RJ!;gDmx8|8{t=VpGC0Xl~Q9`cKYSVqW+w><}PwY zOzm&%8lM6dC9-5Mt}O{O&%BZL?!l0>HCG0v<<@?x$`HZtrDwSYWG-jn6^c?C_k1+T zCWx$B!f@y7TTj&!2FE;`|0NJ-ihL+^3I9sUU7X0P8;V4kIJixaDxYhV<2r*+^FQd~ zd)KHnlfB$dswTc_v=aA0pbwg?DQD%xOX#)%X|`w8D?Zqau=fG#w4p zFObjYR;r@|n;%w0IjPnt4%p;Dk-r%(H(PUPf3r=qzs2)Q*;R6J6Hb`OH85H5Bc^%F zeGWL%YCGLS{?%H8L!PeF*V@Y5Vp;|!^=t%~)`+Orh%LX~$xk<2m~ey0b{`d(7Kpo_ zv?S|%O+k9kxL@Q!{A#O96~xQMMg93S+5U6|x<9>WC)DrueR@0|ZU%69KJ2}@ReydH zr@63%8q^_rEY2_NRy|)2j>)(P;Xluqv*;VKC*7V0LK;1T(F;{~&R=AFsGr5TbL_Xc z5M#nK5vAbwOCIgtH{UIuJW7<#WP#bRX8gL zQsGy*oB9^FQ;H_9I$itfP8|Fz{KQ9?A&$R8V>zsM$@xx6r*rX&^IJRl3laY2U#mTc zCpK4SH7Q+b7-{WXT@zu;p|si>^qCN%Wwb-}Ofl2tQ-iMICR_OgsRB%S9kL{fYb%8h z(>3FM=zCNzlSKi_P_m3Cf?I0B5*^_n`pRur=@)s{B4>kr+FSpzuWWHz0PQXqG^}Re zo=0lsgj0YJ|6eAd^6>v;5~XsYGsC2KQe?D!S^tZww+xCaXuCymcX#*T?(Xgc3+_I+ zy99T42=4BX;6AuRaEIUyci#6q-#K;v?WyYCHG8^idaq~6vsvsSFL5#|o<6oD!+W14 zFQp>>*DZGUhl&7-8(5$m{?%$K0X+K6{@@@vdPxV%oXAwi{AY?YiCMt&)$H|7|8_8A zULYa#931xdwF9e>T3*eSc0JyE$|?=2oRj>Y1S~Y@!VwE(M-a_1GblkB@BTBEWwt5Qd|iVW7%x27 zlv9N%Tea#115dnR!X&C?XSqG*-<78RLr5$!{a-@j_9FSegv5FHe+da@?q_$APG5#k=mqW`j`JwjHI^fX_sCX-r~1+OMFtw z-MD0~Za-=k6}$eE*WUDK_u9|)-1UrkqL1s3IW@H}-@yB;>E_QTtQM`;9mU)cruh#` ztzO;f3hl{kz79Y34@dY`$0W0*a4tc+;2AqgEm$2n*hBr!&_GPXz<{DHh3NMJwIFd+ z4s6ez%0;e+My%0p7@Bt}=n7{29Iox@A$@xS$GTai}VJ3DyC zYe96R{0<6}RK!4LecFH5PF-%Y*KcW;wTZbe!OoXm;rZ>ot6!c6eUQigxT&LM=Wk$r z#GTny^pZ*(W3~h6mCc{E`rVY>G!1q*;Cy6Rd96uWklK=sI&Uj@9TQMfA{%CZ$13>xK33`{mqy&@^t}RBVql;=_FVbd=de2`c{Ac43(D? zC>xJUw(>J53}Pz$hp|ggf;)KLvh-&mr@VevHSu$lj01^`y5UeCz(Q%Ap}^eFBKMCb z_x0j=DKraZYl?rT##v=?{+rww*aO3FgMPmR-JJloBVMDJz0?b6&zb5i1zBMoI^6`^ zN8D+KYJ!m!_d#pNlG)h3=B|b~O?r@p`%w z+We)UOLfSDXcJSp?_EFcg5|-pR76)wsu{#t zo={XgOlC{y1~v!piaCE3c|R*+%)blGXX8GZtVs60nD0SRdEe#cBpC=y2f7}uRLx7d z-L+!nU-4LLFGQzJ8*^uPD_z}vWU<1BB;ClN4O|6YU;V*)z213URp6yxn~l(%0xSje z`5$J5PQKjG8q?jYgjk2w?KgHxsyZ)x)lqSjn;&c$R=dwI2|9lm`kdEEPBz-Huzzk{vyTHNRp$WG%vsq;SDP$R(P(1OwNvZjE_@3f# z{^4^G$=Ju*an?(wa5Tx|WdAefRN;lTk*-m3SHLO_r|<3M;E$+k%N_fr^C|yuI?FVB z9k2TAouitO?g+|~!!;m^!bxf}1^zmN>VspwUExam?6;ntgKx8M?e6RSHX`My=&x3> zp2=!##F=SIBPya_+0qrNW^8Xwrs70CcOTJBeDxxU^`6166Jc3CzkO7#19Z_NSr`)B z3L;pb3%>)G(IO{yiJ51W`E67dv2DrrsCC-sQn{9!m;aRNBk6GJV26CR#?E0nUmiJ$aNpeo&lScB7i55HcuRata zIjOqVY;pMG&~f8Ie8P&2BA@wR_1A}AqFnBRnn#&e~z^;M+^FBisOkAj`80G zLveF|zrYQkV7l}%la-4LbGkczTFnwI{#=Lu=;cc5XOZNXA+S|Bcy$ul`S^;c+wZf6ew}UbRn@rXknCuD`4<7m z)zS2Yhuei?JFRDUnEd8~y~90g(SDB-`H~qDUbmdOL87bM^%)b?u5AsOkNb7_(apmF zDz_JW1m=fn_k6C9qU_Mz7%W(bG3l6q}Uj z{&)1zecxN^yoR9bb6{_8CLdxBaqwKu-k8iSz0=QEVkRm6;Min*L{bWa>OlN84JE-= zJ>%Y(c1ZJ(JjPQtKIWvopXT=$E2_Hi^HLopkpI}wFdUT%5} zUg$-*$v$TnGXEw0&>6g!&rr#25pKPAm-pyuXgP8XsM@(z>=|uZ*=Aajt6iQD3$dL) zPTi`Bd2eK)^o*FF+A<*VZAer{`_^9*Wx&iXEB$kYWY<_Rg51KZbtkjri@4YK&J4t? z-+ewbf9<3I5l_g-SrPf*5Pa@ivzka#255KDC=RxY$d4}baGeDxh^D5AQ2fF|!9dEb z(5_zjKfBlr z=$2@SXziZsR$H@%V))zM;N`YgzsnbxpTlnQ9uuP^9=^I>1TH^GFH}DQZwIZt=)PE9 zX!}u7o1}u#o3E^DpA2;$xHUSH!AreFs1`Kq)_qEQ@C?; zf8@3b^2bAjh`WGEnpe*Sv4hrQa~EDDC{PvS7P5o(IhZua*qnDLwpzZ^K-Mn`@1cQ| zqqFJq{d4!n+3+*?$-vq6&J#$THX0d;ii+B%gJUuHk(Z|*;U_445l@QalR`Ok!!FYyo95jBRo{6pN}@KkvKFHfwz%`g`6@sZ2Iwvc9t#K2a&eHOO;ve zj$@iX#7-2d(&)iMzE77}p$vu$Lq+C8T&8%2o9e>Yzq!5UeFNdh^7UoKBOuWGW4466 z9DF;e3Oq3p5etenzh$Zqyh0`(6&0TZgL$>|q3TlO8ik`$hCri`M_kc)BVhIX!NY@@ zzBeTgLz8RSlmcTS>BdZj`#SIsQ_jHkt15(}+OrH2t=d^JWVvi%5Xu89=q2=^n!%YG z1=M|21giNQe>3WV+tUZt70Qr7u)v>xZ%({;R@alk z_IGmu{CTbCRCd=F@8bCH9ssZ;61{k~_qyU7cA~jg=2N!1b7i}*M^6uo3U}~-s&M{{ z_ygA9RWWqY2*j!VE&hIi9rZZyhXU%p*(fwK~{a76!_clo|BuH=yTXki@0!EVyL9q&D0>1h$~9)a}ql!V;aD<}&= znuSi$dWy5JYh}Evs4CH5AK-KOt^tc=^ zoGPG1xt^g@C0kJD$oLFEXf0+dJ1n7BzZ^bj&hm63cvHw$MO_J5|H}N-yvP|cnxW?d zWB92IQT17au%Ek_8t$Xs6%T+$4&ipY4@)16GcDpKrFm>nSxO|-&1+0~KSq4k@=nyR zX>R6#c86%hQ`$vxO-PajIfjK$l*EXkvH~v@i&|NKvxgUqCjscH(a*EG3C&|8o=4yc z^7l`2bbZU9uqznYPoEXP717^&c&317;WsK1L9OHcCWfiyH9h@)ifQCfnHa-|0L~VX z59@-SEq?vfpAnZS7w<+ucEzq|obY{xp&FHAe$l1Sb@cM)WAtK)TjP)@$1|zj9IHIh z=hvi3@@vYWaQ&6wxAGsWB~O`bduAxTw-W`U&B?hkj5h^!Sx(Y(sU_ixXZk|oAt5f{ zx%kyB>&I*WswX~eq4uUO%cIQw4yjq!$t=)#K6EGE(fQE(>>K&&01_5h07)a&WrH@c z!sv7?9)Y6cWTueEoMWVr5M}(}^LLD35sS1-M8*Ntog|Kdhg1KP~;L zdSQL#3~6tRW}GquDI2+OsE&&)G_kQpPEhQgf)AR(m${Qs$0LJHnDK6)9`&EvoIq3T` z637}8GMg$F-$y??XJ}`k@#$Kpm8P)y>yGdq)` z)eHOiNHVXx+9AVu?74uRN2hmrn&0rkq_c(2mvt{ly+dA7=gNBh&qxxT`eeF?7}LA@K%Lf+E&^b)zS&NhflUg7-v0kmZNkbB5OPZY3?g%9e+02T zuw7JM^u<0lHfBA!v{5L)Q!tW-96)nG`Vn1~*Wwor2y4*W#Z^kT9W5@>S##%^E3l8& z4o!#V*bfrLz^uOaO+v<&7!C?s)QZ_xjLHLU5=E3Xl9v-z%Mmn?*>5M8I|zR^t+5Dp zqt+x_T6llDK&}K^$pkOW6A3;@>a-nk=bV!yZrBUGK{6^W;I|LIoB7&fhzwQD(G%yC z0yW$$C$0JSlf119#J`*Er;r~VmS5Nya+sAMQit4vD+MJ0&podb&~4i{D$5OJ)0_%# zS9yxBL><5J;lS#3%StuW$Q15kir+6;d!Qx;=Ef;u!3NKeZ5nTni5E6=3F+asrI!J_ z@eDNPOfvVKE%mTQ%C{`&5^A}SpY}~^F;lp>Ayw;h zC|Unf62SItK5{O+^*dy5p&yY>HdaQhh-jE2(B`I?z|qCrezR_ERwopWLbpT>+lO>F zU|=Pi9TeQydxSjO+8rVfb-s+gi34K!gbb&lpslUe0E26ganDQ}SdMinvkbTP6^gk6 zn-7W+Yk*o8SpBvyTNW2HK*{Bp}4m=KE<@|gFwU|pzrw>+UBU9?yjXlj>W9}w;z5v+( zLbpsTU0}G?{DE7(<*$6Vww;4pt*ZyOmYJZ44%pN~yuG#VmXNZ2tnE!h$Fd(a3%^X4?xWjgC@@e-IR{bxGb&-ZYGgjvgu;+~u4|)7VM# z(}@6bZZk}vNlb-x0plGl4kaTg-7iqXgtnJOYx^0vC))@sOM7d^=erX|#Va#RWR-(J z7MouZkt8|q-^X0WAUv}9!2;2F4W_9xm}uIYXWmyc|JG68NskkLEXK;R6M(*y!6T24 z{k@*M)Squtd<5ya>x#bx)tjk76+~4Rd6}r)3yRt1yy>(u;9%?=dpE(bEPE+#c3SxOU`eZL>}5W{ z4+`Im8OZCSU7nLPxw<<3az4vHJP*|3y+*OTOGJcIuaiyu%Nb~M1jh!TApI{!xba?CMJ{?u5x4BpgL zgLsQ1!>jmFlm;j}N@BM{p(CXA&2P4QQ7;JF>C&G@;!8kIfK?iSk(!lgX5>pyB6Ycl zbR9X0P){n1iyUutjLqbc*W%$)u7)pfI6M&Mrn*0lpI&QygaI~5DZz-fj(L}jfL5S5 zlFy7|f8|9`Q6aIrNA26fPqf&3GqoyL6>|R?GkJI?DC;QMF)6W&gN$w~hF=k8cZl$k zhs;*C?>ensYECrFHuBIwIqCbmFI5isVbd^)B)qZ$dYLP;{W#~#>58FQX;s6Jr{v%y zMBOJgg8st;L2in_cQXzLt8}$l!)N`k?j5}s5GX?2-tn#rvAMJMrqV~95lXEUUtX@2 zTqmmKBkIXgQkNQBA2VM8eqOxA2JZnxYBZk#){A%C21xVg#IQDkj11D5r`ffB2GG~< zwSusKES@a0X?%>6Sj|bo$ysTOu3LDHN&sm+?1qBINt(H?VCvYTf($p9?^al~e2L#kOVMF;|aVu|n4NF5k`-qUf)0v#Oh$c)nz%`4AP?+G{oWm7L z*_Lu(UsiDjF4R{`@zMhY1Ovuo_V<`VqF!hD>?g*l`$W09bKiqm#yf1uTJyOnHs_(x z#>COa)W_Fah$z_NVP;3#^Z-dbC=A(^=Yk7UP!gfwH~C8TF)14cp?FbXn3N;PAD243GvV121!k<;X9)nrIkMFImkfk!^gpFD(_Yrz z-NVvG?6~t@zf0qIk3SSvB%}Sx^5*gYT@gZ`R)Z~Hl)>Z z2u5l2*}mmi|8%Maqcpy>0L7UQ0m&Y#JC40{=A&TQFJB1KM8Cm-!+#Z}5R;>npS@s5 zlT?m=E37(Xfz{)DuT4-UB-cU6?)=bZ)jL+(gaSY?ts&ZOj?iV#Ivo0r$HpwCWwRUb zfQ!ihp-}uUc#PZVb92P+iq;lL(pOjNSg4>T=KU-?bymzP?S^cQ`Tp7RIjSiuj9As` z&cJhGVC+6MmkKLCtQXyCd8dpZG@1yG?9#|sI6>%H4!(awsG6QE+on?XAt2zA<)-`T zS_oZjOm`r=Riy1%yquFARL14UW0~^@Ukz`JPX-pgIg5d$h*CwaIcsd{da3XiqJ~g8 z_HsQGb8TiLFBFxrC>}1b+^xB71kqVf$yufQ$454ysjvEJngFyZv7IXsfMTq7(H}N7 zZbc@58@!ex-$AN(JX`yFZ!+Hb#Qi(8@5H!A7?15IHwM(*e!BjHc93Pc^{OpUbE^Gw zXk0UY*-rN}mLn9GgQi`L=H^Ht-|+w)_Ac&VK2QB$OS3_;TL!!Pt}k-Mgrpuq_REt~ zi|`UwspI0U(zk!v^K=-6v0o-6nbU>Rxt5<>S%LlwL1kCY0VRd>TAzYFUt#i!|Fj;? z{MzKb&T2(=g?RWJR&35l;>=?4Ox7js(YqW-f!fSL z*Rz%L+1UIl#2PM7LUDwkC;A_`#5L=B!7)PavYjBIt3_}GwpFrR@kv}EgJ&fHu=Gk< z)j0x^^VBpUA^@IHc~`t2rq2(xBS3BR+r(~1FlYF7PLpvfSD{oU`kBt3w8agzdIwy+ z2++~EgvKd0k~vaL!bR--bHtQTEl2#?@KkBX76=ms#WvVZ8SxSYa~QLM6);yHEW5oy zvI?5CgT_fxotk(jdVaRjhSj3uDrN@KXJkDq{<7-3@Q6~OJ=F!9mhxMcn}vwakx{nO z1LwrO(Pa?gApuE&=9|H?W1bl^^7TX3DRPe}gsV%BwFi!ZgX}|6_34lj{Y~hH$mZ$Z*%lz_@Z%pkHIy^j;d~ho| zJShv#1dd42%5~ZVEjhW-TsnuWh1gilwW77))`oVLe3}1{tUF&iuoL-0a1y~EX?_F+ zmfX)QVKxoPxk=|}J-QROg5esZym^V@!%To7Ln-49$^;ei@db~tmb2ZlUb9XSGY%&X ziH-y1s=*GtQ(tH{8xB|!;zuV&C6}ob*M6-!4PHh$Cw@(0w9noTn~#O}&eI}ITH46C^1Y*%1k}8nJZVu}HZZn|^I2bw z%<2L^L1%u4d`Kn5sP*(TEhM&?*!KpQ1C&9V;}Y{`HWCf)V4!@p8uLu#cWPYl6AIhUhp$T*b_+K{f^a;Qs`W;9q` zh#5wS3}#d+tW0E34~=9@gDs2+(HIcVV@8C4MVJ5WFVCROx`gb_Mp-VH)_iec)~W8G z%^9CT?yoKx$#!aBAO9h5_Ldwr%eXE12{Q_Y*k65_SQc`6)N(>5>|7`@YHK1RkPGIP zJX;Q_vfS#)KnS+V2+Y56)tWp3V*68k@8C)&WoJ7qk=DIv27N9k#_h2h(%1PLL&nh;E>c_++gOI^`49z@V<~UobuG z=hLok0zi|r7AVL2GmmUOW0GHKd)N`4Uxahg{~`e3lp`# zEB}3ksdrFom#@s3+6Y_&LJ=LUDOMA}m<;$~b|boGcz3>PUg(104m}{liQ?DUl9gvCr|Pjf`A3&3v3?F2$4a^T zB$vCJPz&m^*tH8eDEON-$Xl8XIeP0;uSykO&TMCRt|^lU0WTN@>9G3UGuCo!Tsr7a zA_AvtvYOGgoVImD!3CEi=ON@&;_xER4;YmxKDHqOj8-M#PrVvdC~5{6h6=tAu&U@tC7R@yhkP;~nAH{;uVvsAO+Wj*|=rHCj&DR+YaVNc+06Db@ zw(t;efTs#ahn5+h3^}id0@fbsuzDEM)V<}7{u6l&o-=qYGtAciHH{qA^T;ScJ(zRp zAwN1#EUK~onWd6wP<2@-D%5yu>mye*hLRM16B`L$Fn4MTSyW9$p{MK8jMLFpLm*`I zY>RRZ%ZG;(PL}t*B2jLj0x~WQJG0Ug#kvdw=#Klo3u@7MF00HCd2713CYlyEE_Ra{ z^4LF5k32oz{uqH2D~9zVJXfFTR@dcNjtFPO(_HOaDvX@%>os^(xU|=*%;%gZx<53U z?<**4c>VKwqa&05qI>0t6)=s?Ku!xhAi3qdtl1xT0O!;l)h1iE_VK9LiEC(ozvsku zm@r@@Z%6KKyE_xWsY`lo*-9}+L8WP953%c}m_#^Zt9g_l6jxUE_XeiMEWY6WI?3=l zr+LVq`}a%LvlvbRFq=KdIc`RREOBxA@m@C9RG2qeYi8pm;m(=&gA5HTu`fUF4?L^; zS|o}nb-#2GDjnRws6l2NtkL=O5=3ET+9^ei=(k!O1c=@!^bh8S)iBj`*>w(gNKdFc zXfb$eEu!?L4X_sX9XlKwa3B)Q%GNiK%(&hVcE!+J=|5f>@Y?1T)6D>(C0}3pa zPvGWlZk4eEOKbMEUn>Z}T+4~ddeYPMMKw-(ecz7Ud+!xR5i1h}90&02QbQ_@wb%Rr z0$1FH-WGzoT24A-VuG@hU>43w5)L$YCI_(U#o1LWfyB*~=+?-&Z@Iaz$u`|Ki?V1? z|6^s{Ht@*#LoFqIKfU2R2hJ|5rj8Npd}64_C0yIR$i5i0(6A=3WgA{ZB^#nu56@=O zGb zvy8iKiS!XW1|cxiaNuaeH>vZw)W+^|TX`cl57Opi1yG4;#nlX1E2zwsK&#eXt)E_fxWYHNWZYsw}6;R`VQ$^ zJ0%i224!oMjUB6CB3WWgUlr6_5Vz}>aQL=KK@?itcu_B6JyhK2$4er0;Z4UiX4$5) zR+kl`h!~gKZGuwCNbU%_U~`Hzn|VEKQZHO|OLs!pJF2%nB8lpwUsL0)IE-t!z- zm}d={>emgoCS9aS=6>@9L-|!73-?Zb-+%(HI&E`+nz67USiW@f-!ED#A#e3v$Ez()Cpfo>Z;#cmTTI{mRl#Pxd5ICR>3fFDp|7cIrci}dNW5dL z5(~Go5*Xw>aENkkw~M`i$4P&F-w4c=kj0G917s#A97#G5R^VHUyN0hLZyJNL5KJmL z)Y{@EzE9M=G16yBI%@6xJ|Q)nWb$+vr`HR?v__>Q@(?n2&Y@{$4y{O#ImuTon(!GKlO=nppb;G8{&|9d3DT zjw%*^Z-2B;9}kSM)i^$29Wn}==yJ9-!q4`+@N`6&4rUERwn3FjV*bVHi2-5Z(f4w( z^kBTSZk9z6fs_~dRIpher??x&#u*GtAw3d@8Tk~Z=AZOL!bXv%B9N{`zfAtHvL^Iv z8A|u(?rGXSa4KYGTXowY15{7^nF#E6~ znNc$=?r3t3a8#PCl-}Gd>#H*1)tJu3)z-(VTE~$>xe@EAm1jL=@heySMw4bTUsu-D z2M6$=IMylJV$Ysqb7jU`@5@^oljgN9m!BrcOW8qotZow+`WaLn-!NY7s+4f`LyI)# zSLbsZ^`Y{M)-M{l@c0iCnN-NT-N}~AOzVFG>3`X4Nv|-BKqz4_+Me=XGiH1q7bbD|R|MAP;fb}aP%tdF4l$}n8&b9{1;6;?G41&OstfDnOh8l1CK(iav{quK2 zA(|t8loH#8-U9G9w=L_G(l(Ps1AGKHzOmCD^fQnAwLyts`Vb|fT2)$s1mlSpx1R$k zD&3$ceqr#ysroCNETi%j9(#>{eWzU$3Efd~SPOz^zD$$?~;9G98S_*tL}w$>U?CH ztchMimHt`bI2&b%_#2>afm+|=ZTO?SJINbI!o4xFn$-^+9~1Lo1(Y;k$%}l{0ps$`7n8ZXg$J^lnqLf4H*ciSH?w4R4b*~;Rg_)wskf02=*S+OmB{9I4= zYEq6<7NwF z9f2N#1V@J5v#L;JOta?r+5yz4R_TmGyk)zuMV~9ScUv{J0)>X(x!=Q~ech!Jt!I{c zO7(kk*bOf6mjF-BgTYmP^|9y!qNupS)*4LmD>*%U=qi{5P9*I+CCkr7Z6NBT7c0}d zCUF3i-fshK)>P)$I=nM2S2NH9G?`tD;Qd(m7oo03ld%xO;td^9_AkZVXSgNgfz|XN zAIgjOn>G>a6oQChfsWFZN3X5$(AXD9#4;}5U)p6nn}n)>7)Z^EQ;Ka|G4q`tUL?;I z75WGz4KhM2ydQ%IaSnm?UN!8MiY&x<`w@RyauSPKX`TFy7!B(c?;vN6MrAiulQRe8 zjN0nYfP!{kLKRAgFF`2*=vcvi^ZYBJfk$EZdLGn3_5Ywpf=ZW&uLw1cX80<5(wd4O zcJ$x$?cJ*C1P#^7-}B$E%HZI1rO(zk8t+^SCMhR|X#}}CmRQVn1&?TGlDIl5F(N|-B{{}Nt)?h&2xuHP-H9yNx4_{&Qos=h& zV6Bva{b^D2$7m!%SDdA&L>#N2%~&*jgopLvC1ewR1Pw-eby}ZZJ9sM8!?N}Zw}E;3 zJ*PdE{C=I?Hy?eg1Q$G3LN25a9gqRUW6p__3fdjHz3ZLKXW693; z1%q5&Rke)i>=@XKCxn}d$hz)K;3GUcxO>HzBKW@!iSx~OX}7=(L_phZ8g_|wW>j@0 z@z2Rb)VUP0sVOhja%QDqhEa(gpCkxspb)hLA24T7Ke=t~4Oylcl{50FjBXp_ZLwi- z4%D0qA98egjlPKATy6&}LW2+hZ1 zJ(70^$JfL**3|ylhdc;fXt3p+u_0MphVsI35BDz+5Axn74H*7T92oqmk-v%X0Tkvv zDC<+2$q@h@bHHC2{G>}i$5ploO{F^veyy}X(HCbwGNo$=E z8_WvvsE`2s0GQzb`x=7T1WOp9*LtB-Bn z2QJ+Z%~*qM!@fRYCTZiC9IkG3e83WuC?S8U#?BTDq}fQM42#*EGmW=<)9AuNj!|#~ z+JVJ54t~|nge5LnEU3R`%u^mwPGcy}YDh~}9_ZRZ8QRPi(Lca`2H<|ThiFzXzO?DB z>`tX&{{=P3@Hxrg6tr7*5pQLqhJ;M@e~<;FWHuu+m%#X1O?kU=1uBTj6isV^pToO@ z^Cmo2Xs<9(X1h@B$B^zcdSsGGVj7+!9;BjdpA1&g>r6Wma*5db3z>}oQh8_=v>CYp zbS@d#Qq@!2FC~9-rRi6>CKnsX?ba64-*5N&->r)=)8a)ejuP__#Tlv;X*b9^U~)A; z<0193x7Akld`l!FDYXbVphb%)qtGQB=ohh^rX}Du>(JX|Q z;eW93udl^yIc>H!sZSi-_V}t>_*G~eZjZ$|c{46x?qt}OBAkaqkwuWh7nRP>PzlQM zu5LcNeHcM=@s7?sCz4ZV5}TIgRr}|wN0vIq^-HFjk(oSX?ZPm}PqQ6lx{42C!tylG zyFE;Lf3YasaHo+3MT(!8t)_|l>d0SjX>fh9@OLMyeWd*dRyf}DF|aO#QqV;uS7J^7D}*L+rX;%lqnz^d$d;S zD1%2qC2b4N`SXHQ7ESi0h~c+zp^`{QrucP(ie%Kt0Z>??Vl)v1E8uAGZvO)-{1R#9 zm@Og83kl?2MvvxA{l@-_E6glE)>i%pR}fkq*6A`Oz-&wx53?>VcYZrt>E5K6Owi;|a$)uRbIOyJRcQAvFq<6#0s035TTFQ7w>y5r>SQ{DqG& zWOS?W7I|D=u60D{C1gaSkI2!37~~)_E)uO7R4uTRQ8UiP9Av-nGhAAM7)`2T+w{KK zgMN?g>E>->=DWDa3=Ojo@r~#lm2JU>Nz=>zLrYD;6MH~q%cRs0Ezcdw-BRt-dQt6t zJ3OeP1r#@9Z?c#@P6YRPf=R53W zd4>_8VN()>a(qQ4RSL=})9UEO2hoFBE_y3HGniSE^7RU2oF!XPnKk1UKS|Q)n&r^x!>jsNc7v0 zU5l@+mX8nR-nDH$wTeBx?!VXaOQ}@`Pb;|wu&;dAcH$dCKt!r?F4GsA?mH;Wv_-&l znc^uNd20v z@gkZqN@sttQwyuNl0xXDk&(N>xZPpV6}uQKe39}mF~I}XLx20~wyIT5Zv!oi5r6Y;qdS+zgzQ?gZ`g0ygZ9@Q^|&KOzK>oopO~+(9bp z4tB!Ny7gf3`LsLGJwWKqYw5+$!{zRJ@FJ)7`cZQtDdX$rV@Gc%L4W71_BzncKkxz2 zPmFvh0JxziJPA)$!zD&lOa!IkRUyXyXK(bXj$G`CZrnt*MYmZO>g2V})tvm~t4`!~ zTCk3R$u{w~$?dkB&Q}{DtA$8BRy+Cz3x&WrByYRGD)*x&5&{3G1i>C8oSH0ZP=Xp7 z(E#2X6$z`Oo6gS}RUB81**wQsP=nk>=l>Ixq*{)1Us zscC<0EWvbLV-55YN{g?RC9Gb!!7js{r0B?y`Qq)kaZpz77=F|LzzVq3-*^f1`cCB~ zOxbfZRVj9*+`uGvyk1)2nc=@D?h{vyPsS?4BC5D^zER$k6b-bd5rKOU;lc<9VYj;lA>I#$JloN~OXm0aFJ%aMHYZIdG7o zlquRB$QgKBpyocj7?{L4Gd<zpy*%#T`Y53XVUs@ zOE}~uo+iqa=<(i!1B(`PhQ4c6-kf97bs_ib*7Ede6`Yryaw|udoDTvYP~TV9bOFb9 zE56ZR47DZtGDo^_x_I8vPjv^P60aYjLBc<|2S4;jw_7(f8BDFa02JAFG~152#f9+3 z+jcfxN)N@`TsEqLP-kB*x0m<>o#R^{fPm}G z=pJ4>re~)am$>-ME9B1(dYw3WUx~T8oZb;ODlo`9cA{L7AW%~-Rpz|*S|^?_9T~lP zC`k@!xhwK@dHWsYBkLWL;GB3cToJNJD=|e}<8_sIMv!7|jA{+>--MsQSk>+=M+w@2 zC^NXqfkJV&@{oJcawsg=SA*{i60y(x<|U$ov|{sT&T*!Ch&CXtn}RH|r>sSszB6VZ zLT}7(F6;>a5l+3zRA@_#P{gr$xg8w+ik7fPKWUQfwTO=B`cz22$H-eY%CE;azH3Iu zKLxOrDFm&i94{nC0Dg2VEPhDT)GcVN7b9Pd@7+(;K`m>CYfdNIHD&7?%}V4)6A-hMF+pRzErVANc=HD(?`Gw$`q6>MavYf6x$L&b~JI&AcpQDdS5+4Q=15{DT&Gk~9M-@xFi)JMoqZvWP}F1�iln-XiG^4d>+(XgJugsGmsVxGO>|&4 zV$H+BaAsIoG_IR>UP*F`9j7kjx%5WZVg8dDzfz{yUZ6;W{vnCS%45}HI<~(ET$_;j zWpsAjAVZ(+&s1B(!lkTbN_T}!_ER{UnvjXh-XNzEc+~+V`1W;~bO0ns7Ady+r>~ac9Sx#l+%SbJnpd zpWJ2$1x_nk_3gp!(h!`}SB@n`&oe1EZ$YABWKT_n=ul}si z@W2iThS`Ri-8-$kV_A8LiX&ND4%*rpN*VQGFo6{zeE9z-l48b>H{2DQ1(W~-s{ieN zg6e;K>JDs4^?#~T>oe`@M43603ii1+4sbr^kpDPwVl?SrnDM~%&*2R^s)ZAZK3_^* zyT`0-fcVtE>+DM;Iq^hU2oR#qv%=>Dx7YJ0*sf z=u2ceBLLFM=HQb(V2`Ci6U=PPTlfIYz#9aa$dc~Rk`v)VLO6&DEK?GACeo?lZOkGd0oqyX<)4|v^*l}i%P|SM6)=JrAEa{U zYNHKlq>Q6K!OB}c0;ZV>)~bDU%D>IMpp$yqg~*hPo&xCMl-M7=(L5gP^bv|Aj*5xG z0Hk8sKMyo1jwc->Wq*iL;F7takGo@Z2Av(vLD-pqi{mC4FeLGi2uL8x9zcXKQ*;_o zIKf9ux)#OA)+LYWgYQ9Lbb|fQhYbi5jh|D{rWEl6_DLK+?K8GeynBEqIw<1hOn_R+ znWC%eG>=+7-hMv_6UQS*=%5&}%#&{72`HxoVn42Gzgi7qewW25!QO+=w8#57YlPmf zoobP<)r$tZJC3uddurrijaPGMKDP;SxBjn|B{46Y%&{Y3X8}O|=pBm^mCh-T)vIO8 zfTS|neMlza6f?ha1`QO(P-si+L8Tp^3pHUJ>n&s@7l;;mQ8h`&>F7!k9cx4|1hC^J zCPu9j3a`sfgo5rrpzna2D7ZQ<=)^4}MZ zkOUf&zGC{i7$EBX$W1K|p$iy3x?Qc3=9c%itA2LEr1L4qcxLbKx4ewU_}al`)$yPNUJfMYGvO}7?M0OlA@D%n#?POm6GmiS8QDv|Dvb_s7J)_ zpw5eYZ9@)4G)CK!)7@)U+5R$t6{|@n+y-It5WqW{3oqAWHjqM(TmgyF8;(Hv%I)?r!~+BPS|zg zj6gHP4hbFgXO4;rkDtYY9vh@2c+R}Z`fEscZ&j@J@HaRLoy_<@AQ&T@b zyeC1QqIMGW-b85HQ1&eqog0`4CVDX&Ut;U8%d})ZkeeuZLce}p_~DAx4pVP?wcxc& zeu-`yWX6*11$#K{Z3B$66CpA^Gfhgeh&7ehn7PoA;Uf)$zuY7}e9{^Ob_j_?pduuq z@4~O7bP?de?nl*GacN&Bc%Hrq8~~z79k}5^9QI z0aA{($@%Q(hrxy5O&8yiK5Op&Qj5f8Fc=JG27?)zjGsKQ$>nh|BZk|X!$p>$gj|`G zr^ZVYMP`r#G38`NSuWfuH;eNM3!6edn{bwol_>`nv!W#D zAYFs4`Xk+QFvrvQfnnYX!<38foTg8nD5S^2&2U1(OINd(46hql9%o^p0xfqI0EiW*FAVoM!fB=m z14vG<)DUJ-(vjQ~8JbGwqoO);2D=Wyf)c*vj8KnWR7PW5P}IzAY3nzHIma0daU9Q! zJ=R5nAZh?5|C7RZLb;!a0389A)WGyhxe(q#PI^OH%4G!rU4;gRF&ma?P_@v@O#0p4 zg#|Up(s<+LV6PjXAK4X|LBz|Xc@EV6Ylmr&BgFuzi{u=SNhCLLnxQG4gD^14C5~XG z>hmO*wFuA;G>H^brpbu$sSenmGG4@nHzQvPYtdCwdP)qXNDN~&N?HM|rPIDk}l;N=`dX#+x zdLCf1vOHs4iYC20k$g78aPAHb`LeFlQ@7JO2C+(HoF(IUUh)*u{E(JbY6HqD5TlyDu%k^UQCFg;4vT<*|m7IyjSShINw z1B~d5M!B4AO(|IAYmhr|#>%m8wH&0^pdEH~sDddWsXjfaeZ;4R-^ zh*C-=*}`sVi%2|RkUU}XG^`r3z#@*9=es(U0368EDJP*+bmT|lh|)F@n&f|m^;bae z=m>hHsU4F=bTqH3Hg++Bb_C!XRd7r5^m(cE%HmaVa2>0JViOr2e{kdkoJ2h-l9W6R zZLsTFZ8x~KZA6du<@3XnPDlQWM4p1zohCUGPu9>2Fr(qzjKcO2M>q{WxKh57>Qz=5 z(9|?rQ&T-+a>W2cDXJ6#i{TlHvQo}c!sEGy26`+3>p~ZFIzD>x1OU>|OSx6w%5$qp z1*;5oylm?_i^T;ezlDTn zw%+;6Go%D*mcWXZOrP)!_B<)Yl-ue!3e`U7l^OY%bzGu3@G_nguVYBzz>|ON_j=R$ zh5YM6^`Z+%j2(DUc%U17zZHcCjE~J{n8O1vOb)=-0iWB8rdJ0Zlr!)21RZ!RKLCvT z>~cXH?UQrh$ys=>ra_kmWFL4UALK4x1eW@EVmSk+*r@$Rsm zRQwiyFrJliOEG7bCx@@V>M6AuB__Z$DpDy_@FH~zqDdI%pdf(#MWAUeGGI|6#HkXe z#e`iYW_2n9pGJ^obcK!?NEU;7C>>u+hFJX?(O7P!LbIP&IPG*`d!H04I+nYqW+1>f zjA%is*eJ<8&~{+8XNd+U&~$4phdH`f)jJ{in1O&>(vCm$K9sh_=0a}E7$mQZ(_}2A zgOZUaV<5`mPz_H`7cZ3lGO%ECFtC+AeF!=oJ9LRa7{7_H?M^|%DAVzeL)->^(CHi} z7L}SN1u=1dm6&3?1zUT9Buco%`;nPvEtyM!VL}>aP863HB9;)?0kwKwnGq`FTh;Is zr!J^K(?-gpqA5?p6)Vh=dI*|w^_6HzXBdq*5fkg)qwLx5j1Ex{(_$UCP}3IKV!B!y zDNDGy5*v+RjZ{)1SP!%vP_URX9x3A_`A9-zPt)~_=`vDMrTqr-ko`r&K$j>9NDf_> z$mDudVF#wjLU*_#VU(e?J=_9$pyQF6ie~tX5{Lvz1O@ZDYdbI}3GPWi`7B{xG5EC% zNu_%zuS_^*(f5Xc4XH%xQnddNh{=a7`5(DKfDuyu8I1q^K@+@+&M5K7@krUK7GosQ``f!>}@{Q>9TvH5Fa?Mx%dUr59r(MbL^FnIx6! ztirY5Es&6$K7ODvfz*rp=%}awd{Mq`ulN(0x758P^S1gG(ac9bP%g49liy7l0bo3^ zI@K`jC=|Ee@4#uPb6g;QUgkp*X0d5Ww=yOP;WRAa!p~yi>+ill;%w^6b`OG{EER;j z@wFUOjn=M$1_*|pR}UCpr9bMqQ8RZ6K)#G$3~%Q~GLcrBy#N|nsysp4`d zLQn9?iIsDNXc9eE0@A6a`F$WLarZP>J=1gDF96(=x9zWr;4B zBQj+-3()f%4qI@7SmKYFM7KH^Yuq0T%cuLNJ{sG5_Sfy5F5+a2m9e@gQi4_+;bEfL z2z^)~2*2XAAk=F1QBTPH0rpL>N+L2Ne4b4t5Lb*}f*40WrX&Fb)t-+;+@e0mh|rFqS}>Te;n03yByK49zH3 ztXOF9K4>=T?uBP`n#OZ&`qQF@wQ-lu3S?7`J-a9+Ie!4U-5gKj&IZ^6D>Ay?%R2hM z`yGS~RPt{hd;s^Me;?!^U%mhehoA1J;_ZpeAhnMU<10KD@C{UkedYF{8P!b zdLIq8cXpe>*49o? z$1RhxuG+3^*UL6x-2PA zZfV~+;h9h4t`^rU+O@o2Bv!2?Ljqhl96*xgZ#m&qN)l&8A?QfX^kacOI0w2P)In-u z_IQ{Fxz$O9Qe#HJ(0VxKVB(6ioQQ%agoLd^av;(G70@hBtnv4PMO$3R|HP;!1trPy-N)X%=aXf%6Jk|*_1Im zHM*;PQ_kA?O>?PFRUfy;>fa!w<^m0>-->oWEo!&1k5VDqVA1808Vg>2?KNgiH5y*d zI^Q&)<(oRLYOgv?Gf;);Q^PA+(b+0&#j||qhlU0|RbFlDir4G0N~)+%zNIl*(u4g$ zvYl)q7l`)B7?PM>>7`F<*kq zsPX?h$cV_~kB$L+5UgHydxf&q62?FPCFG9m_lCHZtnxXZjVNglqY51PpRQ4q1!=)tC@w&wg6GPqg-RVnx@M07NwVIF4z zI!q8Qtgz_H5RG%j>Y8hmCGvvVMw;aZ+;-mV-3nOChldq=m>24Gn%kDb6NlSQd()(>W=8 zJe8V^j$%xwN(P04WXXL9NtHYaU5A_r;drJv)=0^xz#cA@Ly66Q9KF~=hrgUDFVrbb zsFVPMj*->g6=yB~+T188wrgFq6em^oE`^G>M^fX1V}1d%mKO68SgjMsQcclv5dcmT za16FC1S3piAn$n+YTZ4FhI4Qwa2@ZaQ<{k_BdNh!;>On%#t^bdyN4BSPp)s2IRd{G z+AyZqlG9~2VM2{^3#H1B2QHrgNlamIzr{-Tn1XJi{I1K_3DAfpR7|WQ|2+;b^Hlp} z04<-Vmxt?j>a-v?+(Ml$JC&M-VMDIFB!fx-t5tcRIUZAR^p|B= z-G~P?D7Pi2jN)VO-jbMbR!qkt_qunk6l#HaJAhcY7 z*N*0l?wDL;fw{rm-?fe>XBAA6$J*~$!D?g_J#5)hL_|gt?PRZNgK1Q16}nOdpT+Wj z+v;HNAgkP^?xNm<#m$63ZzS^b6rH>krhx`PWh?g9UuBNkYZWpKn2ofvi&ItZFI(js z2U5JtCWKcGBV{9u(=knOtQlGb3_X`>TzZTV3`@2-QSQ}FD=>3Gr(+%Cl|!H8A|yiZ zjHjU-p2U)*!xBkAu$9IIS6(hFc{5JwnB$E25++|4@fDT#9Cg$=rO{mAHOVA1SH>Aw z>Do}3s1GK=I9$!N&|0|!y~Cy?2$*pWr}z_`NmdjSHJ*w&t<)!&)E4@PW>{Q`p1ICJ zxUZ?B7m+Z=ocJ05UxB)zZh!l8miypc)3tXb(1*nBy11>HF`X7d9~C&j&IZ9A`iuO} zQ^%SLXM9=C!BMt6p2}&RK5KbSk~y+hJ5do2DbQXC#qk`GlLUdvEaoq89ABW6Mb_uf zHnm^6!C;Ofbo)*@d%o@^BnyTl!$BF>Wh@^2{@$aB+?2$7Z(sfpOgYBC_x9g=&Zypd zpFe9)j$h<*En`EYc*~x+8L12$;MErJtHdbmTYI}Z+jw)&SQVU!ib|&#$d*lQDEK7ki8s$u?TuIbL?l#pfP?Dx9~Y`a4gT#9CGSm zq=WH}^4P2vz2=^4-?|V&6=YY0Bu#s%J|j;e^zJ#_tw3+IuXV9pq?n;EUPy*;VMPfo zWa{G~RB52zjdedZ=ZePJ7D!afBvLqst6R>;#?0JXnw*W6p}n@0Jr!o8IiI)gyx5(p zaBG}MS>?^y`9DryoL&5U_TqG_`1$;M=f}z6+1W2|PM^Jcwt zHCfvuEnu^9UU;qz8caLveh}p&A$1zAJjR6)wTfdfr8p>&OZz7z=mSk?c4WlX4*@!a z6V@R~$cSdD5)~D{7AhQ%V-^{05gB;UkjS;pahQR}HOQ<&uJaV@18+H)(grA3S;pxw zSBen8H^DqA8kO8Ha$}Zv#pkvhzo>;t z;dSx$^Ir8`Or}asEWfXza5oqz3)X~=CxjPxEQ-L2S&pl~Ucw@lWVG&U4%{kJU<6Q3 zLoj66;09Gh*XL7$n5PA9^$}E5MVf~6Aj#v{qPU@4Lt4;-a>C5z6J&8z?rrUaxT_L` zo7@@>^p{t;jbYZ3&>p;deE9O^<9}V7Cbc_{lCw@&^jILQ<8x}8ka4YaUCbnrx-Mp> zit7XR-~avJ0k)w&troEh-Zu14M7mR_4q7fP&_fE1%HY;AfIcvrp! zcfDJjs;(I=mC}vA%6nc|*EJu9`Ab=3qP6OKa$CiRB%`&DjH~Z*G?kBzA_?*vI_E5x zTmFPGNqwEpBC41ui$FwhtadJapOA3QxZKNQtfgQr3_KQ&5yf0bl-p{#S2WIPL>B0V zO1zDG6L#fitmjtufrHpp`LdQocV@3JNjfy%fl7*jH?EJuhb=TD z5+yNHUf*izQI0~!IT0yKBJHteyc^+!9&|Y4;#WvnvbwS$fGPkkBUWhu@x|^*#V|S@ zdzaL47Bxmrb6*|q)WHQ^k?}mta&COcC8~E?QLQi6A@^={O=bj3*H&hW!vS|!{Q0f)2Jr#r4zKucb>Ot(? z)*$wl4Pvh}h=Cf!vA$$wM$mR6>k11r%%`bZ#}^X(3naAL!eK1AQ&!-^+{ENWMcEK! zCKqbyPG7Nosp|~_!S$eFZM5N9W_cO1>2DIxGF^hF|{|HnBZNxKb3BXO7s+zeo~3@ zbFt-q{aGKxmESg(eXft#x0qY0&~qhj9&7?x3Y9fK!?wAqnCH?hla&!3As> z@&(Sb$pxC?R2c}0-%?Hy+G3m%F7<02%*8o1a<2TqJPKwGN%TEePSibkHNpANFUV85 zP^HQs>dP@?9KT3r7cj{HaT~=u7iOT!Tg-(e%<^5C;(KM{N;gnoCTjOmO9t`B;?E zq2B+3u4E~m#;V|axahN75;dn_=Mcn zD$7;>RTOwhr!=dqUI1@lk@GP9iI5b>bVh`U&%LNf6q5y?k64~mdN`9DRH`{TbhXBo zGnDaUh)>Cs&BzZ_DkqEC_0NK!3n`nI7n+m+2#ypA6>1N~ncP#ppRcic^{KETtBSQn zL*Ayub8ii(g^4@ckt z)|4^FA^%2B)R<+bU`YD)p$bn|$bkS(?ksAsAy!7=#y}nRd#>Jo?e7u0rdBxo;xNpZvVGcTZd$egz%rk}{ zS7MXL4oj3_$zG)^ZhTo2#i-C^T>VA+CT^B}^{gke5O8vD&FV&fP?3a%%ULhF`$fFK z6DVfyIAQJDAZJ3wp3$P7iI&ZC6^8&Pb8Q+D1mV!!7aauX%@A(TRQ;^Xy%ZfZaAfc% zR>xYHa~*2}fG(eDD&K2+R`Xvk?Xk|mqfK+*siH3H%T@J5&jLrtXXsbKk`!l?1Mhcm zDsz-Z-$=EeJ@g4;7ChZWz8{g0MWovV%iE(080joTek?V`$_cBvvU*T-x5ZgZ4vd~r z0gYy4D&F<~6--Hn0Zuuv&t6xZwhnYKzuG17k`9xwj2h1cO zxWr!bak7ru=rk+>oTdV~h;vZfFawoNYfY=UTil2p_s@D!1M!#zQ7l^U7O6*9DUa>w z=hM>{ug@>uzBoNQe)HOyMDme@%_H;s$oC~xOpqU<$0VFE^yvHJ^Vf&3jxUa19sc8m zY3^}XMH4ja#~E1N>kHzxE{fXvsDb#MN)20EA%n-5PRk9vJ#y;WFfa#?Dj2cY{-^PD zQkM{znKQV&Z|;M-7ohYhjxl+;^hgC#x8;gUAWFSs30XvXvdxw$v2gxC;p&gFa%-M0 zv?pQ=(Vi5irAZ|?f<9}C+1T}KePl0p$`7cr$XO&-U*+{u;2FEHgcK`SLXvEK_7DVt zmS}Aba3z;dTu?)9b_oaVogSoa&Za*XwZbQgw3V4hpE6A z5D;Y15>#Gugqzh$%?6-DRq8$SO*SF?iVEWTbJ8S&D?XevZts`_UrUIdS%|@EO?^mE zb46TM0VOAi^>%78RdnX`9bqoF5*`M$e6- zbwoMj&zXb$xQK9}0?OA{&R}37VCp-ERY*#<>cMU>2#ULs=L`+{N+y2yCPA;+jKB?q z!Oj-i?r- zwJzTmni|wCggKtZ7lsZl00-(_$47_Y?`|}h3y{2=))ksAwh%TIV-{Xss4&w~A}L$} zl`v2eB<)$96MBynie4E?Xjr>w6m(*I%`(EL=tAA(Eg1+P5R>*BGd2jLl*f2RF7zkY5OHCSEX*wH zk+8$A$YMYW3cC1=~Y+qP88%bm)8tcric;AXV(N~rAbKnw-b126iK6MC|w{g>Ed z5zHRVw)NUFmu?MzEu0ZS&TwkioFxq>z{y-Lg`faLFG3QPiN+osQnS!!YNboS#UjX^ z8avB`3@FwjUNo)v+4jY3=R*0CQL*L$e(-dp`>) z_Dk9OQpm}%7pl&+4ocW0K-D&Ak){gy(jGdOu09M-QkKjYA* z6QZ?jq0rXFkcjNUdh+*FjoyZ7!dEZ4pc$R1tLY|GzKy0$I8ouQwTg!$mTGCL0t%u@ z24VFjX5^l6CBGHwxN$CCx(ENYaSQ&dy90mwumeBrz<0R=*I0?tj2?E{hn@Cer)}72 z*IgcWxzVDRG$e^2oz4+U=bVlw8Tw1e()r)ec7J;pea{(=LN*NwtBYsec}h_S{ocGo z$4MBNybClFkQIzZ6mzwm>F!(yj>8m(6Jh|_LfX8Bwu8RfEj<0n>;A0+SC(};AayuH zRX|fjK87R(V-$F>P{`_T&7Zo70Ag&*2AJaV7|FpJcCJeyoOL>Xm%@TRxWE7+99qPx zi0HjMK6>%`?1jG_^uOzLeonw`)*5_=b94G4C4?AXA;u9NbA7%-6LiI?TuWO>uu*n} zIqBGofhF`9n5c7eQ04B!3Gxol(D9jvzCS!WKHKX2a(w>do1f3oFNde6hp*3%U!0*g zr|9U->*vSk$8TPrp*KIE!`J^rKOMh*zJ&;ddjaHQ$|dY_!lq!JE+U?o&W+2zy%HMZ zWSrwML1Q)}kaDmZ+_M8$qP3-~Hk{y3xBEDm(X^pg=cy`;k~i+7-|zRI?(V{W`~80P zzk~kv&YuRmd(XDF_jdcwcK_5LJRR&k{S)fn$_SS{xyUg8Q~$<&RR{M6d5Q=G5P>tq z85uJ^-$%#Eh+}K>?jU3q@V;g|ashYBrwtvPruOG{F!(m;cO2L1eRPOk(jk}Hl5>I~ z*9f?$nFI_hfiw`Iy%j$!T~`P?2%VP_ph~8u0%w?04s}oybS7*{_K{Iv9K&LVh7zEL zFTIFeC6H^a7qc-F=9@po{4z+Bap#iEA%OXQ$Jg+7d^90(N_fXdCc#_BN25Fe0lDLw zA#|pgng~F$x{7xC_LbygTd)Uo5XyO6K-PLeuA}N|0Ca{l+2-<^**qotC{|O^nN?=& zOOxcobB|9e|98Tw-n5S;{J+2b^l6p(Q(9cO4zlzb++gwgA8haK^~>vjd$7Ip>|y=i$MgH| zy(gU(7O)Bfc+&g)xzmxYpc$R+!~J`yRziG;sYGr#A^WH();T2*FbjkJhTNO>DkMKd z{qFq!yUxl*kf$gzaL5pXHwLl+4{b&?%#e3U<{t8Xc=N_Xp4z)Sv>8*8A@BRcQ&0Z; zf4puN`JX>4pnw0}H#q>7^dPIGpt5rkJ%`LOXW@iIc}$b>v5IM=UcZ36Ltvvq$VaKo z$i1(_J5O;IPF^U*(a7BD@zEuj@1x&;N098}^Jgb0H%ke}8RJqacV6o|w590Xa#S2L z#|HW)aj7NQkL&_{LcirKBh|06I!Pk)`ie-Z*hhno`QX>*5rXjCWFBP$cRP=&2U*2z zN~=$!Y~hT=;G}m>_|#@9_yf)5g4w&l#MwEMi`_$>S}k`ngKF~BnCJ+R-w;HgF@5Th z!`IcgM&L9^VnNESDtcL`RaK+Zs>b&M+-EFhF&oc+B5R=DsZ>mW`wpGKkf3q2IPReDUyBJ~P+ zaDnudT)fpn)h|tm)%8z`;Q&*pG*bO60foDl9(uI$)HtIx7=C2Oi}nwSiSbZSd??B~ zm~e{&>bfW>eYdr0NNA{8D^spr8@=0v302nZqE9HHD!lukUuM&qp(QLsn`D}1b7zXX)uJfn zG|5KDTi{B+kNylmS#qd#=j`v*edf_6nQx&-DmcylK?5;VA$p`XIN0oTy~gdR*0Hm) zpz=*Po?D_@mt9IDY^FP?^EFb@qyGtHROc6eJTj8oqWas}5eMt-_aG_X-bPrGoBzje zgoO(cN~9>a0qt&52!?cI1yG&0%w5aX zk8T@o!3Cv!rx>sg=eLpVf+H8spNiUnX~lHV+Oo?-bT7Bl!%7>4j)K5h$|-oET0b8X z5;~%S=it^e%%d^Mg4O0M)#*BHd!?BZWI7}Kh)q+>3G)6v2(|})->b_f?Q{@yZG#{( zAWwDKn+=8}!vn93*0!>BqvmySrDKNafv<+8N^X-?k@f7xxPqvRaGJ3izKWzj!R5Cg zjol{dLe7$3*>FXqnbP-3TSF-$u7)a8^!CsbVKQZjSb6OgKT{PL0 zTC3uzsPG?!DCY{oikNb1+$XB^(7yzdbT+IN5oAiYAjj;{y4ZqGP7Aiw3Pp^5^JNIO zx#PdyxZ|c>=Tl>)^60;tr?OR9GE_{o6nxCu1ry zzCQi6*1bJ6{wPVCB^kuDi_l@7F%jaJ@D;H-N%0zj`_8)P)VAnuX@`}V+pghCLr#-% zZ?+9ZmLRuQp$)A|E8pI9yd@lhMO|+)W)G9G&SVIb@f`N;lOiy?&VodpY?dopNJJXg zD$8)ccRjwMY)k<4`hz@-^mWCfYR<Qd|_Y_hl!3 zMVMBy;I7otb-25v4TxEyqTCe$Om~8vZ-UMtgkj7W3T+O!dg&{yuB)0jHgIJZp z6YKs0huq|H`ns$Hiwd`IED%PVjLAnh z?KbID5F5b}^{5>N^!YOyk`U*DsFooHpuX;YNz@HrQoVcj`Av0085W18Q~)w z62QoaHOCT zt~9^K0q~OhpWDy2pH|O*pFP`oIRCwm=ibkMk=t{Rbygc*sDB)JjeQ@M`cq$Cj& zI?6gf3p^(KYEf13OIVcL6#!8BI+^Crvs4kFE^Ewaw*% z@E$~GFqtVK(pbcRXJ0XXiCAvqb`ROda_UJ=<+S><0XjQ6T74clprewu%6c3L653bB z2HuwIYIT3pl^`&)_eh7&GrjB|pr8P&L_xa$ibp}IH%BX3u|`2~x~{FwINBt?t=UKb z!>OTe#CS-&F51LNbbGBzHCr~i&QdlegRUK}9B}UmQVHbB0r^*;S(-RhUm;*1kivzp zI@Je=O=(tp?dg2+9`bB9ck{RY;5Ybpw0n%nzngo~skl6lH~xvI@eL%3RrY_Cr22Yy zzyGv+|KHi(eOB53`@1`P5BvXpJomZ(Ltp1Q$*s!YQiuU=wLkloqjU5|;6h zsB%Ha-BupWa7-i6sb9zr`hCfotDL4NB=pT%ome=4ChbN8-_(tV6laqlV>a7uzwg0D z#7SoVcny~!J&5uTRa8;G-}efIw+7+ht{@z&0pY+xSOZ0`ejQ@bHZSUsxWlq;zYQ}| zM?tr|O-qtGHPJ&%hblR!P}dx)y&)fI#mh&gW`JH&mHfIWEtO)LjD=44!9f(=lKZBT zn-u9$xl1IEGe~c6WmHnxukmU3k=^Q@{!TE2DK(p9tEjOIrNzcjMHSp2`IXTGuDaB* z;K~tl)2`RITllGtKzl4q7*2P8dIT_PobDeW?|ovTVxFNFr>AdD_aPw|tjCnI88vY{ zO|aHt{Z`X86j5peuf5X)ZP6*c^xh|^`n+ZkW)o+K%t(T;N&-Sm-V>D#n#EBmD(w`{ zB>^OaL?*KXnVYwlIv}DA`kA2_i}NW#VT|ci+zQw~EV}>V*IhFxvL!a(`B8)BY24DM5j@ zZy^ZGC%16Q1kG4War3dLAcz!hGR9%6-FN!U2%pkK?GEL3GCXz-&aTO!`Qn3C)>R7z zW~1uX2YJv^qe+Xc{w=#JAdTplh|C4Y8Snw}{&!`samRen&``suE1j1|Qs0uiKa1X< zh5VmaDO;U82htK;wIir{532d~TU_OcQZJq7EW9Kf9Rs=B89Gye?q5T|{mmi`_dY~^ zF4MWPbDiXItZE5!1k_!y7}KJ7Wl37Gzjaj(w%WgKciYzNDoeHml_Nqs@8uE#@FY&F zE5oG|9d^lG04|RFl7)zl&?QZxV}ZOWPK&#Mjt$9$;Q-+@l`~s<3sK7}4VQ>(owlm& z6t_^@ZBM03#Tut2%GO`fKQlW6g4y%G4vNS!f`yr;iXVcKbU8z2!OvxewrCqll9aY z=)yyR3Y)(Qx13ddS{}iNb39%}ndm)H4lt=oh}95R{uN|onbIT@J>?hH8^&zd+uj-N z4I;AhE$)B&^l7+9M*ZO^+}$4TjGpc6h0k^d{b$2x{eI76S0A&Dm%9W1ck_Lq|N3920U1jf5q!9amZwR>pkZL$8)s{gNK|Izo$u4@2V zV*lCQdsegmJncWkf4GBOJ|Sc*bNYIcyes>s=G&j zbylm1R`=pKkHFm>eG;a>>RwH4&XXEl|_=Qz97CA-NCa3VLDg3SX50>~8H(jo6 zyj-GvV%)}?u3>iTrP}XIst%l)SMrEU3dMIhk)`i2vsDhSzd_LHwgwSOWm~RcPcAhn zzpAP9YJJF}wf5YPu6MUYm1&SBS-G`t@of|{oITyGemC6#sn$FEBuT76WLs?aI#Hh8 z8JB{p_863WK#EA`^OUH(`sE8K%Nc|5{XNmGcuJUDMzZy44xw!}T>blYZRhx!cRXZG zTa;Wc4%-? zZUOf^e|d%k9VaB}$O?2sLn(J5IYhag>YSdwFDH#=+qTJpjr-CHCxtzD2Msjb% zPN?rTvwU)8+pkp9avLBbE0hpy&5kS#i}M){6yxc)U;PcL>T8% zO;JbTT(bFtK*~b^g0M*xjj}|L#4U|K7`U z|MI^h%vX|oH!&bhd!}R&?zl0yv8q&$MY`DYyVi%ZGsn}<_|Wek z7*5%k=eA+{f)vVu-aV~E>zVZH+7Q%I&6tUbdhJOlzS|JdxLXs{ zo9D4Hs%vQ`WkaPoTu{Z#B5Lf<%%2jmOHmn4Kr`0X{$|FW0N**WHkrPBFBKT zxTKK}wyrW7g ze@Zfyb7GTf>(Xjw+BN$o4BrMjMX;99W=g}$Yn*N@30mGopTNfbPdtqq`K4OGix_Fw zba_)Izdp0>78gNolT)qBb-r2|_u*-OR+0Y|Wn5eQUn>7U>kq2(|MqtOLH@sw=idE4 zUwlmECLp(*L>tKAT0h7ft=o#rocyAQ4uuSLfeo2`Tb6t?Bb?KSNbbnX| z%SR*n5qW-F>artFq(rICbOB5WvpM<0T4Wi{3((bWx@-Vis4H2Qz*o@!v+&9TQGXi= z>=WW7jd4hj_l<|V3-9`a!WMC(JdWq+w;acEULz@|z+eL@+Cb}2K9hY&$b}8~AlQ_+ zr!s}rP?@b_I;Hv$BAZZwHZ`iAzdn;?;Upv_VK?X;kIY>V)rbvg;ozPY2ONNR2wEY5 z*U&D;xhz;SBpNy~A1$qzGOYzx!nvBn9R1n#XK8h^L>3ca^0)-!MwI*w*r&@Sr zr@93&xr@c=bis;QSn3v*jYUtnt5ugyqv%`!-}LpyOR2Hd+g?(Kam=pVTXjH+B9*&^ zy5`6fdLp8NSWWXdqkd7yQ7aKmP{^i3xP7Js!c{h9Xs2OkcE_#yav2`Hx%n=QTQ@QY zillEGy4L|8ivmTfZXZqKtW0-{hF7eqpdso(4T(W_EmYi4{#!-8L;m;sdA?x&cSBZiGD<|&gPj_&;jbjj&=6~@AVy<4BMC}5fxz_V6C#NIrFMA9 z(}q~k5M@NU*+^+UVHuhdZ2~jfHI?nKC=c7RTotsgdZ?zkN^ruKBv-CCVcjXuCt4m+ zfGeI2gO{8)>CuB-Ej(w3aj>n{HdwyilV2&6%NQ0=s*3P@0 z>7|f0(%eJdpx>W*$eWTW<8%3Y``cI4Ltd>*4|#k2SJdm82GuC4&Fxj1`|EaVbA$f& zu4z#I<4tMESA~1K($V(b)0@D(^K7rw+|IYVtHUj(IF46@d#{4;y+J#IR)G6i1r5)h zwohLj-z1{>^qPR{)@}{*!QM{$4Awx8v2(7ojKqU}iBkHzHzV<2uS6;RXKRr7$8`YT zE(5;X|Le_YyxiQ=!R{I~zP%RYyXCIFUBm`ffc$B>xxx1Kw=0u*p$_Py1GM*6(V0g{S9yH^4Rjw<|Ua6 z3jyTJv~dwyY_@Yl3ZnO|6JtYU1`ckFG??ZHulh)RK?&`KGG5z zbKL8=fSY^P@Lr!=f`_&ebhUj74rCD|j&;OW8=hB%%~X0eONiz(%A=4PnU^W=U=tf@ z^VeKt`taEzzpLfv@bU`tsBpknVfX-oZ)h@N`%M>sbU+%zJ2Fm4Z;EMBe1J*ZFr!!f z0#0kw;l^+(@OTW4>e@vho=aAKMbHG#9$|JMcLykoTlcUrgJY?!g8Qx*#)&8S)ki5 z@-AI$ritZi%?9ve9C4CyN@he-7Wf0joZx7VLNMhsoSK}uR8e%GLdtKzkBpGKD4-NV-hfK~P+#x8R;FSJeczH}O{Tjt0zeKW+ z)l5Dv3};spqOC|4$5bGx3`tZjSPPYw8y17CTt*b~A)eyjRb$_kTdOi96Xngsn`mH4 zTZjbXeJO&8-(rhlFF?Q0I7Z;-HKJT(D()l62-g<^_4?Erl>$}G3G(57QNb-G%<79C z=~&Zdgg8zjHnoUT&8X=_KKh=qnBb(`)GreP5&JTRa1>8FpxX1;*J)9H5Q<_|K{-s;RgO7`vRzPh%Rq^Zp^|qr2AOt zn#EExr{xw_<+OvdhHi>9B)~~*0C?3G7Q#l@M2|1Yd|@}4$@8Ju1}q3Fsw7nKJ2MFi zl_ctR5ZVsVi47WShB~9T%7F^x$=#^ng~9-B&{JlF3*m=LwZQ!>Kg2G7b)_|e^r~IIg z0zLfVq(#wyp{$>j{8trH@{GnJ7?bQLGXH_IsoXNHfFfzjLqTOByCr zjnUTm46>^mkHeCOk$h{VKcV5%-H3!va2S2tS;eRM4F$y}2yDOG6f>|P0`hsW`Mmf3 zEZ%!4Vnii)(oE6HSL*xw#4|`RuvMeqYS3C{zGdVsrJay2&Ea)d=(%ex2t}>&ZmUa5DmI$Gf&fcsZTmg`i0lFW}v(XDXr}UZ57Cvd^}ZtxQB84l4f9j z^xnV!#lZ!{GyNpxa*=Vgw`${V+)&SAqA204uTMx!^3f@d}(XK znp*Ty>DJ7?H6G546z4fF?^f8VT*7mE+iVfD+-$iTPlbnu9d0 zXDO@9Y?C#fQ2w+$3d)tX=wOs`GNUXPb=?WOO1M~Mv8$5H7W)@ly=zkUGU7I)tby5g>mAYDDt(*dJv)k;x* zOJ5HbS6X;T4otINOMluVzkwq_sYo^Qui0hYGAiiZd+)vHRqYom)>WELGi0r!dEn}| z`K-eKi|7h>a{ps{Z?Ag)doXy&|9C&o7vTR%M7TZlP-jZ^1j+hcrG!s#DwL8YLYCTS zh5Cn3S3r_%(VfeNxFDz;7^Gp>sA<EYg&X>y${#t2em>^lGF-le&0T0?gm6Lj`O3)e%OV5!g0;idm zI-7&Wb*X6G%Dz^{14|-yEgDiQ0t2B>ItUSTSXVgJ`&uZTuUlMMW&c-4i+2|PWv^e) z|F`|1|J~1X@B6mPjss;QbD^;y{*AF`z%oefpE}rt%>xTy-n_KBsYn zNHRk+%$3uR@;AmR49C)mtRO|rqM4cL+_%>J%CS4c7x0NsV+vb~kbgjmXsQ0{ z0XsF|Sn&u8@#W1I_?-8}wuNS)6sYw$kFNn-!8c z9ERy#UCrRqnOo>?V!db8ueFv~t>MDDEJvfAZ=4wnyz)6j^AQVdXxW?LukypAiaSC@ z%qV)Qxo|t}_zjLCsfxnE$&3;em_xEouFtgCNL>(Ac5_4%WJIn6lx+E=N>>4g&a*tcq#HJ!j2}Sr^ho|5`=3&Pm}}9 zlj0m}^N1xQI(~)IEpsno#5iOyr3p$|DUSXYA|EB5Ot2RdwG?d%LeR$G+nr}myUQht zPy>9fp|yBb5PQ84V4C6-&Yag}YW3zV!7#;=0i79Q<_uVyCdrd#P^L!kJr?9dauHCw zy6*mN)fBCbuqn+{!=9TU#k-^l?5i3@UZJskLSllq_nyir`(tP?56?}XRrJ3r%;hq@ zwhFjJ|9iUktQP?TZ$H2PrcoCDfbg0XpyY71` z{pBx*r>~D+|6?DW7NbWI$w-)y2yIFo%t;mh`SZSfBg=gL+}%Q2#!OVKZWSpU{5&{I z#_=3+B8N#5eai#S7>(-K2JsU+A(5mSM>$K>#c8;nX>VsnEEIvp0^HHe@{DncW6^{A zB@kHL*RA`qbzftTaIx5$S|$pKQ9-iI?B-S`(4nCu%yI07ty_(1Ok5_{QlQh;ID(^* z7^SFac2~8#zxar!X$-y8ooLtTCKU8Uxg>LQ8G!5bbMVKE#rc$+5I&_ssG44b;CgPw z=tSQH2KW#bBA*&#G+ZNLam=oq$Z6k~l7WMbRTX8o^3B)5tHW?T#_oOio$LBfm*8+H(Zt-V-FXYRA9I zf{k<=aQR1JoJ;1j$r6I5m?jRXnz@3_wyXG};~4-Q;`NSCNs!4kz_PRm~%P<7Q@P1M#CB)v)&O7hCx`6X$3Ab`}+rW&is1H zaH}A?9v-@;>s1Laf|e<0q!UxAkx(>hj!n);Rdj*{(w8p|)UtuQ*=bBee8lo3JLqqr zh(R}o(b23Jw}r#a=THEmn=XN2t5l&jg-c~M*ak+RN~ia0ErHEKZ*^Ht(5>~nWLY%L zOZoCnGg@2(dtFp%PZ4lJNQ5v7vGPaH7~E(!v5r+4Eg8RZB=CMX54)?%l5;+(qNXZhYM$cBt7!_lK^>?g=TUX3CKKGt3H zQ6oepC9bH1)vLdvdRo2CwdvY$TklLa5U;lkutHv{e`~$~T_`}yd4SPDD|eVpyA5}& zoAbgsbGIM^L;jJ*6!WT(wdXpnM1M{`c(JPBs7gPJj17|GSsx%jtiwBqcrn{?@fWL!n=d z?q>$ts`)|8ikoSDrjNVP`5I8Vmd0o5>*jEtO|Gr>n~Sd7uP>khX9g9 z+J;jvQE&AtRf(&!)T$Pj3bw1pnnrv%#kZ^-mmnyQ27q)4Y}C7+3}FYZ!A(vkmQC$B?sPGq-HUF@soR-mhuGX=~+%1{~AuS8l#> z+37lKtR&Rkq4DMpms@EFH(6)yjb2hB>)@*O=Kt%>F~pY61R|8Fjg2(~PF+&#u>ai0^9Aicl9HZ({})cif4hAnkS5cw#|Y$( zw%rn>z`mg^$nENm3_^AEu4fR^^>4)>RBo%qAhg(wQ*NTgjI-3JR2fveQR!9MPe{DbQKYOtM-N*9H@wqKs)8nfI%(eZ!+*TgEy&k;1>KZ<*=>Gtt)|dA6 zDsj%Q%Bu8*J-rkGKKOZk8C%oRJ@57`O}Dl2FNgU$8WuGA$F?!S5LTY;>)MwZ+qkK9 z2^#&%ZA&W~G1^79yEI|k&GNdjnY#*Wa?MRh(FAbVYd!b0L)X$J1r zys&s=Uz4HXdg60LLK+d9e4a?a!z}J1Z8o$qQtc1RBkNg^y84HqLheyT%nfE z$>SOu%Cr8(Y)#8|(Df`4%iD7{I)x~e%XB4f*h-+)PL?~&Hfzcz?CoZCP90d9ZAjhn za2Dv(t$nQ9@GYCFoFT4BG1oI%EokV5mMeF%zHalCF?Q5M!ZoL$MXbC-W615Q2P=>0 zPPj)=^k;h@x}1Z6tXyB33+NWPYb>pajK!`{4mQ20WP4!!+Ah@G;~j7kS6ICsD=CY+ ze_$$Z9MA1>Hy6=sa=L1(OPac6e&_ab8Gia%mmY5|U{liL|oJU3yqlZe9EF zy7pgABD!uOux0tbcKc7O{(pPB5Bb0D<+&aE@3joTr;?QJ=K8Oq(yzw$3*fYyec|)X zEWWCTyD|3Gkhz*!R#jWgBHQY4S8C*v%teqw*-Ar@dJJ>=ZxZQ5mrEOSssuBW91}7^ zwA-*VDPi)to6iGspjO7=H91jqm)5FRAip&Z&6oz;%2k|v*6Z_*Y=Zhd;jAbu9umi6oXx~S2u48>lvS# z+qj`-rW*Y!O-^dZQ_16vBBn;aGN!>Iqc_ew)Uj?&3TfZ=+oxI8ap!C?>Q#1fdkUjc z0803w#mB;6R494QFmS9>dTE&feRqHcyWN#%qEYO)0sS|&=NP-(oyRcs^7gg4hRoGf zd!g4Nd&v%Tmd4tN}bK#jI!CtaXukR6w$dd4b&z* zqe&L2M=;0JxOCgGQ1)8vDTP?kYENnISD-OTApLLDTm70POwscGRWUs_4NQbH+}uUG z-@^)WeX&*P6xG%`R#KRjQ;(p@xTY=?G#uE8Zq9)amFc{9i&y*dF}04x ztEMPex+JG_-z{56!_J=%i{^e8O-0qHwT}Gq@Jvyg=-%+Y!jLRSn1Ti}3f5Va<+W3_ zSzc4Tbyi&4hP(V5=xiuX8)IvXe!sQ5#FKGaqp|OFto1!(-()&U%d#X(arlk`B3)H;yDVbUKIV-H#-mmS6vC(x?s5;bC#!)r6?LhF&onm4M}!INMZp|^@2`kV*{PP{M+->PUpji4^k%XL?k6i zM3RsaEqBXZabLFwJG3@}?xXGCWAM@V%P>JR#^?Kpg>=8y<77-l#^?SZh*)?@ctBZi zsQOpk%PyhckR^h}q$5XxZ2xnVr&7?g#Q;2<(zmAj*`RYtlV~4F6`YP=;j}X)8J6O& zgj~Vhp!3KNA{gaytehF)NZQnz&XsI~ydOy%GxQ7NapWl=UNUwmahoxe3xZhA5l*e6 zkdR_{%2|}dDZ~Fl=X{RdDRU1x$H~8QbXb%|M>xYV8y6F;krRY$+9OFuc*?0D-7OGg ziWi7P6dGV7JD&~hs>wZ0G7C_r16zv{d{WM4G$I0FLn+1@i6$gY2^Z*ULc<9PaUw-7 zIBtN86*CgE6eZ+JQxVFgAg^e+MzvkDwV^3a@R;zR(PS75$Tr~TSxQ2RV?>iQ&yWZwWQyU-20EVNF*#){D`?g% z1xa4nJSF=`fZEkTC?X={6m+kB^c>a+GQ}OGlFCMRug$$VWU)W3rFz>gsfy zRk)<9Q(F?Sr`ngR!EIR+WG@-hhf4bA@ zq@0v{~Hr!NM*; z(G5DChzxTF-9vRGSVN5(3mm`D5t_3cU9milP)sk0{2~ZJ~}USS-KRrQDXdMdfAZpZ<+lKvV7! z5+Po(h%5v55K5vciwcM1R=7(Yoe~`V!f8g{zyt{T@1>oc(SMUQdieGqw9}Ca&9Zh+ zw3TmIOk`AQ$?T*H$=Q&On)Hp*bSS)TAP2D(1Z*I{006xKYM9flrP&uHKca$cK{8n~ zO4kZ!{E@VQuQb@{T(^En=C5!nWxxGuTl&2f167+T2_@&bBy-^Wuo1{FiPN+ENRu`o z6#Q%mn%W{{D^bjXx`A9^HxRP6L|MqV*8D`~7L^j0FklH-E;-dNQh~nGex68>L{LzP zVJ$-J8&^c23BnriEhN~Kpo~t5kPTen1Qz93)>e`oR}Kj&6M_zDOtZP_7G%E(p%_yBh^3?`d4Yd*An)#aE4$>&vbP#dt_!y+h$NUA?NB!Z#`YEhkn6E@tER-;upo zVa&as(D3OlNkV&N^YiCUY0yy`7b6aWK${s!GTXP8myKF+a(H(3%bU~Z_5)J3#UD7E z?mI7$Ml?8_jmlpZpv%mT8nHGM*PrBk6B44yTJi*zkk0+#QniF4vANddArOp z@W#i*FgM2|$&p5khjQm+!XDx4i{WqvA$b;!W038DmEo93b-JcX11pYWB&A<8o$E6n zFqBs&>cI21D!4rGJfQf;m7*(J;;S7zG7J4eHc_$8x1TU{?5ZOlQ^qsoG;{E1Q|g6b z7DHuoV&?;&OL0i-UE+ckkk8Psf+Z5X1MhdpzZ5uB_}P=}4GZ#g7x_`Q(}aS-wNNl{ zQ82h26b$YF1%oOIG}Ayor(=#IDX9PpIcI1P^n-q<^X^T8Ub7jQ4hcttoh`K8-|lZI z6?(R9OxfrP3j`{NRF6h9p<+U!Ld74`Y?2Sb_#uf`n@TrChO=DwY~*JX;%log*hHv6 zQ~WFA#vCP8J>_92q~!{WvKMb&2AV7X zGQpXJ{fcmcs5a>kee7Gs!~g1Ze00Le46Nz;rBL{w!X0of2>MW#NIsxV%{2fI!#NtV zY@#Y{!}xX#@|}j{)GA{gjKAFGcD+qyT|q2~=iLCk`;l<6*xqUEoRS7`O7)<8VC!8_ zdqbQ^qKeRz0geh~7Y|vk)|CU-2X$~BbbR!)#DWrov|05-4W`*nPy%a&f9q}N(mnv^ zYcgK~jqLEeOidCFfl{UZ8e#hhAQra!Cva?nCRm`M6y6z+h$LtnrxQFR84Y1YfrL9@ zQ#K|Eku!gV=SmKiJHUikTcKkc(=>J@i8UfzTQT8iDmSZLEfi`gNmAFbHZ^F;S(PQ# zl4!))6k$0uMT;yUW=}GDns%(LnFz$a56_!h@6PkyHql|UPqcxMMx6N zDU%FB@@zd*+UVleju;WLd&9EKdc?_GPGkW_F$&NVDvQk>np!jxY_SOLGBWgD!pVqm z!>Hb|(TIZ45sF1D%%x@nbGCT#%(gFPI~P+rRukOYuz&iwAbyAi5na7Z^rvL3CeMr^ zV`#RcspV%uMtO{4np}eQ^zWu`*=OJyv9mm5oZ?vYI1wzK5nqD04gc==DHEArfTCu) z_nq6~Dx+zvFpSx_r^*mtjs*JjXWM?p^q)Ju?_gWg&X*m>L-Q{oOXq)++t@Dpo--VU zY^ofLGQy|GGw(b#Lol)C9k_MMIqxwpnLHBtP|%2CP9fls?k+-k5^0zC!xV=TVgT7f z=JwKd&_|oHfTurs-M@7t^-b{{2wC!Ppdw%pMk6G7*nx&b!K zFz^gHShHK|QV3_A4j^^E*Sos93NU~eFh1_Zs)*>lJU)8y`V3rDzw2~Dc zVQyr3R8em|NM&qo0PMZ{TiiIZKfFKlSLk%IC$L}KG~_nZXS3gz0cOH8;qn5RIs4Ae zlL%Ygt;KD5Eg9&|*zm86nX^-+c*Otygm{wFZF3t`GXIb|^W z(_m>{<;r~}KQKuMgA9{|4%Yw(Awy%5&4yqfGI$9oS_1%2;TX{&_+t$K00NM}DH?*S z{1SyM7SB|fA&5wLg|a>-TbGz6aEiB7vEg=q=llLn5TQ#9lVE!%$eugo2o2~2#jW&o zx#6}$Z2Of%O_+5XDN$(ny1&zZVUzm4N-03MDajbhI*~M`hui&^-}MJJ@fRNA6eSEN zsI}^D>8jwb*8qrdh!V= zI$YDzg=9J<$r=C|N(seG*aZpkaBC|=V@z2#3%2_eIYT~}aOE>N7Dg2S^(HVBuQmUj z{%!wuEkZQRaLUA^GhWU0vkibLfC(TeN&qE!7NSi6qidLiC<0e#7F@$PM}RVtp*}cd zygV5(lza0~9AA;qh2}gPonh?auQT{WKY`U7^_x$s*A_(z*jKxX{5`AnGT&>l9*936xna z`ODD_%+Mw{Ov3&e0C^S{LX9z-v9)IllKPKVTUGQj`{|C=sY**(0!S>_)e;+@0GxA%*`}X(!!CHjJh_WG| z6S(v8)ewd+Uw)4+U%r05J&3;he((bAyn6Lw=LJH~U%d#USKqygUZb5CFSlQR_j-FY z2w#S;UXNafFJ2A?;m&0ku8mQGGRROg1ihWX&Wm91JQ#d;vHf!R^40Lgpg(xAvpv|^ ze)<3P9*3CbAKCxKEJE*U0nph0cV0gq?7S%V|Cg^G{r`LUc>?xGI?M2Q!dx%m2{=F9 z|KH%9^e2PEh`XI5jItrvJ>A`Vdmuc^wI@%&|3gv8JvZ=EOfF#zQkcOhVko2ht$MgG z#tP}%mP(w)2vW2O62cH*6UYFYUD#utWLeOpSn>^3T&ej?Yn;Ax5QV z$VdjqXfKA8S5K-)5K-j&4i&G0^dMXFtr>z$}9vinhF(+PVE~ zL^5#72XxA~wtgXTK1J(vqe@B)CjAsr%3W1WO!)KvouTpn0P|{HH!KAoVa_JzqmP{I znxr?>Yfr7KA?Wqi0C@5Q9Q4P1`CC~bL;0AmI;J!I;m>F$UmwFM5(PAA`%CcK?`yzm zKgCck6vgsP%UOuBN2V!c_!7sM%>ayGs@Bs&aX;smC`%AS6r?d^Ba%(279h00l;m6K zounwC6Fg$7xcGm6{ux5pP4Q*w-^?9D?Lp=$zeH+z4^H&(X_=w&&G=VVQDM&o5k791 zkI%(&n742WGlpRt&x(Orx{7@<(DX-n9P{5eahpVmx-RD7z+ZKtUMAs@pQbRJmxcLG z*T5lCVj{nM2QN`ff%=CL03$K$z&KX@14V#b{u6~vG?o3;5PbSvA-G#?$pQQKuB5GR zO7!KV&`w{L;}k1jlqHruWuNfXzHMc?3q|scZZ@aPZ(Qd(Jjm1zNjJC<eF?y_*M`g`fHTyileiLa&W0TXa|%GH`7=?&QIhk#|$6S1uJ zjDMYTMi-L|(Futo@FT==p7C)`KjBk>7T2mlWmfG;FY9^YDIz%o`d^Adl0+0- zB6fpN0@93JBDxTJNl`IBNQA!?VK`0*WjF*=5+Q(-(zL?q6ormRgw*`5q<7!-Q6fj) z8uIJBA<>R(2RfrksDb{tVg zJ#{bK1pe(%?Htn>p|pu7%*GV#^8YNtjeo6K;bepp%x2E~W4UvORc~I*L_f&w0K2Gg zw)ndYjf%a;Nq(t!5kfNE%G~|JEe6MQ%d%BLq;GuO-_%S$1ZM|79iCsD{r$u7?$N>T z6}~=yTGP_|8xFP8lk>xi6Gcx7hGQ5TP z>LuE!2qs>IZD)r>cAX7*NKzu)hxMU`GtVOq4(CU<)%Vw;{77K7u81Zf_}rzFPV ztSSY62U79U`<-5-_&=1B3$4}}UFKumhgEgOBi@O*jmZ-d;h5`%f!T{HZCbe-(Be!& z9eR|HeDofb_Uq466ynhgz+#~w)gWK*OKTCG?AfmH{ho2n7Hy3wcosv3D0{XEP)xZy zE&SwXhofV{PBTPN!k$TTPPe!+^JpKFt%!tlD?L_Xb?mq%*7rJHn`Q^tqKDNJ%fBg_!wPP8&f<2?1d zRggh?C2A)Z?mNHcs5847gUZ%O*7KUPyiy2xTMt*isBS*kETRa!jJ z!+*`-D4j906(JW1?#@TkNlMYoHK!?09Ll9d0L;P(=Bpa+HdsR0&--9kt(f%M=p$t? ziC|0;^bwE@e6-x2kDP=3`OtQXIBSc|Zq6nsVd`b6){Z4CR&|OP!^t>c6BNj!yg)C^ z1Cj(IoKY5JxzGyC*~E1k#D+u3RWLgf&=I{d;|q_iLQJ2@won5k0lFw3IEW^1&U9<& z8+x9-l}@|0J$kt1A}3Y;psKcgAYzk@3@<4P2cJj*cRp8O~y({lCfDkw=np2Uc1 zsa;+r*)5gV%H*#?PLVG2BaRUTM}ty};p7>QRnp$x%@w@*&F=)9GZYn+ipY}#DI z40D6kqLgX-JY1g;KW1dAY(h$743^6w)n{p~%BdhwSro}}bk7+^f0Oq6yWR|O2U*#A zFp9)%#E38{N#F>kl_5y(T4J)rNQy%}2NmmShxHKr5%4ba2~1Oz z@WCmb1zacG(-x2T6EKr=HrSVo5pcHu1F*i{6l|;q0LB@D(M*p^e2%5?8oB!u+OT8= zrZBDTutqtu&(27mfiS0xOiQZe3qPwzCnQ2tuD469%vOnKW}HH?HPA3KuE`?quq~hU zLt$XY`ScQHydG7;-0EQxB72iy9o?oV!`$j&ya_V&uN-HHax1~o4RYn#C{|y2!iHem zFN$4*y(Ud5(WJtv4pEH_8W|i5ER70E7`2d9D^05QO)6use$7>*tc@fd@eCck4i3pQ zjS)lftXMN&&OnCvyqt^yg5gAMd7x>^W@?V{xqs!_>TTvHk}#jy$taLcDGmcIv+=&% zn(Ec+kMG`}zuo`A{%iN$JAb>$)wtOl)LX{J^)R~t>p4ZEJeC_matu`+L%P}<&EYlx%5+4sKDnH|6$e_q@U=vpfN5hDjziA{orY09ENp z#$PEY)+*$dW$?!sj-5UME9^mx69h)0pttvy7a9d&z0L?gF9@dac8aDXn+0-Z46aEG z8IDoFSlqKqatAuNgyB_8#sS6uMqqoe^J040D_jG^p{a5Q$@MQVbE+pdOMUHTZ}c_} zP^F40Cf1Tl#)|xSa-tsrxsn)y-X+Z3O6adURAcW59IH9XGeM~?BSsuuM_#v@HnS>J z5@qkGZX~r!DX*p?kb1r{X=wWjMID~ZO6TMh_24Z}T67K)<&cyu_EdG)kuB+GuD4uIpfngI! zHd{}uk1fWoKE=3_r>DF?c_nDSYg1+uS)5?*jqjrv&d${~`k{OVBL2{9u5*>zpSF_b zo(zdJ(Npo);?$Vjnb+jXTmN&@8_Cm(w&RPrXwp0k5v2vqbNQf+rZ~?po=ID;Y~KUm zX{;gG8OS4lTiZkMQap5(rk|F@lzt-sWtGVCl`3ni_S&WMa!$i^3D0Ml&bRHvTWxbr zdL68DPKq7vb55!qEwoBi1npZ7OE#)psfS{sX1w*2|bM4_$G-Vn4kzd);1zy~_2|xjm=v$t9ID&x^3bA|_29jmk9|NleA7 z#6&nmE!%g_kgfQ-(qTzL60`{tn2=3Cr!bB;!4yR}pKgK}W@EGoZq>g#@|TT1xR_vS?OX#qsz|9e zhrzn_Uc`e&MvC>Q%{8)~2Bh-AoK=Xc2S*}9C$rF+UEtG>n1G!-Y9TOW)VeU`>g z-fh1u1_MXEL(t=j@vdTus+8NHVkn|^5gv_@h|n#*jPVr$d#CRK$$(sCgLOoy-1#o) z1l*A9Dkd;e8z_c}-Q>JXH?ntoO_ zARS9!x#$@`pgiJ;-@l>Q8O(@OisjW%Ejr;@x4eUAFsTSK}cYzo9sP7qE9s zx^#qERusL%Nq&2xHfDa4k&&B89*bm#--ThEABaL``vxb40CRCO;n_9z)>N$`wtIe- zO*Y=nvoQ9#m>!elj1aakTlM{1jD>%3n&E34qcJ+5A&lit{UHUIfm8>wGXju&=|py< zBwD8%Ak_rCqey+N5sXffc*dpvu^5${dD=Ie@bAQ-CdjzHiUDm-OAfhLcbXASpwDJ0 zT153OnYkngVG2cZ1V*$UWn>|Xvx$iFwTz^dJ=c!It=qYWfcI*-QLDR>89sq!>O(Ln zo+vA{sMgt-y%pv+1-pc+XPtAxTWtu=@`SJBR5*%wWvfLO=BgQj-+H_6-u1i(0#`VR zM0hsB;^=DM1gdU=a|Rh2s89HYfWN18-ow6#kr6fRASz!u_qzH=jQ{;Na$@sl^y7Cr# zBDn=hC|n$WxMG;REB)X?3NPIfn1D+Yw|Qm@ya`T{eH0^xz`DhEV-uVq84Ag5QycAu zovx*NO11iK159zkSKVgw>n*tfQ#uwWpgG0`pC z1W_)czVd?Q8UQ6Was=_5++reztr$`&?7Ggmuaqo~YD+KbEDz?4Oi7+Fb*Q`>iXe;& za)lBVU!>ju^$IZY4ktkSYij^Bk?{pKDeeMYN`lBI5x-Sw@oRVzc_JJxCDa z_KTD87L1|*B`m{;28;-26|pej3}|qJ*+l#rC|;%mTQb(2sHP-RDM<%nX%WjmmK}La zZOApR4!YS=Rgu!F?3^vhZOd9_SP4&lY3tIG?nz<`2+0<^ociP&?k6xqrx^*6G;0y{ z49@ve00Cb*#t5YHeGwLIQfgov>vT#tKV$g`qIMO;Web!@ff1>gAAB`x=rUYSBbh3q zHrY-|1fY7PcF_9Zu-NtbsJpr<^HFVk`C^jEh{UeXbYb>^fwjiZ= zoCuvHBtlzH#ByHzt>p<|5(O|}*gUaq!l`TBC?<@DVV`1by5F95DwQ4rE~OOhm_!Ja zs;5+N7qavJR)pjaOA@ZRS<_0uYC-7+iAt2D7qj~6TCwCT2ovij;T4)m0mT@s!?&v-*M#NiM${E) zm~McyE|V;`=s8BkNL#vs}r7)7-h=ixUhb|dvoA(G80W-$Y4ab#Hur4 zMp6=!@$5Y1-v1s+_=*Q7i~{)t*pF(fvv_Tar5M)=|4*Pr3aF5PdmdT>bwpR-chrh+ z=WfB<^DA^?qOirdsJ;_<5{b^u60f-RzE3D*n2toq;}GnVW5UkWvbRuf9qSrZM+sHO z4*SRFK>b>)(s?b~vpk3rD$H5Et0>P1Gz5Ec2jEAXQPX3s*EKQY36(qVqC#B)*zc`g zQW#?dYX7A1mAOr!RWtg3=EU)Dd5hA(EoRQk3FXGd#q2gu+=Eqql@y$!^3Q z_J$3@b-Ez}ZTTV;GE~ZTP)l@@0XS0IC>bk`Wn6Z3LWwgY3CBZy*_`rBDaMqEe2*gF z`C5HbUk$mNti`bzHAe8tGGd9oh(zkcZrb$-C%>zX zoj@Dw57vpt^anol)?qS@2jT zDhaZdko9qW>ZrT|7Y6oXh^N$c%&TV}jpE4I8gQDCYXN7Wf^EcL@lhlk?c?mD9@_dg zCt0$7A!D8NoPQ%3?$L#@A=Zs|3fV-Y=!su=SCT}HdoFe} zQ3N3KH$l-oG&8w$cwb2?Dne_@&=%Lr$}{`K9 z2)`B$LlI<@w6%aH!2S$L-5^C zxIQS)4e!m9Y&(_i)!5WRS8 zlP(H(BW@C|`MfF)%3+b5#`(x@s!>`rIEz7RQ45euk|&Wn(wfxhdY{0Vv*l!Ni&LyN z3ui={wRnAo_ybH58%}4HC%*+(IY@_`YmjNS-p%?Bf*Ox#Pco(ekHpT}gxqkIOEjlQ zoC!zztd-lNSPh0drCsFuRejY+DjT$ga^}r>Xz-vr0Ip0fQJOR`qbJ_I-W=Bzo z(Bdc$kw4P2t3>;%?)4&xkaL)(F-EkIuOy06Va082U|*ZqRVZo*&JdrB&0{64_1qiJ zuJ$O(p1DA65pQaIY4x@?a6*w~MYw^r->-Ia4o{nDr&c;>gXAQ0cJ+k|>CzFvfg6C}*7jr|!t@shKgrR;^@Es8anqq zpi36~Ry0CrJ-J=MaW@@7=c|}pdNo!^tiB|+VllGw>Fnb>i_k}~PSn2WCJ1AaM?mA8 ztkk5`kjd-Nri+$kl20AdQ$2&p*c&9}SCC3}(Oi}q{!CztP+^eDU7;)(;SAltINo}q zfyM(xW<)Ya8~W_QS}B;r=DMmn$Ish?zWm={Yv)CsZk?}Nhci7EAD%X8^+3-pho@bX z+09zaOoCJDQ;8*-UklDT{LX-no&D# zH&VcwF4T9M;wG_61bW9LK|MZy^!CL3dwANjHb#nv;<^fo!kpo21ms*@FEPDPT~Y6p#LoUvS5fjE-Z6I7a!TkLLrl=L$M$1-WOyGqs3qt&=3<4P~(OJ5XA zc@H7WE<>eTXHj3yFMd84G}Nd0JdmMmiW6ZjPr%pEcVZzW@n4qYRgw`#-0Av1% zN`f|3l^4I2BmLF@XynDKAq8}!T;21=(uzfuXpsZ%>_ zqEDVx;U=~VRD+yo2_@E?M=DHBo#BW%S}&xP)6H3562d6UV4BKv>{fyz7wn)!Njzkv zpe7)&yO0yx*r(2}dUaFT;$msUB2#-D!ap&tYbxTw3dqOm)V(0+q`%SrgJCDNN*BbP z^o=x4lve~j>7~6m_N15GGT@V5g3F>$dWqf*{A4p!+O`)eO+8%}+iz#bTj_-XC_7l_ z-C$7qsV@OS=_j}p3ZKfivECIHWexphz$j}-E{8^0Lv(pK z${NDoJ|3mfNeRd(arZ0(C+Rd(ZB16DZ)XCGW;EALvo$~pUc9i2Hfai;*5 z9jv?+!LnO>G+AdT3a03mCL zJ|aTaAVO+=Jq$?5;)J>Z6SCx1FM$eaxtK>{wawM1JZ#@Hkq^k90X{JB+L2U&=m0VT zfBy5)?&0x=y_4gM-NWO9GjYfNpZ~lFqF|R?G!g|Bsc818T6VPVw$H;8pJ|{?Kiwt*) zl3C_m3zO-30bdv>vv$b+xaq#Eh03gNy=Ad7ZOUE1GFPP7mBD3d`8(oe-ici-jF?#n zzc^&3U86_P%qGxG6LQ}HHB%#0Ea0}@D{iJ$r>?-6_g}RK0-agJC$(W`7WgFh0G{bY zE3X(m(;ghU;%9o~xdQ;r>elaE5SnHww+x1+PdncuvJqKl);hiCBdiA#yq-NJzEDWmIAo87{ zYSvfC7PJOe(<|&Fu;wa(HGL|+CuGgK(Ps&0O>b@P7F*NDa@F9PUQIo4bWNYktAp3{ z&e?a5ui4-#d_w@6m0Es5gw45RxgUtlxkdW6FgA;@k$F)z&5E=JX0t+hKA_Dq`5L6n zs{D&#ZI;<}2HVsEbcKd_pm3Yz`nTe3Ruun@B5sxy(~5XmQOviFdFe5odj=&fOW=jw zthCGgz?)@0^~jqQW)B{Fv#D-!dDc>=Zw`L5Nh5Xmn=LweMBr@gy9=XEmQ_FhqVExS za&^FyEjs-gktfaJ^<6R*`Btzg%5eMNEr%_rZNI zgFZeY(pgpe2q$=&Pm7+c)gw;&i;tY$@Zx)&2;RaF-WCku?feXj?}uQ!3r7aCF=BhC z@4+d`LXX{vhLV|lF8(g1OP zV5c>VzdE$jntD9KowmZA)@knk@lMZC!f=A(M!eHCs!F+tr~0XbsOF>XvgC7%ic~o~ zIz2hN*gd`&%9BCiaA>qmUOxqMCSko??@FZPOESvtO(40&tk@e!eb5JVsK2i z{CXBF`WYHy%CZ^wO&RSFoE`jhcz$vAcag~QdxLoT+h+P)RpLm_=5Ts)et2=Bc$9>0 zW~s^9>0c$D42~N}@VD!DI?b6h3^XH)s~Q4j;FzfAy6K0&Z|!P=9}mIn{!afz5TQ#9 zlVE!%$X?4+8__cMEvQn_|D`nT#}f##^bdB}~CH0UpAhZ2}Zilg#(* zaCA(V2=h(Yv$>ZyR!EIJ_7|Ii0arVU0#77>6%u1}){H&TUoZhs0G5(bQGeC)#x2m6 zppT}^k(558Qig7FTKcP*XL)^8WKf#nYbelgq-kvV4h$`;byoG$*_rz6XEc)qRKB32 zI{m4&q*pfUSZi?t$e;QWbd32gy!1C6P2;ARMNdO=lwm7pxpTz8k-oTsfyBy?GzErW zIy0ARc_{rWCs{r%#QA^4-dgIJU)`DgazdP;mmzB{X*pF#^Up%wWQhLdU}5{EpKh z2~zv>`n$`Ssfo;F;5CO_C_ajCNl;O5vGJV{aW>w!6DUO0MhLPzL&b451igsdB)cPq zvLhVFSmhk|l~jd6iC}j1g$eie2{#0<1_QCD zRbns%+ux}-9%=ZWag5^gSrSs~@&V)Q3wu|)n!LZOiAPLf`eU^!Rw?Z_X3-!b!(l~! zho{MnC`&fStp%oiyWU!0c|RTQEwDtoKPdiUG`@Q*{?a<~fr0TCwz>i|{_|4HH=*$t zYFQOrz0XkZ1+Ly(l@;ODdn(nASML>QRe<&F64e0K+fuCtvA&MtqLB4<^d10ay*+Z< zp!NEBH{5!E+xc+o&7K(8`PQ-PjcOMJulJ2RjigtEUhgNqIDEaI-ZJ?0eu~Qi*!!t| z>j?Iy)(b<}cd^sE!LY9(zXXVV4aKEU>}zN)4P#$J_aWifugGLy)h%-uMzZf>vv&n$ zU&mk>Ec-f|%YoU~QC%L*zK-&@2xnhoz4wi0Ut)Z(fc7Qc_l9V1=e4F^Ca<+v2rrV@ zN}X}4C9xH(AKY>amdMN-o#T7FB0gsLtu({WtK)l0@$bgTYZb%gHd^1 z7T8=&sNz4&K#btETEJ7J5z#MWm|Si0XET$JTx7Fd&SZN8a&Vc# z)ZTmoYEM=~@#rAvyg?L$Or)|2GQ7 z;?vAMBaKu<@IsAL8l(%-thZIMb>unL(V%FT zFx1Jn36fv~#Wh=HFf>~h?GClcUk{d2QP+b9OKGf}JnEv&mHOt;QW{lK2ba>)@{e#S z?E}#ANmFyRz4yt}NH$4im z)WhQ3tk9VAsvJ=x936FU<*{9`*zI@ISt3ski_1Z5JelDCeUa{{a{Tm&CNHJpjTDk3 zL3Xa%uM@El5?-0m=Oz#vqT+fVBtZ@OcwYH4uU!r8zx9( zUXY|OC6EXwRxv@G(@U8xv`oPi7o--6xnR8_XoM)!eTU0Yrr0(??;wf*1O0nqN3M8>E+K_h_IRPckTUjC_F&c8h2}2qQWDeQ~w|tdI zoqKUy2I5*b&c*i{s4HbjHUx)>zeI5to989x;0Uib^0bo6JB^-(Jt(&;TBunM7Hw5V z9^152n;vv&Qq?Js&yNZ{fp?|QER%$dWJ zBi&?JnGH3FBK~Qxt!C?b@`OvJ5Biq&_Y_pIynamuHfZ?bCr{SWGAq~9tGw15UKT8x z0kf)z-3Pz!o*f?^|1|VeNd$1E845MPO9|3>n=fu-C?JdS1YScNOL(6|ynbn3n?q6s zU{^(FsMez7vsR>gM#0$+yL)n8s<^AhD;H(TZL^kL!q8Qty;WGn>64sHfR7KQucd;5 zzMMl6CB_3|E}X6eqi8Oh3X2NDP*W@L|MBp}+&9Ol?vYbAXJ%;7Zmv18b%>*)mj$U<+^G zTa(EVy!A3xyR7!S)4Szg72k*;JR2;{E?7(HXEgH?`x(u=1g-M5H1~E3lDn-U7NxxeqlwsIrG_N` z#l|4ne=7rcsKBoxStO?xnx|qXY5%PV42VF8(+SFoV4*T$-NDTM?vZlC-5p+QjaCS% z*8{rCiL_0T$0F~7O*1m(gI!L=d3dJa%+%Sq4cjFNHFZ!A_UeJAX=fJ0q@Yk()XC5LaE6CBW4Zg}DJ&UE)=|)e`;t!CQ5O^24o`1gnEv zt&;Mft@>!$XsgSDrs}qr5p{4p1l#?W-}MJJYN|ZdEup49;*72kXS8Y*CBPwGEi;I| zy|_5FGE>whP%Wqs|8@wz-`-KK))II^n--YRf9vqt`+^lx>@>WPS8WS|3Ar~Uw^Lpb zmXMeB;!r|fa?2nIc?m8HB;+M}H#kB?MEm^%3BB}IdSMu$4i~>!4 zpyz(q>7Jp3T)y{;9OSaTH{hU(uUdm4tPkG`UzMB4>HvicQ;#sc5LY_AC6s|(vHWAi zS$V|bR8)jmS|injP-b>fzIOVmrTK0~&&UcUH*`r27)j-_^+ac)1B!n?1+4FB{ zaDRI@8|%P+n2qx((%D&-RC-mmDPABaBOLX|eLK*C(=$go;RJ8;#Wo0Hzkt~MpTQ`Krx z(z|i>W~9%-wwjUNjc-jt`W&2n3F)o8yXJ#;lcQ5Uc)LFCG)KOJt+!^Qchg2gHhQZN zt;y)!M0iX_UqLduGvlo|fpkez3MW*J@VW^;&;1b4JqHoIv)&{QsD+ZXegzM zmpR{_O0y09q z_x<+HcCUUYoE!bZY=Y~YZ#SVy#U&Y|S*IIv|C@^|fCtnFt{`uX$R{e+TvIqA31%cS zIf)7+HnP{;B4$VxbW+AEZbH5MW$=!QX66I$D3ec%L$F5mm%^bPSPkbGtg$WHkO?Y$ zzfoiYk7Di_n4l~{eYSn2O&)xg1UOknIEMlf+AS113$mFqTVj< zw0NBlA0&T`SSBRMM61fbaH1gct&ndmYzYv_7Qql`AF8wjVp_O{u@J!g0h!`&*4-u^ zTQ*SSW+lnbhT!?2V|z#{=Jw4A(kE*wBD$!(G6*aZqFlZXk?vK_kXSdw0<%F!D4ofN za!X#G4Wwx-k7UHEjB2btZ5$2=xlOZu1k;kp$BOlW;$S{#^8Q)EV=qzeF4Tl1BvZ>e zeapE0%&Zv90ZP$IzMM7d+qwEiu{-pxav^}uuCt@+x&KBjv|ijggn2W)OmqDc4Snz9noR2uvx;$C<{M<~k*uoN9U=nivg5Oi2{Z(3GC-Rt>n^`bb-Kbcng1{Q@d(Q_{jk??} zA)5?APi;|H&*hGUlFcrk!Kro^LD8l|@LO>%Jrn?vz28l#>dUKT(J;fQ40Tw5BBo`cuGWvl z!Tno2o;ZA^^8e<_q8dY<^?%?~^@qzDLv*RO`3u+{3`%^qn)puP?ec6(4E_ebcV+Nc zh~Gq-Y^1?Rma-D82Q@|rnC4;;FqK)#HVeme{wTc!OEK2v#L=dD`)85j%BY2hA;s#~ z8sE=oc2Iy;l&!BUvOw&I6s=2h5O5bL`f!D2^aCRd#>*JQZ!f36Z-Bo6j~Q&!hiL{A zNP$uU;7Yjj=C>L8EI>%1bCqLA8F&e%ILTS*zD);M$-pHXCYV`a>{VRcXEMa^6lGH^ zu?e5)+zOKxY<~pyCi3W%i$iYsdzeIEEFz*6q(^Z%qY5BYXhOm-QO8B$M$|I8A6$t#=`4haS_$g_CYoq!n1yDdt3jI3hM^X{<007IvGFCg zUx_!5026l#FmdP%i_hr8iQlf;=>w1CZV=eSbhniGN~61*YV{cBT@&Z+_PmFdp6Jix zt2XpKB@oWR*i9wGw>|Bb8ojYKRfN(-? z#MGfpB!Ucr5s9M$pfiY&yck1(g`(`gg&Vr5v!7+2GaZ7DI08ZbaVY-%3H$-VoPi+f zfgT7(J0CR%DU~>KQsThCx&nV8&O0t=#gRNCAWtL6q{oD*x+P2=wyTAP zSh}rW9Bs&qf+tVbt|WccaW%r5<|>_=YrfPWJ~-4`?_81fIOpn3XP4X+#p{QcNp|qQ z)hAW*K9Ad_&s}Nvt1iy=(rD@>hdUHx2p zmHPV?y3dN4 z7#$+H1BH13OIv_(^tMRK%m68O*r+zwK>sa=@IXf31Y+>+(}~W*EsxI+W>KOQQ@G2@ ziM^O0$8|NHEw_?FecHNPh?;6jvf>XESqxezuazL*jo+$|u8jN3AMk0GdoTf=QHNM* zw@^&QjDQqneEk7bae2haop1p)9`R20GuOX$ZQFW+P$U8urikIGLI$}eI082?OL&2E%fRZD?3ToFvBFL zfX)&IZ`Ec0BKA-gG@0K)ln9rMtE&&dv6({EePm#-xk7oau?32r@)I0Y4jmin!#JJ5zB{6?*)epxr5xZtJ^uX1{D`omEjfGkX*fYs z*#9Rb$trma27|$?7ca!WgTbKm@Amf2_RgQSU%Y(1^XmD_*E`SuG}wMI*nagVFj%D! zbw4>}F#FSBX=L;0vsuX*{81!a`vobqqIqIho;^sK@v@3BPK;7R`x@Jj=p<^X!R^g)2r`&NWD2$i0r#X~$WR8v9GK)&l;IFaak!hWt_^gV zPH+^Vq*NCGdcvG~Y69ML-=$EPf-s3K-6&R(&uiu1K0Br)H(lp4CUwYW5Zv|d+G2)u zg_Fga?^or`gP%EtMOv@YUH4HMli3s{?Bgc*c+Mc>i|up7WbfUFXi5_Pr_0Xt-{O1T zf35HNpXO`TnL*i>b#P^f>hw`+5?3Fm#rN{kVYivOX{e@^Kz)}7ZQZ_!pIZa9P_Y$J zDO_s8>L)sbr3h7(c$Hu;hImSQ4#Cf*-bC@g%gP5W23gYxhV3~gIhQ^%zM#(pyBPD| zDkbFeBg|E>UH2B=_5>tr&HMKWW}L@QxJtifAObCe5wHg&l}~N8jBj`X&Jb1pt8MU7 zwmsMAB}e@^2QQC8j3S&_d#$1e$C=Qz__{_#Gwg{`&>j}+#z8aGm4-q8$fp`(kK_rf zt29m+8khP)yF+noZUuFly~dkZ@d#Yv`8WVS4#43424H1(cRK=CV)43+#rL|VPzLJe z%al4}$)@=-1SXzSWr~>I1da6hNLtigN1TbMK9wM&5Dvt7iSYf+ndJy4;^rdCa8h34 z_+53Cebqt3zB#PuB1Q74&iaiIgE*#l(?o``1}CZV*GPVi&0_MJDUMJO!pvGsft)sS4WeL5D5x_DSUe%_98;;Uh3N8^J=_0X%gCg*kzpSl&`V;^>0{vg)Aw`7YI3bkb@Q6eO+{Wk6 zYZr#l1ae9!DG1>N6d!^q{}SxRvAPl$OyD(AUufWUsLt(l%!i>#u2FWAVa8A*PVz{qz9gEfImQjlZb2Y1+OyzP+kzW zI<}l(BhR&;kc?B&H=3j*qTq6-0#I=>7Anz!kYmJTi6AHdM?QV3h%0m5O%a*w4uTlK zG%X!p>Ll+s+}_UAxv4hVwOxH*qJ(;zksRDWnJpZl%Y5vJYD>x=E+{3#;IF(Ffs-G> z#oGgLdb0mF5Cq^7(#hJ|p#_7e&l8m48{bJ^(yYR9bu_O$)7Iv-w)Uf5(Fz>{;0^7S zqdH1hHWP``eL86>nB_^Ii&^Sv8j8p6V+DVc{?RH(pb2%BsQAJb4JASZ@oP+FTPu;3 z8mW<8?t8h{9`tOu^`0iBjsf(CFlQ6H!rTN#VlY^@iz!@n8JVZcZ-90Gdl99i5A-*L z;K6Et*1)g3XUB)fKgq`U!9)Y{;k4(`#v4}|Vw|z-{zf>0J<)sk=qShlpg>>0P6=fq z*e9MTM?WfGSyaAiA(mGX3LL&X2AudH<*PJ-n)9{riva=h!(wL5}{L)v0fkSQu}_%P4RIkE}f*rrs8PA zxpL$hNAfO?YZ&9GI42{Cbq-d9x%OAz(&esD`Bj@WZ&SytbBa!th~f$_*Q9+#e$2>J zhEQ&Rb&`Q~G)>v8+{>_?Ax1aKrPPR5F7^fbL8CTgJIkKXO(&|42u^ZL#9|SRLKTe! z72v2g5=`Y51a~hefZ}>Q?wTMNPC%-#?$(QRE&k3KCFOXn;*r%@si|+sDjy_NUPG+= zhKdT7Ax2aPcP)eUi^c$0_QgxWCe5wuSe3tJ1tlB#HzjEv+cTcHN>zkCVJehxx7|c{ z2=uSQ*sb#b_l)0~7aAu*Ys^y!kMc4J6oG3juUrxCaG8dCRgT0Q{`^^QT8#*wD!BFR zAD_$cFad|i$55Q`2F>(M3Toc%3LDh#>df@%b=bEgSM2tQ07?G**<3#j#66ED*Mv&v zfWO(|{xPy%ZGsWbC{rpb&lx6>RI%DrS+Ll~Wx`2Uof?Gc&!4SyN6K{>$w~;l!b@_^ z4bSl>8d|lsg=mK+N;90W5$OGyssyY4pQ%vvpJ@-Q``g88zm2*wPTOs?w^^lSzGmciM<{^9wD)7|s)Ur)~V-#lHn7m0FOTWkU5=t!u%Pfb#YUZfH5 zPfC&$vdNp?r+x$(?7LIw=N|Y&;A6dd0fNXi3|_;KBnb+|N>Ws`1|r$~AL@(wVmB&~}qPZm>xtc)ZHf*?a;P5I{8F}gYH$0DwJ`&ZMd@-ksZ5y;BWZ zoRY}ZUYIVXye)1QTa0W;{q;FBN29|nHcjofcfs`v&FYz6iM!!f$8&GDu9oVJ+W`Oo z?{Wes{G{UX#ZvXOj96Ky>xJcZVN#-(S3a z(<^oTUeWhW(}7^pd2#|lI)^*#3hE9F+=gPxM;30oQC^vMF`7LA zXFiAaYfzcx?cTrEid$KPU!*NjJ5YC~zluBE&Evdx72S&lOlZ?X)`8c-{<8f`na+Ky zqh_aWm2=+O+A(2h2+na5B5-UMwzAbW<#;E~W~|$h7MmtDO1wlFa9m~S6_ys-9&egE zmiaessRDi%EUSo@wgy#=<2i6^tsVEWtLADiml}OQCS={D0fLOdyY@`4?Y~8 zmexB7iO`~_V(wA~Gc={Y4Sw%a23bz~Fp4t1Ocl@6AD??06_HL)&MrzqtwJ1{qEOCG zoPVNp_PM7I2WV#-GusWT9wVNrCgF4JQ=^Nr8+G@CLD4B3J97_by4aco$vb3V3TJ@9 zD+C}Ip&N~5ZQyBa%HZ%+CG(nXw!Vqd`4uvKUy?8?dDab{IV<+-MbArbI-9^Vrzf2s zoc(gR=jlufi_+a23yN|INj!te81Y#*LbpJ7KKqK_K2FBG^MQeyc>D4n+4|gh@7u{^HWpr2VaE?~^@4GUL?VZ>CL4VNS z{yNN}>Na+mmVM{{kCSriq`dID>T#mBC+d3Zdz_{hpQaZ!&TnYyZa!jqD80fnaW@Uf zE1$L(x0J4`9(Hb%d!NQ1q>1>qKKx-!rlsV@K{!F-)fo}Dp;GWe@55bezEmfep)}dRj|P z`wP~7Gym?GfCNbh(p=Kp6unr63Gt8QUqyU1MNIDRs4IzVzwXI@IsXR^kN8}_dwc;( zZ`S^P@*W%?9PERO6L5NP_T$Of5!gKj?@xa^+uc6^Kc1Y4C|os+o`c=veemna+0XAz zcEvY89Du#EgWZdR{VsM`u?P(jW+JK;L>PY{f}%B`Ly<04q)A(MT!Nl7q@FK@Z^I43 z{(L7XGUfb_4-*upC=-BktK!j?|FQj|g8%dS5&!3Yem;HL`peoLNoN@zPZ;>+=mutJ z6C5UCf9?Eq|9^vbI7A6W!C}OWc7#zj1iPoZdv6bdo&I3$FI#d{+xiQ*#?ztf-Z(}g z#CXb;8&1#=6vfug5X-YfU+|eC9ZoLCQa%?;qPz#n8(vFs1%ZmnF7-}{yr~PRekuMs zBg9%6N@kOK$1v6!L?0y#&$8hvMBvUA^=@ ztE;J&L+ws#RZ1t8!){=w4>#Ah#*0|G=5^l5LOQszFGr}WiE}r?((*gh7L!`R?KTQ+ zJ6_9oBHN2w*4Xw105muh#56Xsf>{Q~e6i6uu2~q(x?WWeL{&_tb2(qwl{8+6JLJ_2 z9saJ@W|lq`mK>I#O>r>gs>`s*fljeb7vw!voo`AA?|xNrrSnhov8B2ZplEuHvOO|Q zA@{2P_GY{PA{cCfzi#(m2ZJ8y@4*ya;utfGX#eN@5-BSEpD@$XXnJBv33}+3p+qQR z>w5bVF}Pi@6mD#(yb=^Ao_N>lBq%%4ia8s@i_B zsE%Z-HS49&<2>5PaMKqua7nK+Q)&>6hp7NUBCSPB~RQ4T54R&sWy)w3+!JLtw zP=YdnlD=Na3=`koV#;tbHmNh^0(K;$Dzu>jBE$+^)O0Q@QQA;58&1Z`MJdNk)~SVQ zg~)(e&F^UuMGJ;+KuBPWXownW+fq+k-8S*Ss(rD2(*x@)jh%w_$f6Ca`e6`etlOr~ zTv|>cGXaib&qF4!)a!H)Wv+>>mv=YH=GX@t-a2{mfVc#7i)*PTlx?%B=*A$X^!AEI z5%LQMsQ{K%AjjNraH3;HdX;AVQGfYIHxZZ9^A(zP()G`1c3men6fv^G)y{W zm-(QEXrj;sJeFt3_tY=FPDJL_ph6OzPlNKK^KN={PYyb$bFp)ljTVLts-C0SE16UE zCe^oHFNb4m12@+ISfQ^A_Z$l(bDox0pLJ~dQi|MsZEtqA=W6^KmS0orw^U)i=C|er z2N_)bHHB&82BRre+s4{Y-!t?Yo$mV2y1L*FzWIusakCUGw6oYs&{WIOnTudhRl)pg zH4U0$k|X7srgoyIp4C|82-#hEm8bbiQ~EygHD-m#N}8^s0q)exz?KZL45Mol$+JNx zud;)dG(1Du`>!Bi)GSp!?<|rF>DB(eO~q~!YKsqPcLSM8U*yQkE3ii-6TUmd@){kS z0GI$Rp57@~$0LB5d`ves*9e=S>;_ZBpN`cA2QBdQ1#M3|j1d!BC}1&qcJFu^qXZ1! zfOS2JZodNkcPJUN$p-iX;DkBD$5V3oPfBpY(hfBuNxwXw>(1# z8qN?N02${a-t>8s`~fasy@*igeCrwf15y3mIGw=bd@5@bGz{(Kamr2tR_$A7%GLJA zBS12L(1^Q9-FGPfS04#sWk?q9XB%auH8ZJb&d*ASU9c#t*ITT;Rm_0oGe5N%eq00x zb$}Y!kRS9a%U36ld;|2P@%F&>0QAIw~;WKro3yl#kOT@~!=M9UHaL*?E&NG>&I7R@*vAMsNk1j+Y)h6Q@ zfhnRCj?t!=7D8Td1aVwDwUh&R%$BI4L-{x*&RM)~U@ETuDxQ_i#HHV7BqcE!&(6~f z!Dx>plw}Ynj5@RHQY;)0{FPCy9x+JAYIPh~yh0^N;f~aDwT`OBEGr)NsusWt{*&MY z^!}0btfGMx>1E35kAa!?|B?K%`|kb0IrwGw-QoW3#o@{E@E-|xF3d-N2or7ht-vp; zk}9mqVC~DPCjiv7D#|GM`8%rb{;wN5x|?Xp=~^!xDE)Q2zx}G~c~W(WSj$-O6|xRN zZ6Pt}o{|V$s1iUH0X88nrbve@yeX0h9f7~S0bG0uPKT2SOiR~!0?0buoN8{q+)9c3 zPoRYkWCQUvlNM=f$tD*!`%1(*Wbo>oK*MgW?v5JehE7mT5TkV;$3{ajH`dW=PP>>; z{bAy%0!1+j8H)N+9aC39F~5e^gFw8qp1tYa-rnB!HY%-CT5nXt zHKlXZDdqHS(rv%@*dgWZ) zIb8Rgn=f*(ZZ(6&yP|n@{qDuPbC0goFe*Ostfj3}ciwF(SVannRI%Wd48|sez%JQe zcFcFr2bK|0v^Qow@5;cmV1^PU$iw66{R|SqQ3C0}-TE-{n;nDl3W0S?f!8B?V7U?xK}8 zBiWaYY+X8Rt}h;u%)MZ`#xhXlMr0FgS8RfH2Kv+O=f7&%TL*hNXWu=&R81^Gwf0?e zdw0!i_X=yNppHc+gfgz7p_AQIuYQyCA~V;XUZL40czO+Ez6z=1>AlfgiutsooV@6j z_)SPEIQ{|tl@o^QA&9MCzjTEA<4@C1Ise0)ru1iUx}pq_=KPPr%h!X~rTmYVJ3Ej0 zANTUZJL+mOKpH}StRps&Ww}-a><+>8_SzLrq9Kr_j$pbrMGQue!QmPJW{6zwU{P$n zn(f|U$Ba^IoC6XasphH;`K~WlU|G}y>p<>7hdE_r`cAwq_A}*I@~Is(4LtXIl+OL+ zr={T>*mGp(%RqqlHjpZV4g#vNUN>5CB0U6u1j4a-0^U=U1$;3YWA60|HttFH(09%+ z3+_25;`aH*a`klSw$#+AtE|*P5^*QoSDQ7UTu4_D5zai>y4Ll8eGwIAIJjz4PHDT^ zEY_>lo0sssit`Jr?%vr`cwTm4>GX6fpNUX413arz_d%~$CyB2`eTrTy{PU(J)n&T2 zsVd*0QEl$4ZB;+zC7V?f?cA;{AFQNdz1pshvwj3s^R&|by)8CP)h!`)F!d(w?6i#B zDZVp*Pt`quJ{mfpkIjQ!6(l4=!Jyx{`~<-$MwG7Z(tx>ehN?yXm6I%=Rx}lnnCWiS2S)M>eT4%Mrvexkg#Q@D!2U-_otTG?fcQq|7CS8H{5T z2XvN%3)V-|I2R=9qUahnOw5@(EiP!lC}BZ{WbX;&txfIXd9QjB=;VR^E6wRdMUhIx z=02VHJF+`^7P7StpHA;x?f6+kdSQpcU#qn$lIoI+?DF~#-Leb@F{W&F3&583-(cr; z`Tob3&tL95uK(`iXO-(ecP^9nEWhF?`c7`cF|Nuo`UdirWKlfDD!u%|y;Kc{ZZSM>VV z$z5BnamS6ODC7;vW+@s1gS)o|G-mqY=@1-~1a)2Eruk)z>ExKOGX$gAt_CeNclA3} z5i2)oNP+6wEKCDYCAD_oMTRB(I}hu*?gmkSVO>G*oT~@`01Reh#7>1^g{Ym>VZ_us*P|98z*kiDnte+9j#pFH1v?rro8=>$~q zajSIe!F1I@0!>Lm(VM61dIDDaumJ_zFTvAoc_keH#XWWA2>`(a2t7;)W#At^1cs8& zAU;a9QZC+|OAvF5qQKp?|5li~o*ZTibVD3}{KHL*8kGYoCwpSkmfFGqjqzJU-$xxN z{NMUdWU5nDCjZS(C9NoplsskOR^enU{*ZXvcm#d}y{G@T2Lkjj(A(a5(F4E#4`36N zs83KhA)t4Grzip>XFdJ+7BjFd{up7Kls1PR_^&shcY1RClb>Qyu!f@OEHS=DkA3B_ zuY6;DMccuTIHT-Wl0|Qxu8WUNuK<4lz8(ibvJL(K@a76U`y}Dso^Joo=Vu$OU5;C( z{nD_KrQV(F?jLq3c(i+dad5VO^6T+J{kYpYnRoSKeOJ@TKY5b&%d5YKJ_wYo?8}Y^ z=Lg3ZhsOu+%)s#B`2Eoj2WMTzhn|nd-mXwtZ@HntPx|2OqC#Ac4Bjt(KKQ#=nofg) zq4Z76q%fn%^e@_nH&>>IZQ*naeERX^{qg>X!_zn0vVL3Kn9Xx>iH?4g#WRLRe- zd6iZPdN;1C>mvv3_Q9`It~3VvsMHLFrJ|ii#PT;Q8Vz0QZ%@u0UnecA$QklFj-LN9 z4%1gV+Bxfn$KLbUd%oq~^N>~&3-{L2me>cFM4pZ-gWEntIODx7!kK`E>pdOlj>Tzz zkhhP$8Hgvnr^QEGHM?&;-ZTFA!}h-IaFG}Y>uLrLm1|tYFq8={5v^-Qkt`c+V%4y&EnXGw=Uir0+dSv0 zhg%J#0&$L46NOl5$7)Vno<@+NbCyAd#xtj6y;+H<=_yJIvDNpo6&U$DoW?Dj{5EcO z58cvB(=92%MpRH_rZ^E1W#=ldrVJ4#P_}lS2Lq4(opJlB01cDCih^;8ysrb6CGbmc z{?Ja5>I&9?&wo}h;&$i~-`Oj&Zk@teZ#wOuKorZ67W#Q1O&!+e+^M1!%AM+2S&UYK zR;?O#%sAIy2{cTaPYa*}ISkNBx{?3!WHK#NHZ26prrt+<=kzpdL9z#lxt;-?A@tyn1$GiHyv-q3C zF5qZ{6U=6o9@LYS*s>wtv2mTVY$Hjr1x=#eM)A}qSi&$C*I=?)>K{iHwbKj<088UP z^{gAayEtifrM3sowmBcv7+$g57R5W1Js}b5EI2RRBDqaZimk4t7gwjX7Z!06dX3v& zT$Ze>UdJB7>7J`aa8cHNx{{vauvyu#&q$22@@#EAT#4v5lNCx{hwyCGqo&A;YE>t| z%IYYtKs(Jyh~y|PFS00RH{WaDbkIVlt2E&RMR|-ecS3R=d$~K`EjkfJ2~`-rrl5J` zrXn<4+k3(J9KX@V4r< zK^i|O=o+vZYt5~5-|YN8{v-TsuP>(V;O@I8+{Q2WG`M)zPtD7!iPg7tj7KP(g)w?d z$W`f7-Mm{#qn9|eL0E3Jc913cm+>RoninP4Wv+4p_~G#4c=zb=!~Ve!?|*Vi00~X5CW9=2 z*$ngq{6FZ8U`$a@fP}lFeS|Laaf=mg_$PuwCK>5)Z<3#rP zYvj8aSZj-l+%*(7E(ux$vuY$LC;IOe79w>6*qyYtiE zt&!hjER{>Y+a=n6`{8u=Z1?DVZo!Jn%;)N5?Ssu78FGVARi8p&pdBRFzrd_AIIqC3 zaL9M`FPa@WYi9QF*gI-^tL{`SI}p{8y#BqPb`sq7+tZNxz#TDLY=W=56uuIc@~y;ip=AtFyP|bZXGJA zT-RYJ=n#fPT+P}fN_lWFuluQ>(HwtJVmSAq#5IZYDLNu~Qs-4WyQ?K@5Ya-XkWGdi zc4ocQr71O#^%PiUpU~an>sTFkmvEl_Fv%{04A!&iep61Z3OwJ@{PNWMsd~}IxAyyn z8O8Jc!EKt=%D`WCl*3P_Mf-9+Jy#=(wQ- zTxC)h%iC!;s$7#&l(JP)H-IW)5iTMUN+H3T+WdhD&X;osD5#Gg;<0{T26u;)*ZmW`7;=51mi>Ws~i`JKSwLpzCm- zSOfKg|2;i9yEr)e@aw@3AKo3FUmP5N*xlbhJ2*C#oz~O&OC-F4!JwH%=RR47pJy9`pX^L zHXQg>ePYmj^DtZa-j05nxUQg~7dZS`VN++N`Yh1ae9->M)6#MWYT5ShY7~o|HdH>I z|G1${;_lBI{2cG#z+uV#PMD@ahds}`IW?H)jNlvDgta}syW54W<+AiPY|GB9iaWv) z3QNB73QqP7+4i)$nqEU1cJ0E)#WUq zYB=+sD%8+uJzto2e;2iF@G7ODe}Q$t$T`b!G6w4na@HDfmM6RPJw+MV*Z@74Gt#T> z3WO7KlYrn1e8dq5@{dFD?@!}7d`^^#fV#c8#Z_9E9Bi2^7Q!; z{V5}PTDqBkKJjb3tovvn@41uXtaj%HdWM~YuQcCrhF78mgy||ki;G+3xRA5kL}qTc zw}IH5JHV~HAD%1hbAulvQ@&JWO1raJ=e-oI#+`>i7V9#5D}vppY<0#1dcCqx-KOmF z80Kgt&G0pj(HKRZ)rJ{ChhR`YC@Ep??tr?)V1;o3it<8(Xyx0vN6s>ZJ0C9n3|FK? zF-E;5l%3D$0~%8&go@tTK-p8I%TQ5yAnWPDEvXB+G`VH2R(!zT>f<>;f{PzDfEHzNH+H!x?KdDcVnn^Q^>st-be4pj7)ura0>R*5xh9Bp8q%-Gc>Jq zgJn*)^#*H0-F>dD=yUs+PpK_E-t(w7nzPMt%CNZ_qt&CV`G%Q>{>~qXuD(`P+Lm3k zp`yMKs&<3K_DH5E!uhn(XTkqQ?=WBG_}iy(r>~Y_$w=hWHup(=yNVp-4mAY8ylD07 z425}QTZQ3wxT&ejPH=}&q|`N7tnzYe#t~=m+h`Kt(s8Y zl62~0yrzd1_u33lZ!Y4*PME{$Z^E(qKs@i2IP#~%eIH$J!LjRCdpbLC$D{@B{b;B^ z^dtILE?@Fzf!0x~lfE+a+KBxCK#};%U8mByqYUS7VqSddYAX<3!#5Rprh#HR$Jyig zSB_|Ti-a7B%u$*7Kb%S2c~owlxHTE*e2msuxZ@XnsB&4PFCbD>@PyW;7}>tr!p zs#Yb5RKxef7}5gp++y4$6zGj(atY(2jQrc<#D4N#$7=n07x(|w-2a%;boqN9^-uHt zkAt1pFJG4Ke|-7;)!^~|$NTvCwA%Y0VG_06-o-78=BY^BiiF3eJBD?PuACKlmlG)6 z>zIm{Ti5?Tdw<{6IF2lil+=^R4O-aRw(0bwhN z7sv8gGIXZl_u9Wl54I&)eo7n~dgI+QO>C)DDwRs5s#0n98d10_Z)zNxmDe{k+H~J% zaq}YdeS9}e{9(t$fj)<|4|oFE4gfN|LM+fx?4y0~-G`Xa^Eeog@5EcCK%`4}56nF4 zwJxQx8|ktzgP#|vJjH~b3=cEB*^4to?{IXZzZ{+%WiM1$VDc^rMLfrta-;j4P&8A& z{~mi0a*rY$9xC%KQQrrrrybRgxIE=*MaxuCur3gSPQ4)htf9?O9t3lSHIpblV!Ko5 z^8p~wt%sV=st3MH?f2g$Z-kXH{h3%Q(ihhK-**)XMA7pa@}|7z!*dH?g9SFd;1 z>;Gdst62XFuX7d)oR0(4Ewi?&mZr^GhTAF&uWI(!mxCd>JdxYsg_eIxWUAK zQnr#dZbkK1dG&N1gGg(pkP;O~w|gvV@vzxCv|X)X-!Ls33)?iBS~I?y9i!xq;s&OR zuWGa??80N)DfS-09l07sy0Sg zodEfYvNoYiL85P8=eJ66K)1lomTB?1O{_ff2DG(pXAu=BABvF7a#ee&uoQ8fQ!U?~ z;@J!vtNOXT9ZTz5?FBhe>*zeR->Ri@9p(=EYnc4 z2r~wjQ=LsniM1Cr#M)Y+7+Z6vT8s2dvTbt1OHn;vsc|w)I-|cW>xEvjUG(d;$*-8s zBh6pe#an^#l_)P1>T_=9vyFlJU4;8ucX(Bx>+LhN8~1ge*U3vF~@RK?{|P7D0}hR=zdnN`RO1mSt`zH`BLhxlCc!H&~V% zrWyoxS+Ck(Tx)+UGfVmYA3A(bwxk)LV*kJUZs)DB|9|)9?Rx+JD9;M^|8{XPkgOzQ zIyRHQ{950eSd zIt1--$+I*^RGAr45&13XK<)O>0~0+O}qM_FIk3^^J$sm~9mnwi#Q?Otm$tw}x}AR_j}9>j8OG ziffkCN)v5iqSz&kv=&>}l+v)L=fh3ST@HpmB2GTptZw+?S<}mkWLBOq!W28$-v<|m zLyMtIMWo;y_KM6s#n2nsd)hLwG+!*&u_~31ss9HeI$fNxXlVyfuK(}uyxuY6f8XxD z-COJbkMXpu|0i=)vQhpNwNUBL)X$pMuUSiUC|NOSC#kTtl;216EOr0qc$$YFl94Mw zM;w$$Mg5~L32&1HpK;U0Jj@PToCK2y5h8ybCqGJgnjca+mddNBB2{4icLMs$LoOYG zS|G1#7u!IS#q|Xku(p945FX#_$LzCg&$tk#CpDKhuhgszsmAf?XsCh2F}=od-~tB+ z0dmBUQw%VjqA1x}un{HY%{9L`$)L4q-&KoR8cB;A%_Q&5mb6p96zd%@*fl%f^L5yE z*REAnu&20VNMXw*v+@)}uQXTWHWld)FHojJ!h>FrW5I8T*}mj2Pb!fsS-r7#&s$A4 z)jojQt7FgM7nSXUE{$T;eIAnoSaUAvw3YmJHu{jEQB`z^UA45v8mWG;z)+OTT`QVO z!zz&SYcCgtD{jTiG#<|X5`009B`dcp5bfF6fu3fm5J)|G6_1rg4XHUw3lX>sktHZx zNScNjQVYxohWJYaav0$mqEi$TK%5AMh=4JUG`mDC*<9y-xk~YvK&d4rtK>m?qjP<^ zkgM_)0QMtidl_r{iZ=#B>ZG-$V?SG+r z-QK}}`BcY$y)*28uXpxdukC-2@vO-H$Jof8y~ko~)AIur82i#RKSv&k;*jkNe~8@) zqH8nYvoHf%7-Z%JgInnNOzRZ&)Hj;)XfIwCa?fkP&z}NlG@-rAH)5$`_hx;4TV@+e zSWf<9cZLIx;^@)xzr5Xfo6G<5`pw$^^C-_(jQjYo_t(k>|Gd zvCa!R90zWQy&#J?DKwKP1k>Y1s>hL^guPYGBFUSomhv@=4A><=gpugNn;{%4cx$h# z5GjDM05($`;K}U&ug)`UvdXzn zbrH?Otb^^Bd@7_e5t2@*q`o?(>=UPIdNh~>`YnH9dOx53zOZ^d-fYH!4f$*dZd#mY z7UPVp<@yq)sT>Jm1ZPAYQuf=HnmlLOGplA+x`&5-!`>!Ah{~EZt~h)7@|L>t=)r8Y zaW8oIp~`Z`%eo@KgjIcL`p%=%(uD!eLSNqY(Gwf8?2Q=n1tE)=OBJ&6rVw(0iJ$NH z&frYVGP;0$;Tzj*uAe9O`Z+uPmUGxLAETA%-ToM#2=f6a^@Re3@i`i%*N6pdq_AX?-+ zRt$X-6OnPg=qQPDwmg{@|K^9wB^jRSlKaKTQL8el#t`a-uLhqc+gd^O#`z}`I5RIeN6?**kb?u4SaP?5-TvOI zMU%H0;H^`^M4>hbL+pNl0i5vbe$;#cmJimJZ2*e1+a|h^oCU^xrfpn!Q9T>9Yp!d~ z2hFhE&@Z2lJ8fr|(X696k-m_6JyZfzIQXCiaw42Ib(6^}DT&JBrY+fGtK2msHy z0h?)0H@QFn8~x-u-2M-cpdqAF(9LRc{kXfum+*0fXME;vfiB~dF1SzT@;x9^xcBBQ z(J$b(SoKrnrU?4dr_=;9QLWb!f+;q{iM)`K#^}g;?c*b^@^6d|ueSW4*+o-M2Wnyd z6o6myHvfh)VHF)K0KZ7b)O&?BoP;>=W^jUrvG0%g=o6uE7dRa)BRj=}4tx(1q`7L- zm&>K?wY5@U0sF7|etv~*%**F;Cmo4iz}_9U&Oqw%#eXilWI#@PNRX4hszzv$-jZDY zL~F7|*=R|&MZUDuO$(`R0Ll_UmXAo}VcvTm62fp%JkI7Py9$#7kn;vqg+NiU_Rk*7F1>!P|)D4=~Dt&|I*_O9Pc~P{ehjph413b#z z(yqR3XUb^pw6_jW?4!tB!IhWoAg{L4Chj5j@nk*a*2Ek3vi6k*rc*?>IFA;t4h9@GC@!@^8!9Ke~O@khA8s5CRS*p zDXozKPLFhJC%}arUA^alJzNpDDr)?Trj(`Snc{94b^fg%rXP#L=8QAtt_LRpCe(92 zU>Di)eiX8a?~Rc&cYO37;~V2>Zu+eqeleOVTB`RdqXg?6l1`c}ys_F!rbpd46rr{> zZ4@%p;SJU;C|pX553|vbgxRPo@^XHPTUWOR9cJUl%)99*1yJiGkx@#xr&oN1=d!}%~6T^yfZzW+G7&`K2u?Iexz z2ay5G%KSQUDj-ZTp+!Ab89DLzmtN_)TXNmI#VuDfnsstl3D~`ze(f5Gr>|enP5-)sO*WI!y^xf{H?ItxAoeF+oE} z$Q_Q{A3Y!Ca?I!>pLFK($CJ|niPqJ`zX&T6o)#j0^+MTRw+4%?uDv1-v3JqnGYss^V)6wPe+2DuM z;~KM+Pf$KrlX*ma(r}WhH7YS#U&o4@5xrHmJPYzK;OITe2|M%m6E(E=NE~)qrn!_Iy;BVm;cRJ+93Ym-!G#gwY zMn~^2hlBIMhf!n1j^2yL8cwrnVY(_#F2TyKh(z!>xcwPMx#g~nZI%YV=60)mXRGW} zY>ga#DE%xv-MI}_UcT3$*Wri!5Q}h*4=}VY2|L;nc*urzK}Z^cr}9K5j7&7y-0?M@cS2_lt&3JtpMZUsoamI;%@j)Mrf zm_~?9v2Q;qp#wClm>|c{^Wz9L0cln-L88xDfLHQZEyzHK{i)@pVY3u3Ww_*a=mnF@ z1gA?MJJ7#`fqR);7&@))Lm8c$Ps(!yU9VtPM%uSFr=`(q3-eOP)jlze{wy>zjdog@ zng+~P=B5F&g~@3^Y-M&DFrUWsOp&TPKO4|`X~$`V2)}oZn5->dm!9m%D%YE20V)QO z#DWDlK;2SgOp%d(?uQ&kMI4vWVq@3qE%xIX`her0NJZBCM+SHB@XH}jx>W6ZvO*WE zRRY#hU>R$wZkVju9o@Fs#LOj{b0LK#O<6t{MXjQUc!Z+BVIt;ws3o~d9{Ir@wOR*2 z*-ByoaQr!p3b9}x>tbRDR6${}2W~Ww{3~*Mh?)*m0TRtZI`6q&)Cj5xO&JInhWzM7 z4rY@U=`1XQPiJASa&yC{;8h1j)Ej#aq^Ry}7yV}y%^FPhe+>NjKA=&ImNhf{tjMYb z%}3>@JMyg*-g;BViu9${B_HY#=vSTsEm+GqPLbYN&^TsGt64X8%dGqaLzJpeTZbqa zWLi~-lG(yzg(z8LV(tx*Qh94hDnpp08i?U6sv_ve|2O=2esO$$`H$ltE>BNJ7sqFpgQKJK<56*&$zk{XzucCHG21Oo z)=?{qK+uu>G%_MbQmkaoEn72M76z-*oeLiNR$cndeBO)tpc(tvM~P!f_P4h!%R2ui za(=$Ygo1~M{e@f@1(>T&=6Vx?J88LRdqvLR;`Z9~ZPyHG(9swyh(eizx|+kZRbmDH zRxAAF{H-NEy1Y^hO1*To1!y}?y0p1RbJCTx*n(d8_)a=o%d2qGt&jt;a<@W3iz{#|tls3g8oxjjFTl59gVnUXmDym6TUpu$TW*$3LxemRIfkO# zhEG3*TYcoX;HdOQfRIZBO@=6%d4%w@{rd-62BBNCKrWY%&G%ARrZ{EI;>T5sqbj|? zqlf7s=E5!~)ySMU)Xz7dgUO{Rt7x_5kvE_(H2x{-MHI1k5d4S; zIk@s%(2KA3`M*Dbd*H+r^jy#dUCUMp5iXqZ9|r+KPEp#IA;6j zC3;YxZ9T>ySshTC(ptYY&`q1u($qEcYgjQ_0GYZ7RH(Z778g=_%fK{Ne@lR^ToJBp z`+2CuP2x}%p4jU^m7-Cv);^0AnQZ8o7SR*M>MY|DVEwf_xn-r>HIb^fcAJ7sqe>Wg zx1NtC$hB-gQZc#@c8VpFL}qpFYNLS`qXZ=6g(PXNZe#IjLRh)Xv^Uri5xJmxj|m;k z$rBQ+Q%v~mEP-q(Qb$zeUWk5L5V0 z5X{AJHo#2OZ5gqqmZC~45tGB^@P3T&%yy7Xrcl-p&%D>oo!cHov)nXT#@C%#TTA+G z({K5lJM!3?S~24-uL^@Zs~E>jL7wEhwh*hCm?@d>>=mEtojlRD)uJUGwMnRAV~MJg z%#iEFvvRG)`$OK>X&<37<6lXW2A_zxl`531+{$_48l40n{ZOslr68JM*9QAn8;NTf zh-=6aV}ej$o~|vBCYlJ{d-HYyyQ*T)Z9gBo27JkE>?M}y_VcYTiFhU(tasyB47^)a zH|}Uzptg#6&)7t+rBoB7o}rYgyU9kMJwm-;a_B>ER-1iN2vkf4URxh)hrO~~5Yz>m zG|V64D#l5>B`Bpu)u^e6og?w>yY4bii^DGH8|k0`Pq{HxWr0wkd}do^;P zzbse*Lm^he3%6OlOf9lsW*uL0Qpf` zrh*s_JrOEj+Im)1r&#eN{}$<)Sw}RJ$?q{Drp19!93_UiDj{Cfdest=OMkE26W5C0 z(%M?BYYOUXg=!mYl8-or)r#5M_IstYxKzIkl;r659UqeH0tThwQmjCC;^S-RXNAOX zi$i$WjI zXDG1TGM#NX6l+EY6u1@^U?+ZMs;E_XCs{Jnl(Moi{9M0sR#sLPh9g0*0;Ng?KskjE zho2I<^Z=cQXdj$QZMLH;x90xK31d@-Gj6E;?}hHCRdpzVeD4p=R+ZGr zbq*AmI7C5JM;Am;8dLXCZDNwCit&X{4jE+9z_m~Bb44p$Rnd$oUDmL42!A~#DmfY+Qrev%hI;30G7Z&y|>f)sT~`BdF!>;2rb=2;-02X z*i+MZheg@n6C^@g<`l7$80M0KtFx@uLZhr3O{e8nVd+2um1w6}C+|!9TU1#M5NI%0 zfTi^y_We%b)~kdac{mM6POH!oyD8AidCW40l$*X=RM0Y@_1)CvPQnH*YU|zQODM+w z-Y%@F{Xca06@E*Z0V?+YyYF^(^!@+d&d%F+>;3;@Jdfc2k(@M;oa96ZQuIhx0@-K> z#uW45YW@8MzCm{ToPl&%eTIY87-7b{OV~OHE%fzS8Xzj|3MmnvlzE{UR!_>x(BhJn zlmFOn#pbI%74m=Y)f?0Q|9Wrt^;-Tv#?!L=Pv_b-bP`G5(3#Jx=6tI{s-I)O!0=l) zf$DRXu+PHL9>u;0RaEz!r2`(4uutNUCn0vZF+RaVNd;jGdFD~SJTpWZ2iXJPY0Qv^ zkW>*I@m&YlLM(gWyAZp{+2WAZ^biq+Z&7ru7K{nfp|BO5?1Nu)cZ3M_t?P{Vt%lLa zREF{&Qd9Ob|--R^G&8Ne@$azwrtJqEvY*>c)_3A!QMrgc>udseFro5`FX zls+tc<|YGH#Eg6E@WjTCZT-LY0@n*Bt*HT3`@h$@_5aPA*X#Y?qdd=V{r^F5=UNST z+G+sykqod)mYRJahBl^C9Ld<2{>^`pzK1(W;2c-*8jmN5um6P{VN&k_s{sI%=W8$6 zk$?N^hbjMMDu!NqcD;pFpj7_v?(DtW-7({T-tD|v%m2rC+LHfPBivzmS5T#&G&@1`lI=q`x4@S)40DPJtBel18@t_(8UuRJ-Pr4P(pRkJa3Ha3wn z#h|;kAF^h= zG57`a{s7$#_OT0W_XRnSAi&!Io_UiH0Nv5?*~Q7(@oCq*B1k+Od&MZ8j-+v^LGm6L zXTX!^uYb)q&F`tG*{(buTkp0IYm ztrekqEo^*we>^xkJsypcIb*^S5~_sa?BXB@SR-fz{c)h0s8Dw@bDeD2a^bep#N9kVxaM)xFKvqX&3Z-s?G`q z{TvbO$4q#s7xEU}9R>jL5eiFyh--1pNtK&0{ACp32y{<|0J?632mxap0kik~UXwA7 z1Gk%peEnmPBwygI3%5PZYTGuaZQHi(Y1^2#ZClf}ZQHhW`*;55yzjknBW^`T)Q-%k z{8CX_d$0AZwR^&9D>~V#$<{Z1>U?v)Ta&_J(IHDI_;nq@QW&I1@T{%LKyaPew|sGQ zGJo;n{9(o%h|K74ovCWSK9NCk_7)cJ&W5=ApS3$CIA)N`e{1)b#8W@zKFHkmxQ#QX zp9we{N9V`?NCfrO2Zvo08Bh6@w&d(>qK9oH=5@#Mzh1gW-wLbJ*6h)mEB2Qst6Usc z#ZtP5IU>x``Jab+h*Op^-k~5;oP_6){&C>y;N|@C;o#vt(=1N)b2$Y!@}gw^&4hI% z-Moftu_k3gc}V-Stk#C-)#>0n`D)AC)>|@}SGVt!9zO8QWe3MzLk!S`VyiR;>%@D6 zKDlL))!&y|>V2p$g`7Fx%x!&QY~_yhcGB;<;`fo^B0{sPz#`<0c4KO@y}kRWENye^ zMqM3lgK4`-A2o~+06<19*9#Y5qs3Ezbp&@ciPvvD_xhHFhHe~sq`ya~c}+Pj~c3)NPQte>+L zNGOf+OK~-$UBlsbV=spnncub#==Uu?_%i<=cDC<~OMco%yf&>(Zu3^mQtMs{GZy2( zzI7B)o*n5pr~Z&AS%n}!F`ysomp&F|Z9Itkswgm!(Tdc}UEz;?y;wajd)%tuiMS7U z_do9UhuzTq`|4SbK5vQk<|KZ$QOFmZ*_=Gz+FL%{^<6(_vem2K@%`DlpxNCb6)6kb zq6@34w08R+PhC*~8&9=7{r+MOjla6Yi3iR@uhW~_7Fjc>N!%1N?2^ru#53?6aQ+cQ z%~XXN#hqS7_)aLj!7`+2^sh*fLgI)kn~-K0{GlE#6k74A zh|dAm!Y)jkg!aQE5L#*n>dzj&poUcEM@&|Ts=n(iv>n2Tp8hdBp!-1r9oEIT@QKI z+X?R)(H-<1G2tvqqMq;qC~)YkF5-=evD1pxa}*u%Gd^-NJ|FHC8l-)#5dGf^>8td9 z&x3bwDOuZix@+ARUr)KxHrUayJFDj>3$*672^kBmuIs6jHBY@gefRMhJY#6?oR4Ap zizRFpdMQS7FB1#1z6YaaDpas<2jq$bB6s5Zza3YOT;bWg!Pr*IGb(oX+VvTVDc*Ko zyTty}=dJxav$C=_4pGb3g<2M1J2z|dElIUA8(v@o9LR&vrF8?iP839&8UObDUQRni zl%IG)f*LT`vLW!oXg@L`&)og4Y=`U7>$$pGQuSvh=|3S#zn|^rPwo0}w=5oMK~Qd& z3KGAV4~(7!SzlEh*3>J9=Ob%I1Qu=54P$>@TEtbqJF$%q+^nKCA^QnD%#mw68W4GT0%Z-hED8h!m14A zd>l5Sh-TS7PZ+fm^>OkV7E>hn1eeQo8QhUth5vtKz_=w`EnU{ZOFMWgOVyv9K)Mbk zjni7Y{`3NCeeTd2f*@^dtD(t0_;=7HKk6p;?%Pfg^}Br$erqCSNr=U_ehxa4Ck1aN zB>Tp6LIrbAJ5OCC;=9!?F{GkpMrk(BBFgtcDIG9Svh~wpp8~BzvQ!Z14Cu^MFZuYL7e>*Wngr8E&gF=myf8b$n^lL%Yt-EtqnJcv zs*&eUkJN3WX9A39`rbaqzrV;*>=Vd2q6WB;8F|KZNkwG06jC7gtEg^f!YFCzCk|Aw z++P~=<$DsspmxnGg_wZVLyfD2n3f6CM%!J)i=od(LyeiTDWgYN3iUn_Woz5a1>m_@ ziI{1x;|aWR@9IFkXCixkr8ht~w14w`3_a=hZBcm9z}(GaR&H$hTniw{(f2?>;5~ln zO9^36FP{{%zTmH~cYn2gp4W7LmGiSneGEVSjKoHt^}fyCm-W8ID0^Cn?lwk%*SBEV zYb&2CKAx=9dQ(oVX9QAo?SCKXewhAcnfdgk@A^*i>b?y|f_~^eZU^~l5x&oZ9b(vh zfE?%NUtWX|q`}_)#PVAH=?U$cwhK#`L(X7G$p+`uQtIL9A%uat1y1YRRL&K5uP0}Q z;V!gVL^cqi)T`N#bi5V8vVj4OZd4>i660)iIhjR)<&i8ITaMt?R8WTx?UV*Y*=&EN z-jZ|o9M26;{nCSCX-%H3@=*qTxom0dD!UmqU`|(t__I`5)wo9tIhzR42-v*~%2&A) zASbx(MXmZ{9a2d)bpX!M4}JU3@*Vjl44FVlj%3$#gFOR!-(%{av18C*&%dm*{Hk@V z13zm6W_CsE{e`4vLi&+z__1q7*1Sn=K_PBCQfg2Wa%>^ZzPx|=Y_VaZf5 zIp?8zS9C%*DOqcl(_i)V&ujd)9XtMsW6G%5cG|Rj{jYMVzWdu_P28+B7A2nSHf1Xk zP@4?{r@thva6Z54N8(duJqby`K7v2H$6N9aCFLYPq#YXCXsn1f)T3G2@?YGAA=mn= zUjnFLNzI(5lc21}HZ^dEZ87nvroRmkk|lXuUO_Aw9P%455p_n6a@Jq zzRakFW(xMRg3twGV}pLfz3_{kl!@e1`SKi@TtqMpNyUp-lTVyRE|#J=br zvbV!>eFk&&?RUmjm%iEA&=URVBZZ|xv7d0|k5Q$0vPNgJpOQ{V5(9wv`NhZ(s*F$I-C zj%AYgHnMb!bME%ng*)`_u58#z*r7O^T(%)OYb|}du7V4%Js15580Jy>9!(2dhSjh7 z0ZmJa6xN&l9&J7EmocMB`&~Eb-))h16Uds&rFkR?1eEB`9%7}!zcT*2+idy4Q|p#O zrem^fpbl}2e5b0jr-OXxFJX!*uGFkUWo0{!gVnMa^6OGKpzL(DtxpF79(fEQ+-t|b zKlsxFL6Mmm&xOOxc7l0<_o1f$o$@<<1ZrWpTVI}wGf^FVOqgaotDE_Qn(hK-I3UIA zy(t^dDgkKh136L{tB3%@*5APP#dxR)b>lMIPB@?#ggt=j8CWv?kT-o{g8^K%Q^W(g z4|k)@W9_II2DkVZPCk5!2vgmjq{bYglU%|GZXaxDcx@2ZKmR&?mx*45OA58jVW9ts zQUH;-;s}p$B%1-^U|*mGBY?rG+3|?4o?z?N+2yi502csQlB!k~i3IaL5R2tRo{)Ve znbHt2|4_nSDFOt~JT`fD5}4BiZPA?->|s6X@+w`@?w%*FoVxH#*48L_-gGVM zHs@Me^xpn#z!tUVD4!eVsLBk*P&MpR&ygT@Sru(OfM%SJ68W4h5r|sz5ASRrYIegp_9Hye&&~+0W9!mp`UB z&B*a=f-fMV$_P=PHxdZD#gFisTm>Xyip0ef zet>vG!pIvfvo#Hd`KkNKJfq$(B&Hd!%LQi9yHp65Bn?}9PmNsJy|ljk$ipm3(PHkx ze#KJG-2N?jk)bs0vSyU2Lbt^>205Mlgq_KFR*x(Ma6cE`#Gj+dgwwsHSvn&(yThbS z!zY0pqv<(%LkYO+RZFC2e%fmtZqQ|K{pUEY!~I@D*$#MWnEhHe&C}Tc=#T2Z2i5;< z)um($Z=N04Tv%3<9l&Onf$sa{(J3qR4F=blvsxb$=xyJ=-g*o22S2QWg14uZSqKo3 z@eZtCZ}s6eXc#cCQj%xJ1XrwU{VsHMp;2oBrdQrPuf&_4w$GDd=;{}D(z*7 z8~{NVwL!N6R(&i#<6YC0xknq=t5QL?0;J^*&lpBs4j%xdgrqJBgAaxa3&OS;gRpV6fMB8DDfOT*Xzk?ZC~5d*b~Lv zFNb&U5Ah}^UWftOB<(UeaQgkCNN@p;!Z?y)T(PZ&GZMdV9FTz6p1X|gIr7IdNT>%% zJhhpgeNFmLOJ2vK%zY7#vqs9#<^C3mJzk6cH!{n) z?$6ej#q(pvA5S~IPWP7QGdZqyyFc5PU(gpcn~u@sWB^g$HH|jf2B+x*1DeK9?_!r+y*w z?ugsLb4YsoPW^lv0(A)=ar_JX1sM7>ijpFyibB*I|F%6+fgx6OPNTe|VzrPmhFDC~ zZR2QomkPVtcw3@zgN$4!7(eiTAp|4`@M6`XvUv(*IV8AM<^5rDZtYw0X3oe8bxVk< zHH0(nVNVIt`pbs4MR8vn%M?fg&AynEA2b+A>^=^RX% z#xrP#r~|U`m%%v&Hw*}I%>FugJsF0Y7S@1eD4_YNmEMrg@zLA#gVT=cOp#-_hBc_OOg@P*sGW|TyCH}IH4n?ydG zZbl=eeds!SYw+p1O3?c@dCIVWTAu@= zG0Q+@2ClNPs-#;>9$fS%?0E8u@d$gsOffx78dOYbMnG^p^o=3DM!=CjL8#-3HP0)Z zGoNC{^Vv^!1k%TNvfHx)fQJ$J1oZYcAVN~)zDtOXnKc`b5jw8q-Z^yn&4SH3v23Mm zR!y7I!FY4$JWu*JLIo zB_Ty~i&?sZ`>>({9WH$OnR29)8=p$__x&c~_&XP<6!+1ZquB#qWZF@W$s{&&fhH2_ zhSmq=f8~4q^sM`XbF(anMlm|F$*z4Kfz0>PM(luj=VxDk_F`7Vi#F4(c?D_^zei*U z#0px-y+UY-7MIbpA-veXSoA75_q~)Mn-;b zcLRzNg8Qu9^uPH-19LnJDXK;XRu5Z$v6!&5VpLgZM*;@O{ zi0Hh)8h9uqHTNv!(uw7g$AeDmuWYWJ-QTZ32S5`dhc3aVQ0WIM3HVY@o=LscC0l}?VbM#^h}hIC!XsL#Ok4+|b@AOl&8K;-%R9F6k5z+pnKtaE6zK#; zExl%G*GQTTtH>QKcNx=cu0opMiD+k+zCl;T{R$~orsGRBdK1`I41@4b&%J}YwFwq68Yq*pWu!*& zxZvgt1Z%SmgAhV~>W(Bd{9~bnD7USQBi=vtH;&{ij>633!qcz=25g{*1W(?elf`C8 zYGdrMJ32C%t`b>b%i%g`aZ4xxrF8*8E0?rQnPdjda-+9j!r6VFIeD^l4WKmJ-660t0@Mlk9x^x~GPa}?izeFhG3%7p zcurihZUuhmTqSW45HS1WSxUEFr6g#P)NPyL2B+QB5JvDnN`M<@(+}PC6hHm1aev?& zB*IKkXhW}IgwHw3bQhlxB#E98)!wY*_Z>|Oh+*ZxA$lxRl@4+35QLKj;vM*@_bigr zdBAG}Z<=lU3gP+`K=k1b3&{ghbS5u5PaaK&;$84wG4iHFrCnSJp;%MuOAZqQU@oUNe?TSm@cLOwlnDeHevW2i?r0XOp z5n^kfiy^RAd-s;1y$$d$b+VWX<7Sl1bX&@gmy4osR3F%9ilfYd{V|jB2FmXXHxq^b z+CJnD;&in_(6E;Wg2I4tA4LY9CzUB2XPbyZh-Sc;)Q%Aj2+(m#uq{N|Qz^&|j!1FfW_Y&R9jrB!Y&Mawb)Y#DMX0JU+99*o9mBTsf$9qIB7$ zF$3*Hk0Yik`c)=6E~I!6u!1m{_;Agx7wHwaBX24#F5OZWnWnQ{f7YpHyGR_*$`qph zl@spem*^t{%(RP7j}lz1hs+1`+sp@QZgN+>uoWJXL#5xn#Ob3_#m7+Y;uQ05yFq0v z+jisax`|22yEn+zP?zzvZI;Y_B~qKQ&_HePdnFXqOz3H6O?y={H|?T(T_}0R(Vbxu zBz7T{9HiLM!Wj%l1?5sulfekdpbLT)~l*=v9I+b)fQb0(}5yEPjn#o`>3d9%t}Y#qZWOG+qqjcTp0WDhHu ze@nqp&<+5ltbaebKI?WfPJef~Kib-Sdq$O*yyx(s88K6Y5079zSgzlBtb%MtY$~jw zX4muHoy6g|fKn!ht(I;IyEqR~^)YAPhu0L}Vl)NdQ#E7Qh#Jv^$6!Uy`fAo;G%qxz z3$w^xEZ&4?8lHRsd1G);oY}HIrR-Z)DY_kKv^=t2BX=|4 z|Da*?)u+@hvLT~%!oFHt*S*6QYc?i&>1yg#Y)sw61&DAMNQ+O6jp_ZqkU~)NX;F;hK%TMPPoGh zXLOTWY=djz;00;x4T*RclN-RveXUsav=FIh?KCFfTnRKFYZSU~8)w#m@DnZ)igPohs|NrgpDc z)9&VOq)n0q z)~g=YU|EbtY@frK3n(L$;m!nU!qTlQ4ra-1otR<{Tu37m%?fB>*Vw1UaIx{_u`8R{EXKh^Js}(H0_^-LS>SxkqwZ692{Qn<50iu}y*SV)(n(JLde@R>M4DiFa zaA5j|mXM5NZUMu-l*0}wQNYyE-)ozmkFI9Z6F=>&k8dBx1>ABBU*{DNeypP~rLu9_ znmt~UJubi^pVY?FImK)l>>BTyN&|6otuq<{|)H-$q*mzZ^QQp zO5iWDlw!J3AE#3&+cu)IeE@-S2rvWWEff>5mp34-#@T=d*;<8{<8dl!U@k0(ozz&# zztx6DAKiYpR7jZ<%nuxWK#jh`p%)Dn!Dk&3E}xm$`dYP)5pY!+AIzc4#`d!it&59| zDS^kjHD?W3M?zo!W|y3JF78+?_9gtC`wdEB4s{A}Zg~`@mX-j1*2me!%xTuj9ZxNU zy-Eo8LKp#Db-GLKX;{#s{)g&2uVMTjQvVKQ?0-1@{_7O;zp}1+Q;LD6DN;)rl*yTV z;5+CU!jWS^D$bQaA4IrP%0)P`f;eu>90L6-&x0yV!sg3ttc1m>+x&E$1)#$Q-E=p< zsHPCLl?lysn5pF$^FX%)!6=1Qbm`16nwu1JJ-^h#HR@7VC&%xiBXiOOi%@I-gjVUq zdT8gRj?-br&f_5;6xNg@X%@B!V-GcaB;2piZ#>O%q=Gy}~=3GQb z!TS|AvtaU-zOr*%Q(wg$w$cUbIUzQ_Gy{^HyO=Q*B62GGl;;rJNkn3%`av|PdVdiM zBOarc3Efinz5d#ASzu|BYv(Et+cS*zuqmkW)5bi56SwKm*rgg!@dL545N#8<*DSJ| z;a<;PqM(mJd#t@8fp)XRcQ2?lt;bJV+s%+gtIB#ZW*Ov-m=Mu&UxmN3q_)|^GsKrW zDqs+t;TZtVLwW!y|G(k;cNO`eNUpK5#~V*2m7>klOG$Yk@-|+Xl#WLDB5!Afoy{#Z zD%x%;j-dIrC#wlv_03jgGr6>jtn}-a{iHc|2Ec!j))JMe z3_g(=0U?09w#*o&iK2ax3e!j#yT)qd@jO9fsMWSAYlQDCOG)F;-T}1xQ{9jK4@TLG ziAZWFGHrO7V5#!cuYNj-%PLYBgU>@FXF}J^FD+;&Qb}*lH)8B%b2+ z41%BrnRW_m{97)}q;xfhm=4#+CSlz!en}%)M*qtVfO9bwU^Xi zX;|%3Y?$VBx^OD4@(-LfmRKA%4+ge28sW%HR#?=hXH~>OH%f)uRgtm|*ON}FA#9Mh zw`!(DoBn@N{)7U*p$`PHz!}oV{cvUuN5R_Hk|qp`4#GdF{+W+=g_&|>;p1mT(ec6k zaI(iK{V~VrDP}pmANF3lVY*c-4H2S0;bQ!62lo#Hx7+FQU453f;o~b^c0PhdB)R;!x-|C;U_#7R#y~4zCL@I=EumOc2)>$m&;XhWra{1 zn{SL=4c&Ic|H%6Jas0>OcsR1U@w$lLo|$hOx$Y8+#yHw99RMR94V@=z@r;T@E5IrgIVxD$*-W% z6aEG}fT8olmm>!Uz840o=*3B=dF3uk1zK^%-hpO=m$R3vgQKh0TTn@Rh#`te(H)RO zXLxDTw~MiVNFPjaz|xdtQQ%eqIVl;-LUwkC_-q9!2U-?rQ_%42j-kD~v!d@h;{Sl$ z0-uO;7K1yai}6A!dkca|3$Y`(-`j(?ud6#Ovu8i-#bQc>Xrz*~`DB?T+q!rM_T-%r z@gziPY5)>9yzMIwO*7T_P`&vt0JvA``m>`{c>QBjljBR?Ljx4t=;iGFo9C|xT%Qqy zwgh9MrqU0AIHe+-lmGC38X?6Cq{@tVO^&tQrIuAHNCf7>3tsTtHv%;7dkD>E094I; zSYO!CE<@k)5I4zKO0sp*E1DL=N|_s)`cTT%C|S1qE22~WqcGd%X8Ag0JAT1TQsm#9+!$ih7bOs zQigVM%PL65DOmko+7_mby4wfzLrg4!)c(YyDov9#8Vqb_p=)g3rU(_D_lgxi^*#!a z_ytE;Y@|id2;klJm!Byr$^nLl4^AE#!6empal#Fbz%q4eaNJYXq=k9oS|9grkLqE=co#6$@ru2P}ou@NW;{pCdr;_j(j*yz}u%c(>~ zrDLZ=5l-5qzc>ghC{C^|B~x*jIN!4nd;!taoOzwYzn=de%a63|l?@?v!~a59IRAn19=r>aS{i{Y5|Ked@Ma`$&tPPcGjoS#s@r5HAtX>=A!dM4djsar8YR)qEAKp*K zM@HaoSe|_@5}lhqlj!efFf#3WsLy5*H`Pb<;*54g#s2&Y%G6a}xU=q#};$akqXG08C3cT}_?>E6U z6ko!DQ~KpClysIqJ14rnQ@;oS zj>^)YOd$w7AtHo?wv!wo9|RI66-)%^@xsc3ieW&ABr|ItA2JfbNMc_fYSeX!Qcw`O zoVZBNkBZo7Vr*HDVx|Il<2HRZ3c{d_rbRR|F5Bc7p+GP?R1g)BsV0~(2NwXHrVl9F zkrdNvV83N`EZyFB?-?@;M09DU4W_DWq=t#r6cel>&O7@ha4%jObsr&^-xW0E9z4<~2tN6}V z>d6+y#~tiRbnhSV?9=NWyY5fa8A=#O7>4=E)?mG7F??%l&e#ADH!XUu$f80EnKY1`6_`iRrV#Ee*$XeDfz7Ly) zIiZAJ6X)#15fO1$;)~zZi+L;XG)#9-NGBpe_g>B8`O_(Mj3ny|QI1)vqS?Lj-O|be@ zu(pShcpRuZ@V7&F3uBFWfFiq*&F<>oG7g)oS)3?RS~>$F*<9W(&)KCr!7Y*holL4m z*|R<7CSIHkbhhOC`=_RI5-sFkon6hKz|-Rt_oRXkjjxTS(90QNWfT@v;?nj?Ygl_rJxt-Bi<-eBV9fc>Y9IvcBvok_^#o@#Ql zRdNQW)FwAuzgb>P$;uaH1&#jqf~D#jA1ie>VGOc(xJq?uY%#EMfdc>%^e#Nt9RGqP z{bsyv$k86{4hL~2x)3<_kHjs8;w*zu1exU>Ebh1Be%|+L{y!Z75dF~0P zJZa5^ZOdifYS-K-v*qf}XVErIpt*|QOtnaRGrCg>+DGK(rkM>4pzSk)i(f=4yKFpb zfoQXES{odj%ydwVLgO!{13`Un@QQ@3S~Ahcx?Yv^tMYnQU46P=8Lry*?y~HVi>6ib zCrbE*J5S`1ubyS14N}?dDuqsZ;wn>k_LE}J%4E2+<-wPUwOVCHzwo^PXc}0;b^gRR zL`V(>K`bZRCi^XZK~*3WH?jSH=QnK0X<>F{5X{e%e@Y}JItKK_6+zQ@c8}(K}=$Uy>WA*#b z45Ws<9fIBPW@u#HkT@ahe&SNi(X%rO!s|H?)fSxT{m zHavcD;sg;wh@cz>B8+tE>9+pAJqv;!y7D;&Wr$z_Kgz=5KWgqe9^+z2r9>A_`Tjwc zW1u4mbhR!6_9c-CDn(UhDlU$X9QbR-Ng}c)%@ULPubxq17T3f8v2cwJNo}+yUDC?9 z7@P>!d*8e2X3REi!bUbV<0LwK$@VUi(S<)=;^LO$D(SR?(9Yz%T_HZI;vdcBvM$ZA zA+EF3neR`RTrsh`=l+?E?~tTM3_`PDm_j`!0oQRb{QOyHEb64pw5^7ChRm z>h({)0R!sq;TOUfX(!uXrSox#6e_ZOzIy#GjltfBawDBF7Dp(s@XidVY%J2`}l`@9r)j!IPOzyjm*rc|6hjT^H%2@%8Er%D5{(^MbuYggR_?DarS6GhQs^&6e0X^9DADL&3z~rMqbJe ziTzvF?9IuyM@e=dNPq`<)OcrIXudZC20Z*W{`1otSJrh4J{~77rOZOC`lux6sAON2 z86%_#R#=@55_VD?L@}sQMC-kfudlzIyuP?w)oZ;+rH#rpFK5ji_1`+zCp_ zXf>lMJ8-G_bEdTKrjoWt%Ghsa95;Yi4AJ-YRi}^9*tc4Z>+;<arTFY~kFRcfPt z6GHqV%o6ZuxGuvf7l&Z#^oxy?ODunRp_@A=KqrVi&mEi*ubgJP{7g&pMMC^jFeb^L z)mbMdoa;pDpXDW~9E=*P3Z4FCcH$msUJ+^TKYy(A$^o`jv4Wmobt$5gj|XLXQeTjP z%G?eKXr-z=@w)s9&ag=n52G2Jv`1E2`-kPT1hQa4&VrCb&_R3}^%7f?ikey1BYGWdAj83^$JZnRvarTF?xh-@{eF@n0#S1l`-88&zTF zP>D=BFo83lo3TM(&R1Fzv8GZzk_#!h)(p=~sy7oV(e=7UGA}Vz_G>O7Xat>}LVbEm zJVH%{!T5bPr-?aX-cFQr%PX1ouCssiAPdK%*Cc;B=j9&PlQ!GiIEpmWCDlZw$OQRA z*H{~%@?CLrR%!F@&^)Nrbl(X^y&(6L^{|6hcTj+3pM@3-7*2@!wUbTdBLCUa8u-V5 z$1jJ!ZBsGPqqr;{|9nt(o!K2dyz#A+^B{bq#O3+Tr*i$R*XjK}wetO+0c-Q^omg`C z`CW{;G8Ns0F43XwND#0N(}2O%3_&$alNq+et4WA66DyFKyJJs)ef7H*R=3kT0WCZ> zhCsmVRMhJ`cWa8*rauN+WELq~fL?xnRbdgp6(dHb=Cv+!OC7lw_g1C97c z|KtN#;>cbKHvHPyCGbGd6Ha!jF`sWOE<`a)4Kl zuF=|apLA<-A2T^=I1P^iltGjmLVuH zpMt>8kU-rJk~Z*syEibH_65CmnYk7#>qISUm?zM&BwE z=Ft*{EuZ+zV9!O*??-U!RHGASH z>=49B-$R&Vw{zT&WZxO){Du-^d;bmd!1Z~~mv8g6eK6=;KlzpLLDJIe3UPwP$?gC( zlpP~}{Lb47%eDPnvn%s6%nHuB{{GHF-!a2*=OEiTI7xMWGYI<0+^n5?(gm#NYX^NV zx&wEtf<@I`=a=qJlC8`69#b8CzuxYg^gH+-RToCjy?(ozUF*<&nLS^wFQ31DV@&&g zw=SOpV}$UeePrGC`wE-ism+{!R?WKu`yi%4qA&staUd75O}B7n9nkL}0fG7n0f3NF zUy=Er(IDU*dw-=r2X)5R{@#EjPfQv%iU2{(V~ij=V0s0G_T%nAM8q2W$;W}M-~oyF zx%mfTkbvM|kXX!X{H9NhP0@!L1;nLzi3#X}1W@d5nFkO@Xn(bDpMKokCYKGJ!0*(} zEpM}xB!;}N6MdR*_iJTM#fiDC(63+J#dky!2%^MLPQ&d&wV6W{WB@QYifr-W0)Fpg zSb&@WL`GnNq7cInuVJoB6*$SDw5OF6J%g0eltM0B^AP$tU>F6!^vOsxhP9)h__&Y2 z>G%&dkg2?Z$Y#(zaCFI z=3Cp^95D#+T~aN9I{f5M-u2n8M=!y8{tiPAjUY{WwtN3LR}_Yp1=6ag(gumz#Qg-W zoFy3BwWPs@TP9m<$E@@~20@!E2LC=0{)HXwpc{k(p)w@0x1t&e8@-j}{>cCXtEz$B8-3H( z&Vfv&)>NCaCsH;FxE~w`=0!JFUy37G&##|yDC$;47LZR~G3-yJ2X7MEfSfhO=Rji| z;5S?hjOS!H!<7k2-JY>KMFF{Jh&<_2(y*>3rf$*bRSUk24*&4u_tx70`PY&^92mqD z-liDBy@c(>GVUVFbj{e+o`*Z5I1H?I4QI)PMUSHuV8`?uupn_4Ir}CCS-UTq@xFClCjy8p9A5*FLQ}n?zlK>qil22}50DI2Sv@Gq?7Ma}9OHTzBZ# zZ=Y^d&@Q-N*-)^$UvWKmg69NN9|5^s^-|0po7Ks6&WL$IcZFEDQeOIMB9W(iD%K(R z@Jok6ZedI~z!l%10Yg=HLemQ(E}jC3>N4CS{fp#|0D2w^lp)#*hpt!g>kkDpE28?T z{ZSLPF|4WHzcEWHp(nBXY#s%;_ohMrD8>5q;88HpkSkP@$!x$k!KN5_P@j070|jBu zoIJ7N-nHx7o{u?M*{SL^Z{LgC2Hhci4TE<}#CY(jrX-^N(CS0;4mG>jPA5%k-i|N7)0LvBp!jFUBU;JgFIM@4c9_=oo5p58le8o5$N55?~nQUl5&Eg zB#jBco#70})sy)Nr|c3C0SxyPa32J=YgBtOKaw9_dV|X+SiZ5geuq4$I6a{3@?|%NDCaIF&+iZHJQosV*&qy#$ z1fFj{LPKHrP%OU)o&CP}4Xzo;EFAXib$9no-*xv-H6>o2-)*QdJds)&^%Qqy8TC|g z4oFRy!v4X0^LUKr{WO&t6;*h!fJsqh#|~%G08;v{mM}jPu5urlk0l5J;mR$6%tDd% zUYNn1JGjXgR3}qGi+q63-fcc*wzvTD2nJGUbjfLr~Tu&U2|_dxebE1I28m;X&|d&`R$~8axPdH z9BD?99YVCugLrnCnR7s?mBZA7ar^A?7rT)Ys+Cm zn2`nSd0uA&@#qTZ>K-19+GlN0*0~_^HXex!;|Qb}Lxw^Pv`L=ZKE5vdx#jHe?6Y zhX-K~y@)7%w}}3T??6LO!9(Iu%J58+Jlb!SHQMsn2pJ}v9octSik}>oJqZ#L{86 zyI&C&_9%M9m?F+5&L;(lJZHL>ssNo|;7OF}iZ>A!XwYPDY6SG^d8u{akGa>=0z?e{ ztfsc8P-L}E;^k2Fg480_V7)8>;{&yH0jm<@iv*>jzL-KZLVXkvGpJh=3trLMe*Yy} zH7(_BqIOb3iNKN&Yoh`zwO5GsHT-gC`mhNo7N4GSh+xWR2&Qi(CZIN)*Eri=Kb)v4r$^x?b%g2dR%=y8OXHyZwWpK62xb6_$p zg(DKe31AJDGsq53{H_2nzo5@U#Zf-3!od3kW#c5`DpnTdJewpYiGaK&AGb_=eDcX6UB_$&j8ACrR*^QlTkE zRvZM&0L1?2R|30a0kl?8?A4M;iGIHWLhF$t8bpE!Q+}T^_q&&)(|^@U%#rd68mLX7 zVh1p!kF@rg-Ue}_xk3l%GCYCAO#S|=7^l+gPQJ0HQZ>h0&Xl2MJWuZ${G_0=x;F#{ z8it)svR<X!E0rC)kO)W6JN7|t{>(mKN^gvY>CnV=3M>c_* z3Ca}gX}5JHv~vCYu1~>}k@_CT9q;fW)m%Xm)&;1i$co^u1e2wC%M12cebjM1tGgxu zJbO0Db|&0n7Jp!s`lZ9_hv-@=(hABAHMRWB&f`#@ahi4E9&HqAtse0=*&gVt4N@G<0B+R3*uO=5vbj3`X;4!Nq1e6)pq_1S+S$#;3BAGTK zJxV;riJs-D>M}k7*eqt6xZAZeBSO~0On+}^Lbn|g8R?;vV1a#==*?|8SM-X96}n13P#5TjqBG3s}+qEU4M2B4xni|Y+AM#3Fh6O8V#lT8XZ`l1!iHO$=qe65YcvN4f<5cT;Vy6F*oi4?YK71n*u)fZP6zQLQRak{ppe4nTrW#Ab0#ak4yq+gTPS42x1%bVg|y|#Da<) zJewtSCEmqIAG4vq$jX`#dvHEq1Y8+R31EMUh~8NU*BtEMnWN6)Wr)*nG}ekU&ftCM zutq_Ixjy70tsN1DIy+ju^R$sy#uKxn{)%WnH-@Px$ti`KG6Jm<9*2?D{Px`fRLC_d zYhdh!#dkRuEA#V8v%>idMHxB_;6qOPGnh>C{PTD}+x)Jvd_0KM#(^jHiXM~h$1=7^ zRXO(o1yDam0#GJlxz7ZC`m^kLkr`oCe&#T_WE!oG1g)p@I zk$zjoR^$;O-7Bs9WUo5|^3@Wed<7&-C4nX{ut|2ICXp~Uv|Cw8xJ2A~9=bDZf^{Nc zV#eAy;=$yHsk*Lf{%)Se4hGkrxkA095yHW`T^PNv3e11(x2(+U#FuW~&i9w1Rt@E}!B5k$rtQ%pgid}rRMdZNo0pS2)X6c{Bu_h8UmbC0d*?|{h=MA~q z7r|USXpF6>xBN;Nt5U9ycBE}DMr=Reisc8BkW4zhTZqOQLy;-A`<>aRA26q6LuEW< z@j=u3Q!5UNl6^V2d#L6#c3Jl`;bGmeFtCQ~lMEL<8p!iWgN;GDgb)F~OS{PZ*qWos zYAL0cb&k;zx4I3Jn*_qZp50@3h=UqD;uf3WhJ+{y|MzRkZhAP^z#EM`Ju@3M(=^F! zJFbIUDxQ@mH1rb6)wMDM#0p6I^Dx(%a0LEu;`TACdb+1O4pXXk;m`E3vN+{6yAt_Ha#!M*xiJgArPuI^gOA(s|sArnX>Hgac ztN`h^0nN|a-N7-k)Z#%WOb2`B08>BEP%Xao-uoZzf99veG$#`Qk{rwp5F~;36KF!- zj&ps&Y|k);4T6p`%l*3mcSe200=D_%m6}!F8kHyL96o2Wu?DJK-JTN@4QWKkJ~!r= zgp0=R?`UvDF`PT*ObI2LGHU31obBLcVX@|T+PmOEE@b2p8!Rv>PAMZz`V;8!*8>eopq;cVTqq zl_;F*+{G0HkbrE1yz+K~GW%8LJ)jE@D}AJ5C7S&Tyu#HfVvI#Jq~Ezsb1n^EhYa$nY+i4f0upHyZ4InUzF51 zXuOG00&74O2u0<tm z1{4*+G{PQNy611J*L_GGADISf((NlRWJHO&DXY4*O_t=RmXhS<&sYhwNwfsIwgF;o?_d}cHJV`b|%n~`4w29RfJ=HwXD6)V28MjypBl~w? zw$6^RrMMS?oP>^;d_jNh%LW~x#PgpiD<2tTvkCDzQA~56M-iB3t5uZp^0zOMVE@Eg zVOz8ik?>VMOd(fKKgiI9;)NmyT@fNc#pGio>pFWNiWuf6)0J#B66wkowgK0Ng0_Mf)|ElFdb&5S9A%a{_zrwo~bn zz%9KIEBo?WwlwgtMh>&QvoxyP691KBfjGd)*8p{PcOtA;W=BbP5*JnjT+p;cyiafW zz#;hlo9gb~Q}jK{C02+ai(Osh2xb3sL)-Htl{>!Ia0c@e+RA@dSSj-ItiI5Q#^)Ln z4>K3>49WFqrdN#YG-$v0_#%ROF=o5tPvAIy@;dJTTo@WJXDz|%I0HgGDVjzg>Gh23 zUPy!?V3pSpb>Z#xa{;fr5r(Srfut<#pBuurLl-8ztUnUj{mvktMv>%?0zb{gUqa8w z4YC-?8&IM~Mr__cwf^009QWIk5m5G(J=O*_m)!9sUFK%532mynTi0HBzF3h59s%*{ z_=PAVPjS-3Gbf_zr@hWxk`WQYBR=~neyI4s=%9d&qS@lLtOZ4rJ)Amy^1T^c`TaIl z$x~nGsMYlX#w*>)F^v!-08Ta%#*bf9<6Y@_Hta+pa(^z`zZy}(0hdeJ_0kA5C&3FD zEylv%WtP)%lgqh=PJpd^F7^@P7uu*%l5P-U=o-MW+*6Uuv;Wg}o_b|fu6?rpF0#&7 zIg>aS`P%M`Q<)(e+H?biTqxca){G&dZA~`Qs@4&g#}w8>IVCvCH6G;xw;lrzA>?_G z!9RlL7WoPKW|*gT5Os%f92tlhOy`hTi~%cp^4Q8s`2<4hva@f&>nu!biEw=j6`UAS zs(p*xODq``bj1S3o)F0M7<~QTz z!opsxL#D0ry%nXLdy&PQc`o<94<*qG&{eq~M#2IbIKAJz;;8Vjs>y|(n>F_no9y|Y z=e|q}U`(@91AtX}Jz|*lZ8x-XUp}gOK=g~8WH5svhxphc0UtBvD=`g#woh{N_mI^Y}$1dj@^>s!`j zzDaG|L@M~x(WOtOn?>ndiFtdANHBrvX=v#;fi&p&q1&YCSl<2-E~-CX%U>fKn;X-O zIhUJAWT8c4dCe_hiN+veM{HsbA{$(4^qbuRY65?|I)9ZY7|ueh=)_U~`%Pw1B+nMo z47En&NEJ1h8G7?_zwT58gKfnnXUS%>=nc@fq$}H7PbNhK8KK6_*^FrOz0`;;N@DFF zPa_qyE{=fSiWh`zTMbK(R(@A3XX*-igy7|>IPy0)mcrxj#epF+H z0b^?*xlsl`$K~2)mz{Fuum2{I$vNEY@!Fo6T4o&d>BDAo)Rj**Kx_RCnSt{d%ccsS zG-hpVKLl*$kYd3GN*e(Qv@R%WbaqjM0T3vTVzR#Ndv}#hxAOP9N*1Iqs>eUX>nv1F zcoa8(91f&UK?xog$z;T>;@Xvh_Hw~0-;_2;CLdVFtZ^(Tu^uKJUF=NOa6P$2QqelUd6#E>yKZH9hby@aQt)IfiJXw$v{q1M zN}Q-PF*d_Y$(4((>wdh;b4Z1TQ$mg%YuT4(>>rW+J<9ue+u4 z?l@t^M}d{lJVZ~svVr0$b71Q0d3Lty39n`7B}mfwhHVa#RszKF;>yCpT?;OYot`3f zpjrrQ1EM=E| z)NwL@=j+i$srrsP;D#Q@yrE^=pl%|*)%tGwxbF7UdVF=Kg|9)8LYVlb29H;tCT?KF zW%R0gVwMOkX&`2Nk4ewc1lg{nyBXXTdULjQ^sdR?W4ZsdC7I5H4aw<`O01qi32&JM zxD(CH*N_qM=$#B~7EqvRnhuOhV$a{7SAV)OOcO}X9VSSq*O2?D!(l_$ij8nD#?zTPk@wQo#-DW_;Y6q~mlZU*{30vi7cRgKU_}RV! zkO0i+6X?K~*bW2qD|k&u4#7BG0Jd>~N|t$oy!J|gN<8*a9jJnoRF)Zd7i&%Q({r<3 zwqI6zK^=6p^0a11aB9)N*qP$@yUE!t=%{M|I-}^VIVv^~rSQ8#94l2c4FI@9ufqpz zEp4v}Z&bwIGbLCer_?1M(QA76EcNw$bh2|>tn|JS(=w7bU#NUqLV6Vk>m>5L$*E-h zK&RiZf6J`|cc@yMB~#=xla6Y64O*%~w6knpO~krCAVD?@53gf>D%0i7N+$i0Ms<_E zwznJ3t7WHsn3%1n{> zrrH#Ss+R)SJ#uS)CTQZtsAz5GQplQF_?f}#zmMiQrHCMHOAi0_;*!UQv8$d|9<%6A z<5u|e)cSlf#4WR(S-`flMOgGav4kIgr2fC+@V(0=aAPs8Z{^w@OKiu%KsB^>iJEB}Eo&zdyoNltssZ*9GQHK( zKx*}vaiYg}VYahcY;`gwHqj<5DB2=1mYH^I8uQvD#ZIFwqvG#7ydTEQL?^^G5wG7& zcJ(xd5^2tqiA2jfCRqu6v{@$%9ID3N2Yr^CUli%{QTdGX=^Gy3 z(D-oaCK(9vvagV02=ab}LWztxu$+WAGL)&n_`1Kr+kV{%|AbIbUcznE0B~D3rTSk) z8&Z=R6UFLb?jhuhWAxMJ*Xb2g*cLQ%V3I5)}Vk@Ge(VaKk&2HvgF z|C;?U7JdnOiIU5{t%J9gM@BtnQ{+c%K$0<|dps3l(R=jwJ!Aec6}ax$@ThKT_P6e@ z9=%VF(JZlqp{|A4Tl5mnqSbIcFM3H=vF>b3szz&d-?l`> z%Dl8%&qKJM31vEd)5=A#oe9lorF;rAANm1$Dr@Df+tYdj&Ow0|1W8U~nN{|TA2{+^ zkDJvFNuqA~eu}b;6=qipZPrujFOtJOR&BR@p@wX~+Ou}uuA#SBPiGXK>4G=vN996i zWlv76TGwFs;UOxMaTeI)N##j;4k>XOR;WtKADeaFcyd&i%mYjfEv+$ly}KAQJ16EN zHSzd8^s4!3^VkSY`K}^qa*!;4!n@D6o}G+0QKO9+r{#axuMvdbYg*+i(fphQv{J3- z`DvM^MlR6KdZmCi_Rtry^=^`_r&W?A6!8$L_tDjU97al;11KOLZq-G0pNd0Yw0QM@ zB{F#IkQawledB}ry37Xs`4)(ebHk)FRoCC>Q9XrNxfc?x*s`0VntZn+;u;i@P!~kj z&XyVWTYju6vYu=o;PKGCWxH(0Ym`M}xc9eV75=}}O=KGHDsqL0_Wm%sh;#|yK;@b4 zfrjS>w2&C%Kc-4k2Q<2+anNY5-`Ve)TaRirx&_d&w=Q_*BQ(gUL;3l%p8eYw6XC*?)oj_#f@l~Ep zCy`=$2!4-DGC37SNzCbopoq8U?bP>xW}K1mPj_{M;F-*xZL$+X0#Iyk*0q-3U~7L- zI~FMc8KNqvO<}zT#>_e?P@?6(yIuo;*yO8wt*RY@k5#@4l=XQU zbSv9him)%;1Y*|#Yb>bT4WsAB{iij;XpMa0TX81U`kWz7tQv*L_%!pD+~O0VX>We{ zD?3#+?c5glyTxy|c}O;TQEEqeRP*FW91#hi_O24G^Vf)jbAq&JjPNj{lqV}q_B?ZU zg2clc&&N7cPkS>_rg;G`RJDhTk66uj@z2WV?x#9s^+CCrl^%NZaRt1ct#sO9K#@0F$pzKYqSzx^1JMEC9zWmKbq2h) zL5W}6GCE5Zt4#(@KcW;{$oL#eRaC2LVBd?xUud}*bDFLqxAxsE8#eXB39-K3(~%1exM_- z=eXu>-X|=xzYvnO|2?#Q!>C^d;4#kphdh$q(fUNes9%&d$2HIjW5>BvgpP`!ES8ZL z_=k|{FshWY%^)2*k~HK@)0;g%U#7b)d2SQ|AM}2K}i6d;Evze)o7@A)uA_TJIRc6pT8r zxqsK4b^kWv4{Y&(PN#7uT7F66+ZL>%$@2!fvn8L%!5CC}4%0m-29{p;A}T2+qJESL zGGD+h2C<`QuX2c#*soNk;0#tQ_N-;<1Hn>F$sUg#DQoT;wpQI34L5XU*sW$`zQ5iv zI(5ihheAdrj4~;gS2zMZb>(rOy#zo~N`ucd!Vj#cc}&EOG*KtB=m7QBKX3Pr!5aK5 zGe4Od+}8aQ)uZnk>-)z{n*hh;TUGYA361l+N9Tm{Ep)sLCxm-h5OOm0P6=DM`wQ;|=ckFbQEzN~_^YGgu;;smE-SD+De3OZ%E4tTEgWtr}r0R%{I za%*qyV}(l?FWoO^<JF-^ed$IS~1{QvJ zA#0S;oCH(JkD#6QKx>^Q!5d!6v`Az}5!DZq@qBeY5mFz7aV02c!sDPFPgJpE7_0E*`l)#CGQh)TNtML%TC`MRq*=t>a9uvBxGVPq}oS_jCBX> zT@aP0nS5R5yBZUAs(y}uW0pq+vqP47hnA*2pInjh!!lL-ec-l}rEU1kVY+$UfL}6; z8F@4cv`$iyOk+WT9u)R2nuXWy_~_{Tne(t_Uit2k*b;k)U5ecD=zRBaS zaPKrKGp%@``ep{{9I78Jo6XR_X3X9#MU$V%8a6_Qeq5>P*SmR)ls4brSZ!Fw=4xIg zTeKSS3~G9pTprI~QQPJ;3p~b_Q?E4t2gX(o#T3VC#;8k-#_r#-#UIU%GC!mijOcjG znxE_xMyL3Cu8W6m90sU6)Hb9nmn`2z>xV=2o3q#&F0k>Koh^#}i$MtS7YL(CaGZ)y z8iEXQWN!pT2UT67h2Iu`B!s;Fw}*?H-y32CCdc_|Mi2u=)cNa&%i1sxl&+{dQ>VNl zP=AuUx%kO7O~ghSl~ej74x!9Te7V|d3A#W88ggt2x4MhaV#Jm2r%&EIMHG=!B2xwC zScus_)T6jI+Rpb`?SF>{N`3`gfBb~@%n%y?>01Uc(9buyDuj3Gf% z@Zv4IXCJ*IbM;Tk^L`M})dY6;OA(SLXG#@KJzWRmHz9g}sb4~43K4%hw9=*)~x55O?58x;#s98*5U+P?bS9CK6RSIE?i-FJgv6N7XW z@j_y6gG)0hHi*`CGZ2yHkq}50xebf6+`Ifv{?&d`qd`q^AV6e}0eEEFtdhtIWEc=n zwKL2Zbt)GeE;0{Rfw3Fta56uY(p!lxrc0Q^euSOF~=>&@a~RLbr_tfh%3D`!S4Xuf$hds zo8Rho2gX25Uit=k&U07#EAVYVpAJH+YayT3TWyPOUOFObt*%$~)Kx0GN=n7XZNN2gnoQ8jmev z`f0{GaxcI-_pW9Mqv?#@U+B{;3!pXEX@Y3FjTQG&RHUgm&1!8XHLxfrNk6?2MKY-0`Iysqcx?o}ORatvvNX)Q$xvy9DJjV5Zkw zssdNA1WE{;yR2_Q^$!4OYx82OeIuS5Mu^{Nr+%xl@O!zyS2VgBJj0I{%O4^MEouYu zLzR*C(-47r&gpvNOs=~}%T|sx|=M;2EQ+`n+9Y_C8&#vae7!w|tPR$Z5Q%q?G8c;fV zBVg<#`>aiy$511{Rmeq8%!wmWCQg_ph*~*>^og^0-WLX$!zG8c6Fd0ESF3awj8*z_Rc z2fDD)d=se=T0N7n<4eGY3zN%0pMi(6 z*6OiDt}78g64K9#tdUwc6U}o*5h43YX*+tCk7#C*np(k{8Lz0l!0Te2?et;bcC&|K zMwg*yi*v}3`q>Gk0WWW3LK>!E_%LDALLORc!LZkIG$GA7?*a@u*!>-nC1n*pk3l9; zIJNxPW>!CU(87!z2jD^V!}zPpOY@EDi!nv5Yf`f0NI#e_A-ppn`wo(o-IWbdKoBOv_Tz&xP^7jiC!D^shJSg zhB8g~U8b+1+|3Z+aEkVix>Fg*PESFZ`5D3r3(7yaaP&N73rwi&CYG_)@JHJKABK|_ z^q^@_CbT+vkR@nB+opwLrv`|_{$QpKnw)gx?_T8+GO0#K|2L5@CDz^{i+L1HF2Z>r zwwoP)Aqq8GX=!q1LNL55kvr+!214&@qs(;*BI=rcO*B6#kM-n@V1;@P^n(u(n8vXF zrU7apjp~~u!Uh+{dC3GzKC{{z7(m~8=kE~4sZ((IwqqI5NYtb92BHwdP%$q?S3fI{ zb<|crE{}V&vw2F7VzVQw6bgRmIU+8omOYgPS@sdUVfg0L1{#CpHYpzhoKpE#1Igvq zL$yAQY8u)c0U#h1oF+v3+v(#_8FDt#!$F8&5^W&OaJ2RiRSk8}Pw!X^Ce1hKPXQoH>!!yNXcs)So}qixMJv4k)Pj&PM!z|K@CjGDD)g7)V7n_p>7Z>^lM$z)b=KmIWKN@7T!Bytf+V$3g1@>#~Cdv z?q-iep&=9G@9Pj9^2fc66Z4swB_c1dsrw(Q8nPihVb+dEs+dBpy{m8Sizr%V6UZDP9>4UzlZJC`)fitaA8lPsKo za_vtDrs5RdF^0wnxr+~tDH^8q@Iy*GJ~n(APESh^E|kAnP9!iV!Z{QiyN$XnDH`-xB;zeALw3v|r+^jVO86~=A*s0ysl7=24X=OC$${|o<3f5UVY=oY@ zE`cQ*;e6e_S&0CaWMjxI3n{endg7Y48%{bmx`N(sQ}|$tkwlWG{g?x95p*_|9xMab zt}RDO(I8D@%gnfv`nnn3 zJL)HOtf^}m@4u=ME0;Qd3hY|O0KVLH4X547K9aFm$fY)B)8BdN3cG7FP^<@)szQF; zKdca3Uq3Pf>#gB1aR{~XJ>>4~$gT-sJ$RoiM0Z0}l$KnYp2HD5qW?bPif86U z(ijWq!?ggmW6djAoR*rX!ynu|YH~9evF%wLX%GNi zV%rz7ZMm|eNx3I&(+K)AwMtJ;aw@i*5 zdM|2^}k9v81EGsD`+!@<9##E@nTiq2FedQJ!B zUMYR_c1b>d#k-0CqbDBvE~jEt5c&O#+(|Hn(3Ja<#weDfl)e0|Wj z-J$(RXNZ_z}0G5iVrcfJquJi}I_`ki46gA#2 z_<#8M+&sA^F>0guwJGRCt3geV%&5AM7j?XYyySFpb94EdBX5c|!UF2wWM4>qM$u35 z9%Uzo5vhrAg^6_MpcRSRpC9jirHUw`MkU9~j7U&24U?NTe_Lx?^p*ogQ;?f^9ki_{sr)<>O73}xtbWFrQE75RK>qIvF6yhFMIxiJ)=^@<5kCE!GH?R&pzGt4%HLCFOPki#d6RYjIUVr)W7#6Rw9T0-NA4N<)5Sh;&8TaCE+T@^(=oo(Q3L6) zfoV4N)8tI|>BD1kUYqv67NXY;CnLsw+g5E@esN=-X}tc|G|bnTc)b(3J#m(vm8NT- z@99YZtyErre3U!23HZ|;|96|tA6R$+@$w0fAL_Vx^PU{|S;f{$<{}JSA(1PxRNDM=b**Yt6~-j$sKTElxlF%pVXm$r{q;QGEck>E*W#)V$Pi@kxqF zo~_Jj@6<$r}Rwd2PP8O`VroB}eK(bj?>FNlAiP;T^eF>ae`x_uW?d$_$)r(7Dl;Y&!8 zWBSa!MVP1QL`~*P(v=^I@rZ4Ffn~qp^mgU!ZF_01eeH%}W$@5g=O$Qce)bu2l~`G3 z{jntUlRh-E0z54fV<>OP$Xwo7r$tyoe_Z(zm9$uF4j^ZyO z^gNE@H&uNu8N*aswXuGrN^~#UeO8O#6}Ob9q3;7kj*H!1s>P3sTRhXK?ncbVu@0LM zrr}Tcl1-1|W^x(+hR2onI;)QBs`oo@x#kc))(7cJbNk& zyUJ`1d|3nktzONw6IprKp^(CWc7h}AUqtU*hftUXfjvY+*NbRCnA*Z_YSNBfrtNes z05Z|G#Yn zbhhDZf%*tk3uv9|1hgIgBUKWj323}o8lvg!tE)psKpAT1yn*Vw)5@ru(q);4aVP41 zh3Tmmf=ZhL)8 zf@>XyS0_!2!FEj%q{#8)t{73eGo!S^03#wT1{xL{NeatHjWv<`&2KE z1JO87H{GBf!XIKpC{BSwJK*qn@w=ByNa^fZH0<{|COmCfP6PLVPLFJOBhfDhPpSUZ z?I==_NY4w|a`V_B(j*!}jHH12Ht7KLWVEBWM1Q&2bf0PLhcT-FakdGXQ46C+2VI6j z(y7G&$>7)tXWO=PZ``oEuy>5H*va+W zwdwF}rQ;)g5WZ8*c`a_2^wrhE(lh4ImG6ILp%4hy*;iOFgXPz=G^P9_4YJ;)`_$S& z_a7m{W_cO5fCWE}%Mfl-uwI(GK%U-$cZHq={~?URytDV###{x^%;STl*W_FUR1x%m zzckPNju-#zllfTxX;SoMK;~!b0(q$D&w$dy)((1C#-ARkkFy2rxrjeG_&>c1_|yBg zH}$uGHK+WXu>QA(&tLoh-+MObYijwwfd%3DY`5|Xohk0E1>>QT`)k=cHHzv^Ptj-P%> zB@r_SKs3_7PL=r zazOZJooDVa9DuMWrDei#x{EIQmjMSZlL`1KoLepID2=d!j64LIW?&`pXMJgg3=;_ZLE7`2yaTz zOXvCgB9RmnKNg=*`$!70g?##q&@Py_f#irFC+mJivBu2GcNiFf}*cuL%D@YEm?kRg_{MOO;Pgio?5&TdgvJ`cHaHERJ2AdT9>I%X;nK z?Xg8M7s#x7d`u?O3l?8VxGKcOBsSTNH4OlsOXKT)Bej`C4^8t|Z z>P0XQ#yRrU^4e9pzT#OsVcYnJI^?a#-~I1n{Y&&*38b^Vuz8Rl_fPqYM)Ba__bHWY zzN&q}sx?mE>(cF-m0H*HXaFF`($@Q6&D(^0qlUUQqoR>Z`d~K&=Eq8?NKr_ZQG!2* z`v`+ubR&&_*pX(k`(n%{Br;lW{x+3*Fdxt9VK$08lU!0hg+&JO7hi22Amt;31`SBJf#SBUvo7S*CY?JB@)@b%C(E0i8eLhHqRh57)m=e@}uWfUr6o3IBL{ z*AdU*SVcJO!(i)rgX7JZDMRX}!1vqNl|&YNM2Zl%dzkl)I(9hNRN){#jD_p4nc0M+ zZbC^*CTheQ-7+}UoKrPx&8o&AHSjUT@Dp*hH>}14B4SF0mxaVSuUxXdS&MEdZYssP z(TLigy^be8d&%hcJp6l(zCX-&PSC=}Fza0|TIBeN_>-YSf6})7dhnxVB9mSFpGQ6B z8-)qJ*9gJ1i$d{4IaSZ3Mh{8HS+DYLunCEdf8IetOPO)g#Q=sbm7U0$b_crUdkv?M z7@n0?N5hbE*0o4JzfT^v0mCiHR3ghbXstQ_chICjgq1zT$lNR^LO3RG7I7OPrCYHf zgI0`P78SRkI0ymBNU(FzHJDmH^$bv`TCPp2x!bl&j(7o0LdKZN@wKew%2%$ycjc-> zrXM7|FN|iIH>N#FF}h2X+@E7d(ak|pUU5Hs*?K+qy+aUS=ss>VHEJX#DXI;tec+{%VygL5=*OgDFP%zDp-&HmDz+XXbHeIO} z~Y+^SIW)+mpG3EE<@_KYT>FC+rFK< zhgd!@_x|Hl8Ej}WM_Wn`{+18uBM*nT+PS%>i}%n=MMtC_^L;|L%0*jxxMd{S{0n%p z*G*xn#IOo_n1YhT4sBrzhCi7>*0UlW^7*R=K1jOMI8dkxt;E!Ig-0(wgYrI|6n%5x zR)!OeW?Hw+H0A|L8@IpoW#uhU1fqxp=Qd&pzaNOmEXg)CJM=o5GZ$lAY1a5JT`@bK ztVNBZY_FAb2AKiK)Gwwc^?1h5-KJ?JpkiYcM>0SV^7>rmx<$4^95cXFH_EbLDBTLr zxItKaLKq2k09Xa=O{q!5Q&05pht?}_4Sx#H!~X2P62=v1c8+c0ng|VJ=_s7l#+q$f zxgulztE+Q<$@A@S>S`qm`BhSD-d+F zP7x)${M3_)TTAp1GMCik53(nOP;50zJZqW6&(-fT8i3N!wBLmZ=V5~`kHkoV%vdL_ zpbD*(av~#A)nc45M6+@u-i8IJ&&eGrSHFt8oPq)zzAFble5^1#vEX6U4Mvs-tF~CI zM_lmqnw$#3TE-4s5;2pgEzq1wR+X7|!)vWyxWw*KsB1;pq{~r_V@Shz7Mljcz?a7T z#2rRW+UgmEsT%%eNRM{1P7+}-!M-rm(>=y*Gce^ak_|6Ww`fe@xYMVzUu;WLf&L5O z9-x?5Pu+2u4-pUAT%nL3=zBTu08W9Oxja=tx3fVMg~;5PXmPAd9EcS`8t#`gK{S<4 zqrBM=r(?TXm31-$ek&j9$M70@7+L+Gs(Je3KNF)1!845WK%a+n&c@AXWQ)$Yo4l&kISVQ| zq>T~hF&-T|Qn7Ll>K$f^D7C3*R;uJOvgD&sx^LNUuag45_%n(*I|M5>>_cH000DNG zVU=sH1=h74bTD@(1%W1*Z_d*8H-qgQ8XW`j2^aA=kRcoBFP91j^bKPEm;c)T^QVqz69+ty~$8|!a|BHWL`=86jC97*_L-Wj5x+CZ7P3j>jHZ!f3^`S8 z?2OqjRAJNrX<7IM!SLAR(5PdbDF1p5@xv5`xRhSA>TX6KW!OXv3`%v5as02DO5;r$ z7Cb`boS|ZmKpBXCIN!O^f6c)*uM2xNP{u!*$5arTg?1i`rme{f!B%3HezRH2tGa5{ zAzMY`X|IJFb346jj~a%@i%pT*{jOf{ZRiNb+0QAE^K%N=w7@|c`OQi2r%gGXdcO3N z*8lcIFXT_z@o9WUurjDHK>lsfPNb33i#y@dT;|@D#jBaN@xw zC7;TMAtXbEQ+#R{Oo6LqW7c{x(gG1iIi^#X(<#awO!1*_kV%(<PT345b*<|3X% z?3>CkagAmb?3kWbx@?l~tU*BqmMc$FA}-!Kp?Rhi8^t_x7Nh>}Xmf`+B- z1WfsDxbQ}q7Z$IkHWGMnrGwro&%XmDXiL0j@H?lYuSF;{o!IfJ|C;n~iJsSlZSS2> zDmvb}YV(>KGahF~Bvs2%;mO94sVL8(M)Ti5GN55hxdI%7Uwb!0s1K3{y9TJiCGv(_ zUY7_yY0`}7>(G^MAv~)@ED&m2fBXeJD*|+hg(Z!DQpjauJ&#ZQWS8vECqx@WDSYVZ z>fQUynBa_tS@Gh+dMy9_N^B}2OyS^IW5JNDbI(~{XRiTAcGQu;z+HWL^zk&=ufI5_kaNdvqp&UG+U6pc$L4Q8-Y!2=AA$NFeoTr zX+E+G>}N?I6?x?=sOgp)f;)PdchEd`rV?aET3l-7xO8Lr{4O$>*SpH6Ib8N1GwRT8 zgf%-1h4%@#u*h~IB2o02RQ`Evm0F#`yf$toE>^#XP`D(=99j0)T1Priocz~StDZtA z;Trh*VBsDgmi$fDb^JifA$W(;5WcQNGKEaD*`OP&diXv?H0d)`Y;->k)dq6vx}i>R zu@i;7y}Pu4=D3k=v0m7yXpYn)Quxczb# zBH{#5H?7jt{h}g+DBJ2twj=_sRlB8pjTYpeGR5;?)qJe5#X4%azsnDx!M5n{66HkB z?qN}!Ac{95MM;SP5Z{8+LKA&ljUoJ-4E$fk23anR7o%@Oxo@k4R#gr8 zMq6`5+9VK|rVuZB-Wg`h#F))SyO>;B7(j0Wz&}Jm1}<5f0{ z;fkx@MaDKM*`6~b|4wG~Krv01j(moML9dUI{bLTwa)u^V(-yI-3XY$3AH>y6mwkd8 zbhXTP^UhUbIsS_{11Aa(n;WcYvjf&h-iBU4vd>uQv&lT~$t$e?N#>ru(!TdMP2yv& zYDo%J;sA_TH!TOX#cq30c#IjoYpKG;Al6)f9Qn&03wnkXRMBMz zLJMksA;1B%V7Kxz{*LbJ+j%{l=PuJb*~qGg4pG{L#?-ffO9q3+=iBZ69jbpSqMr)) zL8)*OqK1s#MP>Jz!j8;Uj4oMkGdSBUi~Hx{rsDVZv-8h< z-I%jw++ISmS>Ar`3BJ4vW-W6E_6_-!-h-w|*|+&FH4O+us~aC_u1eyh5Wcc;lv z`E(1(Mv0%6^wbb7e447gR#Gom$+($O77?ti(HNKlK}>=FzGeQ-Za)0Z=V`!{=h_EnTbs`!`XspC;q1yE#Nc@cjnE=`1Y1(5K93 zjDFm0oBN73qR5sRN~I|&W_;QydoOPpuiwSIqmW^%#WvLb83ZM8jAILIZN<y%pAVEoDa-CGP5M7iR26Fe9I6>G@O|Vcc9`aAn6;K_q+&K5g`4G11nzm zt6&nkFIj}ReQ_)=eG9Sba<1l|yFPbZHytbmkayV0lU__JU|uxw)8mnET)T-x|5=& z$-<=5CLvhedJ6UgjEf3lS#~Modbn-1fUq6I8ecb+nvQoK3yCC;iDo3^} zdinm8+4%;o_xu1PNNW1;Z3 z{IycLQ1)&`U*Bz(>o+c>d>Ni9^$wo7*So!yx0x%vs-#$wyC&DIdKMvzJ1+tc!S=jQ z&jH-EHcPV%wV$amtcD=7)xp?xYlok^G$E}8HwCXgijs@kQBOgB8+*w2^6af z(wG3hhHl?X$qBEJ^A|NX$z{@nF6QJ92zp;8d8D1%128VI6l8J<*kj2F^^fBqZ;-F} zF?sXe9jAdhxp{cGILRUGba~18dHKG1Mb}wi+Pt6)`t>D$ z{hlJ}*(B1x@_SX6 zw=%$YY(O1oma1ZfhY?2DP+>#wP03h-Z=7H{UAP>N_Rv`$q*vkuli8=p$U@?3L6Yoj zC}&4HYl6KhS|XVctVr-*J_fdCmbuP5%kR&`6#!nUxG|TBZ%0Ar<9WW*S$i{@&z#8y zk{9Va{$;@xRR0tBOv}>fS0%;M5@W8YP?H46 z>!Yh5a=}>NgdyTmtG8GWz_D#M;PTdk8|cv;w4%>t0|5#{YD%=1;1`2u$K2I+k9xp$ zr5CE^zpAtFG+8|cw;S!@KHVx)XvOk95a)!lh|b*HAS+@BHErZ8x6PJ?e?W8$9q)f( zB{T&OIqIDD)r#S~=+9qSo8NkY&}6|!BkvC*G)wQhdW;dKstpw>gvQ>J<}X3zBi)AE zrC=?|N*}OKHERINsofy07#^I&{-K-Nh(a}PTo|J}hP>mx&UL^R6LjEgD3x)|&X9GLQ z6}&Of;41u(y}$hN85$=#!q<@g(2Bqo(!st-h_RTfpr7m>cXPefg)t}BgmQVs@Bg&F zaV;*6C^B zoAq=xC!1*>FYJ|KxJsYWJZD)VvUKvanppX1CZ3l16i`CIv1__X_`tezx}zpgqf-Un z`e_Gd03{*AcvsxZ_Dgn(JaM}7gLgj=H&!(0tHj0DhFFpIV6&a zvi4V&O*nn^JqfDWg7$(R%Hz}eXS*uQHXz+@x%SK--;t-s73=GMIf_2P8$b=aJR5p5 zEFt-jm$r>KC2*N)Z~wlO&X^~i;4PqaXtInpcgFqpLJ`B1nGs)@r4~D@2WPyZ**F)H zmPQV?4S#83PLOg9!xDaKmg1SGXCaL(_W>OCzaFXx99O38A}R*{&7`1m=x6K-jd)4j zr!;cj;m#l-R4>+_0cUz>__>$QLXve1yUxy(*}+##`jnenL}`(mg^fn*mb$qOo!bp< ziHch>36hQr=lMdx+X78glBkKW!+@=mu^(@zM)l1>(d$`zXCYt8BJ1TM*}mK*Jx+Q( zIVQcH))PZia*>c;&qb+IFHws9)61USQn2pslkVkix0Fk}M#46~o`@v;*$LU*{h%Uv z0^;&M=|Z=$^{#*8ub>LUc20j@s;s*EV zvm#Bq_2>Vb`l1VtOJ@dImk>oQQBH4Yoz@+vP=;r2Mw&4a1V z08G-mHX zGd1`8KJh$4!iElA&4+x%wal4=+2 zd)7G98X8;arSx>Mw=@ua+*^NB7e1quM*v347F1tOxXM#HDep5yq4Pr9&=-`b0vHDL z5Cr^Pe`^Ed*-$d2j?dmw9gkezX6QKHIl@(!&v%XYvC~~Vn}*ESd?J!cs)@{CbsA*ehA+K6&r9cU(zn*CiPe`l_>z=DXSNhJ|K(}Di0 zGQcJ!WD}aGe1rx|cf;`ADn7yMwtu$vzz7E|=T1cOr6SJgt>OR&HuB(k4T=w@k)+sb zIQL2D*!v&<@gLrPF1R|{fLqW;zj=Bg3SH}jzg@pg7kR1_9|EfcUSZo$Tx&g=B|VyH z-gCcfMxD+9@Tas?pq47?LV5Y#Mmnstg!jj0Rl54N@LypQNFu-((~sH*cX2Ag_tl69 zs|EAGZ{27)Q=2ZBOR0Q!LLbauE)r+*Xk#X9l@9`YNdHhsd~QZldl>3jfc&3JQCy@< zjf60(uhW^gDn^DW7bIacMfzuBOE4*gR7A4Wc^g>G*Ow~Lp}Ei$ToGMul?)4>usKl= zkjAFlBXQN!Vmti2XHLr=G|=Np6-;4#1@cQilr(`c9gs2omymWBIahisk!?VD#>?Gk zFM39L-;v9`XeZj)Bs@#3(UOxxBJ{xQu8ni}CS zaDy`O1yT#N*D$YHFH?6*L}MwGs@n9Y=)8c|!&eLpdM{}F;fl(n+6*`@ zWWrQB{jf%nFJDAEu&+SMq#1VFYP>Hdm>%1*0V>C;;-jlv#Sgq>Sxq9yPp%E9ZV!)-+c#67q01+(!x&K*ySg&ZePp(Av zr4HEwXN?8%;>;ke7gpJq3x@Fc(JBqb2zJcSHi*I0RPF2^LA6(|qDpto{KA?pU4=4n zg~vwJu^#cNV2+g?bIpft_Eny87u?T)Vm2AG_sUl=o*`5F=-Fgr1If?|oo2{;z{6}( zI>UepW5~!Z*(lv9Gl-<6aQA&_`sq6DLQp__9Ga35AC8=<^|#+{?sqWEo$Fxhi-zhI z22T2!HVocy64@2=3;Y_v^(&YFefw?S_kWLU&}@R{YAjV&;bb>Qkplsx+Fce{4>n{h zvC-B0ZaIl_L3Y0}M9eY|Nc?=n5$ODscMV=V*uWx{=p?Xq`Y)m#WRqV+d)DzgV^?Or zA-X4i0LpWT@tt6*EY%I~xowaWLq4upX?sr~;*(of?}MJbi1xhSc_uUqEE6TYity|$ z6q81-XPJf*1J$Yczb~S_?VX+8>FYPI56)g6zdL_@w&|S03U^9GoeqY^dsQ$Oa>N{K zrrU~szw0!dzxO+2?QrCziK1Q z10IhQxzdu+u?t@VA0{l9%t#}N z3qvskv#+2<-w--Br$rK)-`n1(m}OLQfw5T&npOq^VE4$GC77Y-4n`rsS%fh6ism_^ z65ANiO97sP=9Cc`Q+W4MKhDsc=zN5}*yJfFC7+TJRpf%P3>ZZkm+@NhExTZz+Vjes z1Y2R>a>)k#qYEy2;qn{=7a|Jwh#U zwyoVfoRwRpo7u?;C-mdz?qOv(^*mv!?{Ol zWL010Wi%VjC5<(YncBuLwx75vFkPkEG<^y6`|e^72tad}taN9EKHNC(PZ>?djQr?s zBtg_NeVBz#rgWA+9@f^NmoIzye=9|g-|U|YVoItEo!GFTyJSWrnb&45@5FUA;t z6p2i~ygooH4#%=h$GWf#s9BB}^e!+00bUn0pt)q1kX^t#6#m1xzU+lMLs&?lV+;LK zsWX)dOq(u1UlH0)aqm?S_83iaQ?tBEC!}eEtbcAi!8Sm-X@A^wab6?1cX9{7$*+gV z%WFYKBDLNzDD^sHnHi%lHHfaV{vunvig!iT4b%BjQKcZDw!e?}3_eA3NYuOc_h*|3 zqG-LW5MB*0|Ni9Q?CiI9r?39m*39tz>;E`9J3l)9@%NKo5C8dSLO8>O@Y+H$X$1*+ zT7~s#V8|EvgO0l|hW>id;}-!{}0?%$W&+qYAU|G4cd-7Z*}pb-zV&Ck_vgc@D+(=Qq`@ zJa5pg1oN^NJ@fP8PzK*Zo@od&%oOQYZ$|oCgp&6uzmQZ;>S3!Zr>nIzv#(mmjO{0I zXn{SG;XGKDuq>WL4Z*FIz18qh{tBC_FLu|215sqj9IwN+cRlu>J^W|Sb{H=$ zHeGtlEsQj-3Tah_-h0z&o^=>e9mmC)a>@}*1P*HOY~l{x%2dD>dH*gW$KsNWFPJ2| z&$h_k&fd-z(y7ZmuRk_C2dp6oF32;kMl5Lr!N6@1Ff5HOR8I_gdc=BGfPv^uQ~T0} zRFO;-sV`n@nuk%vqtKjgz3?Fjh2%Fa_ z+DN?Bh!RHps5oBr$%gX}fSafZ1}uh;@KWHJ13#$9Os!v1qE_eLbe-_&Gtf|PMurOx zcSF;LbQ{sE0h)6=s2<$HZ~9x?!JLam6t#T6`gntH*rfC7$e9$$bjrL}OgE z1oRRnDD+rDAcxQe^U=vOLdi&sg<*fqL|oB{zrG07dL@i5ex+fJdE#SgVR%=kr*u)h z%81}SV*RqOC}!gcxZ{dS0FEJAo_QkkIY68+YZo%|2IaXjpfR2-cpcil$ucPmx!kks z#w!dgZ(oWUa4Qphod~AF8z{FuXf`pkTy~=zg}t$_to?9kxQ0!))x3UJ!Kfd1x)S~r zUYlXN(6p8>_kO>8_PdR@Ewb$={?A^aSkJI&YSXUTGAcJro*RP}>5&4O^*w|oN?EKSQh+*XQ%v8W>nv~bJ(nx7ZRNqk_7|X4BAJw$M z8NtTrEJaQ4kU$%q8$3_qI8BSMkM{*@zEm<+?9(V2N`c}m`nv?#oqQy28_@zi1Eoko zkQ>n~2_pMIH6T^eOTm+pRkh~hrRcw9)r{!(YOUF=UkT2|kY9pDfE2~hIg7$%eEj(F zV?|}eGtv+AAb~n~2Upq;4!etpW z++YB@3>P0PNECtAW@RTO7nqax>u4w1?FT!jHdf0s)i-CSM}D*6XpM~S;#|5le!)Vv z!W{#?`Dz;&pu`#CR~DnY7yv`2hUvw)Oj}0cLgN}zM$b-4hWFd@?qDjki0228S4$jm zQOvAE%_`|71SD>#g>l#-N#R|FAS;fpOMXqd`B)?@NZS(}OgSAc7usYS<8RkFsA_ng zAdV|Yx@JZzW1dwzOlatu3+1&34A9!L^e=a#kI~1z5A^D#&9UK(%9)DO0IzG{6iF9LR?wP zF)3y(*Al8m7lO)WS!m{5g+YXX?C^{2p`-(v(cjDHLDtjHw-NnIiEPsh$O(x)X5;(X zbYUl!Uqw&+0gFfd&PQ{~?|uvxm(3$qPF`rP;Em~M0daJl72Nb5csm^gI$>x7X$YYb z6{n<%0SxAeH77J!q#It#3XB)xV?-KELj#x3FvEhC>l2YN8%x(me%vp$!EqXVK0N}% zGHeV&ot+JIi^8JK_l(O$WX;pT62&HCQONE5GVXyt{D8?#46c=tlonY$B9sgyQ=`D5 zA;?Pry-ZARqE@ci%PL=It5yGg{Q5W3?stzG)fpPq9tk2nkPWkPgCL_@BoTyK<85)3 z5y{nu&3&7g)q1D&dU+v-tY7BU!pmdNwW9rWsT-NQ)o_!5qQl*wqu%L)uU z;)SkK??1iVs(mJZx9Y&}s*BP8{OI<7;}teTW#mKoGf+vpIHaM|tTPEPyq2td7pxYoW~DQrp~)BPJn>5-=H705h`&txm%SR$NvhP?2Qj zL}E&vNO8fUoJkH>)FMk%e~a(|VdLBg9an)v2XMCbN@#8cmM5TjE(MK8B&G2OGepJ7ferz5gKR!Tv}N2o1buK#6z!W4&wnGm5tnT;78 zbvkEa>=VR-2H3G|HllsSat!UFFgF7TRQLmYSK)CDKCr3KR%ipVRiS`nPBTy))r=B&pUwy*lAN$WI(slg&&ZTLkRRiW*EchL_(8;&6Y= zQyz1TX*#C^G*|t`EO%rL65DK0EB<6)rcfqstYurynFQm+pbqQq>C`3EE~PNT#1zyd z;Sv+zITGL!dO`&TLY9GWj;uA+H7*A|K3G2b;cT6Rd)c&oJ{g4JV4VRxeo>3U-FLFPykxT9v9>ROm*t@@%iaLPTn0IpKk?rig4=pJC0VlB-M$#()hl1JsFZ73PCCZDqw$* zthB%TMX{;Yz(WbY@IRT!WHhN*-8x5k7z_@AWm--k#3}kY*_t6j-47`pq9bBXl?pRc zVHisyCXAKs;JCNxrz&_T#(9C&PYaOXkL zb(aHHal<0?E6K6)Ty0QH$< zpgfJp8DpjHx%obE+v>Gnyx4pG9YpI#HQa!IWNN98t*(oPiXF(Nmca(M5!J|XoBp;46`5&>!C;yY|?d*L;zL$a~u^30Cm2TJl) zTdqe4zOZ27dV=MFhc}O$FitGlF5X7|T}Jw+Pjp4(!wY?d_|ptB67uBO!;|DZZk%-y9vj zK0bTh+lzL->vVp}Ql?6+v)Q;dyEAUtD|0x}p|rvu&mj8>&xS_e8t5x3Stl{VAHOJc z1s>NYWS9I4kl>$m56;NZS(ki&aCUUI)%oq{{HJ%noRi-UPEQYx&yQZ8k$0!$@ZIsN zqw}M8$7kf-59HwZALQqw<5ycQzBT)pW3C~P=2AH7U7ASOxKpnBRO>0thJ`tQhvrPq ztW4?IQko?l$eU|#!Ka23{aaH(6Cz6pZI=1+?!NJprOVFF&d&3%zJmYm?CjM4ySuaZ z?B903`ufG*^Jibb*n9SGJG)=+JpcON$j;-y_kLyfk8l6MFxWLoxYrmb5CzqK8}_>WNM;xEm)woj}LT<5YjW1o9T zK9)~dn&^LU^M5OCETI28d(WTO>Ho85&mQUjrzlTI$%R(P&=YcY^6Ec&mU-wMfsfXJ zGr3O=P7V%#0yYfz*{Y?j`33Wcg`C;p(e~jh2<~cgUZM-PVNjnuA^#g9W?C)RuV(di zo;>M<|8{f^4R#+%)UB1K{qIgNwf%b;A}7-Ms?=cq{@>mE`sf#{p$JS{{Ix^ z&p)@Hc4m-(HU!}5_FsQ>I;IwK$;bPkq&LDLh|XgJZaiZ9q3)B3+BN_Cf4iHT zr1#ff*y%t2>|uioTQHyGAb3lbyn>+cXPD@cXTzgmCj8H@(E-4@+2;OY(`U$_Iv%}7 z@z3$xSbF3Go9vT6|0Iwu?XNIEBc|#V(s1R$&n|{R7|GF$O2WNGWUqAos)l;(K@;Sf z{80$a>QBw*EKA(aSIn%6eX`qe4|>z{IHEGS(L9<8?sgur1@nnJl-f_Dso~6OcFvg` z`y@DPG?#N`?*lr#INyX1@3QxlBVA^e3OFWG-tPE40X<%9Cw0EzH*fNqmSmFNDe zDG(aIoO-AF_0;IYDhwrXRZP9O`cYf0$q`bxjB(Vvq^^O2ejhgLRK_d3#R)I#x@3dc zHCI_-RhY|pj_hA#bCX{BD*_PGHnT3CN$Xqi<)4HH|wwZoLw}}qv~e#AMKOB zL8tt3m})LK-|Oeh7a!PUi+q6=Df=%Q7DK&9U*G`;hn@Yd@i>}lIM`Y6@l7aR_{Qcm z?@}3I^SXm??&B)@;vp=fX?n4K%g7vy_;+w3?k;!U1E+j@2VsS8e((oj$;1D5IS7}R zReH^&>hm3U--O$@HIceGiE1EFE1q(#iBqk~J0?tRPqfdr9%dX|Yq22ao>%vHL9{r6 zZte8>G{Qf&bPKY7AWPkz=pUR*izNTgt>>Lp7U=(Dnp1EuzR3;10{#Ej&v(A6>;J!c zzWb>E{}ko3>Hop{oysB8VOkm&q+=FosH{hG47gNXe#7yBXF!LkxS(leFV`Q2b%Za@ zd8-$^2IM37x8v=i!?2$}mtRM&7T3VcnnN05Te+PUS{)SNk)G#L(z#lB|U@r`Y(C$wM*Dsbr-4_uXi3x3|-fkbL$!h&FwNz%r07 zHtFn(yH!|cy*sVcToo&u+QtsN9ZJ=FKdr%fabv8e?XluUHn~_!fZW?Nz;S1^eQnO8pa%a|i&va{^LZ9*bHm@>xZ$Q# z=T;7@N7O#`5r6vq!6A{%nlHE9Rq}$yOVFE}vqjiop+`dU|H2qQ%Ro5q^V@^+@bpz% z0Araff#1!S9?f~V?3aRW#+SE;+QG@unN2s+4m+B2_d$CIcWwhh8?0ja^*8g|+k^2Z zv$RE~X-ZB+LJkToR7_JQTcSG3=@N|lvt{A5y6DZy2@C$TQ&`!a^K7_%xd*Q-0C(zx zS+Fkr`1YpbS0*8_>S~j5`8XNNOooE^Xl4{AWf~8gwwS9jUTaGYiOM7Y$s+8(UX8Ax zjtP3b{2&h!>vVV+#!*~hq_nZ3c7y-!e-=Ia_o#CKAmToXfNN zK>qs6=S=gdD4o;#2ZkQL?p>adzdwtf*;E4xgS~elybFX%H;6!{hc0IPj5<%Bo(qij zjOp60FqttUG1W8`!!H#X^Nf#+tZ6o$K5ePL!lOn7eXZ=j0GaNe8<+2Qdf)zY^XdEl z_t!t4{`J=vU9wSuhg=ga&M*c|a4Wy?p!uBle~a#aD@m$XyZ~O{|M~3M-qii?^B0f) z|DU9M^7~)nPuhU0CQM^mR(^frjv-gEf2IyMZe@UXUTa^`L+pk#^ zRI~Q=LIbBZUz7{Ze(S#$l5DU)maHU#;Y>j{5He=nO$_&RYqc6N+f=&DR(2|bO+Q>S z;O(d66qhT{f{oK+&GIZ*^|b{I3#2sRt55YyBF0=#{oEa>A5D{8(v?Mapng$Iy1(s% zUZq}Z_!umkNU=9OD?Yx0q$y9F{H_dCD~`U}_4-&Hv77$)EV5|+Zf?oO8tVeL^$$8u?-f6_Ze;=g2i*8g zeE7A)r(sEj~CCr`s$JY`xNC<@_*3QxlKODT|;=&uG9i^mxgs9z|OCRH!>UU0_5c z)BcZRaE9$dMl|>YU+wMebW4TDR1bIWs)xHv^l;braH=bo1h{O5ep;q`5)R9oeK*X= zGz6PfHq9)lQx`qPX9I$;-U{7Z>aCF;v4VGxxGy8}hGYEV;FNQ5W3|;T7*Pbmnd8xl zOOI=tZc2}&xN<59)2=&7d&IMPL3<|@QsOx#X(p?=2-5OstV0DJME?>z16QYM6hc1I zFm2P{dlv50W^g@Hzu362;R*c(^wDCv|B`h7GjmyD&&cc3(|4zs3jp?GF2yBJ7>0z7 z3kfKc`?sEuuS{bQb!RvF(rWZ7KX(5!BlU7Dv`yE^E?GwCKr=}s3qwQ# zaL^fJiA$u%Chpe{Hms$O5$pm|lQ^Y(tZt>-KNJ$<`3I>NZQ=)vlxg~<$;|9@m?k{k z9*L`-7Ta2i;v!{gB!tEkad4=+ymU{>!S`_a^{z%dWxX=_48HGD0j)5^ztujnE<2Ui zrWfQj1S4L+4u0-q^1+q`{2xU8zGMFH#ft|1Z|5=o(WjzpEWF_W7foe z*cAx>DSZ&(pQl0u8X(92=n=!WNf*@<-MT{vFc`DW=4yMMYjcTW3pXq58BmCj(LWsL$w}Fhv9`osB&s{e6h+Jtiko!iRsgWd^zKb zCON+Tw`s2IX~Kt0>99M&fM1gC|E`@i;h0}GRMar)TI1D`OdmyVBVoB@G1V;Dhxpyqb&h(@ zlOj#=E1ywebIxYW%hzjL(zE?DcjfNZ>}PwMv-WJ8EnDeTz(NPVn=J(0Gnm$QMz~IF z*sX8_gcwa}@;>k^IZ~uMrg`ZL8fekNKD;C}&&|wMevJ$PtgFq!#kEXb4eGar+P8{R zxy7`yvh9<6y>VEqhAbDxM(CLyRJ;kxvA!zyYe-ePa!rbi|KYsyF?2HoZ>x2u(wp0( z0_>8e1t!LJIl`2WRITQ0^(p#r>l?l%_%GPx4gFcX)$P+bxU6x1SLXYR#_Nq~y3qXI zCm|D4wZ|9P^Tw**M2=YJ3fTrmMjXx-xElxtFCTk%ukAJ#!rbf$&Gnh(-M3^?A z4YjX!{+W_;c zf8X5r&&dAuzrsIz@cf|ne`)W({EnO=2zqPHh#{S3fkHk(n=4I)22hwCI8DO{I(M`|Ly*GV7k%f7S1&PJ-L0piX}g6#?fBc?mP(l6Si| z-PpsgO*{VU?W0Ub1bzZmVM9og72^vgH=U`9{S49pLI&0gh6!|2$ats)WX@%97c-&b zG_W7@*Ii}GM2R_2H5;<2tFBiDxbU9qe8l`?XY%VanL1Hpo>BbN?;t=T@a*g!Dz$d80ep?q#8vho~ewoc*gGYC39t3pC@Q&*_i zGh5B@uQ-+6sW!~3Q}u-p{|v1A4OFf|p6P0Bo8_}XDyV+`RsFeZ4wmY4gV$z>HLz^+ z^z0B+h}n8{AOwA zh$-&~$k#aE?E%4A&8NW*?J%WE0kydio=3IXmc(`X^SS;oE5Li_=z4HtCUtL2GdhGD zX=~8%|Fie?&u!bvqQC2}z@y!3E61WFf2G&wx_8nfJ;$%j$Bo@&&NOdYh=e566u|=}(_;TxINv|eKMTx2@7C({&azJ}!Lg-rL>L{E`F~8DhEzR8|lxZY-IG7Qy zYm2uBef0x=E&9LC1A3?DzlX!aa{b4r`^S&xzxVRHfBoN5=0%F*5c5zMSgsR5ngLDA zBHeXua%=TbH5cvj$p1)YY~xCeQuC%$qZ9mFcPlnU@VM*C1LJ7~rZ)n)q-_ zmc>>Ld{988`WZ|S7CM8Q-&jXqssdsjxDA#_6F05(GJ#@c%P&il&If`*h!VZRaVqWY zh_M8*IpIhFGj-#YgIE`K+{ua-dGUB<+jD|1?Xu;2#{L(3 zh@^qv`6s&~bHeu&AUcz3p2ka}bj7JIE?*M5*2QMtQ7u(GC7H=NDV4R?Gt+F@*HL&A z;uOJJM!N|OE^lzU(UP>ji#~ym`(Jn#)yhk?fR_oc`p z7EZmQiB5$L-+~x2`?hTPW==S#A(7INXK{dz58%IacAkyL^cs1+rqXrKIguKrI@1L- zCC=vL4{McWv?x$lyJ_11v{L7ImmpTK_l@w}fvEo*4eS%*B#Cf9koPwadB1u$9~90B zH_oDHf&R#FBc#7G*%nSh5)yR%&e_=91yMlqlqMGKX>q^-bcduR5_yg7a+=GA6-T1s6Z10X71O4% z!Af+uU5MMUjsaeoUoAXyawsV7o}1(9JY5^XG8a0~RKM#m+j5n^l?l};6^VnzSILXb zu2jX1#qcb~G$x4bMju5M?ex7It%1TH@3kdsxToBB<)FGHIN6KE>U7D9S=wp?%O)p} z+tsQ|r&e_?kgt1t>!nl+b<<1gSroA=`&J#WqDbU!p{_YHMV^qTBxbWLN@=fXWSNzS z#wcL35!^mgTHz|2GQg?XneB0_zHEU9Pj0_UA9Z`^I)$OBhoTce* zu<(jC<=14rs1VWbu0@Jl>VGZzf03E_7B)cJ{r}^qmHbbG$NK;G^ZSDJ{|#@!$tV$N zA9iY;)C7&N_6lM&p>qMsUNy{-FUV|M6xt;H%=jU+QT8==e5xA3QyB zz#V*hxH{fqhNEajypKxsJ{mR?Xa&5VmdNn*xOw`j^d=$AW;aA!L)#eU!=r=d8LXfl zVe4F{jm76um9RApzgm}ohpA_}R|d6|k1Ho1{@f6qj!51(`5ceVZ;TwY-w76JGw4I^OiHyV%G zN!AtdV+%LM$ZNDl+ z+2bpmB<7x`buU=ra;kONC0}jlNH<^PZCz{zMEhFH6ZkQXI7vArb0Rql;(;PgaJWDL zxN^gl)?|?`Perxad7dT}_GhBIPuu0!d>?l5)vfC_%1S4Dw@_ zP^bs_6#q*B`_6?{7D^_|o5?p(Bb4?K@h2xz1rvWnIfXqReV|c=$}7kmKYT@zL2B)clkww%v*?OSDH(=x=T*`%CtyVcDCxg@ zHb%HcKFGcRs|>0OzgVUOBiZV1HN%8^!sxK@=o?#R1 zT$07oZc|sE~JN5)>&(*zF*+@1wVQ(pWRpImKlOR1i<@MujXC25Cbc zR}e16A1>8`^vmTTw&km>l}VCLvHn2vR%zTs3&5PHx?%004}A0v^Op1AyjlM$5x=%s#{g}^jz!M&M`YVMV^Q9oOxRC zl^z#z)`}hQJUe_KKRnTZ^%h~Z9}bYOhhLnuC>}7J^`p}N%1TO>(n$CdlKw~*KX5ja zTV}4HNZ#_0U)onK?VxhypwcaTv$SJqA-n$3vRyz2svX19-qrNHz$pepHe&2DOOQ90 zx{)!1Ryfhl;p+MB`k@4AVl?b_)t|4k8R0ZQ!!Fv*lBC+|lTMTVtJkz< zpuGqvt?UF%ACL*Tey8GapGDD6GzIsg_wnNgix!a2^pn)fMat39s=)2MpT1lqE3O1iwxX7$)K82x|QRcZ=ty}W&bOeoMCIjzcBVJ!{35=3Pau_e6D zbGrP)0l4gXJ`}wv)GWwr<>qIRvS3fu$-1HfZr}?2pR+&<5xVJ`1&TuIs7L z{k+$gn6%WKEtJIlO1%p9pcY=sR&=X&Yi2)p9?p#x=UET$R@o|D!n1qZZBVo9 zYRMYqMjflHQTXmOSK+t~9Oh$-Qhyq2H7F9xFTW8}>ZgyyMe?<1SglA5gg)scM9^W? z;8f2msdyf?y3(@$t5e0h&Hr*VsMh}*KH7ix^Sk%`UnO&GxT@CUdHqHn45%#EBrpAQHV%9M{~?o(2t5Y#wra*>qM4_qGr*|%xuxiz4`4^ zQvzlAo#73{#3vDjtwqTHK#6E+{^|i+U%;{Ab1cXYl}}-B?3D`;mHV)b^G0Yz{hp>4 zyGR{y`lwrDAH$!;S!W}ZhEUzK(8S>|Oz-My23u!tX}C!qJ*)m&Yl&3=7v5z#8XbIN z&7kkO=MeQrEU>GkZ$`h%7u%(9gp!(3^i&Dqw&3(?0!0#)g@cnhB`Pt86rEh3X|b`o zAgKK2h{njMT-yy1Ax?01et@!Tj$#UATn@2boGv>W$xl$`a{h{lsco(9_db6{OU_W# zv6`Ww6|3ePtRcpAOMP+yO9&#sBNVf^r(3VWa?PPtrBLomvKYF22m##da-!2;`2v6* zK7LzgF36Aa;mIk|f9h5)m)T9rlYFxP{$$9 zrzE%(*$iRJ64KM-87m3WHJ~Rd0Om2bGRqX;y@7aH4~t_owX9{^4>oWPm$xBg}Gdzw=)4-?7!oqrN%3pqX_U`rB>wlb}cg5&YNKz7{Bt*N?2D3`VfBt+TpU5_! zKX><#)-hw1t6OCXhd2+;;z_hXoXBC4Sl{x%b4J4|wL$&FR!Ssk#!=2$F!YfN4y+f$&+)>w+8(HNzwXm(e%yT81~vm}CEszJ2tbQ1{rR)r*Ua~Xi^@^kQ? zIg7Fxc}w_=3ZcIAY82OtT#b(PO<=$eVIi`aaYn;65*9`5$}0X0wucHes>7uiLtdb+ z^00zq>3#86`vNQ|=9+K9F4bJN(zZcIdPRhsdC0A@vG21O?vY8y$ z$|HYBHQKWj7;yPVQJhQRv&&+FW|+nntD3oj9k;9Gq7@kc!bID3 zngm`!9`|Z+b%=#8q4LTr5xEEywyAhp1XDF!4R))MR*8Z+QTZh$BUUIR(C&;uOB|y$ zsk42lttqg}mBsI(@_`@-$p(?gSvFDkn2Iup5t&21i8!SZ!kp7Nh#2Nd8I7xt%z8)I zANfJSrX{@0?4O+GbJnYt49^WjJHkVMX-8GU1HWMkYWc)`RLdxuG-sw}q-r|F0_n>a zr)t^2-RvZy0e;T1I6WQgp^!m0M$pl;7aJV*1d1sJEDlHlhH^ZnlZ*q#y0}s=CxoT;_8)ps@^bs% zx~bOxHNZmPIOKR;XF%Ke?{L4I|9Agj@Ob`vFTZ=Y|MgWtA{9r)PDN$Z{N^EuA}K`R z2IA5iRyCI?3r5WWHXRO|!r%FAPm{>SwX{~do8e{{GD#^wv=)Q4(m<+98Rq6vj&*+C zDS8@P)vvjRT9>-Q&y$lBJ(;v=SQOtfF3IYf-S0$=x6wpfmAKZSQz`vX%@IZcL4&L$ z)^gzKJHjggGODBlAsqcI2!G4Tm|m|0=8`OY^-&3uxl(LjHnz6*;+)#;)4lf!x*_Ar zakO2|{R9=r%UQ%A#=2`>YJ#Yu#3hrkeDzn9Ppj9tHeDHRp5bs;XdrMtO|tmby$>CvP8cQ3y$ zXaBvD?DXRMjaz?)O&^TyX9n76`9aQ#+gW|4kGrw?YDl`4#b>_P;Bb~sZ*26HVAY#^ zmAQkG7eO~(=(xzWlG8qCIbjHL~xBe!08POmj^sCT%M&yv)F0aY#M7G>B%YHuyyQU;Eo26v>D|l z`2s5pS7#yD987}TF5ysf*0E_PgrRhm$QyM-ln-9);R8E5(#NybLd}6_F_-m=s+s$0 z+ONQb#aDMgraGLKQ|#Zxk+aGUxG`VO29<$T@VieNVE#xa9fW8QD* z!fAqXO9zet`@r2dwm4m9jX6r)9XfCBaJe}{xXn7VccgU|R>OB2cFf83yT;5GYW7z- zs|ETkGipo8x*4;d-_#Aaj{k?n6SjH(&v0;bSkC`4e0qHJ=>NHo-xu`%NOpSh{eN&W z`TORbK$=${juXfpZL=pxp?ynVklocCIfSa@UC$w;zu$;M$c5G55L)iWaREmXxa~nS zxN%yYN~J+HJCz=qL+Axg@d%6Z_o04#Uk;^nVlH-9T}kR)E2L*xI(=F2iUlX{+X;JrY;1*LK*o*8oz26OV{^VYP`*YRF&kIF z`zAgC4fgByz3D-$G4D6^y){6&t?x~Pec-;gl|3{~8uVRyXf|x;G>)OoO=B&n8@Ori z)FryiB>8%$=JtM3Gi7)07yZ%>o|fM_{y!+Ka)U%5ZSh~nhn4uRqv7$R|L;D2U(o-z zIRBrYHt+az>i>Lsw;v4hP9wjdhdXlfm5I5YlMlY$h>OpLbi;@*2bmkkd)ZJPqrD!Z zy{Z;ItmuCOr`DH_^>QTVgYqhU;YcsVfRAxrU&hzey60`q)3m9Lza8hBSXcn`>-I6h z5LTY;oBEe(Vcgcc1VA6SZ)s&GMziX6moALESzfpHT&Q*RmB(|{tI=QGY_9Gi%%$O7 z#c|#Aw}$^GVIrMyvc40jCH`ypwBrBSKYG0XcR#-`=l^-D*y%2uKo+My7*9}vQ?om$ zXmdNCkk!}SIELy8yq0&!0&&YUFg1{BT|~KcnYXviuXd4^y(!gZwUli(^Q+C9?b@2Pv7r<*RV-Zhil&e?k!x|GJ&Rr`v#0SR)O1mv zk0r!j*P*bKIaiv2yLB%tAK624G~7&m4oN^mk{6#R5;3_W>SDnf&t-dFmDw^ud{g1r zDZ+C}7CkoZnRmbjeNkU!dvK5&1 zi9a{RF`*(vvkyC!8YW-Z^*o>kYH1u^T@XdL^{eU!@2R2 z0;>iIhE&+j$F)e6yAa!nWH()zJPwvW3-T;Wr|ZvWUeu_qc_zr)wa|PJ-u$9nR#50R zk%SJln?0!c*d_j^YpwUWE;}p!ti)|gV(09Z&Ax3{1UGiPcAT8u``7QdZgWE^0lRFk zuHO{ab3WC>xTR&LfIdiLPg;6B{CH??$M;M9arbMYC zx>lya+LUKB#Uk|x7I+ppw;c;(ufd;Es1=R=l=^-JnvfXE|Azh5uUWzrwfC=@>9J{K zLY(6IE}G*WR?zE<*{`jqEEN$#R4VSRiA6 zoki)cowCpJhU%@g;+ig3DwWy{%<@rro>NBHD~zA6xrAvwsh)PvE9czXbDZB8mCHeI zWloa`NuOt&lelmydS2OagX1ul99m+O{3oWTCaS?l7wly`e~-BxB-wyQVZ)${!dwni z6UEvmALEa)iAMZJe2i->9o4zQT+W=PfZuHh^m07_3jEGDVy7zB@*>7K4AD-nC)FZp zZk=w+=2{9!EN~q9(h4hBqnLf`nIT|>61TN$>hynqN}?H^#Eg@T=$I|~|KR9o;OhUw zqy49k`v1NBw$NLgN*N=d);vtQ+%RG^%4h^dwi6s&;t3IcXA50SsX!u262?=3#FRu4 zM5~|~PJ=0pCwqvK2&Z(eH@xCGj>FCtipfO9H}5K^f{r6a|EKGtH*vH;EQT*7N+=;5 zMKmUU$A5AD>$%*XI$P*Da4CBK{2Yapi;h2`X&?So=y&|lpS%zMng`QKU;bnM6!W-W z02<-oGE2Z}5uGQ#xJo)t{1LwFJn_?6(s}a#J6q^I=9FauoxOM|I)1|0?<7b&J`D-( zt9G3I-tp%mU?GvP>1=|=ED8zl(AlK_U_A_V>;K)$XD?p8^k?CQ`e?cTbv)RAT3Y{y zhX=#Q^?x6~E%ZH2V?3i~HT*93lpwq% zoOEU+#Zvv1m@B+%Y##YS_~R^!R4^kPNt>@`3#Hp2@1G=!82Z3?6nYAYpBTH8v`rbx z1VJq02q$@wZz(;T5r2euZa_#~XoX-EXZhEt475=}{z5H8Tw zlm=52;8?0$aNGbFE9N9(35v;;<|33yMPBi6g=@QJYeO>};|bw@r{glA(*f*Lzx1FK)cjW{E*E1iqS)oL~JpW%BWP16?piQjS;M;=L$ZJQ4nP!C0sN7TTZ~-7c?cJ z)9E4EACE~&1RDDL2Y>7I6o3Q&TYvC3^^LSciG%@vo+l)rI6^c|vJ{D6N@f_|Y@xFm zo{)Epr3KHLr6AcWT_ofL2{5}l2!%uhoPzCjf?mKnL8iH7R8k34+F20`r0vx~h+N}Y z5|I;>udYtVT7@lLy~|qy^%Q)$8f?g#A^Y)!#@C%riYIMg6`T}LY%oKAc1Qs_lp7{%sF0k5TYqjUWnzG8Y4PdAYqV>6%fglIr_RE zv|HTt!6ILPqU(1$AsJ;Bxku_qutFL&7C3&PW3*ryx?))rqKIA+`9=_e$aTswqVbsV znGz9dUl8^rsl!6a+s|S|W0B%GAR5?+oOHHwwzN5J%Mtu8zidhRH_ZMPvZf!|Gtt>X zYUmmxkh^kSy_Hg5q$CbVv9k0*a4Vh6>8WZjm0knOw}~L>*y)IraXcZK4Z~kM- zgmSnP2&U*gi?SI(=L!%4NPFmPYd3(t2<~3|po~LDQNQJ~xpl|`(rIOZi)1&dk z!Px70oemBHBE%~ek~VaoK}$4aVG(fLh_}KQTNjvzQ!Oj*M`XyPs!im)VPRg+K^IQ#7FcT6;$#Y2-Ao*b2((JejNJy9vbVMVXE>yRm z`%MYuqD&2`TA110DkOPd(+ZyVdcAt}Ra52Wx+_8v9+61zP&i3e|5Q!kn*{%nkz5BZ zX5+8FP=2w(xO+dL(eWXPgZ#?o=g%Ey&|wl4Bldk?yBSG5KglmI8?)l=v-9&0Z{EGg zUm)dM{DHICiS-a^LPNmW*nP8vTxNFEh_$1*k_DmuhGF?^!tL^sESC0oX?dns?NHyh zDgqlne+K$>w(MgIAZuwwxr@JTHKF(R0j6GHuisp}JonS<)UnBKaX>s6-0u>`nVY8=4>n_(wOl;?wm~INBH_;IGjO9nMI=rbUP4bI3m)Vu9(swilYcg z?HA1!`pgF$<)w)_^}M|jEl)iUIDV(pbVW~mxr1%9(0`R6O5XYA6NXN{>d1A%c#14A zr`x;IE)3ELJ~k(IJ&>`C#41vx%My|CM< zBf;=SBpBKx7;c6H!#g0suuK9iG|NI3Y;=VM0uw}Ig#Uqxfb!Fl8n>sl6J3aKXq=Hg|v|IJJ3QqF_-$84P z|LAY&);&P3r`fy(8`k-EbIgte##VEi|C@nU7Xll_&w8b*K zU1I3Hgp)DhMo|62#$yUjM`#wZAd{92%-QnAGvEJpe(>vzPSgbVxAOn?GC_I)7DROQ zGSRQ%iJCk!hLoZCf##N<1sP`%ifDWZ-qZgweak+5+lifKDdQAJqR)w7(VX-odi(J2 zK`&tn~neKmQLtJGvwI7TkHtDN2q$fuLefH-2y_D(y9`wJ1ZA}MXcB}}^{|H#J z_`BT34$=3V;V@t`6=0MSK0}^)=BXKii8aq4tW(Z;pK&SVk;q1Zh7@xO35RrdAPf$Ub#>)qK+$J>(vMUV(f{e}`ODYm5Tg2B zr}J|h5h050cC;`@BU0u^_zFo;G~q;T5i~|uoThMUE!a4{!klzMDpF2IS?Z9-;6w$o z8%W6>Bk$QcIy?8!_s`DH&i6VW&MyA>=I0Ca;n}-)&t6}gy*x*6-l6AjUcWfIID7N@ z9KHDgJ$wBx^yAs<7kh|M-redis", not "-redis-master". -*/}} -{{- define "litellm.redis.serviceName" -}} -{{- if .Values.redis.sentinel.enabled -}} -{{- printf "%s-%s" .Release.Name (default "redis" .Values.redis.nameOverride | trunc 63 | trimSuffix "-") -}} -{{- else -}} -{{- printf "%s-%s-master" .Release.Name (default "redis" .Values.redis.nameOverride | trunc 63 | trimSuffix "-") -}} -{{- end -}} -{{- end -}} - -{{/* -Get redis service port -*/}} -{{- define "litellm.redis.port" -}} -{{- if .Values.redis.sentinel.enabled -}} -{{ .Values.redis.sentinel.service.ports.sentinel }} -{{- else -}} -{{ .Values.redis.master.service.ports.redis }} -{{- end -}} -{{- end -}} - -{{/* -Reject an unpinned image tag for the bundled PostgreSQL. -A floating tag lets a chart upgrade start a newer PostgreSQL major against the -existing PersistentVolumeClaim. The server then refuses to start on a data -directory written by another major version, and the only way back is a dump -taken before the change, which by that point no longer exists. -*/}} -{{- define "litellm.validateBundledPostgresImageTag" -}} -{{- $tag := .Values.postgresql.image.tag | default "" | toString -}} -{{- $digest := .Values.postgresql.image.digest | default "" | toString -}} -{{- if and (eq $digest "") (or (eq $tag "") (eq $tag "latest")) -}} -{{- fail (printf "postgresql.image.tag must be pinned to an explicit version when db.deployStandalone is true (got %q). An unpinned tag can start a different PostgreSQL major against the existing data directory, which makes the database unreadable and is not recoverable in place. Crossing a major version requires a dump and restore." $tag) -}} -{{- end -}} -{{- end -}} - -{{/* -Whether the existing Postgres is reached with a short-lived token (AWS RDS IAM -or Microsoft Entra ID) instead of a password. -*/}} -{{- define "litellm.db.tokenAuth" -}} -{{- if or .Values.db.useIAMAuth .Values.db.useAzureEntraAuth -}}true{{- end -}} -{{- end -}} - -{{- define "litellm.validateDbTokenAuth" -}} -{{- if and .Values.db.useIAMAuth .Values.db.useAzureEntraAuth -}} -{{- fail "db.useIAMAuth and db.useAzureEntraAuth are mutually exclusive: the database password can only come from one token source" -}} -{{- end -}} -{{- if and (include "litellm.db.tokenAuth" .) (or (not .Values.db.useExisting) .Values.db.deployStandalone) -}} -{{- fail "db.useIAMAuth and db.useAzureEntraAuth need db.useExisting: true and db.deployStandalone: false (token auth targets an existing RDS or Azure Database for PostgreSQL; the bundled postgresql subchart only supports password auth)" -}} -{{- end -}} -{{- end -}} - -{{/* -Database env for an existing Postgres, shared by the proxy container, the -collector sidecar and the migrations Job. Under token auth every entrypoint -mints its own token and assembles DATABASE_URL itself, so db.url is left out. -*/}} -{{- define "litellm.existingDbEnv" -}} -{{- $tokenAuth := include "litellm.db.tokenAuth" . -}} -- name: DATABASE_USERNAME - {{- if .Values.db.username }} - value: {{ .Values.db.username | quote }} - {{- else }} - valueFrom: - secretKeyRef: - name: {{ .Values.db.secret.name }} - key: {{ .Values.db.secret.usernameKey }} - {{- end }} -{{- if .Values.db.useIAMAuth }} -- name: IAM_TOKEN_DB_AUTH - value: "true" -{{- else if .Values.db.useAzureEntraAuth }} -- name: AZURE_POSTGRESQL_AUTH - value: "true" -{{- else }} -- name: DATABASE_PASSWORD - valueFrom: - secretKeyRef: - name: {{ .Values.db.secret.name }} - key: {{ .Values.db.secret.passwordKey }} -{{- end }} -- name: DATABASE_HOST - {{- if .Values.db.secret.endpointKey }} - valueFrom: - secretKeyRef: - name: {{ .Values.db.secret.name }} - key: {{ .Values.db.secret.endpointKey }} - {{- else }} - value: {{ .Values.db.endpoint }} - {{- end }} -{{- if $tokenAuth }} -- name: DATABASE_PORT - value: {{ .Values.db.port | quote }} -{{- end }} -- name: DATABASE_NAME - value: {{ .Values.db.database }} -{{- if and $tokenAuth .Values.db.schema }} -- name: DATABASE_SCHEMA - value: {{ .Values.db.schema | quote }} -{{- end }} -{{- if not $tokenAuth }} -- name: DATABASE_URL - value: {{ .Values.db.url | quote }} -{{- end }} -{{- end -}} - -{{/* -Environment shared by the proxy container and the opt-in collector sidecar: -database, pgbouncer, master key, redis, user envVars. Both containers must see -the same DATABASE_URL and REDIS_* so the sidecar reaches the pod's pgbouncer -and the same spend transaction buffer. -*/}} -{{- define "litellm.proxyEnv" -}} -{{- include "litellm.validateDbTokenAuth" . -}} -- name: HOST - value: "{{ .Values.listen | default "0.0.0.0" }}" -- name: PORT - value: {{ .Values.service.port | quote}} -{{- if .Values.db.deployStandalone }} -- name: DATABASE_USERNAME - valueFrom: - secretKeyRef: - name: {{ include "litellm.fullname" . }}-dbcredentials - key: username -- name: DATABASE_PASSWORD - valueFrom: - secretKeyRef: - name: {{ include "litellm.fullname" . }}-dbcredentials - key: password -- name: DATABASE_HOST - value: {{ .Release.Name }}-postgresql -- name: DATABASE_NAME - value: litellm -{{- else if .Values.db.useExisting }} -{{ include "litellm.existingDbEnv" . }} -{{- end }} -{{- if and .Values.db.useExisting .Values.db.readReplicaUrl .Values.db.secret.readReplicaEndpointKey (not .Values.db.secret.readReplicaUrlKey) }} -- name: DATABASE_READER_HOST - valueFrom: - secretKeyRef: - name: {{ .Values.db.secret.name }} - key: {{ .Values.db.secret.readReplicaEndpointKey }} -{{- end }} -{{- if and .Values.db.useExisting .Values.db.secret.readReplicaUrlKey }} -- name: DATABASE_URL_READ_REPLICA - valueFrom: - secretKeyRef: - name: {{ .Values.db.secret.name }} - key: {{ .Values.db.secret.readReplicaUrlKey }} -{{- else if .Values.db.readReplicaUrl }} -- name: DATABASE_URL_READ_REPLICA - value: {{ .Values.db.readReplicaUrl | quote }} -{{- end }} -{{- if .Values.db.connectionPool.enabled }} -- name: LITELLM_PGBOUNCER_ENABLED - value: "true" -- name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS - value: {{ .Values.db.connectionPool.maxDbConnections | quote }} -- name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN - value: {{ .Values.db.connectionPool.maxClientConn | quote }} -{{- end }} -- name: PROXY_MASTER_KEY - valueFrom: - secretKeyRef: - name: {{ .Values.masterkeySecretName | default (printf "%s-masterkey" (include "litellm.fullname" .)) }} - key: {{ .Values.masterkeySecretKey | default "masterkey" }} -{{- if .Values.redis.enabled }} -- name: REDIS_HOST - value: {{ include "litellm.redis.serviceName" . }} -- name: REDIS_PORT - value: {{ include "litellm.redis.port" . | quote }} -- name: REDIS_PASSWORD - valueFrom: - secretKeyRef: - name: {{ include "redis.secretName" .Subcharts.redis }} - key: {{include "redis.secretPasswordKey" .Subcharts.redis }} -{{- end }} -{{- /* - Inject LITELLM_LOG only when envVars does not already define it. -*/}} -{{- if and .Values.logLevel (not (hasKey (default dict .Values.envVars) "LITELLM_LOG")) }} -- name: LITELLM_LOG - value: {{ .Values.logLevel | quote }} -{{- end }} -{{- if .Values.envVars }} -{{- range $key, $val := .Values.envVars }} -- name: {{ $key }} - value: {{ $val | quote }} -{{- end }} -{{- end }} -{{- with .Values.extraEnvVars }} -{{ toYaml . }} -{{- end }} -{{- if .Values.migrationJob.enabled }} -# Schema updates are owned by the dedicated migrations Job; skip -# the proxy's startup `prisma db push` so N replicas don't race -# one DB on every rollout. Placed last (after envVars and -# extraEnvVars) so this override can't be silently shadowed by a -# user-supplied DISABLE_SCHEMA_UPDATE under last-wins duplicate-env -# semantics — same pattern the migrations Job uses. -- name: DISABLE_SCHEMA_UPDATE - value: "true" -{{- end }} -{{- end -}} - -{{/* -Proxy-only metering and metrics env. The collector sidecar serves no HTTP -traffic, so it gets neither. -*/}} -{{- define "litellm.proxyMetricsEnv" -}} -{{- if .Values.billingMetrics.enabled }} -{{ include "litellm.billingMetricsEnv" . }} -{{- end }} -{{- if .Values.metricsServer.enabled }} -{{- if eq (int .Values.metricsServer.port) (int .Values.service.port) }} -{{- fail "metricsServer.port must differ from service.port" }} -{{- end }} -- name: PROMETHEUS_METRICS_PORT - value: {{ .Values.metricsServer.port | quote }} -{{- end }} -{{- end -}} - -{{/* -Directory of the collector's unix socket, shared between the two containers -through an emptyDir. Empty when the sidecar is off or uses 127.0.0.1 TCP. -*/}} -{{- define "litellm.collector.socketDir" -}} -{{- if and .Values.collector.enabled (hasPrefix "unix://" .Values.collector.address) -}} -{{- dir (trimPrefix "unix://" .Values.collector.address) -}} -{{- end -}} -{{- end -}} - -{{- define "litellm.collectorEnv" -}} -- name: LITELLM_COLLECTOR_ENABLED - value: "true" -- name: LITELLM_COLLECTOR_ADDRESS - value: {{ .Values.collector.address | quote }} -- name: LITELLM_COLLECTOR_BUFFER_SIZE - value: {{ .Values.collector.bufferSize | quote }} -- name: LITELLM_COLLECTOR_ON_UNAVAILABLE - value: {{ .Values.collector.onUnavailable | quote }} -- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS - value: {{ .Values.collector.drainTimeoutSeconds | quote }} -{{- end -}} - -{{- define "litellm.gateway.collectorSocketDir" -}} -{{- if and .Values.gateway.collector.enabled (hasPrefix "unix://" .Values.gateway.collector.address) -}} -{{- dir (trimPrefix "unix://" .Values.gateway.collector.address) -}} -{{- end -}} -{{- end -}} - -{{/* -LITELLM_COLLECTOR_* env shared by the producer (gateway container) and the -consumer (collector container), so both agree on the transport and the -shutdown drain window. -*/}} -{{- define "litellm.gateway.collectorEnv" -}} -{{- with .Values.gateway.collector }} -- name: LITELLM_COLLECTOR_ENABLED - value: "true" -- name: LITELLM_COLLECTOR_ADDRESS - value: {{ .address | quote }} -- name: LITELLM_COLLECTOR_BUFFER_SIZE - value: {{ .bufferSize | quote }} -- name: LITELLM_COLLECTOR_ON_UNAVAILABLE - value: {{ .onUnavailable | quote }} -- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS - value: {{ .drainTimeoutSeconds | quote }} -{{- end }} -{{- end -}} - -{{- define "litellm.lensConnectionEnv" -}} -{{- with .Values.lensWorker }} -{{- if or .enabled (and .mode (ne .mode "disabled")) }} -{{- fail "lensWorker was removed; deploy Lens from its own chart and set lens.* instead, see the chart README" }} -{{- end }} -{{- end }} -{{- with .Values.lens }} -{{- if .enabled }} -- name: LITELLM_LENS_URL - value: {{ required "lens.url is required when lens.enabled is true" .url | quote }} -- name: LITELLM_LENS_PUBLIC_URL - value: {{ required "lens.publicUrl is required when lens.enabled is true" .publicUrl | quote }} -- name: LENS_GATEWAY_SECRET - valueFrom: - secretKeyRef: - name: {{ required "lens.gatewaySecret.name is required when lens.enabled is true" .gatewaySecret.name | quote }} - key: {{ .gatewaySecret.key | quote }} -- name: LITELLM_LENS_SERVICE_TOKEN - valueFrom: - secretKeyRef: - name: {{ required "lens.serviceTokenSecret.name is required when lens.enabled is true" .serviceTokenSecret.name | quote }} - key: {{ .serviceTokenSecret.key | quote }} -{{- end }} -{{- end }} -{{- end -}} diff --git a/helm/litellm-helm/templates/configmap-litellm.yaml b/helm/litellm-helm/templates/configmap-litellm.yaml deleted file mode 100644 index 03e4f620206..00000000000 --- a/helm/litellm-helm/templates/configmap-litellm.yaml +++ /dev/null @@ -1,22 +0,0 @@ -{{- if .Values.proxyConfigMap.create }} -{{- $config := deepCopy .Values.proxy_config }} -{{- if and .Values.redis.enabled (dig "coordination" "enabled" true .Values.redis) }} -{{- $generalSettings := (get $config "general_settings") | default dict }} -{{- if not (hasKey $generalSettings "coordination_redis") }} -{{- $coordinationRedis := dict "host" "os.environ/REDIS_HOST" "port" "os.environ/REDIS_PORT" "password" "os.environ/REDIS_PASSWORD" }} -{{- if .Values.redis.sentinel.enabled }} -{{- $sentinelNode := list (include "litellm.redis.serviceName" .) (include "litellm.redis.port" . | int) }} -{{- $coordinationRedis = dict "sentinel_nodes" (list $sentinelNode) "service_name" (default "mymaster" .Values.redis.sentinel.masterSet) "password" "os.environ/REDIS_PASSWORD" }} -{{- end }} -{{- $_ := set $generalSettings "coordination_redis" $coordinationRedis }} -{{- $_ := set $config "general_settings" $generalSettings }} -{{- end }} -{{- end }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "litellm.fullname" . }}-config -data: - config.yaml: | -{{ $config | toYaml | indent 6 }} -{{- end }} diff --git a/helm/litellm-helm/templates/deployment.yaml b/helm/litellm-helm/templates/deployment.yaml deleted file mode 100644 index a37193f0efa..00000000000 --- a/helm/litellm-helm/templates/deployment.yaml +++ /dev/null @@ -1,258 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - {{- toYaml .Values.deploymentAnnotations | nindent 4 }} - name: {{ include "litellm.fullname" . }} - labels: - {{- include "litellm.labels" . | nindent 4 }} - {{- if .Values.deploymentLabels }} - {{- toYaml .Values.deploymentLabels | nindent 4 }} - {{- end }} -spec: - {{- if and (not .Values.keda.enabled) (not .Values.autoscaling.enabled) }} - replicas: {{ .Values.replicaCount }} - {{- end }} - {{- with .Values.strategy }} - strategy: - {{- toYaml . | nindent 4 }} - {{- end }} - selector: - matchLabels: - {{- include "litellm.selectorLabels" . | nindent 6 }} - {{- if .Values.deploymentMinReadySeconds }} - minReadySeconds: {{ .Values.deploymentMinReadySeconds }} - {{- end }} - template: - metadata: - annotations: - {{- if .Values.proxyConfigMap.create }} - checksum/config: {{ include (print $.Template.BasePath "/configmap-litellm.yaml") . | sha256sum }} - {{- end }} - {{- with .Values.podAnnotations }} - {{- tpl (toYaml .) $ | nindent 8 }} - {{- end }} - labels: - {{- include "litellm.labels" . | nindent 8 }} - {{- with .Values.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: {{ include "litellm.serviceAccountName" . }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - {{- with .Values.extraInitContainers }} - initContainers: - {{- tpl (toYaml .) $ | nindent 8 }} - {{- end }} - containers: - - name: {{ include "litellm.name" . }} - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - {{- include "litellm.lensConnectionEnv" . | nindent 12 }} - {{- include "litellm.proxyEnv" . | nindent 12 }} - {{- if .Values.liteadmin.enabled }} - - name: LITELLM_ADMIN_AGENT_URL - value: {{ printf "http://%s-liteadmin:10000" (include "litellm.fullname" . | trunc 53 | trimSuffix "-") | quote }} - - name: ADMIN_AGENT_SERVICE_TOKEN - valueFrom: - secretKeyRef: - name: {{ required "liteadmin.existingSecret is required" .Values.liteadmin.existingSecret }} - key: ADMIN_AGENT_SERVICE_TOKEN - {{- if not (hasKey (default dict .Values.envVars) "PROXY_BASE_URL") }} - - name: PROXY_BASE_URL - value: {{ required "liteadmin.gatewayUrl is required" .Values.liteadmin.gatewayUrl | quote }} - {{- end }} - {{- end }} - {{- include "litellm.proxyMetricsEnv" . | nindent 12 }} - {{- if .Values.collector.enabled }} - {{- include "litellm.collectorEnv" . | nindent 12 }} - {{- end }} - envFrom: - {{- range .Values.environmentSecrets }} - - secretRef: - name: {{ . }} - {{- end }} - {{- range .Values.environmentConfigMaps }} - - configMapRef: - name: {{ . }} - {{- end }} - {{- if .Values.command }} - command: {{ toYaml .Values.command | nindent 12 }} - {{- end }} - {{- if .Values.args }} - args: {{ toYaml .Values.args | nindent 12 }} - {{- else }} - args: - - --config - - /etc/litellm/config.yaml - {{ if .Values.numWorkers }} - - --num_workers - - {{ .Values.numWorkers | quote }} - {{- end }} - {{- end }} - ports: - - name: http - containerPort: {{ .Values.service.port }} - protocol: TCP - {{- if .Values.metricsServer.enabled }} - - name: metrics - containerPort: {{ .Values.metricsServer.port }} - protocol: TCP - {{- end }} - livenessProbe: - httpGet: - path: {{ .Values.livenessProbe.path | quote }} - port: "http" - initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} - periodSeconds: {{ .Values.livenessProbe.periodSeconds }} - timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }} - successThreshold: {{ .Values.livenessProbe.successThreshold }} - failureThreshold: {{ .Values.livenessProbe.failureThreshold }} - readinessProbe: - httpGet: - path: {{ .Values.readinessProbe.path | quote }} - port: "http" - initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }} - periodSeconds: {{ .Values.readinessProbe.periodSeconds }} - timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }} - successThreshold: {{ .Values.readinessProbe.successThreshold }} - failureThreshold: {{ .Values.readinessProbe.failureThreshold }} - startupProbe: - httpGet: - path: {{ .Values.startupProbe.path | quote }} - port: "http" - initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds }} - periodSeconds: {{ .Values.startupProbe.periodSeconds }} - timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds }} - successThreshold: {{ .Values.startupProbe.successThreshold }} - failureThreshold: {{ .Values.startupProbe.failureThreshold }} - resources: - {{- toYaml .Values.resources | nindent 12 }} - volumeMounts: - - name: litellm-config - mountPath: /etc/litellm/config.yaml - subPath: config.yaml - {{ if .Values.securityContext.readOnlyRootFilesystem }} - - name: tmp - mountPath: /tmp - - name: cache - mountPath: /.cache - - name: npm - mountPath: /.npm - {{- end }} - {{- if include "litellm.collector.socketDir" . }} - - name: collector-socket - mountPath: {{ include "litellm.collector.socketDir" . }} - {{- end }} - {{- with .Values.volumeMounts }} - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.lifecycle }} - lifecycle: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- if .Values.collector.enabled }} - - name: {{ include "litellm.name" . }}-collector - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - command: {{ toYaml .Values.collector.command | nindent 12 }} - env: - {{- include "litellm.proxyEnv" . | nindent 12 }} - {{- include "litellm.collectorEnv" . | nindent 12 }} - - name: LITELLM_JOB_ROLE - value: collector - {{- if not (hasKey (default dict .Values.envVars) "CONFIG_FILE_PATH") }} - - name: CONFIG_FILE_PATH - value: /etc/litellm/config.yaml - {{- end }} - envFrom: - {{- range .Values.environmentSecrets }} - - secretRef: - name: {{ . }} - {{- end }} - {{- range .Values.environmentConfigMaps }} - - configMapRef: - name: {{ . }} - {{- end }} - resources: - {{- toYaml .Values.collector.resources | nindent 12 }} - volumeMounts: - - name: litellm-config - mountPath: /etc/litellm/config.yaml - subPath: config.yaml - {{- if include "litellm.collector.socketDir" . }} - - name: collector-socket - mountPath: {{ include "litellm.collector.socketDir" . }} - {{- end }} - {{ if .Values.securityContext.readOnlyRootFilesystem }} - - name: tmp - mountPath: /tmp - - name: cache - mountPath: /.cache - - name: npm - mountPath: /.npm - {{- end }} - {{- with .Values.volumeMounts }} - {{- toYaml . | nindent 12 }} - {{- end }} - {{- end }} - {{- with .Values.extraContainers }} - {{- tpl (toYaml .) $ | nindent 8 }} - {{- end }} - volumes: - {{ if .Values.securityContext.readOnlyRootFilesystem }} - - name: tmp - emptyDir: - sizeLimit: 500Mi - - name: cache - emptyDir: - sizeLimit: 500Mi - - name: npm - emptyDir: - sizeLimit: 500Mi - {{- end }} - - name: litellm-config - configMap: - {{- if .Values.proxyConfigMap.create }} - name: {{ include "litellm.fullname" . }}-config - {{- else }} - name: {{ .Values.proxyConfigMap.name }} - {{- end }} - items: - - key: {{ .Values.proxyConfigMap.key | default "config.yaml" }} - path: "config.yaml" - {{- if include "litellm.collector.socketDir" . }} - - name: collector-socket - emptyDir: - sizeLimit: 1Mi - {{- end }} - {{- with .Values.volumes }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds | default 90 }} - {{- if .Values.topologySpreadConstraints }} - topologySpreadConstraints: - {{- toYaml .Values.topologySpreadConstraints | nindent 8 }} - {{- end }} diff --git a/helm/litellm-helm/templates/extra-resources.yaml b/helm/litellm-helm/templates/extra-resources.yaml deleted file mode 100644 index 33190d96fc0..00000000000 --- a/helm/litellm-helm/templates/extra-resources.yaml +++ /dev/null @@ -1,6 +0,0 @@ -{{- if .Values.extraResources }} -{{- range .Values.extraResources }} ---- -{{ toYaml . | nindent 0 }} -{{- end }} -{{- end }} \ No newline at end of file diff --git a/helm/litellm-helm/templates/hpa.yaml b/helm/litellm-helm/templates/hpa.yaml deleted file mode 100644 index a651f916d21..00000000000 --- a/helm/litellm-helm/templates/hpa.yaml +++ /dev/null @@ -1,64 +0,0 @@ -{{- if .Values.autoscaling.enabled }} -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "litellm.fullname" . }} - labels: - {{- include "litellm.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "litellm.fullname" . }} - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - {{- if .Values.autoscaling.behavior }} - behavior: - {{- toYaml .Values.autoscaling.behavior | nindent 4 }} - {{- end }} - metrics: - {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- if and .Values.collector.enabled .Values.collector.scaleOnProxyContainerCpu }} - - type: ContainerResource - containerResource: - name: cpu - container: {{ include "litellm.name" . }} - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- else }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- end }} - {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} - {{- with .Values.autoscaling.targetRequestsPerSecond }} - - type: Pods - pods: - metric: - name: litellm_requests_per_second - target: - type: AverageValue - averageValue: {{ toJson . | trimAll "\"" | quote }} - {{- end }} - {{- with .Values.autoscaling.targetTokensPerSecond }} - - type: Pods - pods: - metric: - name: litellm_tokens_per_second - target: - type: AverageValue - averageValue: {{ toJson . | trimAll "\"" | quote }} - {{- end }} -{{- end }} diff --git a/helm/litellm-helm/templates/ingress.yaml b/helm/litellm-helm/templates/ingress.yaml deleted file mode 100644 index 3d6e3f4af7a..00000000000 --- a/helm/litellm-helm/templates/ingress.yaml +++ /dev/null @@ -1,78 +0,0 @@ -{{- if .Values.ingress.enabled -}} -{{- $fullName := include "litellm.fullname" . -}} -{{- $svcPort := .Values.service.port -}} -{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} - {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} - {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} - {{- end }} -{{- end }} -{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1 -{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1beta1 -{{- else -}} -apiVersion: extensions/v1beta1 -{{- end }} -kind: Ingress -metadata: - name: {{ $fullName }} - labels: - {{- include "litellm.labels" . | nindent 4 }} - {{- with .Values.ingress.labels }} - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .Values.ingress.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} - ingressClassName: {{ .Values.ingress.className }} - {{- end }} - {{- if .Values.ingress.tls }} - tls: - {{- range .Values.ingress.tls }} - - hosts: - {{- range .hosts }} - - {{ . | quote }} - {{- end }} - secretName: {{ .secretName }} - {{- end }} - {{- end }} - rules: - {{- range .Values.ingress.hosts }} - - host: {{ .host | quote }} - http: - paths: - {{- if and $.Values.lens.enabled $.Values.lens.service.name }} - - path: /lens-ingest - pathType: Prefix - backend: - {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} - service: - name: {{ $.Values.lens.service.name }} - port: - number: {{ $.Values.lens.service.port }} - {{- else }} - serviceName: {{ $.Values.lens.service.name }} - servicePort: {{ $.Values.lens.service.port }} - {{- end }} - {{- end }} - {{- range .paths }} - - path: {{ .path }} - {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} - pathType: {{ .pathType }} - {{- end }} - backend: - {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} - service: - name: {{ $fullName }} - port: - number: {{ $svcPort }} - {{- else }} - serviceName: {{ $fullName }} - servicePort: {{ $svcPort }} - {{- end }} - {{- end }} - {{- end }} -{{- end }} diff --git a/helm/litellm-helm/templates/keda.yaml b/helm/litellm-helm/templates/keda.yaml deleted file mode 100644 index bf585d0d4be..00000000000 --- a/helm/litellm-helm/templates/keda.yaml +++ /dev/null @@ -1,58 +0,0 @@ -{{- if and .Values.keda.enabled (not .Values.autoscaling.enabled) }} -apiVersion: keda.sh/v1alpha1 -kind: ScaledObject -metadata: - name: {{ include "litellm.fullname" . }} - labels: - {{- include "litellm.labels" . | nindent 4 }} - {{- if .Values.keda.scaledObject.annotations }} - annotations: {{ toYaml .Values.keda.scaledObject.annotations | nindent 4 }} - {{- end }} -spec: - scaleTargetRef: - name: {{ include "litellm.fullname" . }} - pollingInterval: {{ .Values.keda.pollingInterval }} - cooldownPeriod: {{ .Values.keda.cooldownPeriod }} - minReplicaCount: {{ .Values.keda.minReplicas }} - maxReplicaCount: {{ .Values.keda.maxReplicas }} -{{- with .Values.keda.fallback }} - fallback: - failureThreshold: {{ .failureThreshold | default 3 }} - replicas: {{ .replicas | default $.Values.keda.maxReplicas }} -{{- end }} - triggers: -{{- with .Values.keda.triggers }} - {{- toYaml . | nindent 2 }} -{{- end }} -{{- $prom := .Values.keda.prometheus }} -{{- if or $prom.requestsPerSecond $prom.tokensPerSecond }} -{{- if not $prom.serverAddress }} -{{- fail "keda.prometheus.serverAddress is required when keda.prometheus.requestsPerSecond or tokensPerSecond is set" }} -{{- end }} -{{- $selector := printf "namespace=%q,job=%q" .Release.Namespace (printf "%s%s" (include "litellm.fullname" .) (ternary "-metrics" "" .Values.metricsServer.enabled)) }} -{{- with $prom.requestsPerSecond }} - - type: prometheus - metadata: - serverAddress: {{ $prom.serverAddress | quote }} - threshold: {{ toJson . | trimAll "\"" | quote }} - query: {{ printf "sum(rate(litellm_proxy_total_requests_metric_total{%s}[1m]))" $selector | quote }} -{{- end }} -{{- with $prom.tokensPerSecond }} - - type: prometheus - metadata: - serverAddress: {{ $prom.serverAddress | quote }} - threshold: {{ toJson . | trimAll "\"" | quote }} - query: {{ printf "sum(rate(litellm_total_tokens_metric_total{%s}[1m]))" $selector | quote }} -{{- end }} -{{- end }} - advanced: - restoreToOriginalReplicaCount: {{ .Values.keda.restoreToOriginalReplicaCount }} -{{- if .Values.keda.behavior }} - horizontalPodAutoscalerConfig: - behavior: -{{- with .Values.keda.behavior }} -{{- toYaml . | nindent 8 }} -{{- end }} - -{{- end }} -{{- end }} diff --git a/helm/litellm-helm/templates/migrations-job.yaml b/helm/litellm-helm/templates/migrations-job.yaml deleted file mode 100644 index 7aa96a59102..00000000000 --- a/helm/litellm-helm/templates/migrations-job.yaml +++ /dev/null @@ -1,122 +0,0 @@ -{{- if .Values.migrationJob.enabled }} -# This job runs the Prisma migrations for the LiteLLM DB. -apiVersion: batch/v1 -kind: Job -metadata: - name: {{ include "litellm.fullname" . }}-migrations - labels: - {{- include "litellm.labels" . | nindent 4 }} - {{- with .Values.migrationJob.jobLabels }} - {{- toYaml . | nindent 4 }} - {{- end }} - annotations: - {{- if .Values.migrationJob.hooks.argocd.enabled }} - argocd.argoproj.io/hook: PreSync - argocd.argoproj.io/hook-delete-policy: BeforeHookCreation - {{- end }} - {{- if .Values.migrationJob.hooks.helm.enabled }} - helm.sh/hook: "pre-install,pre-upgrade" - helm.sh/hook-delete-policy: "before-hook-creation" - helm.sh/hook-weight: {{ .Values.migrationJob.hooks.helm.weight | default "1" | quote }} - {{- end }} - checksum/config: {{ toYaml .Values | sha256sum }} - {{- with .Values.migrationJob.jobAnnotations }} - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - template: - metadata: - labels: - {{- include "litellm.labels" . | nindent 8 }} - {{- with .Values.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.migrationJob.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - annotations: - {{- with .Values.migrationJob.annotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: {{ include "litellm.migrationServiceAccountName" . }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - {{- with .Values.migrationJob.extraInitContainers }} - initContainers: - {{- tpl (toYaml .) $ | nindent 8 }} - {{- end }} - containers: - - name: prisma-migrations - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - {{- if .Values.migrationJob.command }} - command: {{ toYaml .Values.migrationJob.command | nindent 12 }} - {{- else }} - command: ["python", "litellm/proxy/prisma_migration.py"] - {{- end }} - {{- if .Values.migrationJob.args }} - args: {{ toYaml .Values.migrationJob.args | nindent 12 }} - {{- else if .Values.migrationJob.command }} - args: [] - {{- end }} - workingDir: "/app" - env: - {{- include "litellm.validateDbTokenAuth" . -}} - {{- if .Values.db.useExisting }} - {{- include "litellm.existingDbEnv" . | nindent 12 }} - {{- else if .Values.db.deployStandalone }} - - name: DATABASE_URL - value: postgresql://{{ .Values.postgresql.auth.username }}:{{ .Values.postgresql.auth.password }}@{{ .Release.Name }}-postgresql/{{ .Values.postgresql.auth.database }} - {{- end }} - {{- if .Values.envVars }} - {{- range $key, $val := .Values.envVars }} - - name: {{ $key }} - value: {{ $val | quote }} - {{- end }} - {{- end }} - {{- with .Values.extraEnvVars }} - {{- toYaml . | nindent 12 }} - {{- end }} - - name: DISABLE_SCHEMA_UPDATE - value: "false" # always run the migration from the Helm PreSync hook, override the value set - {{- with .Values.volumeMounts }} - volumeMounts: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.migrationJob.resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.migrationJob.extraContainers }} - {{- tpl (toYaml .) $ | nindent 8 }} - {{- end }} - {{- with .Values.volumes }} - volumes: - {{- toYaml . | nindent 8 }} - {{- end }} - restartPolicy: OnFailure - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - ttlSecondsAfterFinished: {{ .Values.migrationJob.ttlSecondsAfterFinished }} - backoffLimit: {{ .Values.migrationJob.backoffLimit }} - {{- with .Values.migrationJob.activeDeadlineSeconds }} - activeDeadlineSeconds: {{ . }} - {{- end }} -{{- end }} diff --git a/helm/litellm-helm/templates/poddisruptionbudget.yaml b/helm/litellm-helm/templates/poddisruptionbudget.yaml deleted file mode 100644 index 1715b94c1f6..00000000000 --- a/helm/litellm-helm/templates/poddisruptionbudget.yaml +++ /dev/null @@ -1,33 +0,0 @@ -{{- /* -PodDisruptionBudget for LiteLLM proxy -Controlled via .Values.pdb.enabled and .Values.pdb.{minAvailable|maxUnavailable} -Only one of minAvailable / maxUnavailable should be set. If both are set, minAvailable wins. -*/ -}} -{{- if .Values.pdb.enabled }} -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: {{ include "litellm.fullname" . }} - labels: - {{- include "litellm.labels" . | nindent 4 }} - {{- with .Values.pdb.labels }} - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .Values.pdb.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - selector: - matchLabels: - {{- /* Match the Deployment selector to target the same pod set */ -}} - {{- include "litellm.selectorLabels" . | nindent 6 }} - {{- if .Values.pdb.minAvailable }} - minAvailable: {{ .Values.pdb.minAvailable }} - {{- else if .Values.pdb.maxUnavailable }} - maxUnavailable: {{ .Values.pdb.maxUnavailable }} - {{- else }} - # Safe default if enabled but not configured - maxUnavailable: 1 - {{- end }} -{{- end }} diff --git a/helm/litellm-helm/templates/secret-dbcredentials.yaml b/helm/litellm-helm/templates/secret-dbcredentials.yaml deleted file mode 100644 index 8ab89a4579e..00000000000 --- a/helm/litellm-helm/templates/secret-dbcredentials.yaml +++ /dev/null @@ -1,13 +0,0 @@ -{{- if .Values.db.deployStandalone -}} -{{- include "litellm.validateBundledPostgresImageTag" . -}} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "litellm.fullname" . }}-dbcredentials -data: - # Password for the "postgres" user - postgres-password: {{ ( index .Values.postgresql.auth "postgres-password") | default "litellm" | b64enc }} - username: {{ .Values.postgresql.auth.username | default "litellm" | b64enc }} - password: {{ .Values.postgresql.auth.password | default "litellm" | b64enc }} -type: Opaque -{{- end -}} \ No newline at end of file diff --git a/helm/litellm-helm/templates/secret-masterkey.yaml b/helm/litellm-helm/templates/secret-masterkey.yaml deleted file mode 100644 index 60ab4e74c6b..00000000000 --- a/helm/litellm-helm/templates/secret-masterkey.yaml +++ /dev/null @@ -1,12 +0,0 @@ -{{- if not .Values.masterkeySecretName }} -{{- $secretName := printf "%s-masterkey" (include "litellm.fullname" .) }} -{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} -{{- $masterkey := .Values.masterkey | default (dig "data" "masterkey" "" $existing | b64dec) | default (printf "sk-%s" (randAlphaNum 18)) }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ $secretName }} -data: - masterkey: {{ $masterkey | b64enc }} -type: Opaque -{{- end }} diff --git a/helm/litellm-helm/templates/service-metrics.yaml b/helm/litellm-helm/templates/service-metrics.yaml deleted file mode 100644 index 1d23fe39606..00000000000 --- a/helm/litellm-helm/templates/service-metrics.yaml +++ /dev/null @@ -1,17 +0,0 @@ -{{- if .Values.metricsServer.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ include "litellm.fullname" . }}-metrics - labels: - {{- include "litellm.labels" . | nindent 4 }} -spec: - type: ClusterIP - ports: - - port: {{ .Values.metricsServer.port }} - targetPort: metrics - protocol: TCP - name: metrics - selector: - {{- include "litellm.selectorLabels" . | nindent 4 }} -{{- end }} diff --git a/helm/litellm-helm/templates/service.yaml b/helm/litellm-helm/templates/service.yaml deleted file mode 100644 index 11812208929..00000000000 --- a/helm/litellm-helm/templates/service.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "litellm.fullname" . }} - {{- with .Values.service.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} - labels: - {{- include "litellm.labels" . | nindent 4 }} -spec: - type: {{ .Values.service.type }} - {{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerClass }} - loadBalancerClass: {{ .Values.service.loadBalancerClass }} - {{- end }} - ports: - - port: {{ .Values.service.port }} - targetPort: http - protocol: TCP - name: http - selector: - {{- include "litellm.selectorLabels" . | nindent 4 }} diff --git a/helm/litellm-helm/templates/serviceaccount.yaml b/helm/litellm-helm/templates/serviceaccount.yaml deleted file mode 100644 index 7655470fa42..00000000000 --- a/helm/litellm-helm/templates/serviceaccount.yaml +++ /dev/null @@ -1,13 +0,0 @@ -{{- if .Values.serviceAccount.create -}} -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "litellm.serviceAccountName" . }} - labels: - {{- include "litellm.labels" . | nindent 4 }} - {{- with .Values.serviceAccount.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -automountServiceAccountToken: {{ .Values.serviceAccount.automount }} -{{- end }} diff --git a/helm/litellm-helm/templates/servicemonitor.yaml b/helm/litellm-helm/templates/servicemonitor.yaml deleted file mode 100644 index 68083d0da61..00000000000 --- a/helm/litellm-helm/templates/servicemonitor.yaml +++ /dev/null @@ -1,39 +0,0 @@ -{{- with .Values.serviceMonitor }} -{{- if and (eq .enabled true) }} -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: {{ include "litellm.fullname" $ }} - labels: - {{- include "litellm.labels" $ | nindent 4 }} - {{- if .labels }} - {{- toYaml .labels | nindent 4 }} - {{- end }} - {{- if .annotations }} - annotations: - {{- toYaml .annotations | nindent 4 }} - {{- end }} -spec: - selector: - matchLabels: - {{- include "litellm.selectorLabels" $ | nindent 6 }} - namespaceSelector: - matchNames: - # if not set, use the release namespace - {{- if not .namespaceSelector.matchNames }} - - {{ $.Release.Namespace | quote }} - {{- else }} - {{- toYaml .namespaceSelector.matchNames | nindent 4 }} - {{- end }} - endpoints: - - port: {{ ternary "metrics" "http" $.Values.metricsServer.enabled }} - path: /metrics/ - interval: {{ .interval }} - scrapeTimeout: {{ .scrapeTimeout }} - scheme: http - {{- if .relabelings }} - relabelings: -{{- toYaml .relabelings | nindent 4 }} - {{- end }} -{{- end }} -{{- end }} diff --git a/helm/litellm-helm/templates/tests/test-connection.yaml b/helm/litellm-helm/templates/tests/test-connection.yaml deleted file mode 100644 index 86a8f66b10b..00000000000 --- a/helm/litellm-helm/templates/tests/test-connection.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: "{{ include "litellm.fullname" . }}-test-connection" - labels: - {{- include "litellm.labels" . | nindent 4 }} - annotations: - "helm.sh/hook": test -spec: - containers: - - name: wget - image: busybox - command: ['sh', '-c'] - args: - - | - # Wait for a bit to allow the service to be ready - sleep 10 - # Try multiple times with a delay between attempts - for i in $(seq 1 30); do - wget -T 5 "{{ include "litellm.fullname" . }}:{{ .Values.service.port }}/health/readiness" && exit 0 - echo "Attempt $i failed, waiting..." - sleep 2 - done - exit 1 - restartPolicy: Never \ No newline at end of file diff --git a/helm/litellm-helm/templates/tests/test-env-vars.yaml b/helm/litellm-helm/templates/tests/test-env-vars.yaml deleted file mode 100644 index 9f0277557a4..00000000000 --- a/helm/litellm-helm/templates/tests/test-env-vars.yaml +++ /dev/null @@ -1,43 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: "{{ include "litellm.fullname" . }}-env-test" - labels: - {{- include "litellm.labels" . | nindent 4 }} - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: busybox - command: ['sh', '-c'] - args: - - | - # Test DD_ENV - if [ "$DD_ENV" != "dev_helm" ]; then - echo "❌ Environment variable DD_ENV mismatch. Expected: dev_helm, Got: $DD_ENV" - exit 1 - fi - echo "✅ Environment variable DD_ENV matches expected value: $DD_ENV" - - # Test DD_SERVICE - if [ "$DD_SERVICE" != "litellm" ]; then - echo "❌ Environment variable DD_SERVICE mismatch. Expected: litellm, Got: $DD_SERVICE" - exit 1 - fi - echo "✅ Environment variable DD_SERVICE matches expected value: $DD_SERVICE" - - # Test USE_DDTRACE - if [ "$USE_DDTRACE" != "true" ]; then - echo "❌ Environment variable USE_DDTRACE mismatch. Expected: true, Got: $USE_DDTRACE" - exit 1 - fi - echo "✅ Environment variable USE_DDTRACE matches expected value: $USE_DDTRACE" - env: - - name: DD_ENV - value: {{ .Values.envVars.DD_ENV | quote }} - - name: DD_SERVICE - value: {{ .Values.envVars.DD_SERVICE | quote }} - - name: USE_DDTRACE - value: {{ .Values.envVars.USE_DDTRACE | quote }} - restartPolicy: Never \ No newline at end of file diff --git a/helm/litellm-helm/templates/tests/test-servicemonitor.yaml b/helm/litellm-helm/templates/tests/test-servicemonitor.yaml deleted file mode 100644 index ef8475339c3..00000000000 --- a/helm/litellm-helm/templates/tests/test-servicemonitor.yaml +++ /dev/null @@ -1,152 +0,0 @@ -{{- if .Values.serviceMonitor.enabled }} -apiVersion: v1 -kind: Pod -metadata: - name: "{{ include "litellm.fullname" . }}-test-servicemonitor" - labels: - {{- include "litellm.labels" . | nindent 4 }} - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: docker.io/bitnamilegacy/kubectl:1.29.2-debian-12-r3 - command: ['sh', '-c'] - args: - - | - set -e - echo "🔍 Testing ServiceMonitor configuration..." - - # Check if ServiceMonitor exists - if ! kubectl get servicemonitor {{ include "litellm.fullname" . }} -n {{ .Release.Namespace }} &>/dev/null; then - echo "❌ ServiceMonitor not found" - exit 1 - fi - echo "✅ ServiceMonitor exists" - - # Get ServiceMonitor YAML - SM=$(kubectl get servicemonitor {{ include "litellm.fullname" . }} -n {{ .Release.Namespace }} -o yaml) - - # Test endpoint configuration - ENDPOINT_PORT=$(echo "$SM" | grep -A 5 "endpoints:" | grep "port:" | awk '{print $2}') - if [ "$ENDPOINT_PORT" != "http" ]; then - echo "❌ Endpoint port mismatch. Expected: http, Got: $ENDPOINT_PORT" - exit 1 - fi - echo "✅ Endpoint port is correctly set to: $ENDPOINT_PORT" - - # Test endpoint path - ENDPOINT_PATH=$(echo "$SM" | grep -A 5 "endpoints:" | grep "path:" | awk '{print $2}') - if [ "$ENDPOINT_PATH" != "/metrics/" ]; then - echo "❌ Endpoint path mismatch. Expected: /metrics/, Got: $ENDPOINT_PATH" - exit 1 - fi - echo "✅ Endpoint path is correctly set to: $ENDPOINT_PATH" - - # Test interval - INTERVAL=$(echo "$SM" | grep "interval:" | awk '{print $2}') - if [ "$INTERVAL" != "{{ .Values.serviceMonitor.interval }}" ]; then - echo "❌ Interval mismatch. Expected: {{ .Values.serviceMonitor.interval }}, Got: $INTERVAL" - exit 1 - fi - echo "✅ Interval is correctly set to: $INTERVAL" - - # Test scrapeTimeout - TIMEOUT=$(echo "$SM" | grep "scrapeTimeout:" | awk '{print $2}') - if [ "$TIMEOUT" != "{{ .Values.serviceMonitor.scrapeTimeout }}" ]; then - echo "❌ ScrapeTimeout mismatch. Expected: {{ .Values.serviceMonitor.scrapeTimeout }}, Got: $TIMEOUT" - exit 1 - fi - echo "✅ ScrapeTimeout is correctly set to: $TIMEOUT" - - # Test scheme - SCHEME=$(echo "$SM" | grep "scheme:" | awk '{print $2}') - if [ "$SCHEME" != "http" ]; then - echo "❌ Scheme mismatch. Expected: http, Got: $SCHEME" - exit 1 - fi - echo "✅ Scheme is correctly set to: $SCHEME" - - {{- if .Values.serviceMonitor.labels }} - # Test custom labels - echo "🔍 Checking custom labels..." - {{- range $key, $value := .Values.serviceMonitor.labels }} - LABEL_VALUE=$(echo "$SM" | grep -A 20 "metadata:" | grep "{{ $key }}:" | awk '{print $2}') - if [ "$LABEL_VALUE" != "{{ $value }}" ]; then - echo "❌ Label {{ $key }} mismatch. Expected: {{ $value }}, Got: $LABEL_VALUE" - exit 1 - fi - echo "✅ Label {{ $key }} is correctly set to: {{ $value }}" - {{- end }} - {{- end }} - - {{- if .Values.serviceMonitor.annotations }} - # Test annotations - echo "🔍 Checking annotations..." - {{- range $key, $value := .Values.serviceMonitor.annotations }} - ANNOTATION_VALUE=$(echo "$SM" | grep -A 10 "annotations:" | grep "{{ $key }}:" | awk '{print $2}') - if [ "$ANNOTATION_VALUE" != "{{ $value }}" ]; then - echo "❌ Annotation {{ $key }} mismatch. Expected: {{ $value }}, Got: $ANNOTATION_VALUE" - exit 1 - fi - echo "✅ Annotation {{ $key }} is correctly set to: {{ $value }}" - {{- end }} - {{- end }} - - {{- if .Values.serviceMonitor.namespaceSelector.matchNames }} - # Test namespace selector - echo "🔍 Checking namespace selector..." - {{- range .Values.serviceMonitor.namespaceSelector.matchNames }} - if ! echo "$SM" | grep -A 5 "namespaceSelector:" | grep -q "{{ . }}"; then - echo "❌ Namespace {{ . }} not found in namespaceSelector" - exit 1 - fi - echo "✅ Namespace {{ . }} found in namespaceSelector" - {{- end }} - {{- else }} - # Test default namespace selector (should be release namespace) - if ! echo "$SM" | grep -A 5 "namespaceSelector:" | grep -q "{{ .Release.Namespace }}"; then - echo "❌ Release namespace {{ .Release.Namespace }} not found in namespaceSelector" - exit 1 - fi - echo "✅ Default namespace selector set to release namespace: {{ .Release.Namespace }}" - {{- end }} - - {{- if .Values.serviceMonitor.relabelings }} - # Test relabelings - echo "🔍 Checking relabelings configuration..." - if ! echo "$SM" | grep -q "relabelings:"; then - echo "❌ Relabelings section not found" - exit 1 - fi - echo "✅ Relabelings section exists" - {{- range .Values.serviceMonitor.relabelings }} - {{- if .targetLabel }} - if ! echo "$SM" | grep -A 50 "relabelings:" | grep -q "targetLabel: {{ .targetLabel }}"; then - echo "❌ Relabeling targetLabel {{ .targetLabel }} not found" - exit 1 - fi - echo "✅ Relabeling targetLabel {{ .targetLabel }} found" - {{- end }} - {{- if .action }} - if ! echo "$SM" | grep -A 50 "relabelings:" | grep -q "action: {{ .action }}"; then - echo "❌ Relabeling action {{ .action }} not found" - exit 1 - fi - echo "✅ Relabeling action {{ .action }} found" - {{- end }} - {{- end }} - {{- end }} - - # Test selector labels match the service - echo "🔍 Checking selector labels match service..." - SVC_LABELS=$(kubectl get svc {{ include "litellm.fullname" . }} -n {{ .Release.Namespace }} -o jsonpath='{.metadata.labels}') - echo "Service labels: $SVC_LABELS" - echo "✅ Selector labels validation passed" - - echo "" - echo "🎉 All ServiceMonitor tests passed successfully!" - serviceAccountName: {{ include "litellm.serviceAccountName" . }} - restartPolicy: Never -{{- end }} - diff --git a/helm/litellm-helm/tests/billing_metrics_tests.yaml b/helm/litellm-helm/tests/billing_metrics_tests.yaml deleted file mode 100644 index 52185c8dd9d..00000000000 --- a/helm/litellm-helm/tests/billing_metrics_tests.yaml +++ /dev/null @@ -1,100 +0,0 @@ -suite: test bearer-only billingMetrics wiring -templates: - - deployment.yaml - - configmap-litellm.yaml -tests: - - it: is disabled by default - template: deployment.yaml - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_BILLING_METRICS_ENDPOINT - - notContains: - path: spec.template.spec.volumes - content: - name: billing-metrics - - - it: renders required URLs and Cognito secret references when enabled - template: deployment.yaml - set: - billingMetrics: - enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_BILLING_METRICS_ENDPOINT - value: https://telemetry.litellm.ai - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_BILLING_METRICS_TOKEN_URL - value: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_BILLING_METRICS_CLIENT_ID - valueFrom: - secretKeyRef: - name: litellm-billing-metrics - key: client-id - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_BILLING_METRICS_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: litellm-billing-metrics - key: client-secret - - notContains: - path: spec.template.spec.volumes - content: - name: billing-metrics - - notContains: - path: spec.template.spec.containers[0].volumeMounts - content: - name: billing-metrics - - - it: renders the export interval only when set - template: deployment.yaml - set: - billingMetrics: - enabled: true - exportIntervalMs: 5000 - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS - value: "5000" - - - it: requires the endpoint when enabled - template: deployment.yaml - set: - billingMetrics: - enabled: true - endpoint: "" - asserts: - - failedTemplate: - errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true - - - it: requires the token URL when enabled - template: deployment.yaml - set: - billingMetrics: - enabled: true - tokenUrl: "" - asserts: - - failedTemplate: - errorMessage: billingMetrics.tokenUrl is required when billingMetrics.enabled is true - - - it: requires the secret name when enabled - template: deployment.yaml - set: - billingMetrics: - enabled: true - secretName: "" - asserts: - - failedTemplate: - errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true diff --git a/helm/litellm-helm/tests/bundled_db_images_tests.yaml b/helm/litellm-helm/tests/bundled_db_images_tests.yaml deleted file mode 100644 index 8f0860c2721..00000000000 --- a/helm/litellm-helm/tests/bundled_db_images_tests.yaml +++ /dev/null @@ -1,94 +0,0 @@ -suite: test bundled database images -templates: - - charts/postgresql/templates/primary/statefulset.yaml - - charts/redis/templates/master/application.yaml - - charts/redis/templates/configmap.yaml - - charts/redis/templates/health-configmap.yaml - - charts/redis/templates/scripts-configmap.yaml - - charts/redis/templates/secret.yaml - - secret-dbcredentials.yaml - - templates/tests/test-servicemonitor.yaml -tests: - - it: should pull the bundled postgres from a repository that still publishes the pinned tag - template: charts/postgresql/templates/primary/statefulset.yaml - set: - db.deployStandalone: true - asserts: - - equal: - path: spec.template.spec.containers[0].image - value: docker.io/bitnamilegacy/postgresql:16.2.0-debian-12-r6 - - - it: should pull the bundled postgres metrics exporter from the same repository - template: charts/postgresql/templates/primary/statefulset.yaml - set: - db.deployStandalone: true - postgresql.metrics.enabled: true - asserts: - - equal: - path: spec.template.spec.containers[1].image - value: docker.io/bitnamilegacy/postgres-exporter:0.15.0-debian-12-r14 - - - it: should run the bundled postgres init container from the same repository - template: charts/postgresql/templates/primary/statefulset.yaml - set: - db.deployStandalone: true - postgresql.volumePermissions.enabled: true - asserts: - - equal: - path: spec.template.spec.initContainers[0].image - value: docker.io/bitnamilegacy/os-shell:12-debian-12-r16 - - - it: should pull the bundled redis from a repository that still publishes the pinned tag - template: charts/redis/templates/master/application.yaml - set: - redis.enabled: true - asserts: - - equal: - path: spec.template.spec.containers[0].image - value: docker.io/bitnamilegacy/redis:7.2.4-debian-12-r9 - - - it: should reject a floating postgres tag that could cross a major version on an existing volume - template: secret-dbcredentials.yaml - set: - db.deployStandalone: true - postgresql.image.tag: latest - asserts: - - failedTemplate: - errorMessage: 'postgresql.image.tag must be pinned to an explicit version when db.deployStandalone is true (got "latest"). An unpinned tag can start a different PostgreSQL major against the existing data directory, which makes the database unreadable and is not recoverable in place. Crossing a major version requires a dump and restore.' - - - it: should reject an empty postgres tag - template: secret-dbcredentials.yaml - set: - db.deployStandalone: true - postgresql.image.tag: "" - asserts: - - failedTemplate: - errorMessage: 'postgresql.image.tag must be pinned to an explicit version when db.deployStandalone is true (got ""). An unpinned tag can start a different PostgreSQL major against the existing data directory, which makes the database unreadable and is not recoverable in place. Crossing a major version requires a dump and restore.' - - - it: should accept an empty postgres tag when the image is pinned by digest - template: secret-dbcredentials.yaml - set: - db.deployStandalone: true - postgresql.image.tag: "" - postgresql.image.digest: sha256:0d0e2f1a5b3c4d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6 - asserts: - - hasDocuments: - count: 1 - - - it: should run the servicemonitor test pod from a pinned image - template: templates/tests/test-servicemonitor.yaml - set: - serviceMonitor.enabled: true - asserts: - - equal: - path: spec.containers[0].image - value: docker.io/bitnamilegacy/kubectl:1.29.2-debian-12-r3 - - - it: should not constrain the postgres tag when the bundled database is not deployed - template: secret-dbcredentials.yaml - set: - db.deployStandalone: false - postgresql.image.tag: latest - asserts: - - hasDocuments: - count: 0 diff --git a/helm/litellm-helm/tests/collector_tests.yaml b/helm/litellm-helm/tests/collector_tests.yaml deleted file mode 100644 index 1365290e16c..00000000000 --- a/helm/litellm-helm/tests/collector_tests.yaml +++ /dev/null @@ -1,272 +0,0 @@ -suite: test collector sidecar -templates: - - deployment.yaml - - hpa.yaml - - configmap-litellm.yaml -tests: - - it: should run the proxy alone with no collector env by default - template: deployment.yaml - asserts: - - lengthEqual: - path: spec.template.spec.containers - count: 1 - - notContains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_ENABLED - value: "true" - - notContains: - path: spec.template.spec.volumes - content: - name: collector-socket - any: true - - - it: should add the sidecar on the same image and point both containers at the unix socket - template: deployment.yaml - set: - image.tag: test - db.connectionPool.enabled: true - collector.enabled: true - collector.resources: - requests: - cpu: 500m - memory: 1Gi - limits: - cpu: "1" - memory: 2Gi - asserts: - - lengthEqual: - path: spec.template.spec.containers - count: 2 - - equal: - path: spec.template.spec.containers[1].name - value: litellm-collector - - equal: - path: spec.template.spec.containers[1].image - value: ghcr.io/berriai/litellm:test - - equal: - path: spec.template.spec.containers[1].command - value: [python, -m, litellm.proxy.collector] - - equal: - path: spec.template.spec.containers[1].resources.requests.cpu - value: 500m - - equal: - path: spec.template.spec.containers[1].resources.limits.memory - value: 2Gi - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_ENABLED - value: "true" - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_ADDRESS - value: unix:///var/run/litellm/collector.sock - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_BUFFER_SIZE - value: "1000" - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_ON_UNAVAILABLE - value: fallback - - notContains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_JOB_ROLE - value: collector - - contains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_JOB_ROLE - value: collector - - contains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_COLLECTOR_ADDRESS - value: unix:///var/run/litellm/collector.sock - - contains: - path: spec.template.spec.containers[1].env - content: - name: CONFIG_FILE_PATH - value: /etc/litellm/config.yaml - - contains: - path: spec.template.spec.containers[1].env - content: - name: DATABASE_HOST - value: RELEASE-NAME-postgresql - - contains: - path: spec.template.spec.containers[1].env - content: - name: DATABASE_PASSWORD - valueFrom: - secretKeyRef: - name: RELEASE-NAME-litellm-dbcredentials - key: password - - contains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_ENABLED - value: "true" - - contains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS - value: "20" - - contains: - path: spec.template.spec.containers[0].volumeMounts - content: - name: collector-socket - mountPath: /var/run/litellm - - contains: - path: spec.template.spec.containers[1].volumeMounts - content: - name: collector-socket - mountPath: /var/run/litellm - - contains: - path: spec.template.spec.containers[1].volumeMounts - content: - name: litellm-config - mountPath: /etc/litellm/config.yaml - subPath: config.yaml - - contains: - path: spec.template.spec.volumes - content: - name: collector-socket - emptyDir: - sizeLimit: 1Mi - - - it: should skip the socket volume and pass the policy through on tcp transport - template: deployment.yaml - set: - collector.enabled: true - collector.address: tcp://127.0.0.1:4100 - collector.onUnavailable: drop - collector.bufferSize: 50 - envVars: - CONFIG_FILE_PATH: /custom/config.yaml - asserts: - - lengthEqual: - path: spec.template.spec.containers - count: 2 - - notContains: - path: spec.template.spec.containers[1].env - content: - name: CONFIG_FILE_PATH - value: /etc/litellm/config.yaml - - contains: - path: spec.template.spec.containers[1].env - content: - name: CONFIG_FILE_PATH - value: /custom/config.yaml - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_ADDRESS - value: tcp://127.0.0.1:4100 - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_ON_UNAVAILABLE - value: drop - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_COLLECTOR_BUFFER_SIZE - value: "50" - - notContains: - path: spec.template.spec.volumes - content: - name: collector-socket - any: true - - - it: should keep metrics and billing env on the proxy container only - template: deployment.yaml - set: - collector.enabled: true - metricsServer.enabled: true - metricsServer.port: 9090 - billingMetrics.enabled: true - billingMetrics.endpoint: https://metering.example.com - billingMetrics.secretName: billing-metrics - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: PROMETHEUS_METRICS_PORT - value: "9090" - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_BILLING_METRICS_ENDPOINT - value: https://metering.example.com - - notContains: - path: spec.template.spec.containers[1].env - content: - name: PROMETHEUS_METRICS_PORT - any: true - - notContains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_BILLING_METRICS_ENDPOINT - any: true - - notContains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_BILLING_METRICS_CLIENT_SECRET - any: true - - - it: should give the sidecar the same scratch mounts as the proxy on a read-only root - template: deployment.yaml - set: - collector.enabled: true - securityContext.readOnlyRootFilesystem: true - asserts: - - contains: - path: spec.template.spec.containers[1].volumeMounts - content: - name: npm - mountPath: /.npm - - contains: - path: spec.template.spec.containers[1].volumeMounts - content: - name: cache - mountPath: /.cache - - contains: - path: spec.template.spec.containers[1].volumeMounts - content: - name: tmp - mountPath: /tmp - - - it: should keep the pod-wide cpu metric unless asked to scale on the proxy container - template: hpa.yaml - set: - autoscaling.enabled: true - collector.enabled: true - asserts: - - equal: { path: "spec.metrics[0].type", value: Resource } - - equal: { path: "spec.metrics[0].resource.name", value: cpu } - - - it: should scale on the proxy container's cpu only when opted in - template: hpa.yaml - set: - autoscaling.enabled: true - collector.enabled: true - collector.scaleOnProxyContainerCpu: true - asserts: - - equal: { path: "spec.metrics[0].type", value: ContainerResource } - - equal: { path: "spec.metrics[0].containerResource.name", value: cpu } - - equal: { path: "spec.metrics[0].containerResource.container", value: litellm } - - equal: { path: "spec.metrics[0].containerResource.target.averageUtilization", value: 60 } - - isNull: { path: "spec.metrics[0].resource" } - - - it: should not switch to the container metric while the sidecar is off - template: hpa.yaml - set: - autoscaling.enabled: true - collector.scaleOnProxyContainerCpu: true - asserts: - - equal: { path: "spec.metrics[0].type", value: Resource } diff --git a/helm/litellm-helm/tests/connection_pool_tests.yaml b/helm/litellm-helm/tests/connection_pool_tests.yaml deleted file mode 100644 index 203082f27ba..00000000000 --- a/helm/litellm-helm/tests/connection_pool_tests.yaml +++ /dev/null @@ -1,112 +0,0 @@ -suite: test in-container connection pool -templates: - - deployment.yaml - - configmap-litellm.yaml -tests: - - it: should not emit pgbouncer env vars by default - template: deployment.yaml - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_PGBOUNCER_ENABLED - value: "true" - - notContains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS - value: "20" - - - it: should enable the pool with the default sizing when connectionPool.enabled is set - template: deployment.yaml - set: - db.connectionPool.enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_PGBOUNCER_ENABLED - value: "true" - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS - value: "20" - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN - value: "1000" - - - it: should pass custom sizing through as strings next to the worker count - template: deployment.yaml - set: - numWorkers: 4 - db.connectionPool.enabled: true - db.connectionPool.maxDbConnections: 8 - db.connectionPool.maxClientConn: 400 - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS - value: "8" - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN - value: "400" - - contains: - path: spec.template.spec.containers[0].args - content: "4" - - - it: should give the collector sidecar the same pool env as the proxy container - template: deployment.yaml - set: - collector.enabled: true - db.connectionPool.enabled: true - db.connectionPool.maxDbConnections: 8 - db.connectionPool.maxClientConn: 400 - asserts: - - equal: - path: spec.template.spec.containers[1].name - value: litellm-collector - - contains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_ENABLED - value: "true" - - contains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS - value: "8" - - contains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN - value: "400" - - - it: should give the collector sidecar no pool env when the pool is off - template: deployment.yaml - set: - collector.enabled: true - asserts: - - equal: - path: spec.template.spec.containers[1].name - value: litellm-collector - - notContains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_ENABLED - any: true - - notContains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS - any: true - - notContains: - path: spec.template.spec.containers[1].env - content: - name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN - any: true diff --git a/helm/litellm-helm/tests/coordination_redis_tests.yaml b/helm/litellm-helm/tests/coordination_redis_tests.yaml deleted file mode 100644 index 0b58b1e6bc8..00000000000 --- a/helm/litellm-helm/tests/coordination_redis_tests.yaml +++ /dev/null @@ -1,143 +0,0 @@ -suite: test coordination redis -templates: - - configmap-litellm.yaml - - deployment.yaml -tests: - - it: should not render coordination_redis when redis is disabled - template: configmap-litellm.yaml - set: - redis.enabled: false - asserts: - - notMatchRegex: - path: data["config.yaml"] - pattern: coordination_redis - - - it: should not emit redis env vars when redis is disabled - template: deployment.yaml - set: - redis.enabled: false - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: REDIS_HOST - value: RELEASE-NAME-redis-master - any: true - - - it: should render coordination_redis pointing at the bundled redis when enabled - template: configmap-litellm.yaml - set: - redis.enabled: true - asserts: - - matchRegex: - path: data["config.yaml"] - pattern: "coordination_redis:\n host: os.environ/REDIS_HOST\n password: os.environ/REDIS_PASSWORD\n port: os.environ/REDIS_PORT\n" - - matchRegex: - path: data["config.yaml"] - pattern: "master_key: os.environ/PROXY_MASTER_KEY" - - - it: should emit redis env vars backing the coordination_redis os.environ refs - template: deployment.yaml - set: - redis.enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: REDIS_HOST - value: RELEASE-NAME-redis-master - - contains: - path: spec.template.spec.containers[0].env - content: - name: REDIS_PORT - value: "6379" - - contains: - path: spec.template.spec.containers[0].env - content: - name: REDIS_PASSWORD - valueFrom: - secretKeyRef: - name: RELEASE-NAME-redis - key: redis-password - - - it: should not render coordination_redis when coordination is opted out - template: configmap-litellm.yaml - set: - redis.enabled: true - redis.coordination.enabled: false - asserts: - - notMatchRegex: - path: data["config.yaml"] - pattern: coordination_redis - - - it: should keep emitting redis env vars when coordination is opted out - template: deployment.yaml - set: - redis.enabled: true - redis.coordination.enabled: false - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: REDIS_HOST - value: RELEASE-NAME-redis-master - - - it: should not clobber a user supplied coordination_redis block - template: configmap-litellm.yaml - set: - redis.enabled: true - proxy_config.general_settings.coordination_redis: - url: os.environ/COORDINATION_REDIS_URL - asserts: - - matchRegex: - path: data["config.yaml"] - pattern: "coordination_redis:\n url: os.environ/COORDINATION_REDIS_URL\n" - - notMatchRegex: - path: data["config.yaml"] - pattern: "host: os.environ/REDIS_HOST" - - - it: should render sentinel_nodes and service_name in sentinel mode - template: configmap-litellm.yaml - set: - redis.enabled: true - redis.architecture: replication - redis.sentinel.enabled: true - asserts: - # The sentinel Service the redis subchart renders is "-redis", and a - # plain client cannot speak the sentinel protocol, so host/port must not appear - - matchRegex: - path: data["config.yaml"] - pattern: "coordination_redis:\n password: os.environ/REDIS_PASSWORD\n sentinel_nodes:\n - - RELEASE-NAME-redis\n - 26379\n service_name: mymaster\n" - - notMatchRegex: - path: data["config.yaml"] - pattern: "host: os.environ/REDIS_HOST" - - - it: should carry a custom sentinel masterSet into service_name - template: configmap-litellm.yaml - set: - redis.enabled: true - redis.architecture: replication - redis.sentinel.enabled: true - redis.sentinel.masterSet: litellm-master - asserts: - - matchRegex: - path: data["config.yaml"] - pattern: "service_name: litellm-master" - - - it: should point REDIS_HOST at the sentinel service in sentinel mode - template: deployment.yaml - set: - redis.enabled: true - redis.architecture: replication - redis.sentinel.enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: REDIS_HOST - value: RELEASE-NAME-redis - - contains: - path: spec.template.spec.containers[0].env - content: - name: REDIS_PORT - value: "26379" diff --git a/helm/litellm-helm/tests/database_auth_tests.yaml b/helm/litellm-helm/tests/database_auth_tests.yaml deleted file mode 100644 index 2c480196dfb..00000000000 --- a/helm/litellm-helm/tests/database_auth_tests.yaml +++ /dev/null @@ -1,288 +0,0 @@ -suite: test existing database token auth -templates: - - deployment.yaml - - migrations-job.yaml - - configmap-litellm.yaml -tests: - - it: should keep password auth from the secret when no token auth is set - template: deployment.yaml - set: - db.useExisting: true - db.deployStandalone: false - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_USERNAME - valueFrom: - secretKeyRef: - name: postgres - key: username - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PASSWORD - valueFrom: - secretKeyRef: - name: postgres - key: password - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL - value: postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME) - - notContains: - path: spec.template.spec.containers[0].env - content: - name: IAM_TOKEN_DB_AUTH - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: AZURE_POSTGRESQL_AUTH - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PORT - any: true - - - it: should render RDS IAM auth without a password secret on the proxy and the collector sidecar - template: deployment.yaml - set: - db.useExisting: true - db.deployStandalone: false - db.useIAMAuth: true - db.username: litellm_iam - db.endpoint: writer.cluster-abc.us-west-1.rds.amazonaws.com - db.port: 5433 - collector.enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: IAM_TOKEN_DB_AUTH - value: "true" - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_USERNAME - value: litellm_iam - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_HOST - value: writer.cluster-abc.us-west-1.rds.amazonaws.com - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PORT - value: "5433" - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_NAME - value: litellm - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PASSWORD - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: AZURE_POSTGRESQL_AUTH - any: true - - contains: - path: spec.template.spec.containers[1].env - content: - name: IAM_TOKEN_DB_AUTH - value: "true" - - notContains: - path: spec.template.spec.containers[1].env - content: - name: DATABASE_PASSWORD - any: true - - - it: should give the migrations job the same token auth env as the proxy - template: migrations-job.yaml - set: - migrationJob.enabled: true - db.useExisting: true - db.deployStandalone: false - db.useIAMAuth: true - db.username: litellm_iam - db.endpoint: writer.cluster-abc.us-west-1.rds.amazonaws.com - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: IAM_TOKEN_DB_AUTH - value: "true" - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_USERNAME - value: litellm_iam - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_HOST - value: writer.cluster-abc.us-west-1.rds.amazonaws.com - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PORT - value: "5432" - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PASSWORD - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL - any: true - - - it: should carry db.schema as DATABASE_SCHEMA under token auth and omit it when empty - template: deployment.yaml - set: - db.useExisting: true - db.deployStandalone: false - db.useIAMAuth: true - db.username: litellm_iam - db.schema: tenant1 - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_SCHEMA - value: tenant1 - - - it: should leave the schema to db.url under password auth even when db.schema is set - template: deployment.yaml - set: - db.useExisting: true - db.deployStandalone: false - db.schema: tenant1 - db.url: postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME)?schema=tenant1 - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_SCHEMA - any: true - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL - value: postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME)?schema=tenant1 - - - it: should not emit DATABASE_SCHEMA when db.schema is empty - template: deployment.yaml - set: - db.useExisting: true - db.deployStandalone: false - db.useIAMAuth: true - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_SCHEMA - any: true - - - it: should read the user from the secret when db.username is empty under token auth - template: deployment.yaml - set: - db.useExisting: true - db.deployStandalone: false - db.useIAMAuth: true - db.secret.name: rds-identity - db.secret.usernameKey: user - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_USERNAME - valueFrom: - secretKeyRef: - name: rds-identity - key: user - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PASSWORD - any: true - - - it: should render Entra auth with AZURE_POSTGRESQL_AUTH and no password - template: deployment.yaml - set: - db.useExisting: true - db.deployStandalone: false - db.useAzureEntraAuth: true - db.username: litellm@example.onmicrosoft.com - db.endpoint: litellm.postgres.database.azure.com - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: AZURE_POSTGRESQL_AUTH - value: "true" - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_USERNAME - value: litellm@example.onmicrosoft.com - - notContains: - path: spec.template.spec.containers[0].env - content: - name: IAM_TOKEN_DB_AUTH - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_PASSWORD - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL - any: true - - - it: should fail when both token sources are enabled - template: deployment.yaml - set: - migrationJob.enabled: false - db.useExisting: true - db.deployStandalone: false - db.useIAMAuth: true - db.useAzureEntraAuth: true - asserts: - - failedTemplate: - errorMessage: "db.useIAMAuth and db.useAzureEntraAuth are mutually exclusive: the database password can only come from one token source" - - - it: should fail when token auth is set while the bundled postgres is still deployed - template: deployment.yaml - set: - migrationJob.enabled: false - db.useExisting: true - db.useIAMAuth: true - asserts: - - failedTemplate: - errorPattern: "need db.useExisting: true and db.deployStandalone: false" - - - it: should fail when token auth is set without useExisting - template: migrations-job.yaml - set: - migrationJob.enabled: true - db.deployStandalone: false - db.useAzureEntraAuth: true - asserts: - - failedTemplate: - errorPattern: "need db.useExisting: true and db.deployStandalone: false" diff --git a/helm/litellm-helm/tests/deployment_command_args_labels_tests.yaml b/helm/litellm-helm/tests/deployment_command_args_labels_tests.yaml deleted file mode 100644 index 6b0d45ebf48..00000000000 --- a/helm/litellm-helm/tests/deployment_command_args_labels_tests.yaml +++ /dev/null @@ -1,68 +0,0 @@ -suite: test deployment command, args, and deploymentLabels -templates: - - deployment.yaml - - configmap-litellm.yaml -tests: - - it: should override args when custom args specified - template: deployment.yaml - set: - args: - - --custom-arg1 - - value1 - - --custom-arg2 - asserts: - - equal: - path: spec.template.spec.containers[0].args - value: - - --custom-arg1 - - value1 - - --custom-arg2 - - it: should set custom command when specified - template: deployment.yaml - set: - command: - - /bin/sh - - -c - asserts: - - equal: - path: spec.template.spec.containers[0].command - value: - - /bin/sh - - -c - - it: should set custom command and args together - template: deployment.yaml - set: - command: - - python - - -u - args: - - my_script.py - - --verbose - asserts: - - equal: - path: spec.template.spec.containers[0].command - value: - - python - - -u - - equal: - path: spec.template.spec.containers[0].args - value: - - my_script.py - - --verbose - - it: should add deploymentLabels to deployment metadata - template: deployment.yaml - set: - deploymentLabels: - environment: production - team: platform - version: v1.2.3 - asserts: - - equal: - path: metadata.labels.environment - value: production - - equal: - path: metadata.labels.team - value: platform - - equal: - path: metadata.labels.version - value: v1.2.3 diff --git a/helm/litellm-helm/tests/deployment_tests.yaml b/helm/litellm-helm/tests/deployment_tests.yaml deleted file mode 100644 index 292eeadfd13..00000000000 --- a/helm/litellm-helm/tests/deployment_tests.yaml +++ /dev/null @@ -1,520 +0,0 @@ -suite: test deployment -templates: - - deployment.yaml - - configmap-litellm.yaml -tests: - - it: should work - template: deployment.yaml - set: - image.tag: test - asserts: - - isKind: - of: Deployment - - matchRegex: - path: metadata.name - pattern: -litellm$ - - equal: - path: spec.template.spec.containers[0].image - value: ghcr.io/berriai/litellm:test - - it: should work with tolerations - template: deployment.yaml - set: - tolerations: - - key: node-role.kubernetes.io/master - operator: Exists - effect: NoSchedule - asserts: - - equal: - path: spec.template.spec.tolerations[0].key - value: node-role.kubernetes.io/master - - equal: - path: spec.template.spec.tolerations[0].operator - value: Exists - - it: should work with affinity - template: deployment.yaml - set: - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: topology.kubernetes.io/zone - operator: In - values: - - antarctica-east1 - asserts: - - equal: - path: spec.template.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key - value: topology.kubernetes.io/zone - - equal: - path: spec.template.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].operator - value: In - - equal: - path: spec.template.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].values[0] - value: antarctica-east1 - - it: should work without masterkeySecretName or masterkeySecretKey - template: deployment.yaml - set: - masterkeySecretName: "" - masterkeySecretKey: "" - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: PROXY_MASTER_KEY - valueFrom: - secretKeyRef: - name: RELEASE-NAME-litellm-masterkey - key: masterkey - - it: should work with masterkeySecretName and masterkeySecretKey - template: deployment.yaml - set: - masterkeySecretName: my-secret - masterkeySecretKey: my-key - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: PROXY_MASTER_KEY - valueFrom: - secretKeyRef: - name: my-secret - key: my-key - - it: should inject DATABASE_READER_HOST from readReplicaEndpointKey before DATABASE_URL_READ_REPLICA - template: deployment.yaml - set: - db: - deployStandalone: false - useExisting: true - secret: - name: postgres - usernameKey: username - passwordKey: password - readReplicaEndpointKey: reader-host - readReplicaUrl: postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_READER_HOST):5432/$(DATABASE_NAME)?sslmode=require - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_READER_HOST - valueFrom: - secretKeyRef: - name: postgres - key: reader-host - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL_READ_REPLICA - value: postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_READER_HOST):5432/$(DATABASE_NAME)?sslmode=require - # $(VAR) interpolation only resolves vars defined EARLIER in the env - # array, so the reader host must precede the composed URL - - equal: - path: spec.template.spec.containers[0].env[7].name - value: DATABASE_READER_HOST - - equal: - path: spec.template.spec.containers[0].env[8].name - value: DATABASE_URL_READ_REPLICA - - it: should omit reader host when readReplicaUrl is unset - template: deployment.yaml - set: - db: - deployStandalone: false - useExisting: true - secret: - name: postgres - usernameKey: username - passwordKey: password - readReplicaEndpointKey: reader-host - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_READER_HOST - valueFrom: - secretKeyRef: - name: postgres - key: reader-host - - it: should prefer readReplicaUrlKey over readReplicaEndpointKey composition - template: deployment.yaml - set: - db: - useExisting: true - secret: - name: postgres - usernameKey: username - passwordKey: password - readReplicaUrlKey: reader-url - readReplicaEndpointKey: reader-host - readReplicaUrl: postgresql://ignored - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL_READ_REPLICA - valueFrom: - secretKeyRef: - name: postgres - key: reader-url - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL_READ_REPLICA - value: postgresql://ignored - # the unused reader-host secret ref must be suppressed so a missing - # key can't fail pod creation - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_READER_HOST - valueFrom: - secretKeyRef: - name: postgres - key: reader-host - - it: should work with extraEnvVars - template: deployment.yaml - set: - extraEnvVars: - - name: EXTRA_ENV_VAR - valueFrom: - fieldRef: - fieldPath: metadata.labels['env'] - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: EXTRA_ENV_VAR - valueFrom: - fieldRef: - fieldPath: metadata.labels['env'] - - it: should work with both extraEnvVars and envVars - template: deployment.yaml - set: - envVars: - ENV_VAR: ENV_VAR_VALUE - extraEnvVars: - - name: EXTRA_ENV_VAR - value: EXTRA_ENV_VAR_VALUE - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: ENV_VAR - value: ENV_VAR_VALUE - - contains: - path: spec.template.spec.containers[0].env - content: - name: EXTRA_ENV_VAR - value: EXTRA_ENV_VAR_VALUE - - it: should mount existing configmap when create=false - template: deployment.yaml - set: - proxyConfigMap: - create: false - name: my-litellm-config - key: custom.yaml - asserts: - - contains: - path: spec.template.spec.volumes - content: - name: litellm-config - configMap: - name: my-litellm-config - items: - - key: custom.yaml - path: config.yaml - - contains: - path: spec.template.spec.containers[0].volumeMounts - content: - name: litellm-config - mountPath: /etc/litellm/config.yaml - subPath: config.yaml - - it: should work with lifecycle hooks - template: deployment.yaml - set: - lifecycle: - preStop: - exec: - command: - - /bin/sh - - -c - - echo "Container stopping" - asserts: - - exists: - path: spec.template.spec.containers[0].lifecycle - - equal: - path: spec.template.spec.containers[0].lifecycle.preStop.exec.command[0] - value: /bin/sh - - equal: - path: spec.template.spec.containers[0].lifecycle.preStop.exec.command[1] - value: -c - - equal: - path: spec.template.spec.containers[0].lifecycle.preStop.exec.command[2] - value: echo "Container stopping" - - it: should render background health check settings from proxy_config.general_settings - template: configmap-litellm.yaml - set: - proxy_config.general_settings.background_health_checks: true - proxy_config.general_settings.health_check_interval: 240 - proxy_config.general_settings.health_check_concurrency: 16 - proxy_config.general_settings.health_check_details: false - asserts: - - matchRegex: - path: data["config.yaml"] - pattern: '(?m)^\s*background_health_checks:\s*true$' - - matchRegex: - path: data["config.yaml"] - pattern: '(?m)^\s*health_check_interval:\s*240$' - - matchRegex: - path: data["config.yaml"] - pattern: '(?m)^\s*health_check_concurrency:\s*16$' - - matchRegex: - path: data["config.yaml"] - pattern: '(?m)^\s*health_check_details:\s*false$' - - it: should not render any placeholder models when the user sets proxy_config without model_list - template: configmap-litellm.yaml - set: - proxy_config.general_settings.store_model_in_db: true - asserts: - - notMatchRegex: - path: data["config.yaml"] - pattern: 'model_name:' - - matchRegex: - path: data["config.yaml"] - pattern: 'store_model_in_db: true' - - matchRegex: - path: data["config.yaml"] - pattern: 'master_key: os.environ/PROXY_MASTER_KEY' - - it: should render a user-supplied model_list as the only models - template: configmap-litellm.yaml - set: - proxy_config.model_list: - - model_name: my-model - litellm_params: - model: openai/my-model - api_key: os.environ/OPENAI_API_KEY - asserts: - - matchRegex: - path: data["config.yaml"] - pattern: 'model_list:\n- litellm_params:\n api_key: os.environ/OPENAI_API_KEY\n model: openai/my-model\n model_name: my-model\n$' - - it: should allow overriding liveness, readiness, and startup probes - template: deployment.yaml - set: - livenessProbe: - path: /custom/livez - initialDelaySeconds: 5 - periodSeconds: 15 - timeoutSeconds: 5 - successThreshold: 1 - failureThreshold: 5 - readinessProbe: - path: /custom/readyz - initialDelaySeconds: 10 - periodSeconds: 20 - timeoutSeconds: 6 - successThreshold: 1 - failureThreshold: 6 - startupProbe: - path: /custom/startupz - initialDelaySeconds: 15 - periodSeconds: 25 - timeoutSeconds: 7 - successThreshold: 1 - failureThreshold: 40 - asserts: - - equal: - path: spec.template.spec.containers[0].livenessProbe.httpGet.path - value: /custom/livez - - equal: - path: spec.template.spec.containers[0].livenessProbe.timeoutSeconds - value: 5 - - equal: - path: spec.template.spec.containers[0].readinessProbe.httpGet.path - value: /custom/readyz - - equal: - path: spec.template.spec.containers[0].readinessProbe.timeoutSeconds - value: 6 - - equal: - path: spec.template.spec.containers[0].startupProbe.httpGet.path - value: /custom/startupz - - equal: - path: spec.template.spec.containers[0].startupProbe.failureThreshold - value: 40 - - it: should render container resources from values - template: deployment.yaml - set: - resources: - limits: - cpu: 500m - memory: 2Gi - requests: - cpu: 250m - memory: 1Gi - asserts: - - equal: - path: spec.template.spec.containers[0].resources.limits.cpu - value: 500m - - equal: - path: spec.template.spec.containers[0].resources.limits.memory - value: 2Gi - - equal: - path: spec.template.spec.containers[0].resources.requests.cpu - value: 250m - - equal: - path: spec.template.spec.containers[0].resources.requests.memory - value: 1Gi - - it: should keep default probes and empty resources unchanged - template: deployment.yaml - asserts: - - equal: - path: spec.template.spec.containers[0].livenessProbe.httpGet.path - value: /health/liveliness - - equal: - path: spec.template.spec.containers[0].livenessProbe.initialDelaySeconds - value: 0 - - equal: - path: spec.template.spec.containers[0].livenessProbe.periodSeconds - value: 15 - - equal: - path: spec.template.spec.containers[0].livenessProbe.timeoutSeconds - value: 5 - - equal: - path: spec.template.spec.containers[0].livenessProbe.successThreshold - value: 1 - - equal: - path: spec.template.spec.containers[0].livenessProbe.failureThreshold - value: 5 - - equal: - path: spec.template.spec.containers[0].readinessProbe.httpGet.path - value: /health/readiness - - equal: - path: spec.template.spec.containers[0].readinessProbe.initialDelaySeconds - value: 0 - - equal: - path: spec.template.spec.containers[0].readinessProbe.periodSeconds - value: 10 - - equal: - path: spec.template.spec.containers[0].readinessProbe.timeoutSeconds - value: 5 - - equal: - path: spec.template.spec.containers[0].readinessProbe.successThreshold - value: 1 - - equal: - path: spec.template.spec.containers[0].readinessProbe.failureThreshold - value: 3 - - equal: - path: spec.template.spec.containers[0].startupProbe.httpGet.path - value: /health/readiness - - equal: - path: spec.template.spec.containers[0].startupProbe.initialDelaySeconds - value: 0 - - equal: - path: spec.template.spec.containers[0].startupProbe.periodSeconds - value: 10 - - equal: - path: spec.template.spec.containers[0].startupProbe.timeoutSeconds - value: 5 - - equal: - path: spec.template.spec.containers[0].startupProbe.successThreshold - value: 1 - - equal: - path: spec.template.spec.containers[0].startupProbe.failureThreshold - value: 30 - - equal: - path: spec.template.spec.containers[0].resources - value: {} - - it: should be able to set minReadySeconds - template: deployment.yaml - set: - deploymentMinReadySeconds: 5 - asserts: - - equal: - path: spec.minReadySeconds - value: 5 - - it: should have minReadySeconds absent when deploymentMinReadySeconds is not set - template: deployment.yaml - asserts: - - notExists: - path: spec.minReadySeconds - - it: should work with extraInitContainers - template: deployment.yaml - set: - extraInitContainers: - - name: init-test - image: busybox:latest - command: ["echo", "hello"] - asserts: - - contains: - path: spec.template.spec.initContainers - content: - name: init-test - image: busybox:latest - command: ["echo", "hello"] - - it: should support tpl in extraInitContainers - template: deployment.yaml - set: - image: - repository: ghcr.io/berriai/litellm - tag: test - extraInitContainers: - - name: init-tpl - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - command: ["echo", "hello"] - asserts: - - contains: - path: spec.template.spec.initContainers - content: - name: init-tpl - image: "ghcr.io/berriai/litellm:test" - command: ["echo", "hello"] - - it: should work with extraContainers - template: deployment.yaml - set: - extraContainers: - - name: sidecar - image: busybox:latest - asserts: - - contains: - path: spec.template.spec.containers - content: - name: sidecar - image: busybox:latest - - it: should support tpl in extraContainers - template: deployment.yaml - set: - image: - repository: ghcr.io/berriai/litellm - tag: test - extraContainers: - - name: sidecar-tpl - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - asserts: - - contains: - path: spec.template.spec.containers - content: - name: sidecar-tpl - image: "ghcr.io/berriai/litellm:test" - - it: should support tpl in podAnnotations - template: deployment.yaml - set: - image: - repository: ghcr.io/berriai/litellm - tag: test - # Mirrors the real-world scenario this feature unblocks: - # user disables the built-in ConfigMap (and its built-in checksum/config - # annotation) and re-implements checksum/config themselves via tpl. - proxyConfigMap: - create: false - podAnnotations: - checksum/config: "{{ .Values.image.tag }}" - example.com/some-key: "{{ .Values.image.repository }}" - example.com/literal: "plain-string-value" - asserts: - - equal: - path: spec.template.metadata.annotations["checksum/config"] - value: "test" - - equal: - path: spec.template.metadata.annotations["example.com/some-key"] - value: "ghcr.io/berriai/litellm" - - equal: - path: spec.template.metadata.annotations["example.com/literal"] - value: "plain-string-value" diff --git a/helm/litellm-helm/tests/hpa_tests.yaml b/helm/litellm-helm/tests/hpa_tests.yaml deleted file mode 100644 index e446f58c8fe..00000000000 --- a/helm/litellm-helm/tests/hpa_tests.yaml +++ /dev/null @@ -1,144 +0,0 @@ -suite: "hpa" -templates: - - hpa.yaml -tests: - - it: "renders behavior when set" - set: - autoscaling.enabled: true - autoscaling.behavior: - scaleUp: - stabilizationWindowSeconds: 60 - policies: - - type: Pods - value: 2 - periodSeconds: 60 - scaleDown: - stabilizationWindowSeconds: 90 - policies: - - type: Pods - value: 1 - periodSeconds: 60 - asserts: - - isKind: { of: HorizontalPodAutoscaler } - - equal: { path: spec.behavior.scaleUp.stabilizationWindowSeconds, value: 60 } - - equal: { path: spec.behavior.scaleDown.stabilizationWindowSeconds, value: 90 } - - - it: "does not render behavior when not set" - set: - autoscaling.enabled: true - asserts: - - isKind: { of: HorizontalPodAutoscaler } - - isNull: { path: spec.behavior } - - - it: "scales on cpu at the documented 60 percent by default" - set: - autoscaling.enabled: true - asserts: - - isKind: { of: HorizontalPodAutoscaler } - - equal: { path: "spec.metrics[0].resource.name", value: cpu } - - equal: { path: "spec.metrics[0].resource.target.type", value: Utilization } - - equal: { path: "spec.metrics[0].resource.target.averageUtilization", value: 60 } - - - it: "does not scale on memory by default" - set: - autoscaling.enabled: true - asserts: - - lengthEqual: { path: spec.metrics, count: 1 } - - - it: "honours an explicit cpu target override" - set: - autoscaling.enabled: true - autoscaling.targetCPUUtilizationPercentage: 75 - asserts: - - equal: { path: "spec.metrics[0].resource.target.averageUtilization", value: 75 } - - - it: "renders a memory metric only when a memory target is set" - set: - autoscaling.enabled: true - autoscaling.targetMemoryUtilizationPercentage: 80 - asserts: - - lengthEqual: { path: spec.metrics, count: 2 } - - equal: { path: "spec.metrics[1].resource.name", value: memory } - - equal: { path: "spec.metrics[1].resource.target.averageUtilization", value: 80 } - - - it: "renders no workload metrics by default" - set: - autoscaling.enabled: true - autoscaling.targetMemoryUtilizationPercentage: 80 - asserts: - - lengthEqual: { path: spec.metrics, count: 2 } - - notContains: { path: spec.metrics, content: { type: Pods }, any: true } - - - it: "adds a requests-per-second Pods metric after the cpu metric" - set: - autoscaling.enabled: true - autoscaling.targetRequestsPerSecond: 90 - asserts: - - lengthEqual: { path: spec.metrics, count: 2 } - - equal: { path: "spec.metrics[0].resource.name", value: cpu } - - equal: - path: "spec.metrics[1]" - value: - type: Pods - pods: - metric: { name: litellm_requests_per_second } - target: { type: AverageValue, averageValue: "90" } - - - it: "adds a tokens-per-second Pods metric on its own" - set: - autoscaling.enabled: true - autoscaling.targetTokensPerSecond: 6M - asserts: - - lengthEqual: { path: spec.metrics, count: 2 } - - equal: - path: "spec.metrics[1]" - value: - type: Pods - pods: - metric: { name: litellm_tokens_per_second } - target: { type: AverageValue, averageValue: "6M" } - - notContains: - path: spec.metrics - content: { type: Pods, pods: { metric: { name: litellm_requests_per_second } } } - any: true - - - it: "renders requests, tokens, cpu and memory metrics together" - set: - autoscaling.enabled: true - autoscaling.targetMemoryUtilizationPercentage: 80 - autoscaling.targetRequestsPerSecond: 90 - autoscaling.targetTokensPerSecond: 6000000 - asserts: - - lengthEqual: { path: spec.metrics, count: 4 } - - equal: { path: "spec.metrics[0].resource.name", value: cpu } - - equal: { path: "spec.metrics[1].resource.name", value: memory } - - equal: { path: "spec.metrics[2].pods.metric.name", value: litellm_requests_per_second } - - equal: { path: "spec.metrics[2].pods.target.averageValue", value: "90" } - - equal: { path: "spec.metrics[3].pods.metric.name", value: litellm_tokens_per_second } - - equal: { path: "spec.metrics[3].pods.target.averageValue", value: "6000000" } - - - it: "scales on workload metrics alone when the cpu target is cleared" - set: - autoscaling.enabled: true - autoscaling.targetCPUUtilizationPercentage: null - autoscaling.targetRequestsPerSecond: 90 - autoscaling.targetTokensPerSecond: 6000000 - asserts: - - lengthEqual: { path: spec.metrics, count: 2 } - - notContains: { path: spec.metrics, content: { type: Resource }, any: true } - - equal: { path: "spec.metrics[0].pods.metric.name", value: litellm_requests_per_second } - - equal: { path: "spec.metrics[1].pods.metric.name", value: litellm_tokens_per_second } - - notMatchRegexRaw: { pattern: per_minute } - - - it: "ignores the per-minute keys, which the chart never shipped" - set: - autoscaling.enabled: true - autoscaling.targetRequestsPerMinute: 5400 - autoscaling.targetTokensPerMinute: 360000000 - asserts: - - lengthEqual: { path: spec.metrics, count: 1 } - - notContains: { path: spec.metrics, content: { type: Pods }, any: true } - - - it: "renders no hpa when autoscaling is disabled" - asserts: - - hasDocuments: { count: 0 } diff --git a/helm/litellm-helm/tests/ingress_tests.yaml b/helm/litellm-helm/tests/ingress_tests.yaml deleted file mode 100644 index aad6ecfcee8..00000000000 --- a/helm/litellm-helm/tests/ingress_tests.yaml +++ /dev/null @@ -1,45 +0,0 @@ -suite: Ingress Configuration Tests -templates: - - ingress.yaml -tests: - - it: should not create Ingress by default - asserts: - - hasDocuments: - count: 0 - - - it: should create Ingress when enabled - set: - ingress.enabled: true - asserts: - - hasDocuments: - count: 1 - - isKind: - of: Ingress - - - it: should add custom labels - set: - ingress.enabled: true - ingress.labels: - custom-label: "true" - another-label: "value" - asserts: - - isKind: - of: Ingress - - equal: - path: metadata.labels.custom-label - value: "true" - - equal: - path: metadata.labels.another-label - value: "value" - - - it: should add annotations - set: - ingress.enabled: true - ingress.annotations: - kubernetes.io/ingress.class: "nginx" - asserts: - - isKind: - of: Ingress - - equal: - path: metadata.annotations["kubernetes.io/ingress.class"] - value: "nginx" diff --git a/helm/litellm-helm/tests/keda_tests.yaml b/helm/litellm-helm/tests/keda_tests.yaml deleted file mode 100644 index c9598646223..00000000000 --- a/helm/litellm-helm/tests/keda_tests.yaml +++ /dev/null @@ -1,106 +0,0 @@ -suite: "keda" -templates: - - keda.yaml -release: - name: rel - namespace: llm -tests: - - it: "renders no scaled object by default" - asserts: - - hasDocuments: { count: 0 } - - - it: "passes user triggers through and adds no prometheus triggers by default" - set: - keda.enabled: true - keda.triggers: - - type: cpu - metricType: Utilization - metadata: { value: "60" } - asserts: - - isKind: { of: ScaledObject } - - equal: - path: spec.triggers - value: - - type: cpu - metricType: Utilization - metadata: { value: "60" } - - - it: "scales on release-wide requests per second divided by the per-replica target" - set: - keda.enabled: true - keda.prometheus.serverAddress: http://prometheus-operated.monitoring.svc:9090 - keda.prometheus.requestsPerSecond: 90 - asserts: - - lengthEqual: { path: spec.triggers, count: 1 } - - equal: - path: "spec.triggers[0]" - value: - type: prometheus - metadata: - serverAddress: http://prometheus-operated.monitoring.svc:9090 - threshold: "90" - query: sum(rate(litellm_proxy_total_requests_metric_total{namespace="llm",job="rel-litellm"}[1m])) - - - it: "scales on tokens per second on its own" - set: - keda.enabled: true - keda.prometheus.serverAddress: http://prom:9090 - keda.prometheus.tokensPerSecond: 6000000 - asserts: - - lengthEqual: { path: spec.triggers, count: 1 } - - equal: { path: "spec.triggers[0].type", value: prometheus } - - equal: { path: "spec.triggers[0].metadata.threshold", value: "6000000" } - - equal: - path: "spec.triggers[0].metadata.query" - value: sum(rate(litellm_total_tokens_metric_total{namespace="llm",job="rel-litellm"}[1m])) - - - it: "appends requests and tokens triggers after user triggers and selects the metrics service job" - set: - keda.enabled: true - metricsServer.enabled: true - keda.triggers: - - type: cpu - metricType: Utilization - metadata: { value: "60" } - keda.prometheus.serverAddress: http://prom:9090 - keda.prometheus.requestsPerSecond: 90 - keda.prometheus.tokensPerSecond: 6000000 - asserts: - - lengthEqual: { path: spec.triggers, count: 3 } - - equal: { path: "spec.triggers[0].type", value: cpu } - - equal: { path: "spec.triggers[1].metadata.threshold", value: "90" } - - equal: - path: "spec.triggers[1].metadata.query" - value: sum(rate(litellm_proxy_total_requests_metric_total{namespace="llm",job="rel-litellm-metrics"}[1m])) - - equal: { path: "spec.triggers[2].metadata.threshold", value: "6000000" } - - equal: - path: "spec.triggers[2].metadata.query" - value: sum(rate(litellm_total_tokens_metric_total{namespace="llm",job="rel-litellm-metrics"}[1m])) - - notMatchRegexRaw: { pattern: "\\* *60|per_minute|PerMinute" } - - - it: "ignores the per-minute keys, which the chart never shipped" - set: - keda.enabled: true - keda.prometheus.serverAddress: http://prom:9090 - keda.prometheus.requestsPerMinute: 5400 - keda.prometheus.tokensPerMinute: 360000000 - asserts: - - isKind: { of: ScaledObject } - - isNullOrEmpty: { path: spec.triggers } - - - it: "refuses a workload target without a prometheus server address" - set: - keda.enabled: true - keda.prometheus.requestsPerSecond: 90 - asserts: - - failedTemplate: - errorMessage: keda.prometheus.serverAddress is required when keda.prometheus.requestsPerSecond or tokensPerSecond is set - - - it: "yields to the hpa when both autoscalers are enabled" - set: - autoscaling.enabled: true - keda.enabled: true - keda.prometheus.serverAddress: http://prom:9090 - keda.prometheus.requestsPerSecond: 90 - asserts: - - hasDocuments: { count: 0 } diff --git a/helm/litellm-helm/tests/lens_connection_tests.yaml b/helm/litellm-helm/tests/lens_connection_tests.yaml deleted file mode 100644 index e01e49d0ffc..00000000000 --- a/helm/litellm-helm/tests/lens_connection_tests.yaml +++ /dev/null @@ -1,128 +0,0 @@ -suite: lens connection -templates: - - deployment.yaml - - configmap-litellm.yaml - - ingress.yaml -tests: - - it: connects the proxy to an external Lens - template: deployment.yaml - set: - lens.enabled: true - lens.url: http://lens.lens.svc:4318 - lens.publicUrl: https://lens.example.com/lens-ingest - lens.gatewaySecret.name: lens-gateway - lens.serviceTokenSecret.name: lens-service - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_LENS_URL - value: http://lens.lens.svc:4318 - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_LENS_PUBLIC_URL - value: https://lens.example.com/lens-ingest - - contains: - path: spec.template.spec.containers[0].env - content: - name: LENS_GATEWAY_SECRET - valueFrom: - secretKeyRef: - name: lens-gateway - key: gateway-secret - - contains: - path: spec.template.spec.containers[0].env - content: - name: LITELLM_LENS_SERVICE_TOKEN - valueFrom: - secretKeyRef: - name: lens-service - key: service-token - - - it: requires lens.url when Lens is enabled - template: deployment.yaml - set: - lens.enabled: true - lens.publicUrl: https://lens.example.com/lens-ingest - lens.gatewaySecret.name: lens-gateway - lens.serviceTokenSecret.name: lens-service - asserts: - - failedTemplate: - errorMessage: lens.url is required when lens.enabled is true - - - it: requires lens.publicUrl when Lens is enabled - template: deployment.yaml - set: - lens.enabled: true - lens.url: http://lens.lens.svc:4318 - lens.gatewaySecret.name: lens-gateway - lens.serviceTokenSecret.name: lens-service - asserts: - - failedTemplate: - errorMessage: lens.publicUrl is required when lens.enabled is true - - - it: requires lens.gatewaySecret.name when Lens is enabled - template: deployment.yaml - set: - lens.enabled: true - lens.url: http://lens.lens.svc:4318 - lens.publicUrl: https://lens.example.com/lens-ingest - lens.serviceTokenSecret.name: lens-service - asserts: - - failedTemplate: - errorMessage: lens.gatewaySecret.name is required when lens.enabled is true - - - it: requires lens.serviceTokenSecret.name when Lens is enabled - template: deployment.yaml - set: - lens.enabled: true - lens.url: http://lens.lens.svc:4318 - lens.publicUrl: https://lens.example.com/lens-ingest - lens.gatewaySecret.name: lens-gateway - asserts: - - failedTemplate: - errorMessage: lens.serviceTokenSecret.name is required when lens.enabled is true - - - it: routes /lens-ingest to the named Lens service ahead of the host paths - template: ingress.yaml - set: - ingress.enabled: true - lens.enabled: true - lens.url: http://lens.lens.svc:4318 - lens.publicUrl: https://lens.example.com/lens-ingest - lens.gatewaySecret.name: lens-gateway - lens.serviceTokenSecret.name: lens-service - lens.service.name: existing-lens - asserts: - - equal: - path: spec.rules[0].http.paths[0].path - value: /lens-ingest - - contains: - path: spec.rules[0].http.paths - content: - path: /lens-ingest - pathType: Prefix - backend: - service: - name: existing-lens - port: - number: 4318 - - - it: leaves /lens-ingest off the ingress without a Lens service name - template: ingress.yaml - set: - ingress.enabled: true - lens.enabled: true - lens.url: http://lens.lens.svc:4318 - lens.publicUrl: https://lens.example.com/lens-ingest - lens.gatewaySecret.name: lens-gateway - lens.serviceTokenSecret.name: lens-service - asserts: - - exists: - path: spec.rules[0].http.paths - - notContains: - path: spec.rules[0].http.paths - content: - path: /lens-ingest - any: true diff --git a/helm/litellm-helm/tests/masterkey-secret_tests.yaml b/helm/litellm-helm/tests/masterkey-secret_tests.yaml deleted file mode 100644 index 296f26755b8..00000000000 --- a/helm/litellm-helm/tests/masterkey-secret_tests.yaml +++ /dev/null @@ -1,71 +0,0 @@ -suite: test masterkey secret -templates: - - secret-masterkey.yaml -tests: - - it: should create a secret if masterkeySecretName is not set. should start with sk-xxxx (base64 encoded as c2st*) - template: secret-masterkey.yaml - set: - masterkeySecretName: "" - asserts: - - isKind: - of: Secret - - matchRegex: - path: data.masterkey - pattern: ^c2st - # Note: The masterkey is generated as "sk-<18-random-chars>" in plain text, - # but stored as base64 encoded in Kubernetes secret (requirement). - # "sk-" base64 encodes to "c2st", so we check for "^c2st" pattern. - - it: should reuse the master key already stored in the cluster instead of generating a new one on upgrade - template: secret-masterkey.yaml - set: - masterkeySecretName: "" - kubernetesProvider: - scheme: - "v1/Secret": - gvr: - version: "v1" - resource: "secrets" - namespaced: true - objects: - - kind: Secret - apiVersion: v1 - metadata: - name: RELEASE-NAME-litellm-masterkey - namespace: NAMESPACE - data: - masterkey: c2stZXhpc3Rpbmcta2V5 - asserts: - - equal: - path: data.masterkey - value: c2stZXhpc3Rpbmcta2V5 - - it: should let an explicit masterkey value override the one already stored in the cluster - template: secret-masterkey.yaml - set: - masterkeySecretName: "" - masterkey: sk-explicit - kubernetesProvider: - scheme: - "v1/Secret": - gvr: - version: "v1" - resource: "secrets" - namespaced: true - objects: - - kind: Secret - apiVersion: v1 - metadata: - name: RELEASE-NAME-litellm-masterkey - namespace: NAMESPACE - data: - masterkey: c2stZXhpc3Rpbmcta2V5 - asserts: - - equal: - path: data.masterkey - value: c2stZXhwbGljaXQ= - - it: should not create a secret if masterkeySecretName is set - template: secret-masterkey.yaml - set: - masterkeySecretName: my-secret - asserts: - - hasDocuments: - count: 0 diff --git a/helm/litellm-helm/tests/metrics_server_tests.yaml b/helm/litellm-helm/tests/metrics_server_tests.yaml deleted file mode 100644 index 085d69ac640..00000000000 --- a/helm/litellm-helm/tests/metrics_server_tests.yaml +++ /dev/null @@ -1,106 +0,0 @@ -suite: separate metrics server -templates: - - configmap-litellm.yaml - - deployment.yaml - - service.yaml - - service-metrics.yaml - - servicemonitor.yaml -tests: - - it: should not expose a metrics port or PROMETHEUS_METRICS_PORT by default - asserts: - - notContains: - path: spec.template.spec.containers[0].ports - content: - name: metrics - any: true - template: deployment.yaml - - notContains: - path: spec.template.spec.containers[0].env - content: - name: PROMETHEUS_METRICS_PORT - any: true - template: deployment.yaml - - lengthEqual: - path: spec.ports - count: 1 - template: service.yaml - - hasDocuments: - count: 0 - template: service-metrics.yaml - - - it: should scrape the proxy port when the metrics server is disabled - template: servicemonitor.yaml - set: - serviceMonitor.enabled: true - asserts: - - equal: - path: spec.endpoints[0].port - value: http - - - it: should wire the separate metrics server through container, a ClusterIP metrics service and servicemonitor - set: - metricsServer.enabled: true - metricsServer.port: 4101 - serviceMonitor.enabled: true - service.type: LoadBalancer - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: PROMETHEUS_METRICS_PORT - value: "4101" - template: deployment.yaml - - contains: - path: spec.template.spec.containers[0].ports - content: - name: metrics - containerPort: 4101 - protocol: TCP - template: deployment.yaml - - lengthEqual: - path: spec.ports - count: 1 - template: service.yaml - - equal: - path: spec.type - value: LoadBalancer - template: service.yaml - - equal: - path: metadata.name - value: RELEASE-NAME-litellm-metrics - template: service-metrics.yaml - - equal: - path: spec.type - value: ClusterIP - template: service-metrics.yaml - - equal: - path: spec.ports - value: - - port: 4101 - targetPort: metrics - protocol: TCP - name: metrics - template: service-metrics.yaml - - equal: - path: spec.selector - value: - app.kubernetes.io/name: litellm - app.kubernetes.io/instance: RELEASE-NAME - template: service-metrics.yaml - - equal: - path: spec.endpoints[0].port - value: metrics - template: servicemonitor.yaml - - equal: - path: spec.endpoints[0].path - value: /metrics/ - template: servicemonitor.yaml - - - it: should reject a metrics port equal to the proxy port - template: deployment.yaml - set: - metricsServer.enabled: true - metricsServer.port: 4000 - asserts: - - failedTemplate: - errorMessage: metricsServer.port must differ from service.port diff --git a/helm/litellm-helm/tests/migrations-job_tests.yaml b/helm/litellm-helm/tests/migrations-job_tests.yaml deleted file mode 100644 index bd5dd457f95..00000000000 --- a/helm/litellm-helm/tests/migrations-job_tests.yaml +++ /dev/null @@ -1,432 +0,0 @@ -suite: test migrations job -templates: - - migrations-job.yaml -tests: - - it: should work with envVars - template: migrations-job.yaml - set: - envVars: - TEST_ENV_VAR: "test_value" - ANOTHER_VAR: "another_value" - migrationJob: - enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: TEST_ENV_VAR - value: "test_value" - - contains: - path: spec.template.spec.containers[0].env - content: - name: ANOTHER_VAR - value: "another_value" - - - it: should work with extraEnvVars - template: migrations-job.yaml - set: - extraEnvVars: - - name: EXTRA_ENV_VAR - valueFrom: - fieldRef: - fieldPath: metadata.labels['env'] - - name: SIMPLE_EXTRA_VAR - value: "simple_value" - migrationJob: - enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: EXTRA_ENV_VAR - valueFrom: - fieldRef: - fieldPath: metadata.labels['env'] - - contains: - path: spec.template.spec.containers[0].env - content: - name: SIMPLE_EXTRA_VAR - value: "simple_value" - - - it: should work with both envVars and extraEnvVars - template: migrations-job.yaml - set: - envVars: - ENV_VAR: "env_var_value" - extraEnvVars: - - name: EXTRA_ENV_VAR - value: "extra_env_var_value" - migrationJob: - enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: ENV_VAR - value: "env_var_value" - - contains: - path: spec.template.spec.containers[0].env - content: - name: EXTRA_ENV_VAR - value: "extra_env_var_value" - - - it: should not render when migrations job is disabled - template: migrations-job.yaml - set: - migrationJob: - enabled: false - asserts: - - hasDocuments: - count: 0 - - - it: should still include default env vars - template: migrations-job.yaml - set: - envVars: - CUSTOM_VAR: "custom_value" - migrationJob: - enabled: true - db: - useExisting: true - endpoint: "test-db" - database: "testdb" - url: "postgresql://user:pass@test-db:5432/testdb" - secret: - name: "test-secret" - usernameKey: "username" - passwordKey: "password" - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: DISABLE_SCHEMA_UPDATE - value: "false" - - contains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_HOST - value: "test-db" - - contains: - path: spec.template.spec.containers[0].env - content: - name: CUSTOM_VAR - value: "custom_value" - - - it: should override a user-supplied DISABLE_SCHEMA_UPDATE so the Job always migrates - template: migrations-job.yaml - set: - envVars: - DISABLE_SCHEMA_UPDATE: "true" - migrationJob: - enabled: true - asserts: - # The Job is what owns the schema, so it renders its own - # DISABLE_SCHEMA_UPDATE=false after envVars and extraEnvVars. Kubernetes - # takes the last value for a duplicated name, so the user's "true" cannot - # leave the schema unmigrated. Skipping migrations is migrationJob.enabled. - - equal: - path: spec.template.spec.containers[0].env[-1] - value: - name: DISABLE_SCHEMA_UPDATE - value: "false" - - - it: should not include DATABASE_URL when deployStandalone is false - template: migrations-job.yaml - set: - migrationJob: - enabled: true - db: - deployStandalone: false - useExisting: false - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: DATABASE_URL - - - it: should use default service account for helm hooks when serviceAccount.create is true - template: migrations-job.yaml - set: - migrationJob: - enabled: true - hooks: - helm: - enabled: true - serviceAccount: - create: true - asserts: - - equal: - path: spec.template.spec.serviceAccountName - value: default - - - it: should use migrationJob.serviceAccountName override for helm hooks when serviceAccount.create is true - template: migrations-job.yaml - set: - migrationJob: - enabled: true - serviceAccountName: migration-sa - hooks: - helm: - enabled: true - serviceAccount: - create: true - asserts: - - equal: - path: spec.template.spec.serviceAccountName - value: migration-sa - - - it: should use chart service account when helm hooks are disabled - template: migrations-job.yaml - set: - migrationJob: - enabled: true - hooks: - helm: - enabled: false - serviceAccount: - create: true - name: my-custom-sa - asserts: - - equal: - path: spec.template.spec.serviceAccountName - value: my-custom-sa - - - it: should use pre-existing service account when helm hooks are enabled but serviceAccount.create is false - template: migrations-job.yaml - set: - migrationJob: - enabled: true - hooks: - helm: - enabled: true - serviceAccount: - create: false - name: pre-existing-sa - asserts: - - equal: - path: spec.template.spec.serviceAccountName - value: pre-existing-sa - - it: should work with extraInitContainers - template: migrations-job.yaml - set: - migrationJob: - enabled: true - extraInitContainers: - - name: init-test - image: busybox:latest - command: ["echo", "hello"] - asserts: - - contains: - path: spec.template.spec.initContainers - content: - name: init-test - image: busybox:latest - command: ["echo", "hello"] - - it: should support tpl in extraInitContainers - template: migrations-job.yaml - set: - image: - repository: ghcr.io/berriai/litellm - tag: test - migrationJob: - enabled: true - extraInitContainers: - - name: init-tpl - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - command: ["echo", "hello"] - asserts: - - contains: - path: spec.template.spec.initContainers - content: - name: init-tpl - image: "ghcr.io/berriai/litellm:test" - command: ["echo", "hello"] - - it: should work with extraContainers - template: migrations-job.yaml - set: - migrationJob: - enabled: true - extraContainers: - - name: sidecar - image: busybox:latest - asserts: - - contains: - path: spec.template.spec.containers - content: - name: sidecar - image: busybox:latest - - it: should support tpl in extraContainers - template: migrations-job.yaml - set: - image: - repository: ghcr.io/berriai/litellm - tag: test - migrationJob: - enabled: true - extraContainers: - - name: sidecar-tpl - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - asserts: - - contains: - path: spec.template.spec.containers - content: - name: sidecar-tpl - image: "ghcr.io/berriai/litellm:test" - - it: should render the pod-level securityContext from podSecurityContext - template: migrations-job.yaml - set: - migrationJob: - enabled: true - podSecurityContext: - fsGroup: 10000 - runAsUser: 10000 - runAsNonRoot: true - asserts: - - equal: - path: spec.template.spec.securityContext - value: - fsGroup: 10000 - runAsUser: 10000 - runAsNonRoot: true - - it: should keep the pod-level and container-level securityContext separate - template: migrations-job.yaml - set: - migrationJob: - enabled: true - podSecurityContext: - fsGroup: 10000 - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - asserts: - - equal: - path: spec.template.spec.securityContext - value: - fsGroup: 10000 - - equal: - path: spec.template.spec.containers[0].securityContext - value: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - - it: should schedule onto the same nodes as the gateway - template: migrations-job.yaml - set: - migrationJob: - enabled: true - nodeSelector: - karpenter.sh/nodepool: litellm-e2e - tolerations: - - key: workload - operator: Equal - value: litellm-e2e - effect: NoSchedule - asserts: - - equal: - path: spec.template.spec.nodeSelector - value: - karpenter.sh/nodepool: litellm-e2e - - equal: - path: spec.template.spec.tolerations - value: - - key: workload - operator: Equal - value: litellm-e2e - effect: NoSchedule - - - it: bounds the Job with a deadline by default, so a blocked migration cannot stall the release forever - set: - migrationJob: - enabled: true - asserts: - - equal: - path: spec.activeDeadlineSeconds - value: 1800 - - - it: honours an operator-supplied deadline - set: - migrationJob: - enabled: true - activeDeadlineSeconds: 600 - asserts: - - equal: - path: spec.activeDeadlineSeconds - value: 600 - - - it: omits the deadline entirely when it is nulled out, restoring the unbounded behaviour - set: - migrationJob: - enabled: true - activeDeadlineSeconds: null - asserts: - - notExists: - path: spec.activeDeadlineSeconds - - - it: should set custom jobLabels and jobAnnotations on Job metadata - template: migrations-job.yaml - set: - migrationJob: - enabled: true - jobLabels: - environment: production - team: platform - jobAnnotations: - example.com/cost-center: "1234" - asserts: - - equal: - path: metadata.labels.environment - value: production - - equal: - path: metadata.labels.team - value: platform - - equal: - path: metadata.annotations['example.com/cost-center'] - value: "1234" - - - it: should set custom podLabels on Pod template - template: migrations-job.yaml - set: - migrationJob: - enabled: true - podLabels: - custom.io/pod-role: migration - asserts: - - equal: - path: spec.template.metadata.labels['custom.io/pod-role'] - value: migration - - - it: should override container command and args - template: migrations-job.yaml - set: - migrationJob: - enabled: true - command: - - sh - args: - - -c - - echo migrating - asserts: - - equal: - path: spec.template.spec.containers[0].command - value: - - sh - - equal: - path: spec.template.spec.containers[0].args - value: - - -c - - echo migrating - - - it: should clear container args when only command is specified - template: migrations-job.yaml - set: - migrationJob: - enabled: true - command: - - sh - asserts: - - equal: - path: spec.template.spec.containers[0].command - value: - - sh - - equal: - path: spec.template.spec.containers[0].args - value: [] diff --git a/helm/litellm-helm/tests/pdb_tests.yaml b/helm/litellm-helm/tests/pdb_tests.yaml deleted file mode 100644 index 5e042e80bd3..00000000000 --- a/helm/litellm-helm/tests/pdb_tests.yaml +++ /dev/null @@ -1,45 +0,0 @@ -suite: "pdb enabled" -templates: - - poddisruptionbudget.yaml -tests: - - it: "renders a PDB with maxUnavailable=1" - set: - pdb.enabled: true - pdb.maxUnavailable: 1 - asserts: - - hasDocuments: { count: 1 } - - isKind: { of: PodDisruptionBudget } - - equal: { path: apiVersion, value: policy/v1 } - - equal: { path: spec.maxUnavailable, value: 1 } - - equal: - path: spec.selector.matchLabels - value: - app.kubernetes.io/name: litellm - app.kubernetes.io/instance: RELEASE-NAME - ---- -suite: "pdb disabled" -templates: - - poddisruptionbudget.yaml -tests: - - it: "does not render when disabled" - set: - pdb.enabled: false - asserts: - - hasDocuments: { count: 0 } - ---- -suite: "pdb minAvailable precedence" -templates: - - poddisruptionbudget.yaml -tests: - - it: "uses minAvailable when both are set" - set: - pdb.enabled: true - pdb.minAvailable: "50%" - pdb.maxUnavailable: 1 - asserts: - - isKind: { of: PodDisruptionBudget } - - equal: { path: apiVersion, value: policy/v1 } - - equal: { path: spec.minAvailable, value: "50%" } - - isNull: { path: spec.maxUnavailable } diff --git a/helm/litellm-helm/tests/service_tests.yaml b/helm/litellm-helm/tests/service_tests.yaml deleted file mode 100644 index 43ed0180bc8..00000000000 --- a/helm/litellm-helm/tests/service_tests.yaml +++ /dev/null @@ -1,116 +0,0 @@ -suite: Service Configuration Tests -templates: - - service.yaml -tests: - - it: should create a default ClusterIP service - template: service.yaml - asserts: - - isKind: - of: Service - - equal: - path: spec.type - value: ClusterIP - - equal: - path: spec.ports[0].port - value: 4000 - - equal: - path: spec.ports[0].targetPort - value: http - - equal: - path: spec.ports[0].protocol - value: TCP - - equal: - path: spec.ports[0].name - value: http - - isNull: - path: spec.loadBalancerClass - - - it: should create a NodePort service when specified - template: service.yaml - set: - service.type: NodePort - asserts: - - isKind: - of: Service - - equal: - path: spec.type - value: NodePort - - isNull: - path: spec.loadBalancerClass - - - it: should create a LoadBalancer service when specified - template: service.yaml - set: - service.type: LoadBalancer - asserts: - - isKind: - of: Service - - equal: - path: spec.type - value: LoadBalancer - - isNull: - path: spec.loadBalancerClass - - - it: should add loadBalancerClass when specified with LoadBalancer type - template: service.yaml - set: - service.type: LoadBalancer - service.loadBalancerClass: tailscale - asserts: - - isKind: - of: Service - - equal: - path: spec.type - value: LoadBalancer - - equal: - path: spec.loadBalancerClass - value: tailscale - - - it: should not add loadBalancerClass when specified with ClusterIP type - template: service.yaml - set: - service.type: ClusterIP - service.loadBalancerClass: tailscale - asserts: - - isKind: - of: Service - - equal: - path: spec.type - value: ClusterIP - - isNull: - path: spec.loadBalancerClass - - - it: should use custom port when specified - template: service.yaml - set: - service.port: 8080 - asserts: - - equal: - path: spec.ports[0].port - value: 8080 - - - it: should add service annotations when specified - template: service.yaml - set: - service.annotations: - cloud.google.com/load-balancer-type: "Internal" - service.beta.kubernetes.io/aws-load-balancer-internal: "true" - asserts: - - isKind: - of: Service - - equal: - path: metadata.annotations - value: - cloud.google.com/load-balancer-type: "Internal" - service.beta.kubernetes.io/aws-load-balancer-internal: "true" - - - it: should use the correct selector labels - template: service.yaml - asserts: - - isNotNull: - path: spec.selector - - equal: - path: spec.selector - value: - app.kubernetes.io/name: litellm - app.kubernetes.io/instance: RELEASE-NAME diff --git a/helm/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml deleted file mode 100644 index 9afce050261..00000000000 --- a/helm/litellm-helm/values.yaml +++ /dev/null @@ -1,676 +0,0 @@ -# Default values for litellm. -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. - -replicaCount: 1 -liteadmin: - enabled: false - existingSecret: "" - gatewayUrl: "" - model: "" - readOnly: false - storageSize: 1Gi - storageClassName: "" - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - memory: 1Gi -# numWorkers: 2 - -image: - # Bundles the prisma CLI and engines, which is what lets the migrations job - # and the proxy's own schema check run without network access. - repository: ghcr.io/berriai/litellm - pullPolicy: Always - # Overrides the image tag whose default is the chart appVersion. - # tag: "latest" - tag: "" - -imagePullSecrets: [] -nameOverride: "litellm" -fullnameOverride: "" - -serviceAccount: - # Specifies whether a service account should be created - create: false - # Automatically mount a ServiceAccount's API credentials? - automount: true - # Annotations to add to the service account - annotations: {} - # The name of the service account to use. - # If not set and create is true, a name is generated using the fullname template - name: "" - -# annotations for litellm deployment -deploymentAnnotations: {} -deploymentLabels: {} -deploymentMinReadySeconds: 0 - -# annotations for litellm pods -podAnnotations: {} -podLabels: {} - -# -- Deployment strategy configuration -# Example: -# type: RollingUpdate -# rollingUpdate: -# maxUnavailable: 0 -# maxSurge: 1 -strategy: {} - -terminationGracePeriodSeconds: 90 -topologySpreadConstraints: - [] - # - maxSkew: 1 - # topologyKey: kubernetes.io/hostname - # whenUnsatisfiable: DoNotSchedule - # labelSelector: - # matchLabels: - # app: litellm - -# At the time of writing, the litellm docker image requires write access to the -# filesystem on startup so that prisma can install some dependencies. -podSecurityContext: {} -securityContext: - {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: false - # runAsNonRoot: true - # runAsUser: 1000 - -# A list of Kubernetes Secret objects that will be exported to the LiteLLM proxy -# pod as environment variables. These secrets can then be referenced in the -# configuration file (or "litellm" ConfigMap) with `os.environ/` -environmentSecrets: - [] - # - litellm-env-secret - -# A list of Kubernetes ConfigMap objects that will be exported to the LiteLLM proxy -# pod as environment variables. The ConfigMap kv-pairs can then be referenced in the -# configuration file (or "litellm" ConfigMap) with `os.environ/` -environmentConfigMaps: - [] - # - litellm-env-configmap - -service: - type: ClusterIP - port: 4000 - # If service type is `LoadBalancer` you can - # optionally specify loadBalancerClass - # loadBalancerClass: tailscale - -# Probes for LiteLLM gateway container -livenessProbe: - path: /health/liveliness - initialDelaySeconds: 0 - periodSeconds: 15 - timeoutSeconds: 5 - successThreshold: 1 - failureThreshold: 5 - -readinessProbe: - path: /health/readiness - initialDelaySeconds: 0 - periodSeconds: 10 - timeoutSeconds: 5 - successThreshold: 1 - failureThreshold: 3 - -startupProbe: - path: /health/readiness - initialDelaySeconds: 0 - periodSeconds: 10 - timeoutSeconds: 5 - successThreshold: 1 - failureThreshold: 30 - -ingress: - enabled: false - className: "nginx" - labels: {} - annotations: - {} - # kubernetes.io/ingress.class: nginx - # kubernetes.io/tls-acme: "true" - hosts: - - host: api.example.local - paths: - - path: / - pathType: ImplementationSpecific - tls: [] - # - secretName: chart-example-tls - # hosts: - # - chart-example.local - -# masterkey: changeit - -# if set, use this secret for the master key; otherwise, autogenerate a new one -masterkeySecretName: "" - -# if set, use this secret key for the master key; otherwise, use the default key -masterkeySecretKey: "" - -# Optional: enterprise billable-request metering. Requires an enterprise license. -billingMetrics: - enabled: false - endpoint: https://telemetry.litellm.ai - tokenUrl: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token - secretName: litellm-billing-metrics - exportIntervalMs: "" - -proxyConfigMap: - # when true, creates a new configmap - create: true - # if create is false and name is set, use existing ConfigMap - # create: false - # name: "" - # key: "config.yaml" - -# The elements within proxy_config are rendered as config.yaml for the proxy -# Examples: https://github.com/BerriAI/litellm/tree/main/litellm/proxy/example_config_yaml -# Reference: https://docs.litellm.ai/docs/proxy/configs -proxy_config: - model_list: [] - general_settings: - master_key: os.environ/PROXY_MASTER_KEY - -# Serve Prometheus /metrics from a separate process (PROMETHEUS_METRICS_PORT) -# so a scrape never runs on an inference worker. Adds a `metrics` port to the -# container and a dedicated ClusterIP `-metrics` Service, and the -# ServiceMonitor scrapes it instead of the proxy port. The separate port has -# no virtual-key auth: keep it off public ingress. Needs the proxy image -# v1.101.0 or newer. -metricsServer: - enabled: false - port: 4001 - -# Opt-in sidecar that runs the post-response spend pipeline (cost calculation, -# spend logs, spend counters, budget reservation reconciliation) so the proxy's -# uvicorn workers only serialise a compact typed event and go back to serving -# inference. Same image and tag as the proxy, second container in the same pod, -# fed over loopback (a unix socket on a shared emptyDir, or 127.0.0.1 TCP). It -# reuses the pod's in-container pgbouncer (db.connectionPool) and the same Redis -# spend transaction buffer, so the per-pod DB connection budget is unchanged. -# Delivery is at-most-once inside the pod: events already handed to the sidecar -# are lost if it crashes before writing them; events the workers could not hand -# over follow onUnavailable. Both containers drain on SIGTERM within -# terminationGracePeriodSeconds -collector: - enabled: false - # unix:////.sock (the becomes a shared emptyDir) or tcp://127.0.0.1: - address: unix:///var/run/litellm/collector.sock - # Events each uvicorn worker holds in memory while the sidecar is slow or restarting - bufferSize: 1000 - # fallback: run the pipeline in the worker when the sidecar is unreachable or the - # buffer is full (spend stays exact, that request costs proxy CPU again) - # drop: count and discard the event instead (spend under-reports) - onUnavailable: fallback - # How long the workers keep pushing buffered events on shutdown, and how long the - # sidecar keeps serving its open connections after SIGTERM - drainTimeoutSeconds: 10 - command: - - python - - -m - - litellm.proxy.collector - # Sized independently of the proxy container; the pipeline is CPU bound - resources: {} - # requests: - # cpu: 500m - # memory: 1Gi - # limits: - # cpu: "1" - # memory: 2Gi - # When autoscaling.enabled, swap the pod-wide cpu Resource metric for an - # autoscaling/v2 ContainerResource metric on the proxy container only, so the - # sidecar's CPU never scales inference replicas. Needs Kubernetes 1.30+ (or the - # HPAContainerMetrics feature gate on 1.27 to 1.29) - scaleOnProxyContainerCpu: false - -resources: - {} - # Unset by default so the chart installs on small clusters such as Minikube, and so an - # upgrade never leaves a running pod Pending. Production deployments should set these. - # A proxy at DB-connected steady state needs about 1 CPU and 4Gi of memory per worker; - # sizing below that gets the pod OOMKilled once traffic and DB connections ramp up. - # Scale both figures with --num_workers, then uncomment the lines below and remove the - # curly braces after 'resources:'. See "Recommended Machine Specifications" in - # https://docs.litellm.ai/docs/proxy/prod. - # requests: - # cpu: "1" - # memory: 4Gi - # limits: - # cpu: "1" - # memory: 4Gi - -autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - # 60 is the documented recommendation. See "Recommended Machine Specifications" - # in https://docs.litellm.ai/docs/proxy/prod. A new replica clears the startupProbe - # above only after up to failureThreshold x periodSeconds = 300 seconds, so a target - # high enough to trip near saturation adds capacity minutes after it was needed. - targetCPUUtilizationPercentage: 60 - # Deliberately left unset rather than given a value. The prisma query engine's - # resident memory is a high-water mark that ratchets to the pod's worst-ever write - # and is never returned, so a memory target reads the largest write a pod ever did - # rather than what it is doing now, and replicas ratchet up without scaling back in. - # Memory is a floor to provision under 'resources', not a signal to scale on. - # targetMemoryUtilizationPercentage: 80 - # behavior: {} - # Opt-in per-pod workload targets, rendered as autoscaling/v2 `Pods` metrics - # named `litellm_requests_per_second` and `litellm_tokens_per_second` with an - # AverageValue target, alongside whichever resource targets are set (the HPA - # follows the metric asking for the most replicas). A Prometheus Adapter must - # serve those two names on custom.metrics.k8s.io from the proxy's counters, - # grouped by the scrape target's `pod` label (enable serviceMonitor below so - # every pod is scraped on its own): - # litellm_requests_per_second: - # sum(rate(litellm_proxy_total_requests_metric_total{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>) - # litellm_tokens_per_second: - # sum(rate(litellm_total_tokens_metric_total{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>) - # rate() over [1m] is already per second, so no `* 60`. How fast the HPA - # reacts is set by that window, the scrape interval and the HPA sync period - # (15s by default), not by the unit: keep serviceMonitor.interval at 15s or - # faster so a 1m window holds at least 4 samples. averageValue takes SI - # suffixes, so "6M" is six million tokens per second per pod. Tokens are - # counted when a response completes, so TPS trails long streams. - targetRequestsPerSecond: "" - targetTokensPerSecond: "" - -# Autoscaling with keda is mutually exclusive with hpa -keda: - enabled: false - minReplicas: 1 - maxReplicas: 100 - pollingInterval: 30 - cooldownPeriod: 300 - # fallback: - # failureThreshold: 3 - # replicas: 11 - restoreToOriginalReplicaCount: false - scaledObject: - annotations: {} - triggers: [] - # - type: prometheus - # metadata: - # serverAddress: http://:9090 - # metricName: http_requests_total - # threshold: '100' - # query: sum(rate(http_requests_total{deployment="my-deployment"}[2m])) - # First-class Prometheus triggers on the proxy's own request and token - # counters, appended to `triggers`. Each target is the per-second load one - # replica should carry: KEDA divides the release-wide - # `sum(rate([1m]))` by it to pick the replica count. Thresholds - # are plain numbers (KEDA parses them as floats, no SI suffixes). The - # queries select samples by the release namespace and the `job` label the - # chart's ServiceMonitor produces (the metrics Service name), so enable - # serviceMonitor below together with metricsServer: the http port serves - # /metrics/ behind virtual-key auth and answers an unauthenticated scrape - # with 401. Reaction time comes from the [1m] window, the scrape interval - # and pollingInterval above, so keep both at 15s or faster. Tokens are - # counted at completion, so TPS trails long streams. serverAddress is - # required once either target is set. - prometheus: - serverAddress: "" - requestsPerSecond: "" - tokensPerSecond: "" - behavior: {} - # scaleDown: - # stabilizationWindowSeconds: 300 - # policies: - # - type: Pods - # value: 1 - # periodSeconds: 180 - # scaleUp: - # stabilizationWindowSeconds: 300 - # policies: - # - type: Pods - # value: 2 - # periodSeconds: 60 - -# Additional volumes on the output Deployment definition. -volumes: [] -# - name: foo -# secret: -# secretName: mysecret -# optional: false - -# Additional volumeMounts on the output Deployment definition. -volumeMounts: [] -# - name: foo -# mountPath: "/etc/foo" -# readOnly: true - -nodeSelector: {} - -tolerations: [] - -affinity: {} - -db: - # Use an existing postgres server/cluster - useExisting: false - - # How to connect to the existing postgres server/cluster - endpoint: localhost - database: litellm - url: postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME) - # Authenticate with a short-lived token instead of a password. useIAMAuth is - # AWS RDS / Aurora IAM database authentication (the pod needs AWS credentials, - # e.g. IRSA or EKS Pod Identity, and the database user needs GRANT rds_iam); - # useAzureEntraAuth is Azure Database for PostgreSQL with a Microsoft Entra ID - # identity (workload identity). Mutually exclusive, and both need - # useExisting: true with deployStandalone: false. The chart then sets - # IAM_TOKEN_DB_AUTH or AZURE_POSTGRESQL_AUTH, emits DATABASE_HOST, DATABASE_PORT, - # DATABASE_USERNAME and DATABASE_NAME and skips DATABASE_PASSWORD and db.url: - # the proxy, the migrations job and the collector sidecar each mint their own - # token and build DATABASE_URL from those parts. Set DATABASE_SSLMODE / - # DATABASE_SSLROOTCERT under envVars for TLS options you would otherwise put - # in db.url. - useIAMAuth: false - useAzureEntraAuth: false - # Postgres port, used to build the token-auth URL (password auth takes the - # port from db.url) - port: 5432 - # Postgres schema for the LiteLLM tables, rendered as DATABASE_SCHEMA under - # token auth only (password auth carries it as ?schema= in db.url) - schema: "" - # Database user as a plain value. Empty means read it from - # db.secret.usernameKey. With token auth the user name is the only credential - # the chart needs, so setting it here removes the need for db.secret entirely. - username: "" - secret: - name: postgres - usernameKey: username - passwordKey: password - # Optional: when set, DATABASE_HOST will be sourced from this secret key instead of db.endpoint - endpointKey: "" - # Optional: when set, DATABASE_URL_READ_REPLICA will be sourced from this - # secret key instead of db.readReplicaUrl. Prefer this over the plain - # value: read-replica URLs typically embed credentials, and a value - # written to db.readReplicaUrl ends up visible in the rendered pod spec - # and the Helm release secret. - readReplicaUrlKey: "" - # Optional: when set, a DATABASE_READER_HOST env var is sourced from this - # secret key, so db.readReplicaUrl can compose the reader URL from - # individual secret components, e.g. - # postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_READER_HOST):5432/$(DATABASE_NAME) - # Use this when your secret store holds the bare reader hostname rather - # than a full connection URL. Only takes effect when readReplicaUrl is - # set; ignored when readReplicaUrlKey is set. - readReplicaEndpointKey: "" - - # Optional read-replica routing. When set, the proxy sends read-only - # queries (find_*, count, group_by, query_raw/_first) to this URL while - # writes continue to go to db.url. Useful for Aurora-style clusters with - # separate reader/writer endpoints. Leave empty to keep single-DB behavior. - # When IAM_TOKEN_DB_AUTH is enabled, the reader URL is auto-refreshed - # alongside the writer (host/port/user/db are parsed from this URL once - # at startup; only the IAM token rotates). - # - # If the URL embeds credentials, prefer db.secret.readReplicaUrlKey over - # this field — the plain value is rendered into the pod spec and the - # Helm release secret. This field is intended for credential-less URLs - # only (e.g. when IAM_TOKEN_DB_AUTH supplies the token at runtime). - readReplicaUrl: "" - - # In-container connection pool (PgBouncer, transaction mode) shared by every - # worker in the pod. Without it each --num_workers worker opens its own - # connection_limit connections to Postgres, so a pod's footprint against the - # database's connection ceiling is workers x connection_limit and grows with - # every replica. With it, the pod holds at most maxDbConnections upstream - # connections no matter how many workers run; the workers connect to the pool - # over loopback, with no extra network hop. Migrations still go straight to - # Postgres. Starting profile for numWorkers: 4 is maxDbConnections: 20, so - # a database with a 5000-connection ceiling fits roughly 200 replicas. - connectionPool: - enabled: false - maxDbConnections: 20 - maxClientConn: 1000 - - # Use the Stackgres Helm chart to deploy an instance of a Stackgres cluster. - # The Stackgres Operator must already be installed within the target - # Kubernetes cluster. - # TODO: Stackgres deployment currently unsupported - useStackgresOperator: false - - # Use the Postgres Helm chart to create a single node, stand alone postgres - # instance. See the "postgresql" top level key for additional configuration. - deployStandalone: true - -# Lifecycle hooks for the LiteLLM container -# -# Prefer the native /health/drain preStop hook over a fixed `sleep`: it marks -# the pod NotReady and blocks only until in-flight requests actually finish -# (bounded by GRACEFUL_SHUTDOWN_TIMEOUT, default 30s), instead of always -# waiting the worst-case duration. After SIGTERM the server waits up to -# GRACEFUL_SHUTDOWN_TIMEOUT again for connections still open and then cancels -# them, so set terminationGracePeriodSeconds a few seconds above twice -# GRACEFUL_SHUTDOWN_TIMEOUT to leave room for teardown before SIGKILL. -# -# /health/drain is off by default; enable it with -# general_settings.enable_drain_endpoint: true. The kubelet calls preStop -# hooks without proxy credentials, so when the health port is reachable from -# other pods (the common case) also set -# general_settings.drain_endpoint_token (or the DRAIN_ENDPOINT_TOKEN env -# var) and send the same value on the X-Drain-Token header from the hook. -# Calls missing/wrong the token get a 401 and have no side effect. -# Example: -# lifecycle: -# preStop: -# httpGet: -# path: /health/drain -# port: 4000 -# httpHeaders: -# - name: X-Drain-Token -# value: -lifecycle: {} - -# Settings for Bitnami postgresql chart (if db.deployStandalone is true, ignored -# otherwise) -# -# Bitnami retired the versioned tags under docker.io/bitnami and republished the -# archived builds under docker.io/bitnamilegacy, so the subchart's own image -# defaults no longer resolve. The repository below points at the same build the -# subchart was released with, which keeps the on-disk data directory layout -# identical for existing installs. -# -# Keep the tag pinned. docker.io/bitnami still publishes a floating `latest`, -# and starting a newer PostgreSQL major against an existing data directory -# leaves the server refusing to boot ("database files are incompatible with -# server") with no way back other than a dump taken beforehand. Crossing a major -# version is a dump-and-restore, not an image bump. The chart refuses to render -# an unpinned tag for this reason -postgresql: - architecture: standalone - image: - repository: bitnamilegacy/postgresql - tag: 16.2.0-debian-12-r6 - volumePermissions: - image: - repository: bitnamilegacy/os-shell - tag: 12-debian-12-r16 - metrics: - image: - repository: bitnamilegacy/postgres-exporter - tag: 0.15.0-debian-12-r14 - auth: - username: litellm - database: litellm - - # You should override these on the helm command line with - # `--set postgresql.auth.postgres-password=,postgresql.auth.password=` - password: NoTaGrEaTpAsSwOrD - postgres-password: NoTaGrEaTpAsSwOrD - - # A secret is created by this chart (litellm-helm) with the credentials that - # the new Postgres instance should use. - # existingSecret: "" - # secretKeys: - # userPasswordKey: password - -# Redis is the proxy's coordination store: cross-pod tpm/rpm rate limits, spend -# tracking, and the pod lock manager. Enabling this deploys the bundled Redis -# subchart, wires REDIS_HOST / REDIS_PORT / REDIS_PASSWORD into the proxy, and -# renders a `general_settings.coordination_redis` block into the proxy config. -# -# To point at an existing Redis instead, leave `enabled: false` and pass a -# secret for REDIS_HOST, REDIS_PORT, REDIS_PASSWORD or REDIS_URL; the proxy -# falls back to those env vars for coordination. Set `cache: true` in the proxy -# config only if you also want LLM response caching, which is independent of -# coordination -# -# When `redis.sentinel.enabled` is set, the coordination block is rendered with -# `sentinel_nodes` and `service_name` (from `redis.sentinel.masterSet`) instead -# of host/port, because a plain Redis client cannot talk to the sentinel port -# -# The image repositories carry the same bitnamilegacy repoint as postgresql -# above; the versioned tags the subchart ships with are gone from -# docker.io/bitnami -redis: - enabled: false - architecture: standalone - image: - repository: bitnamilegacy/redis - tag: 7.2.4-debian-12-r9 - sentinel: - image: - repository: bitnamilegacy/redis-sentinel - tag: 7.2.4-debian-12-r7 - metrics: - image: - repository: bitnamilegacy/redis-exporter - tag: 1.58.0-debian-12-r4 - volumePermissions: - image: - repository: bitnamilegacy/os-shell - tag: 12-debian-12-r16 - sysctl: - image: - repository: bitnamilegacy/os-shell - tag: 12-debian-12-r16 - kubectl: - image: - repository: bitnamilegacy/kubectl - tag: 1.29.2-debian-12-r3 - coordination: - # Set to false to keep the bundled Redis for response caching only and leave - # `general_settings.coordination_redis` out of the rendered config. A - # `coordination_redis` block you define yourself in `proxy_config` always wins - enabled: true - -# Prisma migration job settings -migrationJob: - enabled: true # Enable or disable the schema migration Job - backoffLimit: 4 # Backoff limit for Job restarts - # Wall-clock budget for the whole Job, shared across every `backoffLimit` - # retry rather than granted per attempt. Without it a migration that blocks - # on the database never fails, and when the Helm hook is enabled the release - # waits on it forever: `helm upgrade` and any GitOps controller driving it - # stop reconciling the whole chart until someone deletes the Job by hand. - # Set to null to opt out and restore the unbounded behaviour. - activeDeadlineSeconds: 1800 - # Optional service account for the migration job. - # Only used when migrationJob.hooks.helm.enabled=true and serviceAccount.create=true. - # In that case, pre-install/pre-upgrade hooks run before normal resources, so this defaults to "default". - serviceAccountName: "" - annotations: {} - jobLabels: {} # Custom labels for the Job metadata - jobAnnotations: {} # Custom annotations for the Job metadata - podLabels: {} # Custom labels for the Job pod template - command: [] # Override container command (defaults to ["python", "litellm/proxy/prisma_migration.py"]) - args: [] # Override container args - ttlSecondsAfterFinished: 120 - resources: {} - # Unset by default. This job runs the database migration and exits, so it does not - # need the steady-state headroom the proxy does; size it from your own migration - # runs rather than from the proxy figures above. - extraContainers: [] - extraInitContainers: [] - - # Hook configuration - hooks: - argocd: - enabled: true - helm: - enabled: false - -# Log level for the litellm proxy (sets LITELLM_LOG in the deployment env). -# Rendered as a direct `env:` entry, which in Kubernetes takes precedence over -# any `envFrom:` source. If you currently source LITELLM_LOG from an -# environmentSecret or environmentConfigMap, set `logLevel: ""` here to -# disable injection — otherwise this value silently overrides your secret / -# configmap entry. -# -# Setting LITELLM_LOG inside `envVars:` below also wins: the template skips -# this injection entirely when envVars already defines LITELLM_LOG. -logLevel: INFO - -# Additional environment variables to be added to the deployment as a map of key-value pairs -envVars: {} - -# USE_DDTRACE: "true" -# Additional environment variables to be added to the deployment as a list of k8s env vars -extraEnvVars: {} - -# if you want to override the container command, you can do so here -command: {} -# if you want to override the container args, you can do so here -args: {} - -# - name: EXTRA_ENV_VAR -# value: EXTRA_ENV_VAR_VALUE -# Additional Kubernetes resources to deploy with litellm -extraResources: [] - -# - apiVersion: v1 -# kind: ConfigMap -# metadata: -# name: my-extra-config -# data: -# foo: bar -# Pod Disruption Budget -pdb: - enabled: false - # Set exactly one of the following. If both are set, minAvailable takes precedence. - minAvailable: null # e.g. "50%" or 1 - maxUnavailable: null # e.g. 1 or "20%" - annotations: {} - labels: {} - -serviceMonitor: - enabled: false - labels: - {} - # test: test - annotations: - {} - # kubernetes.io/test: test - interval: 15s - scrapeTimeout: 10s - relabelings: [] - # - targetLabel: __meta_kubernetes_pod_node_name - # replacement: $1 - # action: replace - namespaceSelector: - matchNames: [] - # - test-namespace - -lens: - enabled: false - url: "" - publicUrl: "" - service: - name: "" - port: 4318 - gatewaySecret: - name: "" - key: gateway-secret - serviceTokenSecret: - name: "" - key: service-token diff --git a/helm/litellm/.helmignore b/helm/litellm/.helmignore new file mode 100644 index 00000000000..6b8d3002acd --- /dev/null +++ b/helm/litellm/.helmignore @@ -0,0 +1,20 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +.project +.idea/ +*.tmproj +.vscode/ +/tests/ +/ci/ diff --git a/helm/litellm/Chart.yaml b/helm/litellm/Chart.yaml index 341172da925..7f308a454f5 100644 --- a/helm/litellm/Chart.yaml +++ b/helm/litellm/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 name: litellm -description: LiteLLM componentized — gateway, UI backend, and UI as separate services +description: Deploys LiteLLM from one image, either componentized (gateway, UI backend, and UI as separate Deployments) or as a single monolith Deployment type: application -version: 0.1.1 -appVersion: "0.1.0" +version: 1.0.0 +appVersion: "1.104.0" annotations: org.opencontainers.image.source: "https://github.com/BerriAI/litellm" diff --git a/helm/litellm/README.md b/helm/litellm/README.md index d66bb3284bf..5ca63d90bcd 100644 --- a/helm/litellm/README.md +++ b/helm/litellm/README.md @@ -1,6 +1,51 @@ -# LiteLLM componentized chart +# litellm Helm chart -This chart deploys separate gateway, backend and UI services +Deploys [LiteLLM](https://github.com/BerriAI/litellm) from one image, `ghcr.io/berriai/litellm` (mirrored at `docker.litellm.ai/berriai/litellm`), in one of two layouts: + +- componentized (default): the `gateway` (LLM data plane, port 4000), the `backend` (management API, port 4001) and the `ui` (static dashboard, port 3000) each get their own Deployment, Service, HPA and PDB so they scale independently +- monolith: `monolith.enabled: true` renders one Deployment and Service running the full proxy (`args: [proxy, ...]`), which serves the gateway routes, the management routes and the Admin UI from a single process + +Both layouts run the same image. The image entrypoint dispatches on the first container argument (`proxy`, `gateway`, `backend`, `ui`, `migrations`, `metrics`, `collector`), so the chart only ever sets `args` and never `command` + +## Requirements + +Kubernetes 1.25+ and Helm 3.8+. Bring your own PostgreSQL (`database.writer.*`) and, optionally, Redis (`redis.*`). The proxy also needs a master key, either an existing Secret named by `masterKey.secretName` or one the chart generates with `masterKey.generate: true` and `masterKey.secretName: ""` + +## Install + +```bash +kubectl create secret generic litellm-master-key-secret --from-literal=master-key=sk-change-me +helm install litellm helm/litellm \ + --set database.writer.host=postgres.example.com \ + --set database.writer.dbname=litellm \ + --set database.writer.passwordSecret.name=litellm-db-secret +``` + +The image tag defaults to the chart's `appVersion`. Override it with `image.tag`, or pin the bytes with `image.digest`, which renders as `repository:tag@digest` + +## Monolith quickstart + +```bash +helm install litellm helm/litellm \ + --set monolith.enabled=true \ + --set masterKey.secretName="" \ + --set masterKey.generate=true \ + --set database.writer.host=postgres.example.com \ + --set database.writer.dbname=litellm \ + --set database.writer.passwordSecret.name=litellm-db-secret +kubectl port-forward svc/litellm-litellm 4000:4000 +``` + +With `monolith.enabled: true`: + +- one Deployment and one Service named `-litellm` render, running `args: [proxy, --port, 4000, --config, /app/config/config.yaml]` plus `monolith.extraArgs` +- the gateway, backend and ui Deployments, Services, HPAs, PDBs and ServiceMonitors are not rendered, whatever `gateway.enabled`, `backend.enabled` and `ui.enabled` say +- every `gateway.*` value configures the monolith pod: `config`, `numWorkers`, `resources`, probes, `securityContext`, `hpa`, `keda`, `pdb`, `metricsServer`, `collector`, `volumes`, `extraEnv`, scheduling. The monolith runs as `serviceAccounts.gateway` +- every `backend.*` and `ui.*` value is ignored +- the Ingress sends every path, built in or from `ingress.extraPaths`, to the monolith Service +- the migrations Job renders exactly as in componentized mode + +## Lens This chart does not deploy Lens. Deploy Lens from [its own chart](https://github.com/BerriAI/lens/tree/main/helm/lens), then set `lens.enabled: true`, `lens.url`, `lens.publicUrl`, `lens.gatewaySecret.name` and `lens.serviceTokenSecret.name`. The secret keys default to `gateway-secret` and `service-token`. Set `lens.service.name` when that Lens runs in this release's namespace and the chart ingress should route `/lens-ingest` to it. The old `lensWorker` values are no longer read, and enabling them fails the render @@ -22,39 +67,114 @@ backend: gatewayUrl: https://gateway.example.com ``` -## Enterprise billing metrics - -Billing metrics use Cognito bearer authentication. Create the Secret with -`client-id` and `client-secret` keys, then enable billing metrics: +## Testing ```bash -kubectl create secret generic litellm-billing-metrics --from-literal=client-id=... --from-literal=client-secret=... +helm lint helm/litellm +helm unittest -f 'tests/*.yaml' helm/litellm +helm test --logs ``` +## Migrating from the litellm-helm chart + +The `litellm-helm` chart (`oci://ghcr.io/berriai/litellm-helm`) is retired; its published packages stay available for a grace period. Its flat values described one monolith Deployment, so the equivalent install here is `monolith.enabled: true` with the values moved under `gateway.*`. The per-component `gateway.image`, `backend.image`, `ui.image` and `migrations.image` blocks of earlier `helm/litellm` versions are gone too: the chart is a major bump to `1.0.0` and every container uses the top-level `image` + +| litellm-helm value | litellm value | +|---|---| +| (implicit single Deployment) | `monolith.enabled: true` | +| `image.repository` / `image.tag` / `image.pullPolicy` | `image.repository` / `image.tag` / `image.pullPolicy` (`image.digest` is new) | +| `replicaCount` | `gateway.replicaCount` | +| `args` | `monolith.extraArgs` (appended after the chart's proxy arguments). Drop any `--port` argument and its value so the proxy keeps listening on port 4000, matching the container port and health checks. Set `gateway.service.port` to change the external Service port | +| `command` | removed: the image entrypoint dispatcher must stay in place | +| `proxy_config` | `gateway.config.proxy_config` | +| `proxyConfigMap.create: false` + `proxyConfigMap.name` | Set `gateway.config.create: false`, mount your ConfigMap with `gateway.volumes` / `gateway.volumeMounts`, and pass `--config` followed by the mounted file path in `monolith.extraArgs` | +| `masterkeySecretName` / `masterkeySecretKey` | `masterKey.secretName` / `masterKey.secretKey`. The default Secret field changes from `masterkey` to `master-key`. When reusing a Secret with the old default field, explicitly set `masterKey.secretKey: masterkey` | +| `masterkeySecretName: ""` (auto generated Secret) | `masterKey.generate: true` with `masterKey.secretName: ""` (creates a new key; copy the old key first, see below, to keep existing credentials valid) | +| `db.useExisting`, `db.endpoint`, `db.database` | `database.writer.host`, `database.writer.port`, `database.writer.dbname` | +| `db.useIAMAuth` / `db.useAzureEntraAuth` | `database.writer.useIAMAuth` / `database.writer.useAzureEntraAuth` (mutually exclusive). Both token-auth modes still require a username in the Secret referenced by `database.writer.passwordSecret.name` and `usernameKey` | +| `db.schema` | `database.writer.schema` | +| `db.username` | Store the username in the Secret referenced by `database.writer.passwordSecret.name`, under its `usernameKey`. There is no literal username value in the new chart | +| `db.secret.name` / `db.secret.usernameKey` / `db.secret.passwordKey` | `database.writer.passwordSecret.name` / `usernameKey` / `passwordKey` | +| `db.secret.endpointKey` (writer host read from a Secret) | `database.writer.passwordSecret.hostKey` with `database.writer.host` left empty | +| `db.url` (custom URL template) | `database.writer.passwordSecret.urlKey` pointing at a Secret key that holds the complete writer URL, with the other `database.writer.*` connection values left empty | +| `db.readReplicaUrl` (credential-less reader URL) | `database.reader.host`, `database.reader.port`, `database.reader.dbname`, with `database.reader.passwordSecret.*` for its credentials (or `database.reader.useIAMAuth: true`) | +| `db.secret.readReplicaEndpointKey` (reader host read from a Secret) | `database.reader.passwordSecret.hostKey` with `database.reader.host` left empty | +| `db.secret.readReplicaUrlKey` (full reader URL in a Secret) | `database.reader.passwordSecret.urlKey` with the other `database.reader.*` connection values left empty | +| `db.connectionPool.*` | `database.connectionPool.*` | +| `db.deployStandalone: true` / `postgresql.*` | removed: the chart ships no PostgreSQL, point `database.writer.*` at your own | +| `redis.enabled: true` (bundled) | removed: the chart ships no Redis, point `redis.host` at your own | +| external Redis via `envVars` | `redis.host`, `redis.port`, `redis.passwordSecret.*`, `redis.cluster` | +| `envVars` / `extraEnvVars` | `gateway.extraEnv` (list of `name` / `value` or `valueFrom` entries). The retired chart also passed these to the migration Job, so copy any it needs, such as the AWS region and credentials for IAM database auth, into `migrationJob.extraEnv` too | +| `environmentSecrets` | `gateway.envSecrets` | +| `environmentConfigMaps` | `gateway.envConfigMaps` | +| `logLevel` | `gateway.logLevel` | +| `resources` | `gateway.resources` | +| `livenessProbe` / `readinessProbe` / `startupProbe` | `gateway.livenessProbe` / `gateway.readinessProbe` / `gateway.startupProbe`, written as full Kubernetes probes: the old `path: /health/readiness` becomes `httpGet: { path: /health/readiness, port: http }`, the timing fields (`initialDelaySeconds`, `periodSeconds`, `timeoutSeconds`, `successThreshold`, `failureThreshold`) carry over unchanged | +| `securityContext` / `podSecurityContext` | `gateway.securityContext` / `gateway.podSecurityContext`. With `readOnlyRootFilesystem: true` the retired chart added `emptyDir` mounts at `/tmp`, `/.cache` and `/.npm`; this chart does not, so add them through `gateway.volumes` / `gateway.volumeMounts`. The retired chart applied both to the migration Job as well; set `migrationJob.securityContext` / `migrationJob.podSecurityContext` to keep the Job under the same policy | +| `service.*` | `gateway.service.*` | +| `ingress.className`, `ingress.annotations`, `ingress.tls` | same keys (routes to the monolith Service in monolith mode) | +| `ingress.hosts[].host` | `ingress.host`, one host per release; the built-in paths are rendered for that host, so `ingress.hosts[].paths` has no equivalent. For more than one hostname, add a second Ingress through `extraResources` or point a DNS alias at the one host | +| `autoscaling.*` | `gateway.hpa.*`. Defaults change from 100 to 10 maximum replicas and from 60% to 70% CPU utilization, with an 80% memory target now enabled. To keep the old scaling behavior, set `gateway.hpa.maxReplicas: 100`, `gateway.hpa.targetCPUUtilizationPercentage: 60` and `gateway.hpa.targetMemoryUtilizationPercentage: 0` | +| `keda.*` | `gateway.keda.*` with these renames: `minReplicas` / `maxReplicas` are `minReplicaCount` / `maxReplicaCount` (the new default maximum is 10, the old one was 100), `behavior` moves to `advanced.horizontalPodAutoscalerConfig.behavior`, `restoreToOriginalReplicaCount` moves to `advanced.restoreToOriginalReplicaCount`, and `keda.prometheus.requestsPerSecond` / `tokensPerSecond` are `gateway.keda.prometheus.targetRequestsPerSecond` / `targetTokensPerSecond`. `pollingInterval`, `cooldownPeriod`, `fallback`, `triggers` and `scaledObject.annotations` (for example KEDA's pause annotations) keep their names | +| `numWorkers` | `gateway.numWorkers` | +| `pdb.*` | `gateway.pdb.*`, except `labels` and `annotations` are no longer supported. To retain custom PDB metadata, set `gateway.pdb.enabled: false` and define the PDB through `extraResources` | +| `metricsServer.*` | `gateway.metricsServer.*` | +| `serviceMonitor.*` | `gateway.serviceMonitor.*` (`labels`, `annotations`, `interval`, `scrapeTimeout`, `relabelings`, `namespaceSelector.matchNames`); it now requires `gateway.metricsServer.enabled` | +| `collector.*` | `gateway.collector.*`, except `command` is no longer supported and `scaleOnProxyContainerCpu` becomes `scaleOnGatewayContainerCpu`. Its default changes from `false` to `true`. Set `gateway.collector.scaleOnGatewayContainerCpu: false` to keep pod-wide CPU scaling. For a custom collector command, disable the built-in sidecar with `gateway.collector.enabled: false` and define it through `gateway.extraContainers`, with its environment, volumes and volume mounts configured explicitly | +| `billingMetrics.*` | `billingMetrics.*` | +| `migrationJob.*` | `migrationJob.*` | +| `volumes` / `volumeMounts` | `gateway.volumes` / `gateway.volumeMounts`. The retired chart also mounted these in the migration Job; copy any mounts the Job needs, such as a database CA certificate, to `migrationJob.volumes` / `migrationJob.volumeMounts` too | +| `extraContainers` / `extraInitContainers` | `gateway.extraContainers` / `gateway.extraInitContainers` | +| `lifecycle` | `gateway.lifecycle` | +| `strategy` | `gateway.strategy` | +| `deploymentAnnotations` / `deploymentLabels` / `deploymentMinReadySeconds` | `gateway.deploymentAnnotations` / `gateway.deploymentLabels` / `gateway.minReadySeconds` | +| `podAnnotations` / `podLabels` | `gateway.podAnnotations` / `gateway.podLabels`. The retired chart ran `podAnnotations` through `tpl`; this chart renders them verbatim, so a copied template such as `checksum/config: {{ ... }}` stays literal text. Resolve it before install instead, e.g. `--set-string gateway.podAnnotations.checksum/config=$(kubectl get configmap my-config -o yaml \| sha256sum \| cut -d' ' -f1)`. The chart's own proxy ConfigMap already rolls the pods when `gateway.config` changes. The retired chart also put `podLabels` on the migration Job pod; copy any the Job needs, such as a network-policy selector, to `migrationJob.podLabels` | +| `nodeSelector` / `tolerations` / `affinity` / `topologySpreadConstraints` | `gateway.nodeSelector` / `gateway.tolerations` / `gateway.affinity` / `gateway.topologySpreadConstraints`. The retired chart also applied `nodeSelector`, `tolerations` and `affinity` to the migration Job; copy them to `migrationJob.nodeSelector` / `migrationJob.tolerations` / `migrationJob.affinity` too, or a Job that needs tainted or zoned nodes to reach the database stays Pending and blocks the install | +| `terminationGracePeriodSeconds` | `gateway.terminationGracePeriodSeconds` | +| `serviceAccount.*` | `serviceAccounts.gateway.*`. The retired chart also shared an existing ServiceAccount with migrations; set `migrationJob.serviceAccountName` to that existing account too when the Job needs its permissions, such as for IAM database auth | +| `lens.*` | `lens.*`. Deploy Lens from its own chart. The removed `lensWorker.*` values fail the render when enabled | +| `extraResources` | `extraResources` | +| `nameOverride: "litellm"` | `nameOverride: ""` (the chart name is already `litellm`) | +| `fullnameOverride` | `fullnameOverride`. The new default always includes the chart-name suffix, even when the release name already contains it. Set this to the existing Service name to retain its DNS name | + +A minimal migration: + ```yaml -billingMetrics: +monolith: enabled: true +masterKey: + secretName: litellm-master-key-secret +database: + writer: + host: postgres.example.com + dbname: litellm + passwordSecret: + name: litellm-db-secret + usernameKey: username + passwordKey: password +gateway: + replicaCount: 2 + config: + proxy_config: + model_list: + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + api_key: os.environ/OPENAI_API_KEY + envSecrets: + - litellm-provider-keys ``` -The chart requires an HTTPS endpoint, an HTTPS token URL, and the Secret name -when billing metrics are enabled +The monolith Service is named `-litellm`. To retain a different old Service name, set `fullnameOverride` to that existing name. The generated Secret in this chart has `helm.sh/resource-policy: keep` and is reused across upgrades of this chart. The retired chart stored its generated key under a different Secret and data key, and `helm uninstall` deletes that Secret, so copy it into a Secret you own before uninstalling the old release to keep existing credentials valid. Run the following in the old release's namespace, replacing `OLD_MASTER_KEY_SECRET` with the actual existing Secret name. For a release named `litellm` without a `fullnameOverride`, that name is `litellm-masterkey`: -## Routes to another Service - -For a shared hostname, `ingress.extraPaths` can route to a Service in the same namespace using `serviceName` and `servicePort`. The port can be a number or a named Service port. Omit `service` when using these fields - -```yaml -ingress: - enabled: true - className: alb - host: gateway.example.com - extraPaths: - - path: /moyai - pathType: Prefix - serviceName: moyai-ingress - servicePort: 8080 +```bash +set -euo pipefail +OLD_MASTER_KEY_SECRET=litellm-masterkey +old_master_key="$(kubectl get secret "$OLD_MASTER_KEY_SECRET" -o jsonpath='{.data.masterkey}' | base64 -d)" +test -n "$old_master_key" +kubectl create secret generic litellm-master-key-secret \ + --from-literal=master-key="$old_master_key" +unset old_master_key ``` -The Service and application must already support the path prefix. The chart adds the path to the existing Ingress and leaves its built-in routes in place. It does not create the target Service or rewrite requests - -For AWS ALB, configure the target Service's `alb.ingress.kubernetes.io/healthcheck-path` annotation when the application needs a different health check from LiteLLM. Existing `service` aliases (`gateway`, `backend`, `ui`) remain supported +Then set `masterKey.secretName: litellm-master-key-secret`, as in the minimal example above. Releases that set `masterkeySecretName` already point `masterKey.secretName` and `masterKey.secretKey` at that Secret diff --git a/helm/litellm/ci/postgres.yaml b/helm/litellm/ci/postgres.yaml new file mode 100644 index 00000000000..aa210e932b0 --- /dev/null +++ b/helm/litellm/ci/postgres.yaml @@ -0,0 +1,58 @@ +# Throwaway PostgreSQL for the CircleCI helm_chart_testing job. Applied with +# kubectl before `helm install`; the chart itself ships no database. +apiVersion: v1 +kind: Secret +metadata: + name: litellm-ci-postgres +type: Opaque +stringData: + username: litellm + password: litellm-ci +--- +apiVersion: v1 +kind: Service +metadata: + name: litellm-ci-postgres +spec: + selector: + app: litellm-ci-postgres + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: litellm-ci-postgres +spec: + replicas: 1 + selector: + matchLabels: + app: litellm-ci-postgres + template: + metadata: + labels: + app: litellm-ci-postgres + spec: + containers: + - name: postgres + image: postgres:16.10-alpine@sha256:029660641a0cfc575b14f336ba448fb8a75fd595d42e1fa316b9fb4378742297 + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: litellm + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: litellm-ci-postgres + key: username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: litellm-ci-postgres + key: password + readinessProbe: + exec: + command: [pg_isready, -U, litellm, -d, litellm] + periodSeconds: 2 diff --git a/helm/litellm/ci/test-values.yaml b/helm/litellm/ci/test-values.yaml new file mode 100644 index 00000000000..40c68489ed3 --- /dev/null +++ b/helm/litellm/ci/test-values.yaml @@ -0,0 +1,34 @@ +# Values for the CircleCI helm_chart_testing job: a monolith install on a +# kind cluster from the image built by the pipeline (image.repository / tag / +# pullPolicy are passed with --set), backed by the throwaway PostgreSQL in +# ci/postgres.yaml so the migrations Job and the proxy have a database. +monolith: + enabled: true + +masterKey: + secretName: "" + generate: true + +gateway: + replicaCount: 1 + hpa: + enabled: false + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + memory: 2Gi + config: + create: true + proxy_config: + general_settings: {} + +database: + writer: + host: litellm-ci-postgres + dbname: litellm + passwordSecret: + name: litellm-ci-postgres + usernameKey: username + passwordKey: password diff --git a/helm/litellm/templates/NOTES.txt b/helm/litellm/templates/NOTES.txt index 135dcb876e0..a046e4e5280 100644 --- a/helm/litellm/templates/NOTES.txt +++ b/helm/litellm/templates/NOTES.txt @@ -1,4 +1,16 @@ -LiteLLM componentized — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. +{{- if .Values.monolith.enabled }} +LiteLLM monolith: release {{ .Release.Name }} in namespace {{ .Release.Namespace }}, image {{ include "litellm.image" . }} + +One Deployment runs the full proxy (gateway routes, management routes and the Admin UI): + - proxy : Service {{ include "litellm.workload.fullname" . }} on port {{ .Values.gateway.service.port }} + +Port-forward: + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "litellm.workload.fullname" . }} {{ .Values.gateway.service.port }} + +The monolith takes its configuration from the gateway.* values (config, resources, probes, hpa, pdb, keda, +metricsServer, collector, volumes, env, scheduling). backend.* and ui.* are ignored while monolith.enabled is true +{{- else }} +LiteLLM componentized: release {{ .Release.Name }} in namespace {{ .Release.Namespace }}, image {{ include "litellm.image" . }} Components: {{- if .Values.gateway.enabled }} @@ -16,43 +28,48 @@ Port-forward examples: kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "litellm.backend.fullname" . }} {{ .Values.backend.service.port }} kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "litellm.ui.fullname" . }} {{ .Values.ui.service.port }} +Set monolith.enabled=true to run everything as one Deployment instead +{{- end }} + Reminders: - Sensitive values come from Secret references only. Before installing, set: - - masterKey.secretName (Secret with the proxy master key) + - masterKey.secretName (Secret with the proxy master key, or + masterKey.generate: true with secretName "" to let the chart create one) - database.writer.{host,port,dbname} (writer connection pieces) - database.writer.passwordSecret.{name,usernameKey,passwordKey} (Secret holding the writer DB username + password) - database.writer.passwordSecret.{hostKey,urlKey} (optional, read writer host or full URL from that Secret) - - database.writer.useIAMAuth: true (optional — chart sets IAM_TOKEN_DB_AUTH=true and + - database.writer.useIAMAuth: true (optional: chart sets IAM_TOKEN_DB_AUTH=true and omits DATABASE_PASSWORD / DATABASE_URL so the proxy mints the URL from an IAM token at startup) - database.reader.host / database.reader.passwordSecret.{hostKey,urlKey} (optional, enables read-replica routing. The Secret name is required for hostKey, urlKey, and for host with password auth) - - database.reader.useIAMAuth: true (optional, requires database.writer.useIAMAuth: true — + - database.reader.useIAMAuth: true (optional, requires database.writer.useIAMAuth: true; chart emits DATABASE_*_READ_REPLICA env vars and omits DATABASE_PASSWORD_READ_REPLICA / DATABASE_URL_READ_REPLICA so the proxy mints the reader URL from an IAM token at startup) - - redis.passwordSecret.name (optional — set when redis.host is provided and the + - redis.host (optional: the proxy's coordination Redis) + - redis.passwordSecret.name (optional: set when redis.host is provided and the cache requires auth) - redis.passwordSecret.{hostKey,urlKey} (optional, read Redis host or full URL from that Secret) (the Secret name is required when either key is set) - - redis.cluster: true (optional — chart sets REDIS_CLUSTER_NODES from + - redis.cluster: true (optional: chart sets REDIS_CLUSTER_NODES from redis.host / redis.port so the proxy's Cache() constructs a RedisClusterCache; the cluster client discovers remaining nodes from CLUSTER SLOTS) - - Per-component extras (gateway / backend / ui): - - {component}.extraEnv / envConfigMaps / envSecrets (the latter two are lists of resource names → - envFrom configMapRef / secretRef) + - Per-component extras (gateway / backend / ui; gateway.* also drives the monolith): + - {component}.extraEnv / envConfigMaps / envSecrets (the latter two are lists of resource names rendered + as envFrom configMapRef / secretRef) - {component}.logLevel (renders as LITELLM_LOG) - gateway.config.proxy_config (rendered into a ConfigMap and mounted at - /app/config/config.yaml; gateway reads it via - CONFIG_FILE_PATH) + /app/config/config.yaml; read via CONFIG_FILE_PATH) - {component}.pdb.{enabled,minAvailable,maxUnavailable} (per-component PodDisruptionBudget; disabled by - default — with hpa.minReplicas of 1, minAvailable: 1 + default: with hpa.minReplicas of 1, minAvailable: 1 would block node drains) - {component}.topologySpreadConstraints (standard k8s list, e.g. spread replicas across topology.kubernetes.io/zone) - - Enable ingress.enabled=true to dispatch / → ui, gateway data-plane prefixes → gateway, and the catch-all → backend. + - Enable ingress.enabled=true to dispatch / to ui, gateway data-plane prefixes to gateway and the catch-all to + backend; in monolith mode every path goes to the monolith Service diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index b704782f5d1..13222d8284e 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -1,11 +1,116 @@ {{/* -Common naming + label helpers shared by gateway, backend, and ui templates. +Common naming + label helpers shared by gateway, backend, ui, and monolith templates. */}} {{- define "litellm.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} {{- end -}} +{{/* +The one image reference every container in the chart uses. `repository:tag`, +or `repository:tag@digest` when image.digest is set; the tag falls back to +the chart appVersion. +*/}} +{{- define "litellm.image" -}} +{{- $tag := .Values.image.tag | default .Chart.AppVersion -}} +{{- if .Values.image.digest -}} +{{- printf "%s:%s@%s" .Values.image.repository $tag .Values.image.digest -}} +{{- else -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end -}} +{{- end -}} + +{{/* +Componentized mode renders the gateway / backend / ui Deployments only when +monolith mode is off. +*/}} +{{- define "litellm.gateway.render" -}} +{{- if and .Values.gateway.enabled (not .Values.monolith.enabled) -}}true{{- end -}} +{{- end -}} + +{{- define "litellm.backend.render" -}} +{{- if and .Values.backend.enabled (not .Values.monolith.enabled) -}}true{{- end -}} +{{- end -}} + +{{- define "litellm.ui.render" -}} +{{- if and .Values.ui.enabled (not .Values.monolith.enabled) -}}true{{- end -}} +{{- end -}} + +{{/* +The proxy Deployment (monolith mode) and the gateway Deployment +(componentized mode) are the same pod spec fed by .Values.gateway, so the +templates that serve both (HPA, KEDA, PDB, metrics Service, ServiceMonitor) +resolve their name, component label and selector through these. +*/}} +{{- define "litellm.proxy.fullname" -}} +{{- include "litellm.fullname" . -}} +{{- end -}} + +{{- define "litellm.proxy.selectorLabels" -}} +app.kubernetes.io/name: {{ include "litellm.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: proxy +{{- end -}} + +{{- define "litellm.workload.componentName" -}} +{{- if .Values.monolith.enabled -}}proxy{{- else -}}gateway{{- end -}} +{{- end -}} + +{{- define "litellm.workload.fullname" -}} +{{- if .Values.monolith.enabled -}} +{{- include "litellm.proxy.fullname" . -}} +{{- else -}} +{{- include "litellm.gateway.fullname" . -}} +{{- end -}} +{{- end -}} + +{{- define "litellm.workload.selectorLabels" -}} +{{- if .Values.monolith.enabled -}} +{{- include "litellm.proxy.selectorLabels" . -}} +{{- else -}} +{{- include "litellm.gateway.selectorLabels" . -}} +{{- end -}} +{{- end -}} + +{{- define "litellm.workload.render" -}} +{{- if or .Values.monolith.enabled .Values.gateway.enabled -}}true{{- end -}} +{{- end -}} + +{{/* +Container arguments for the proxy (monolith) container. The image entrypoint +dispatches on the first argument. +*/}} +{{- define "litellm.proxy.args" -}} +- proxy +- --port +- "4000" +{{- if .Values.gateway.config.create }} +- --config +- /app/config/config.yaml +{{- end }} +{{- with .Values.monolith.extraArgs }} +{{ toYaml . }} +{{- end }} +{{- end -}} + +{{/* +Master key Secret reference. `masterKey.secretName` when set, otherwise the +Secret the chart generates when `masterKey.generate` is true. +*/}} +{{- define "litellm.masterKey.generatedSecretName" -}} +{{- printf "%s-masterkey" (include "litellm.fullname" .) -}} +{{- end -}} + +{{- define "litellm.masterKey.secretName" -}} +{{- if .Values.masterKey.secretName -}} +{{- .Values.masterKey.secretName -}} +{{- else if .Values.masterKey.generate -}} +{{- include "litellm.masterKey.generatedSecretName" . -}} +{{- else -}} +{{- fail "masterKey.secretName is required (the chart never accepts an inline master key); set it to an existing Secret or set masterKey.generate: true" -}} +{{- end -}} +{{- end -}} + {{- define "litellm.fullname" -}} {{- if .Values.fullnameOverride -}} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} @@ -108,6 +213,7 @@ Each component (gateway, backend, ui) has its own SA config under .Values.serviceAccounts.. When `create` is true and `name` is empty the chart defaults to "-litellm-". When `create` is false the chart uses the provided name, or the namespace `default` SA. +The monolith pod runs as the gateway ServiceAccount. */}} {{- define "litellm.gateway.serviceAccountName" -}} {{- if .Values.serviceAccounts.gateway.create -}} @@ -186,6 +292,22 @@ by the controller rather than declared, so nothing there can collide. {{- toYaml .podLabels }} {{- end -}} +{{/* +LITELLM_MASTER_KEY, and PROXY_MASTER_KEY for configs copied from litellm-helm, +for the app containers only. The migrations Job runs as a +pre-install hook, before the chart's generated master key Secret exists, and +migrations/run.py never reads the key, so the Job must not reference it. +*/}} +{{- define "litellm.masterKeyEnv" -}} +{{- range list "LITELLM_MASTER_KEY" "PROXY_MASTER_KEY" }} +- name: {{ . }} + valueFrom: + secretKeyRef: + name: {{ include "litellm.masterKey.secretName" $ }} + key: {{ $.Values.masterKey.secretKey | default "master-key" }} +{{- end }} +{{- end -}} + {{/* Master-key + database + redis env block — shared by gateway, backend, and the migrations Job. @@ -229,11 +351,6 @@ IAM_TOKEN_DB_AUTH / AZURE_POSTGRESQL_AUTH toggle that only the writer sets. {{- define "litellm.serverEnv" -}} {{- $root := .root -}} {{- $component := .component -}} -- name: LITELLM_MASTER_KEY - valueFrom: - secretKeyRef: - name: {{ required "masterKey.secretName is required (the chart no longer accepts an inline master key)" $root.Values.masterKey.secretName }} - key: {{ $root.Values.masterKey.secretKey | default "master-key" }} {{- if $component.logLevel }} - name: LITELLM_LOG value: {{ $component.logLevel | quote }} @@ -276,6 +393,11 @@ IAM_TOKEN_DB_AUTH / AZURE_POSTGRESQL_AUTH toggle that only the writer sets. secretKeyRef: name: {{ required "database.writer.passwordSecret.name is required" .passwordSecret.name }} key: {{ .passwordSecret.usernameKey | default "username" }} +- name: DATABASE_USERNAME + valueFrom: + secretKeyRef: + name: {{ .passwordSecret.name }} + key: {{ .passwordSecret.usernameKey | default "username" }} - name: DATABASE_NAME value: {{ required "database.writer.dbname is required" .dbname | quote }} {{- if .schema }} @@ -473,7 +595,7 @@ harmless no-op for the Job and authoritative for the app pods. {{- end -}} {{/* -In-container PgBouncer env for the gateway container. Under IAM or Entra auth the pooler mints and renews the database token itself. +In-container PgBouncer env for the gateway and proxy containers. Under IAM or Entra auth the pooler mints and renews the database token itself. */}} {{- define "litellm.connectionPoolEnv" -}} {{- with .Values.database.connectionPool -}} @@ -510,7 +632,7 @@ than silently replaced by the fallback. {{- $max := $component.pdb.maxUnavailable -}} {{- $minSet := not (or (kindIs "invalid" $min) (eq (printf "%v" $min) "")) -}} {{- $maxSet := not (or (kindIs "invalid" $max) (eq (printf "%v" $max) "")) -}} -{{- if and $component.enabled $component.pdb $component.pdb.enabled }} +{{- if and .enabled $component.pdb $component.pdb.enabled }} apiVersion: policy/v1 kind: PodDisruptionBudget metadata: @@ -568,6 +690,16 @@ ImplementationSpecific {{- end -}} {{- end -}} +{{/* +Service spec fields shared by the component and monolith Services. +Invoke with the component's `service` dict. +*/}} +{{- define "litellm.service.extras" -}} +{{- if and (eq .type "LoadBalancer") .loadBalancerClass -}} +loadBalancerClass: {{ .loadBalancerClass | quote }} +{{- end }} +{{- end -}} + {{- define "litellm.gateway.prometheusMultiprocDir" -}}/tmp/litellm_prometheus_multiproc{{- end -}} {{/* @@ -626,3 +758,27 @@ shutdown drain window. {{- end }} {{- end }} {{- end -}} +{{- define "litellm.liteadminEnv" -}} +{{- $root := .root -}} +{{- with $root.Values.liteadmin }} +{{- if .enabled }} +- name: LITELLM_ADMIN_AGENT_URL + value: {{ printf "http://%s-liteadmin:10000" (include "litellm.fullname" $root | trunc 53 | trimSuffix "-") | quote }} +- name: ADMIN_AGENT_SERVICE_TOKEN + valueFrom: + secretKeyRef: + name: {{ required "liteadmin.existingSecret is required" .existingSecret }} + key: ADMIN_AGENT_SERVICE_TOKEN +{{- $hasProxyBaseUrl := false }} +{{- range $.component.extraEnv }} +{{- if eq .name "PROXY_BASE_URL" }} +{{- $hasProxyBaseUrl = true }} +{{- end }} +{{- end }} +{{- if not $hasProxyBaseUrl }} +- name: PROXY_BASE_URL + value: {{ required "liteadmin.gatewayUrl is required" .gatewayUrl | quote }} +{{- end }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/helm/litellm/templates/_workload.tpl b/helm/litellm/templates/_workload.tpl new file mode 100644 index 00000000000..8f52cd52f2a --- /dev/null +++ b/helm/litellm/templates/_workload.tpl @@ -0,0 +1,515 @@ +{{/* +The pod that runs the LLM data plane. In componentized mode it is the gateway +Deployment (`args: [gateway, ...]`), in monolith mode the proxy Deployment +(`args: [proxy, ...]`) that also serves the management API and the Admin UI. +Both are configured by .Values.gateway; the name, component label, selector +and args come from the litellm.workload.* helpers. +*/}} +{{- define "litellm.workload.deployment" -}} +{{- $component := include "litellm.workload.componentName" . -}} +{{- $fullname := include "litellm.workload.fullname" . -}} +{{- if and .Values.gateway.hpa.enabled .Values.gateway.keda.enabled }} +{{- fail "gateway.hpa.enabled and gateway.keda.enabled are mutually exclusive: two autoscalers on one Deployment fight over the replica count, so set gateway.hpa.enabled: false when using KEDA" }} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullname }} + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ $component }} + {{- with .Values.gateway.deploymentLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gateway.deploymentAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and (not .Values.gateway.hpa.enabled) (not .Values.gateway.keda.enabled) (not (kindIs "invalid" .Values.gateway.replicaCount)) }} + replicas: {{ .Values.gateway.replicaCount }} + {{- end }} + {{- $minReady := .Values.gateway.minReadySeconds }} + {{- if not (or (kindIs "invalid" $minReady) (eq (printf "%v" $minReady) "")) }} + minReadySeconds: {{ $minReady }} + {{- end }} + {{- with .Values.gateway.strategy }} + strategy: + {{- toYaml . | nindent 4 }} + {{- end }} + selector: + matchLabels: + {{- include "litellm.workload.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- if .Values.gateway.config.create }} + checksum/config: {{ include (print $.Template.BasePath "/gateway/configmap.yaml") . | sha256sum }} + {{- end }} + {{- with .Values.gateway.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "litellm.workload.selectorLabels" . | nindent 8 }} + {{- with .Values.gateway.podLabels }} + {{- include "litellm.podLabels" (dict "podLabels" . "componentName" "gateway") | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "litellm.gateway.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccounts.gateway.automount }} + {{- with .Values.gateway.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.gateway.extraInitContainers }} + initContainers: + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} + containers: + - name: {{ $component }} + image: {{ include "litellm.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.gateway.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + args: + {{- if .Values.monolith.enabled }} + {{- include "litellm.proxy.args" . | nindent 12 }} + {{- else }} + - gateway + - --host + - 0.0.0.0 + - --port + - "4000" + {{- end }} + ports: + - name: http + containerPort: 4000 + protocol: TCP + env: + {{- include "litellm.lensConnectionEnv" . | nindent 12 }} + {{- if and (not .Values.monolith.enabled) .Values.ui.publicUrl }} + - name: LITELLM_CORS_ORIGINS + value: {{ include "litellm.ui.publicOrigin" . | quote }} + {{- end }} + {{- include "litellm.masterKeyEnv" $ | nindent 12 }} + {{- if .Values.monolith.enabled }} + {{- include "litellm.liteadminEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }} + {{- end }} + {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }} + {{- if .Values.gateway.config.create }} + - name: CONFIG_FILE_PATH + value: /app/config/config.yaml + {{- end }} + {{- if .Values.gateway.numWorkers }} + - name: NUM_WORKERS + value: {{ .Values.gateway.numWorkers | quote }} + {{- end }} + {{- if .Values.database.connectionPool.enabled }} + {{- include "litellm.connectionPoolEnv" $ | nindent 12 }} + {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsEnv" . | nindent 12 }} + {{- end }} + {{- if .Values.gateway.metricsServer.enabled }} + {{- if eq (int .Values.gateway.metricsServer.port) 4000 }} + {{- fail (printf "gateway.metricsServer.port must differ from the %s port 4000" $component) }} + {{- end }} + - name: PROMETHEUS_MULTIPROC_DIR + value: {{ include "litellm.gateway.prometheusMultiprocDir" . }} + {{- end }} + {{- if .Values.gateway.collector.enabled }} + {{- include "litellm.gateway.collectorEnv" . | nindent 12 }} + {{- end }} + {{- include "litellm.envFrom" .Values.gateway | nindent 10 }} + {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }} + volumeMounts: + {{- if .Values.gateway.config.create }} + - name: gateway-config + mountPath: /app/config/config.yaml + subPath: config.yaml + {{- end }} + {{- if .Values.gateway.metricsServer.enabled }} + - name: prometheus-multiproc + mountPath: {{ include "litellm.gateway.prometheusMultiprocDir" . }} + {{- end }} + {{- if include "litellm.gateway.collectorSocketDir" . }} + - name: collector-socket + mountPath: {{ include "litellm.gateway.collectorSocketDir" . }} + {{- end }} + {{- with .Values.gateway.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + {{- with .Values.gateway.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.gateway.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.gateway.startupProbe }} + startupProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.gateway.lifecycle }} + lifecycle: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.gateway.resources | nindent 12 }} + {{- if .Values.gateway.metricsServer.enabled }} + - name: metrics + image: {{ include "litellm.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.gateway.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + args: + - metrics + - --port + - {{ .Values.gateway.metricsServer.port | quote }} + env: + - name: PROMETHEUS_MULTIPROC_DIR + value: {{ include "litellm.gateway.prometheusMultiprocDir" . }} + ports: + - name: metrics + containerPort: {{ .Values.gateway.metricsServer.port }} + protocol: TCP + volumeMounts: + - name: prometheus-multiproc + mountPath: {{ include "litellm.gateway.prometheusMultiprocDir" . }} + readinessProbe: + tcpSocket: { port: metrics } + periodSeconds: 10 + livenessProbe: + tcpSocket: { port: metrics } + periodSeconds: 15 + failureThreshold: 6 + resources: + {{- toYaml .Values.gateway.metricsServer.resources | nindent 12 }} + {{- end }} + {{- if .Values.gateway.collector.enabled }} + - name: collector + image: {{ include "litellm.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.gateway.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + args: + - collector + env: + {{- include "litellm.masterKeyEnv" $ | nindent 12 }} + {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }} + {{- if .Values.gateway.config.create }} + - name: CONFIG_FILE_PATH + value: /app/config/config.yaml + {{- end }} + {{- if .Values.database.connectionPool.enabled }} + {{- include "litellm.connectionPoolEnv" $ | nindent 12 }} + {{- end }} + {{- include "litellm.gateway.collectorEnv" . | nindent 12 }} + - name: LITELLM_JOB_ROLE + value: collector + {{- include "litellm.envFrom" .Values.gateway | nindent 10 }} + {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts (include "litellm.gateway.collectorSocketDir" .) }} + volumeMounts: + {{- if .Values.gateway.config.create }} + - name: gateway-config + mountPath: /app/config/config.yaml + subPath: config.yaml + {{- end }} + {{- if include "litellm.gateway.collectorSocketDir" . }} + - name: collector-socket + mountPath: {{ include "litellm.gateway.collectorSocketDir" . }} + {{- end }} + {{- with .Values.gateway.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + resources: + {{- toYaml .Values.gateway.collector.resources | nindent 12 }} + {{- end }} + {{- with .Values.gateway.extraContainers }} + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} + {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }} + volumes: + {{- if .Values.gateway.config.create }} + - name: gateway-config + configMap: + name: {{ include "litellm.workload.fullname" . }}-config + {{- end }} + {{- if .Values.gateway.metricsServer.enabled }} + - name: prometheus-multiproc + emptyDir: {} + {{- end }} + {{- if include "litellm.gateway.collectorSocketDir" . }} + - name: collector-socket + emptyDir: + sizeLimit: 1Mi + {{- end }} + {{- with .Values.gateway.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + {{- with .Values.gateway.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.gateway.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.gateway.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.gateway.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- $gracePeriod := .Values.gateway.terminationGracePeriodSeconds }} + {{- if not (or (kindIs "invalid" $gracePeriod) (eq (printf "%v" $gracePeriod) "")) }} + terminationGracePeriodSeconds: {{ $gracePeriod }} + {{- end }} +{{- end -}} + +{{- define "litellm.workload.service" -}} +{{- $component := include "litellm.workload.componentName" . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "litellm.workload.fullname" . }} + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ $component }} + {{- with .Values.gateway.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.gateway.service.type }} + {{- with include "litellm.service.extras" .Values.gateway.service }} + {{- . | nindent 2 }} + {{- end }} + ports: + - port: {{ .Values.gateway.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "litellm.workload.selectorLabels" . | nindent 4 }} +{{- end -}} + +{{- define "litellm.workload.hpa" -}} +{{- $component := include "litellm.workload.componentName" . -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "litellm.workload.fullname" . }} + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ $component }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "litellm.workload.fullname" . }} + minReplicas: {{ .Values.gateway.hpa.minReplicas }} + maxReplicas: {{ .Values.gateway.hpa.maxReplicas }} + metrics: + {{- if .Values.gateway.hpa.targetCPUUtilizationPercentage }} + {{- if and .Values.gateway.collector.enabled .Values.gateway.collector.scaleOnGatewayContainerCpu }} + - type: ContainerResource + containerResource: + name: cpu + container: {{ $component }} + target: + type: Utilization + averageUtilization: {{ .Values.gateway.hpa.targetCPUUtilizationPercentage }} + {{- else }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.gateway.hpa.targetCPUUtilizationPercentage }} + {{- end }} + {{- end }} + {{- if .Values.gateway.hpa.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.gateway.hpa.targetMemoryUtilizationPercentage }} + {{- end }} + {{- with .Values.gateway.hpa.targetRequestsPerSecond }} + - type: Pods + pods: + metric: + name: litellm_requests_per_second + target: + type: AverageValue + averageValue: {{ toJson . | trimAll "\"" | quote }} + {{- end }} + {{- with .Values.gateway.hpa.targetTokensPerSecond }} + - type: Pods + pods: + metric: + name: litellm_tokens_per_second + target: + type: AverageValue + averageValue: {{ toJson . | trimAll "\"" | quote }} + {{- end }} + {{- with .Values.gateway.hpa.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end -}} + +{{/* +KEDA ScaledObject targeting the workload Deployment. The optional Prometheus +triggers query the same per pod counters the HPA workload metrics use, +scoped to this release's metrics Service through the `job` label the +ServiceMonitor gives every scrape. +*/}} +{{- define "litellm.workload.keda" -}} +{{- $component := include "litellm.workload.componentName" . -}} +{{- $keda := .Values.gateway.keda -}} +{{- $prom := $keda.prometheus -}} +{{- $wantsPrometheus := or $prom.targetRequestsPerSecond $prom.targetTokensPerSecond -}} +{{- if and $wantsPrometheus (not $prom.serverAddress) }} +{{- fail "gateway.keda.prometheus.serverAddress is required when gateway.keda.prometheus.targetRequestsPerSecond or targetTokensPerSecond is set" }} +{{- end }} +{{- if and $wantsPrometheus (not .Values.gateway.metricsServer.enabled) }} +{{- fail "gateway.metricsServer.enabled is required when gateway.keda.prometheus.targetRequestsPerSecond or targetTokensPerSecond is set" }} +{{- end }} +{{- $selector := printf "namespace=%q,job=%q" .Release.Namespace (printf "%s-metrics" (include "litellm.workload.fullname" .)) -}} +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: {{ include "litellm.workload.fullname" . }} + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ $component }} + {{- with (dig "scaledObject" "annotations" (dict) $keda) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "litellm.workload.fullname" . }} + minReplicaCount: {{ $keda.minReplicaCount }} + maxReplicaCount: {{ $keda.maxReplicaCount }} + pollingInterval: {{ $keda.pollingInterval }} + cooldownPeriod: {{ $keda.cooldownPeriod }} + {{- with $keda.advanced }} + advanced: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $keda.fallback }} + {{- $fallback := deepCopy . }} + {{- if not (hasKey $fallback "failureThreshold") }} + {{- $_ := set $fallback "failureThreshold" 3 }} + {{- end }} + {{- if not (hasKey $fallback "replicas") }} + {{- $_ := set $fallback "replicas" $keda.maxReplicaCount }} + {{- end }} + fallback: + {{- toYaml $fallback | nindent 4 }} + {{- end }} + triggers: + {{- with $keda.triggers }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $prom.targetRequestsPerSecond }} + - type: prometheus + metricType: AverageValue + metadata: + serverAddress: {{ $prom.serverAddress | quote }} + query: {{ printf "sum(rate(litellm_proxy_total_requests_metric_total{%s}[1m]))" $selector | quote }} + threshold: {{ toJson . | trimAll "\"" | quote }} + {{- end }} + {{- with $prom.targetTokensPerSecond }} + - type: prometheus + metricType: AverageValue + metadata: + serverAddress: {{ $prom.serverAddress | quote }} + query: {{ printf "sum(rate(litellm_total_tokens_metric_total{%s}[1m]))" $selector | quote }} + threshold: {{ toJson . | trimAll "\"" | quote }} + {{- end }} +{{- end -}} + +{{- define "litellm.workload.serviceMetrics" -}} +{{- $component := include "litellm.workload.componentName" . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "litellm.workload.fullname" . }}-metrics + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ $component }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.gateway.metricsServer.port }} + targetPort: metrics + protocol: TCP + name: metrics + selector: + {{- include "litellm.workload.selectorLabels" . | nindent 4 }} +{{- end -}} + +{{- define "litellm.workload.serviceMonitor" -}} +{{- $component := include "litellm.workload.componentName" . -}} +{{- if not .Values.gateway.metricsServer.enabled }} +{{- fail "gateway.serviceMonitor.enabled requires gateway.metricsServer.enabled: the http port serves /metrics/ behind virtual-key auth, so an unauthenticated scrape gets 401" }} +{{- end }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "litellm.workload.fullname" . }} + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ $component }} + {{- with .Values.gateway.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gateway.serviceMonitor.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "litellm.workload.selectorLabels" . | nindent 6 }} + namespaceSelector: + matchNames: + {{- with .Values.gateway.serviceMonitor.namespaceSelector.matchNames }} + {{- toYaml . | nindent 6 }} + {{- else }} + - {{ .Release.Namespace | quote }} + {{- end }} + endpoints: + - port: metrics + path: /metrics/ + interval: {{ .Values.gateway.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.gateway.serviceMonitor.scrapeTimeout }} + scheme: http + {{- with .Values.gateway.serviceMonitor.relabelings }} + relabelings: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end -}} diff --git a/helm/litellm/templates/backend/deployment.yaml b/helm/litellm/templates/backend/deployment.yaml index 7db14ad87a5..9118c6f3d88 100644 --- a/helm/litellm/templates/backend/deployment.yaml +++ b/helm/litellm/templates/backend/deployment.yaml @@ -1,4 +1,4 @@ -{{- if .Values.backend.enabled }} +{{- if include "litellm.backend.render" . }} {{- $componentHosts := .Values.ingress.componentHosts | default dict -}} {{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}} apiVersion: apps/v1 @@ -8,10 +8,21 @@ metadata: labels: {{- include "litellm.commonLabels" . | nindent 4 }} app.kubernetes.io/component: backend + {{- with .Values.backend.deploymentLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.backend.deploymentAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} spec: {{- if and (not .Values.backend.hpa.enabled) (not (kindIs "invalid" .Values.backend.replicaCount)) }} replicas: {{ .Values.backend.replicaCount }} {{- end }} + {{- $minReady := .Values.backend.minReadySeconds }} + {{- if not (or (kindIs "invalid" $minReady) (eq (printf "%v" $minReady) "")) }} + minReadySeconds: {{ $minReady }} + {{- end }} {{- with .Values.backend.strategy }} strategy: {{- toYaml . | nindent 4 }} @@ -46,14 +57,24 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.backend.extraInitContainers }} + initContainers: + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} containers: - name: backend - image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + image: {{ include "litellm.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} {{- with .Values.backend.securityContext }} securityContext: {{- toYaml . | nindent 12 }} {{- end }} + args: + - backend + - --host + - 0.0.0.0 + - --port + - "4001" ports: - name: http containerPort: 4001 @@ -66,6 +87,8 @@ spec: - name: LITELLM_CORS_ALLOW_CREDENTIALS value: "true" {{- end }} + {{- include "litellm.masterKeyEnv" $ | nindent 12 }} + {{- include "litellm.liteadminEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }} {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }} {{- if .Values.backend.gatewayUrl }} - name: LITELLM_GATEWAY_URL @@ -116,7 +139,7 @@ spec: {{- if .Values.gateway.config.create }} - name: gateway-config configMap: - name: {{ include "litellm.gateway.fullname" . }}-config + name: {{ include "litellm.workload.fullname" . }}-config {{- end }} {{- with .Values.backend.volumes }} {{- toYaml . | nindent 8 }} diff --git a/helm/litellm/templates/backend/hpa.yaml b/helm/litellm/templates/backend/hpa.yaml index a414092fb39..62efaacb830 100644 --- a/helm/litellm/templates/backend/hpa.yaml +++ b/helm/litellm/templates/backend/hpa.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.backend.enabled .Values.backend.hpa.enabled }} +{{- if and (include "litellm.backend.render" .) .Values.backend.hpa.enabled }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: diff --git a/helm/litellm/templates/backend/poddisruptionbudget.yaml b/helm/litellm/templates/backend/poddisruptionbudget.yaml index 02853ac879c..ed40bda0d7c 100644 --- a/helm/litellm/templates/backend/poddisruptionbudget.yaml +++ b/helm/litellm/templates/backend/poddisruptionbudget.yaml @@ -1,5 +1,6 @@ {{- include "litellm.pdb" (dict "root" $ + "enabled" (include "litellm.backend.render" .) "component" .Values.backend "componentName" "backend" "fullname" (include "litellm.backend.fullname" .) diff --git a/helm/litellm/templates/backend/service.yaml b/helm/litellm/templates/backend/service.yaml index d480c654784..cd11ad95ccf 100644 --- a/helm/litellm/templates/backend/service.yaml +++ b/helm/litellm/templates/backend/service.yaml @@ -1,4 +1,4 @@ -{{- if .Values.backend.enabled }} +{{- if include "litellm.backend.render" . }} apiVersion: v1 kind: Service metadata: @@ -6,8 +6,15 @@ metadata: labels: {{- include "litellm.commonLabels" . | nindent 4 }} app.kubernetes.io/component: backend + {{- with .Values.backend.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} spec: type: {{ .Values.backend.service.type }} + {{- with include "litellm.service.extras" .Values.backend.service }} + {{- . | nindent 2 }} + {{- end }} ports: - port: {{ .Values.backend.service.port }} targetPort: http diff --git a/helm/litellm/templates/extra-resources.yaml b/helm/litellm/templates/extra-resources.yaml new file mode 100644 index 00000000000..bd809ffb696 --- /dev/null +++ b/helm/litellm/templates/extra-resources.yaml @@ -0,0 +1,4 @@ +{{- range .Values.extraResources }} +--- +{{ tpl (toYaml .) $ }} +{{- end }} diff --git a/helm/litellm/templates/gateway/configmap.yaml b/helm/litellm/templates/gateway/configmap.yaml index d262bf25b87..8944aaa0a2f 100644 --- a/helm/litellm/templates/gateway/configmap.yaml +++ b/helm/litellm/templates/gateway/configmap.yaml @@ -2,7 +2,10 @@ apiVersion: v1 kind: ConfigMap metadata: - name: {{ include "litellm.gateway.fullname" . }}-config + name: {{ include "litellm.workload.fullname" . }}-config + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: {{ include "litellm.workload.componentName" . }} data: config.yaml: | {{ .Values.gateway.config.proxy_config | toYaml | indent 6 }} diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml index 987109e361e..9db08850e26 100644 --- a/helm/litellm/templates/gateway/deployment.yaml +++ b/helm/litellm/templates/gateway/deployment.yaml @@ -1,246 +1,3 @@ -{{- if .Values.gateway.enabled }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "litellm.gateway.fullname" . }} - labels: - {{- include "litellm.commonLabels" . | nindent 4 }} - app.kubernetes.io/component: gateway -spec: - {{- if and (not .Values.gateway.hpa.enabled) (not (kindIs "invalid" .Values.gateway.replicaCount)) }} - replicas: {{ .Values.gateway.replicaCount }} - {{- end }} - {{- with .Values.gateway.strategy }} - strategy: - {{- toYaml . | nindent 4 }} - {{- end }} - selector: - matchLabels: - {{- include "litellm.gateway.selectorLabels" . | nindent 6 }} - template: - metadata: - annotations: - {{- if .Values.gateway.config.create }} - checksum/config: {{ include (print $.Template.BasePath "/gateway/configmap.yaml") . | sha256sum }} - {{- end }} - {{- with .Values.gateway.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "litellm.gateway.selectorLabels" . | nindent 8 }} - {{- with .Values.gateway.podLabels }} - {{- include "litellm.podLabels" (dict "podLabels" . "componentName" "gateway") | nindent 8 }} - {{- end }} - spec: - serviceAccountName: {{ include "litellm.gateway.serviceAccountName" . }} - automountServiceAccountToken: {{ .Values.serviceAccounts.gateway.automount }} - {{- with .Values.gateway.podSecurityContext }} - securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - containers: - - name: gateway - image: "{{ .Values.gateway.image.repository }}:{{ .Values.gateway.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.gateway.image.pullPolicy }} - {{- with .Values.gateway.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - ports: - - name: http - containerPort: 4000 - protocol: TCP - env: - {{- include "litellm.lensConnectionEnv" . | nindent 12 }} - {{- if .Values.ui.publicUrl }} - - name: LITELLM_CORS_ORIGINS - value: {{ include "litellm.ui.publicOrigin" . | quote }} - {{- end }} - {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }} - {{- if .Values.gateway.config.create }} - - name: CONFIG_FILE_PATH - value: /app/config/config.yaml - {{- end }} - {{- if .Values.gateway.numWorkers }} - - name: NUM_WORKERS - value: {{ .Values.gateway.numWorkers | quote }} - {{- end }} - {{- if .Values.database.connectionPool.enabled }} - {{- include "litellm.connectionPoolEnv" $ | nindent 12 }} - {{- end }} - {{- if .Values.billingMetrics.enabled }} - {{- include "litellm.billingMetricsEnv" . | nindent 12 }} - {{- end }} - {{- if .Values.gateway.metricsServer.enabled }} - {{- if eq (int .Values.gateway.metricsServer.port) 4000 }} - {{- fail "gateway.metricsServer.port must differ from the gateway port 4000" }} - {{- end }} - - name: PROMETHEUS_MULTIPROC_DIR - value: {{ include "litellm.gateway.prometheusMultiprocDir" . }} - {{- end }} - {{- if .Values.gateway.collector.enabled }} - {{- include "litellm.gateway.collectorEnv" . | nindent 12 }} - {{- end }} - {{- include "litellm.envFrom" .Values.gateway | nindent 10 }} - {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }} - volumeMounts: - {{- if .Values.gateway.config.create }} - - name: gateway-config - mountPath: /app/config/config.yaml - subPath: config.yaml - {{- end }} - {{- if .Values.gateway.metricsServer.enabled }} - - name: prometheus-multiproc - mountPath: {{ include "litellm.gateway.prometheusMultiprocDir" . }} - {{- end }} - {{- if include "litellm.gateway.collectorSocketDir" . }} - - name: collector-socket - mountPath: {{ include "litellm.gateway.collectorSocketDir" . }} - {{- end }} - {{- with .Values.gateway.volumeMounts }} - {{- toYaml . | nindent 12 }} - {{- end }} - {{- end }} - {{- with .Values.gateway.livenessProbe }} - livenessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.gateway.readinessProbe }} - readinessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.gateway.startupProbe }} - startupProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.gateway.lifecycle }} - lifecycle: - {{- toYaml . | nindent 12 }} - {{- end }} - resources: - {{- toYaml .Values.gateway.resources | nindent 12 }} - {{- if .Values.gateway.metricsServer.enabled }} - - name: metrics - image: "{{ .Values.gateway.image.repository }}:{{ .Values.gateway.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.gateway.image.pullPolicy }} - {{- with .Values.gateway.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - command: - - python - - -m - - litellm.proxy.prometheus_metrics_server - - --port - - {{ .Values.gateway.metricsServer.port | quote }} - env: - - name: PROMETHEUS_MULTIPROC_DIR - value: {{ include "litellm.gateway.prometheusMultiprocDir" . }} - ports: - - name: metrics - containerPort: {{ .Values.gateway.metricsServer.port }} - protocol: TCP - volumeMounts: - - name: prometheus-multiproc - mountPath: {{ include "litellm.gateway.prometheusMultiprocDir" . }} - readinessProbe: - tcpSocket: { port: metrics } - periodSeconds: 10 - livenessProbe: - tcpSocket: { port: metrics } - periodSeconds: 15 - failureThreshold: 6 - resources: - {{- toYaml .Values.gateway.metricsServer.resources | nindent 12 }} - {{- end }} - {{- if .Values.gateway.collector.enabled }} - - name: collector - image: "{{ .Values.gateway.image.repository }}:{{ .Values.gateway.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.gateway.image.pullPolicy }} - {{- with .Values.gateway.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - command: - - python - - -m - - litellm.proxy.collector - env: - {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }} - {{- if .Values.gateway.config.create }} - - name: CONFIG_FILE_PATH - value: /app/config/config.yaml - {{- end }} - {{- if .Values.database.connectionPool.enabled }} - {{- include "litellm.connectionPoolEnv" $ | nindent 12 }} - {{- end }} - {{- include "litellm.gateway.collectorEnv" . | nindent 12 }} - - name: LITELLM_JOB_ROLE - value: collector - {{- include "litellm.envFrom" .Values.gateway | nindent 10 }} - {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts (include "litellm.gateway.collectorSocketDir" .) }} - volumeMounts: - {{- if .Values.gateway.config.create }} - - name: gateway-config - mountPath: /app/config/config.yaml - subPath: config.yaml - {{- end }} - {{- if include "litellm.gateway.collectorSocketDir" . }} - - name: collector-socket - mountPath: {{ include "litellm.gateway.collectorSocketDir" . }} - {{- end }} - {{- with .Values.gateway.volumeMounts }} - {{- toYaml . | nindent 12 }} - {{- end }} - {{- end }} - resources: - {{- toYaml .Values.gateway.collector.resources | nindent 12 }} - {{- end }} - {{- with .Values.gateway.extraContainers }} - {{- tpl (toYaml .) $ | nindent 8 }} - {{- end }} - {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }} - volumes: - {{- if .Values.gateway.config.create }} - - name: gateway-config - configMap: - name: {{ include "litellm.gateway.fullname" . }}-config - {{- end }} - {{- if .Values.gateway.metricsServer.enabled }} - - name: prometheus-multiproc - emptyDir: {} - {{- end }} - {{- if include "litellm.gateway.collectorSocketDir" . }} - - name: collector-socket - emptyDir: - sizeLimit: 1Mi - {{- end }} - {{- with .Values.gateway.volumes }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- end }} - {{- with .Values.gateway.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.gateway.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.gateway.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.gateway.topologySpreadConstraints }} - topologySpreadConstraints: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- $gracePeriod := .Values.gateway.terminationGracePeriodSeconds }} - {{- if not (or (kindIs "invalid" $gracePeriod) (eq (printf "%v" $gracePeriod) "")) }} - terminationGracePeriodSeconds: {{ $gracePeriod }} - {{- end }} +{{- if include "litellm.gateway.render" . }} +{{- include "litellm.workload.deployment" . }} {{- end }} diff --git a/helm/litellm/templates/gateway/hpa.yaml b/helm/litellm/templates/gateway/hpa.yaml index e7094e96106..d416a6056bd 100644 --- a/helm/litellm/templates/gateway/hpa.yaml +++ b/helm/litellm/templates/gateway/hpa.yaml @@ -1,65 +1,3 @@ -{{- if and .Values.gateway.enabled .Values.gateway.hpa.enabled }} -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "litellm.gateway.fullname" . }} - labels: - {{- include "litellm.commonLabels" . | nindent 4 }} - app.kubernetes.io/component: gateway -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "litellm.gateway.fullname" . }} - minReplicas: {{ .Values.gateway.hpa.minReplicas }} - maxReplicas: {{ .Values.gateway.hpa.maxReplicas }} - metrics: - {{- if .Values.gateway.hpa.targetCPUUtilizationPercentage }} - {{- if and .Values.gateway.collector.enabled .Values.gateway.collector.scaleOnGatewayContainerCpu }} - - type: ContainerResource - containerResource: - name: cpu - container: gateway - target: - type: Utilization - averageUtilization: {{ .Values.gateway.hpa.targetCPUUtilizationPercentage }} - {{- else }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.gateway.hpa.targetCPUUtilizationPercentage }} - {{- end }} - {{- end }} - {{- if .Values.gateway.hpa.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.gateway.hpa.targetMemoryUtilizationPercentage }} - {{- end }} - {{- with .Values.gateway.hpa.targetRequestsPerSecond }} - - type: Pods - pods: - metric: - name: litellm_requests_per_second - target: - type: AverageValue - averageValue: {{ toJson . | trimAll "\"" | quote }} - {{- end }} - {{- with .Values.gateway.hpa.targetTokensPerSecond }} - - type: Pods - pods: - metric: - name: litellm_tokens_per_second - target: - type: AverageValue - averageValue: {{ toJson . | trimAll "\"" | quote }} - {{- end }} - {{- with .Values.gateway.hpa.behavior }} - behavior: - {{- toYaml . | nindent 4 }} - {{- end }} +{{- if and (include "litellm.gateway.render" .) .Values.gateway.hpa.enabled }} +{{- include "litellm.workload.hpa" . }} {{- end }} diff --git a/helm/litellm/templates/gateway/keda.yaml b/helm/litellm/templates/gateway/keda.yaml new file mode 100644 index 00000000000..d46085ffef1 --- /dev/null +++ b/helm/litellm/templates/gateway/keda.yaml @@ -0,0 +1,3 @@ +{{- if and (include "litellm.gateway.render" .) .Values.gateway.keda.enabled }} +{{- include "litellm.workload.keda" . }} +{{- end }} diff --git a/helm/litellm/templates/gateway/poddisruptionbudget.yaml b/helm/litellm/templates/gateway/poddisruptionbudget.yaml index 15e89af17d7..313e3cc7535 100644 --- a/helm/litellm/templates/gateway/poddisruptionbudget.yaml +++ b/helm/litellm/templates/gateway/poddisruptionbudget.yaml @@ -1,5 +1,6 @@ {{- include "litellm.pdb" (dict "root" $ + "enabled" (include "litellm.gateway.render" .) "component" .Values.gateway "componentName" "gateway" "fullname" (include "litellm.gateway.fullname" .) diff --git a/helm/litellm/templates/gateway/service-metrics.yaml b/helm/litellm/templates/gateway/service-metrics.yaml index ad9bc05a9fd..e33ae8c63e3 100644 --- a/helm/litellm/templates/gateway/service-metrics.yaml +++ b/helm/litellm/templates/gateway/service-metrics.yaml @@ -1,18 +1,3 @@ -{{- if and .Values.gateway.enabled .Values.gateway.metricsServer.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ include "litellm.gateway.fullname" . }}-metrics - labels: - {{- include "litellm.commonLabels" . | nindent 4 }} - app.kubernetes.io/component: gateway -spec: - type: ClusterIP - ports: - - port: {{ .Values.gateway.metricsServer.port }} - targetPort: metrics - protocol: TCP - name: metrics - selector: - {{- include "litellm.gateway.selectorLabels" . | nindent 4 }} +{{- if and (include "litellm.gateway.render" .) .Values.gateway.metricsServer.enabled }} +{{- include "litellm.workload.serviceMetrics" . }} {{- end }} diff --git a/helm/litellm/templates/gateway/service.yaml b/helm/litellm/templates/gateway/service.yaml index 03a4167a0ab..8f3e7ad0673 100644 --- a/helm/litellm/templates/gateway/service.yaml +++ b/helm/litellm/templates/gateway/service.yaml @@ -1,18 +1,3 @@ -{{- if .Values.gateway.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ include "litellm.gateway.fullname" . }} - labels: - {{- include "litellm.commonLabels" . | nindent 4 }} - app.kubernetes.io/component: gateway -spec: - type: {{ .Values.gateway.service.type }} - ports: - - port: {{ .Values.gateway.service.port }} - targetPort: http - protocol: TCP - name: http - selector: - {{- include "litellm.gateway.selectorLabels" . | nindent 4 }} +{{- if include "litellm.gateway.render" . }} +{{- include "litellm.workload.service" . }} {{- end }} diff --git a/helm/litellm/templates/gateway/servicemonitor.yaml b/helm/litellm/templates/gateway/servicemonitor.yaml index e1bafa6e388..3e5d538b4e0 100644 --- a/helm/litellm/templates/gateway/servicemonitor.yaml +++ b/helm/litellm/templates/gateway/servicemonitor.yaml @@ -1,28 +1,3 @@ -{{- if and .Values.gateway.enabled .Values.gateway.serviceMonitor.enabled }} -{{- if not .Values.gateway.metricsServer.enabled }} -{{- fail "gateway.serviceMonitor.enabled requires gateway.metricsServer.enabled: the http port serves /metrics/ behind virtual-key auth, so an unauthenticated scrape gets 401" }} -{{- end }} -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: {{ include "litellm.gateway.fullname" . }} - labels: - {{- include "litellm.commonLabels" . | nindent 4 }} - app.kubernetes.io/component: gateway - {{- with .Values.gateway.serviceMonitor.labels }} - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - selector: - matchLabels: - {{- include "litellm.gateway.selectorLabels" . | nindent 6 }} - namespaceSelector: - matchNames: - - {{ .Release.Namespace | quote }} - endpoints: - - port: metrics - path: /metrics/ - interval: {{ .Values.gateway.serviceMonitor.interval }} - scrapeTimeout: {{ .Values.gateway.serviceMonitor.scrapeTimeout }} - scheme: http +{{- if and (include "litellm.gateway.render" .) .Values.gateway.serviceMonitor.enabled }} +{{- include "litellm.workload.serviceMonitor" . }} {{- end }} diff --git a/helm/litellm/templates/ingress.yaml b/helm/litellm/templates/ingress.yaml index 9f778e78087..3fb138aacdb 100644 --- a/helm/litellm/templates/ingress.yaml +++ b/helm/litellm/templates/ingress.yaml @@ -4,6 +4,7 @@ {{- if not (and $componentHosts.ui $componentHosts.backend $componentHosts.gateway) }} {{- fail "ingress.componentHosts.ui, backend, and gateway must all be set together" }} {{- end }} +{{- if not .Values.monolith.enabled }} {{- if not .Values.gateway.enabled }} {{- fail "ingress.componentHosts requires gateway.enabled=true" }} {{- end }} @@ -13,12 +14,14 @@ {{- if not .Values.ui.enabled }} {{- fail "ingress.componentHosts requires ui.enabled=true" }} {{- end }} +{{- end }} {{- if .Values.ingress.host }} {{- fail "ingress.host must be empty when ingress.componentHosts is configured" }} {{- end }} {{- if .Values.ingress.extraPaths }} {{- fail "ingress.extraPaths must be empty when ingress.componentHosts is configured" }} {{- end }} +{{- if not .Values.monolith.enabled }} {{- if not (trim .Values.ui.backendUrl) }} {{- fail "ui.backendUrl is required when ingress.componentHosts is configured" }} {{- end }} @@ -31,24 +34,30 @@ {{- fail "ui.publicUrl is required when ingress.componentHosts is configured" }} {{- end }} {{- end }} +{{- end }} {{- if .Values.ingress.enabled -}} +{{- $monolith := .Values.monolith.enabled -}} {{- $gatewayName := include "litellm.gateway.fullname" . -}} {{- $backendName := include "litellm.backend.fullname" . -}} {{- $uiName := include "litellm.ui.fullname" . -}} {{- $gatewayPort := .Values.gateway.service.port -}} {{- $backendPort := .Values.backend.service.port -}} {{- $uiPort := .Values.ui.service.port -}} +{{- $proxyName := include "litellm.proxy.fullname" . -}} {{- $controller := .Values.ingress.controller | default "alb" -}} {{- if not (has $controller (list "alb" "nginx")) }} {{- fail (printf "ingress.controller: unknown controller %q, expected one of alb, nginx" $controller) }} {{- end }} {{/* Backends addressable from ingress.extraPaths, keyed by the `service` field. + In monolith mode the one proxy Service serves every component, so every + key resolves to it and an entry's `service` only has to be a known name. */}} +{{- $proxyBackend := dict "name" $proxyName "port" $gatewayPort -}} {{- $extraPathBackends := dict - "gateway" (dict "name" $gatewayName "port" $gatewayPort) - "backend" (dict "name" $backendName "port" $backendPort) - "ui" (dict "name" $uiName "port" $uiPort) + "gateway" (ternary $proxyBackend (dict "name" $gatewayName "port" $gatewayPort) $monolith) + "backend" (ternary $proxyBackend (dict "name" $backendName "port" $backendPort) $monolith) + "ui" (ternary $proxyBackend (dict "name" $uiName "port" $uiPort) $monolith) -}} {{/* UI paths (Next.js static export). @@ -122,7 +131,7 @@ at "/" Prefix would swallow the whole backend management API) instead of adding to it. */}} -{{- $builtinPathKeys := list "/test|Exact" "/debug/memory/summary|Exact" "/|Prefix" -}} +{{- $builtinPathKeys := ternary (list "/|Prefix") (list "/test|Exact" "/debug/memory/summary|Exact" "/|Prefix") $monolith -}} {{- if $hasComponentHosts }} apiVersion: networking.k8s.io/v1 kind: Ingress @@ -150,9 +159,9 @@ spec: pathType: Prefix backend: service: - name: {{ $uiName }} + name: {{ $extraPathBackends.ui.name }} port: - number: {{ $uiPort }} + number: {{ $extraPathBackends.ui.port }} - host: {{ $componentHosts.backend | quote }} http: paths: @@ -160,9 +169,9 @@ spec: pathType: Prefix backend: service: - name: {{ $backendName }} + name: {{ $extraPathBackends.backend.name }} port: - number: {{ $backendPort }} + number: {{ $extraPathBackends.backend.port }} - host: {{ $componentHosts.gateway | quote }} http: paths: @@ -179,9 +188,9 @@ spec: pathType: Prefix backend: service: - name: {{ $gatewayName }} + name: {{ $extraPathBackends.gateway.name }} port: - number: {{ $gatewayPort }} + number: {{ $extraPathBackends.gateway.port }} {{- else }} apiVersion: networking.k8s.io/v1 kind: Ingress @@ -207,6 +216,7 @@ spec: {{- end }} http: paths: + {{- if not $monolith }} # --- UI (Next.js static export) --- {{- range $uiPaths }} {{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" .path "pathType" .pathType) }} @@ -249,6 +259,7 @@ spec: port: number: {{ $gatewayPort }} {{- end }} + {{- end }} {{- if and .Values.lens.enabled .Values.lens.service.name }} {{- $builtinPathKeys = append $builtinPathKeys "/lens-ingest|Prefix" }} - path: /lens-ingest @@ -324,6 +335,16 @@ spec: port: {{- toYaml $port | nindent 18 }} {{- end }} + {{- if $monolith }} + # --- Monolith: the proxy serves the UI, the data plane and the management API --- + - path: / + pathType: Prefix + backend: + service: + name: {{ $proxyName }} + port: + number: {{ $gatewayPort }} + {{- else }} # --- Catch-all → backend (management API: /key/*, /user/*, /team/*, ...) --- - path: / pathType: Prefix @@ -332,5 +353,6 @@ spec: name: {{ $backendName }} port: number: {{ $backendPort }} + {{- end }} {{- end }} {{- end }} diff --git a/helm/litellm-helm/templates/liteadmin.yaml b/helm/litellm/templates/liteadmin.yaml similarity index 96% rename from helm/litellm-helm/templates/liteadmin.yaml rename to helm/litellm/templates/liteadmin.yaml index 711edaf6913..bc5f2c9510b 100644 --- a/helm/litellm-helm/templates/liteadmin.yaml +++ b/helm/litellm/templates/liteadmin.yaml @@ -32,7 +32,7 @@ spec: runAsNonRoot: true containers: - name: liteadmin - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + image: {{ include "litellm.image" . | quote }} imagePullPolicy: {{ .Values.image.pullPolicy }} args: ["--admin-agent"] securityContext: diff --git a/helm/litellm/templates/migrations-job.yaml b/helm/litellm/templates/migrations-job.yaml index de1cc2b103b..8179c26cbd7 100644 --- a/helm/litellm/templates/migrations-job.yaml +++ b/helm/litellm/templates/migrations-job.yaml @@ -1,7 +1,7 @@ {{- if .Values.migrationJob.enabled -}} # Pre-install / pre-upgrade hook that runs `prisma migrate deploy` against -# the writer database before the gateway and backend Deployments are rolled -# out. Required because the gateway and backend both spin up Prisma at +# the writer database before the application Deployments are rolled out. +# Required because the proxy, gateway and backend all spin up Prisma at # startup and assume the LiteLLM schema (LiteLLM_Config, # LiteLLM_VerificationToken, LiteLLM_SpendLogs, ...) already exists. # @@ -16,9 +16,13 @@ metadata: labels: {{- include "litellm.commonLabels" . | nindent 4 }} app.kubernetes.io/component: migrations - {{- if or .Values.migrationJob.hooks.helm.enabled .Values.migrationJob.hooks.argocd.enabled }} + {{- with .Values.migrationJob.jobLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- $helmHook := .Values.migrationJob.hooks.helm.enabled }} + {{- if or $helmHook .Values.migrationJob.hooks.argocd.enabled .Values.migrationJob.jobAnnotations }} annotations: - {{- if .Values.migrationJob.hooks.helm.enabled }} + {{- if $helmHook }} helm.sh/hook: pre-install,pre-upgrade helm.sh/hook-delete-policy: before-hook-creation helm.sh/hook-weight: {{ .Values.migrationJob.hooks.helm.weight | default "0" | quote }} @@ -27,6 +31,9 @@ metadata: argocd.argoproj.io/hook: PreSync argocd.argoproj.io/hook-delete-policy: BeforeHookCreation {{- end }} + {{- with .Values.migrationJob.jobAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} {{- end }} spec: backoffLimit: {{ .Values.migrationJob.backoffLimit }} @@ -43,6 +50,10 @@ spec: {{- $chartLabels := merge (dict "app.kubernetes.io/component" "migrations") (fromYaml (include "litellm.commonLabels" .)) }} labels: {{- toYaml (merge (deepCopy .Values.migrationJob.podLabels) $chartLabels) | nindent 8 }} + {{- with .Values.migrationJob.annotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} spec: restartPolicy: Never serviceAccountName: {{ include "litellm.migrations.serviceAccountName" . }} @@ -55,14 +66,29 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.migrationJob.extraInitContainers }} + initContainers: + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} containers: - name: prisma-migrations - image: "{{ .Values.migrationJob.image.repository }}:{{ .Values.migrationJob.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.migrationJob.image.pullPolicy }} + image: {{ include "litellm.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} {{- with .Values.migrationJob.securityContext }} securityContext: {{- toYaml . | nindent 12 }} {{- end }} + {{- with .Values.migrationJob.command }} + command: {{ toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.migrationJob.args }} + args: {{ toYaml .Values.migrationJob.args | nindent 12 }} + {{- else if .Values.migrationJob.command }} + args: [] + {{- else }} + args: + - migrations + {{- end }} env: {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.migrationJob) | nindent 12 }} {{- with .Values.migrationJob.volumeMounts }} @@ -73,6 +99,9 @@ spec: resources: {{- toYaml . | nindent 12 }} {{- end }} + {{- with .Values.migrationJob.extraContainers }} + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} {{- with .Values.migrationJob.volumes }} volumes: {{- toYaml . | nindent 8 }} diff --git a/helm/litellm/templates/monolith/deployment.yaml b/helm/litellm/templates/monolith/deployment.yaml new file mode 100644 index 00000000000..7bad16386e1 --- /dev/null +++ b/helm/litellm/templates/monolith/deployment.yaml @@ -0,0 +1,3 @@ +{{- if .Values.monolith.enabled }} +{{- include "litellm.workload.deployment" . }} +{{- end }} diff --git a/helm/litellm/templates/monolith/hpa.yaml b/helm/litellm/templates/monolith/hpa.yaml new file mode 100644 index 00000000000..ee9180e71bb --- /dev/null +++ b/helm/litellm/templates/monolith/hpa.yaml @@ -0,0 +1,3 @@ +{{- if and .Values.monolith.enabled .Values.gateway.hpa.enabled }} +{{- include "litellm.workload.hpa" . }} +{{- end }} diff --git a/helm/litellm/templates/monolith/keda.yaml b/helm/litellm/templates/monolith/keda.yaml new file mode 100644 index 00000000000..d4054432e38 --- /dev/null +++ b/helm/litellm/templates/monolith/keda.yaml @@ -0,0 +1,3 @@ +{{- if and .Values.monolith.enabled .Values.gateway.keda.enabled }} +{{- include "litellm.workload.keda" . }} +{{- end }} diff --git a/helm/litellm/templates/monolith/poddisruptionbudget.yaml b/helm/litellm/templates/monolith/poddisruptionbudget.yaml new file mode 100644 index 00000000000..2be83e57dfc --- /dev/null +++ b/helm/litellm/templates/monolith/poddisruptionbudget.yaml @@ -0,0 +1,7 @@ +{{- include "litellm.pdb" (dict + "root" $ + "enabled" .Values.monolith.enabled + "component" .Values.gateway + "componentName" "proxy" + "fullname" (include "litellm.proxy.fullname" .) + "selectorLabels" (include "litellm.proxy.selectorLabels" .)) }} diff --git a/helm/litellm/templates/monolith/service-metrics.yaml b/helm/litellm/templates/monolith/service-metrics.yaml new file mode 100644 index 00000000000..07a68c453df --- /dev/null +++ b/helm/litellm/templates/monolith/service-metrics.yaml @@ -0,0 +1,3 @@ +{{- if and .Values.monolith.enabled .Values.gateway.metricsServer.enabled }} +{{- include "litellm.workload.serviceMetrics" . }} +{{- end }} diff --git a/helm/litellm/templates/monolith/service.yaml b/helm/litellm/templates/monolith/service.yaml new file mode 100644 index 00000000000..4f87259983a --- /dev/null +++ b/helm/litellm/templates/monolith/service.yaml @@ -0,0 +1,3 @@ +{{- if .Values.monolith.enabled }} +{{- include "litellm.workload.service" . }} +{{- end }} diff --git a/helm/litellm/templates/monolith/servicemonitor.yaml b/helm/litellm/templates/monolith/servicemonitor.yaml new file mode 100644 index 00000000000..29a51a379f2 --- /dev/null +++ b/helm/litellm/templates/monolith/servicemonitor.yaml @@ -0,0 +1,3 @@ +{{- if and .Values.monolith.enabled .Values.gateway.serviceMonitor.enabled }} +{{- include "litellm.workload.serviceMonitor" . }} +{{- end }} diff --git a/helm/litellm/templates/secret-masterkey.yaml b/helm/litellm/templates/secret-masterkey.yaml new file mode 100644 index 00000000000..3af2ee6f07d --- /dev/null +++ b/helm/litellm/templates/secret-masterkey.yaml @@ -0,0 +1,22 @@ +{{- if and .Values.masterKey.generate (not .Values.masterKey.secretName) }} +{{- $name := include "litellm.masterKey.generatedSecretName" . }} +{{- $key := .Values.masterKey.secretKey | default "master-key" }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }} +{{- $value := "" }} +{{- if and $existing (index $existing.data $key) }} +{{- $value = index $existing.data $key | b64dec }} +{{- else }} +{{- $value = printf "sk-%s" (randAlphaNum 32) }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +type: Opaque +data: + {{ $key }}: {{ $value | b64enc }} +{{- end }} diff --git a/helm/litellm/templates/serviceaccount.yaml b/helm/litellm/templates/serviceaccount.yaml index a2fc52f47c0..9b1171a3e90 100644 --- a/helm/litellm/templates/serviceaccount.yaml +++ b/helm/litellm/templates/serviceaccount.yaml @@ -1,5 +1,5 @@ {{- $prev := false -}} -{{- if .Values.serviceAccounts.gateway.create -}} +{{- if and .Values.serviceAccounts.gateway.create (include "litellm.workload.render" .) -}} {{- $prev = true }} apiVersion: v1 kind: ServiceAccount @@ -14,7 +14,7 @@ metadata: {{- end }} automountServiceAccountToken: {{ .Values.serviceAccounts.gateway.automount }} {{- end }} -{{- if .Values.serviceAccounts.backend.create }} +{{- if and .Values.serviceAccounts.backend.create (include "litellm.backend.render" .) }} {{- if $prev }} --- {{- end }} @@ -32,7 +32,7 @@ metadata: {{- end }} automountServiceAccountToken: {{ .Values.serviceAccounts.backend.automount }} {{- end }} -{{- if .Values.serviceAccounts.ui.create }} +{{- if and .Values.serviceAccounts.ui.create (include "litellm.ui.render" .) }} {{- if $prev }} --- {{- end }} diff --git a/helm/litellm/templates/tests/test-connection.yaml b/helm/litellm/templates/tests/test-connection.yaml new file mode 100644 index 00000000000..bc258c92790 --- /dev/null +++ b/helm/litellm/templates/tests/test-connection.yaml @@ -0,0 +1,53 @@ +{{- if include "litellm.workload.render" . }} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "litellm.fullname" . }}-test-connection + labels: + {{- include "litellm.commonLabels" . | nindent 4 }} + app.kubernetes.io/component: test + annotations: + helm.sh/hook: test + helm.sh/hook-delete-policy: before-hook-creation +spec: + restartPolicy: Never + {{- with .Values.gateway.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 4 }} + {{- end }} + containers: + - name: health + image: {{ include "litellm.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.gateway.securityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + env: + - name: LITELLM_READINESS_URL + value: http://{{ include "litellm.workload.fullname" . }}:{{ .Values.gateway.service.port }}/health/readiness + args: + - python + - -c + - | + import json, os, sys, time, urllib.error, urllib.request + url = os.environ["LITELLM_READINESS_URL"] + for attempt in range(1, 31): + try: + with urllib.request.urlopen(url, timeout=5) as response: + body = response.read().decode() + print(url, response.status, body) + db = json.loads(body).get("db") + if db != "connected": + print(f"readiness reports db={db!r}: the proxy is not using the configured database") + sys.exit(1) + sys.exit(0) + except (urllib.error.URLError, OSError) as error: + print(f"attempt {attempt}: {url}: {error}") + time.sleep(2) + sys.exit(1) +{{- end }} diff --git a/helm/litellm/templates/ui/deployment.yaml b/helm/litellm/templates/ui/deployment.yaml index 394eea17451..486e1dae5c8 100644 --- a/helm/litellm/templates/ui/deployment.yaml +++ b/helm/litellm/templates/ui/deployment.yaml @@ -1,6 +1,6 @@ +{{- if include "litellm.ui.render" . }} {{- $componentHosts := .Values.ingress.componentHosts | default dict -}} {{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}} -{{- if .Values.ui.enabled }} apiVersion: apps/v1 kind: Deployment metadata: @@ -8,10 +8,21 @@ metadata: labels: {{- include "litellm.commonLabels" . | nindent 4 }} app.kubernetes.io/component: ui + {{- with .Values.ui.deploymentLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.ui.deploymentAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} spec: {{- if and (not .Values.ui.hpa.enabled) (not (kindIs "invalid" .Values.ui.replicaCount)) }} replicas: {{ .Values.ui.replicaCount }} {{- end }} + {{- $minReady := .Values.ui.minReadySeconds }} + {{- if not (or (kindIs "invalid" $minReady) (eq (printf "%v" $minReady) "")) }} + minReadySeconds: {{ $minReady }} + {{- end }} {{- with .Values.ui.strategy }} strategy: {{- toYaml . | nindent 4 }} @@ -41,14 +52,20 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.ui.extraInitContainers }} + initContainers: + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} containers: - name: ui - image: "{{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.ui.image.pullPolicy }} + image: {{ include "litellm.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} {{- with .Values.ui.securityContext }} securityContext: {{- toYaml . | nindent 12 }} {{- end }} + args: + - ui ports: - name: http containerPort: 3000 diff --git a/helm/litellm/templates/ui/hpa.yaml b/helm/litellm/templates/ui/hpa.yaml index a9b0b51129e..67e17b269c7 100644 --- a/helm/litellm/templates/ui/hpa.yaml +++ b/helm/litellm/templates/ui/hpa.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.ui.enabled .Values.ui.hpa.enabled }} +{{- if and (include "litellm.ui.render" .) .Values.ui.hpa.enabled }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: diff --git a/helm/litellm/templates/ui/poddisruptionbudget.yaml b/helm/litellm/templates/ui/poddisruptionbudget.yaml index f7a3a694e9c..fa4bb44612c 100644 --- a/helm/litellm/templates/ui/poddisruptionbudget.yaml +++ b/helm/litellm/templates/ui/poddisruptionbudget.yaml @@ -1,5 +1,6 @@ {{- include "litellm.pdb" (dict "root" $ + "enabled" (include "litellm.ui.render" .) "component" .Values.ui "componentName" "ui" "fullname" (include "litellm.ui.fullname" .) diff --git a/helm/litellm/templates/ui/service.yaml b/helm/litellm/templates/ui/service.yaml index 52b539fa00c..d544d9d6148 100644 --- a/helm/litellm/templates/ui/service.yaml +++ b/helm/litellm/templates/ui/service.yaml @@ -1,4 +1,4 @@ -{{- if .Values.ui.enabled }} +{{- if include "litellm.ui.render" . }} apiVersion: v1 kind: Service metadata: @@ -6,8 +6,15 @@ metadata: labels: {{- include "litellm.commonLabels" . | nindent 4 }} app.kubernetes.io/component: ui + {{- with .Values.ui.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} spec: type: {{ .Values.ui.service.type }} + {{- with include "litellm.service.extras" .Values.ui.service }} + {{- . | nindent 2 }} + {{- end }} ports: - port: {{ .Values.ui.service.port }} targetPort: http diff --git a/helm/litellm/tests/collector_tests.yaml b/helm/litellm/tests/collector_tests.yaml index 4ef7e3c8ca4..8e9804dece8 100644 --- a/helm/litellm/tests/collector_tests.yaml +++ b/helm/litellm/tests/collector_tests.yaml @@ -34,7 +34,7 @@ tests: gateway.collector.enabled: true gateway.collector.bufferSize: 250 gateway.collector.onUnavailable: drop - gateway.image.tag: v1.102.0 + image.tag: v1.102.0 gateway.numWorkers: 4 database.connectionPool.enabled: true database.connectionPool.maxDbConnections: 8 @@ -80,14 +80,12 @@ tests: template: gateway/deployment.yaml - equal: path: spec.template.spec.containers[1].image - value: ghcr.io/berriai/litellm-gateway:v1.102.0 + value: ghcr.io/berriai/litellm:v1.102.0 template: gateway/deployment.yaml - equal: - path: spec.template.spec.containers[1].command + path: spec.template.spec.containers[1].args value: - - python - - -m - - litellm.proxy.collector + - collector template: gateway/deployment.yaml - contains: path: spec.template.spec.containers[1].env diff --git a/helm/litellm/tests/component_hosts_tests.yaml b/helm/litellm/tests/component_hosts_tests.yaml index b55b3b694a0..047f1e7006d 100644 --- a/helm/litellm/tests/component_hosts_tests.yaml +++ b/helm/litellm/tests/component_hosts_tests.yaml @@ -316,16 +316,16 @@ tests: path: spec.template.spec.containers[0].env[1].value value: "true" - equal: - path: spec.template.spec.containers[0].env[10].name + path: spec.template.spec.containers[0].env[12].name value: LITELLM_CORS_ORIGINS - equal: - path: spec.template.spec.containers[0].env[10].value + path: spec.template.spec.containers[0].env[12].value value: https://backend-override.example.com - equal: - path: spec.template.spec.containers[0].env[11].name + path: spec.template.spec.containers[0].env[13].name value: LITELLM_CORS_ALLOW_CREDENTIALS - equal: - path: spec.template.spec.containers[0].env[11].value + path: spec.template.spec.containers[0].env[13].value value: "false" - it: exposes the public UI origin to the gateway without enabling credentials @@ -389,10 +389,10 @@ tests: path: spec.template.spec.containers[0].env[0].value value: https://ui.example.com - equal: - path: spec.template.spec.containers[0].env[9].name + path: spec.template.spec.containers[0].env[11].name value: LITELLM_CORS_ORIGINS - equal: - path: spec.template.spec.containers[0].env[9].value + path: spec.template.spec.containers[0].env[11].value value: https://gateway-override.example.com --- diff --git a/helm/litellm/tests/extra_resources_tests.yaml b/helm/litellm/tests/extra_resources_tests.yaml new file mode 100644 index 00000000000..4935b3a5401 --- /dev/null +++ b/helm/litellm/tests/extra_resources_tests.yaml @@ -0,0 +1,45 @@ +suite: test extraResources +templates: + - extra-resources.yaml +values: + - ./values/required.yaml +tests: + - it: renders nothing by default + asserts: + - hasDocuments: + count: 0 + + - it: renders every entry as its own document and templates it against the release + set: + extraResources: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: "{{ .Release.Name }}-extra" + data: + chart: "{{ .Chart.Name }}" + - apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: deny-all + spec: + podSelector: {} + policyTypes: + - Ingress + asserts: + - hasDocuments: + count: 2 + - isKind: + of: ConfigMap + documentIndex: 0 + - equal: + path: metadata.name + value: RELEASE-NAME-extra + documentIndex: 0 + - equal: + path: data.chart + value: litellm + documentIndex: 0 + - isKind: + of: NetworkPolicy + documentIndex: 1 diff --git a/helm/litellm/tests/hpa_workload_metrics_tests.yaml b/helm/litellm/tests/hpa_workload_metrics_tests.yaml index a29c53c5ed9..a6c17942a11 100644 --- a/helm/litellm/tests/hpa_workload_metrics_tests.yaml +++ b/helm/litellm/tests/hpa_workload_metrics_tests.yaml @@ -201,6 +201,50 @@ tests: scrapeTimeout: 10s scheme: http + - it: carries litellm-helm annotations, relabelings and namespaceSelector onto the ServiceMonitor + template: gateway/servicemonitor.yaml + release: + namespace: llm + set: + gateway.serviceMonitor.enabled: true + gateway.metricsServer.enabled: true + gateway.serviceMonitor.annotations: + team: platform + gateway.serviceMonitor.relabelings: + - targetLabel: node + sourceLabels: [__meta_kubernetes_pod_node_name] + action: replace + gateway.serviceMonitor.namespaceSelector.matchNames: + - llm + - shared + asserts: + - equal: + path: metadata.annotations + value: + team: platform + - equal: + path: spec.endpoints[0].relabelings + value: + - targetLabel: node + sourceLabels: [__meta_kubernetes_pod_node_name] + action: replace + - equal: + path: spec.namespaceSelector.matchNames + value: + - llm + - shared + + - it: renders no ServiceMonitor annotations or relabelings by default + template: gateway/servicemonitor.yaml + set: + gateway.serviceMonitor.enabled: true + gateway.metricsServer.enabled: true + asserts: + - notExists: + path: metadata.annotations + - notExists: + path: spec.endpoints[0].relabelings + - it: honours a custom scrape interval template: gateway/servicemonitor.yaml set: diff --git a/helm/litellm/tests/ingress_monolith_tests.yaml b/helm/litellm/tests/ingress_monolith_tests.yaml new file mode 100644 index 00000000000..08f7c598260 --- /dev/null +++ b/helm/litellm/tests/ingress_monolith_tests.yaml @@ -0,0 +1,164 @@ +suite: test ingress routing in monolith mode +templates: + - ingress.yaml +values: + - ./values/required.yaml +tests: + - it: monolith mode routes all component hosts to the proxy Service + set: + monolith.enabled: true + ingress.enabled: true + gateway.enabled: false + backend.enabled: false + ui.enabled: false + ingress.componentHosts.ui: ui.example.com + ingress.componentHosts.backend: backend.example.com + ingress.componentHosts.gateway: gateway.example.com + gateway.service.port: 8080 + asserts: + - lengthEqual: + path: spec.rules + count: 3 + - equal: + path: spec.rules[0].http.paths[0].backend.service + value: + name: RELEASE-NAME-litellm + port: + number: 8080 + - equal: + path: spec.rules[1].http.paths[0].backend.service + value: + name: RELEASE-NAME-litellm + port: + number: 8080 + - equal: + path: spec.rules[2].http.paths[0].backend.service + value: + name: RELEASE-NAME-litellm + port: + number: 8080 + + - it: componentized mode still splits traffic across ui, gateway and backend Services + set: + ingress.enabled: true + asserts: + - equal: + path: spec.rules[0].http.paths[0].backend.service.name + value: RELEASE-NAME-litellm-ui + - contains: + path: spec.rules[0].http.paths + content: + path: /v1/chat + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - equal: + path: spec.rules[0].http.paths[-1].backend.service.name + value: RELEASE-NAME-litellm-backend + + - it: monolith mode routes a single catch-all to the monolith Service and nothing else + set: + monolith.enabled: true + ingress.enabled: true + asserts: + - lengthEqual: + path: spec.rules[0].http.paths + count: 1 + - equal: + path: spec.rules[0].http.paths[0] + value: + path: / + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm + port: + number: 4000 + - notMatchRegexRaw: + pattern: RELEASE-NAME-litellm-(gateway|backend|ui) + + - it: monolith mode uses the gateway service port for the catch-all + set: + monolith.enabled: true + ingress.enabled: true + gateway.service.port: 8080 + asserts: + - equal: + path: spec.rules[0].http.paths[0].backend.service.port.number + value: 8080 + + - it: monolith mode sends every extraPaths entry to the monolith Service regardless of its service field + set: + monolith.enabled: true + ingress.enabled: true + ingress.extraPaths: + - path: /watsonx + - path: /admin + service: backend + pathType: Exact + - path: /dashboard + service: ui + asserts: + - lengthEqual: + path: spec.rules[0].http.paths + count: 4 + - equal: + path: spec.rules[0].http.paths[0] + value: + path: /watsonx + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm + port: + number: 4000 + - equal: + path: spec.rules[0].http.paths[1].backend.service.name + value: RELEASE-NAME-litellm + - equal: + path: spec.rules[0].http.paths[1].pathType + value: Exact + - equal: + path: spec.rules[0].http.paths[2].backend.service.name + value: RELEASE-NAME-litellm + - equal: + path: spec.rules[0].http.paths[3].path + value: / + + - it: monolith mode keeps rejecting an extra path at the root + set: + monolith.enabled: true + ingress.enabled: true + ingress.extraPaths: + - path: / + asserts: + - failedTemplate: + errorPattern: "ingress.extraPaths\\[0\\]: path / is already routed" + + - it: monolith mode keeps the host, TLS and class settings + set: + monolith.enabled: true + ingress.enabled: true + ingress.className: nginx + ingress.controller: nginx + ingress.host: llm.example.com + ingress.tls: + - hosts: + - llm.example.com + secretName: llm-tls + asserts: + - equal: + path: spec.ingressClassName + value: nginx + - equal: + path: spec.rules[0].host + value: llm.example.com + - equal: + path: spec.tls[0].secretName + value: llm-tls + - equal: + path: spec.rules[0].http.paths[0].backend.service.name + value: RELEASE-NAME-litellm diff --git a/helm/litellm/tests/keda_tests.yaml b/helm/litellm/tests/keda_tests.yaml new file mode 100644 index 00000000000..1f48654fa73 --- /dev/null +++ b/helm/litellm/tests/keda_tests.yaml @@ -0,0 +1,266 @@ +suite: test KEDA ScaledObject +templates: + - gateway/keda.yaml + - gateway/hpa.yaml + - gateway/deployment.yaml + - gateway/configmap.yaml +values: + - ./values/required.yaml +release: + name: rel + namespace: llm +tests: + - it: renders no ScaledObject by default + template: gateway/keda.yaml + asserts: + - hasDocuments: + count: 0 + + - it: passes user triggers through and adds no prometheus triggers by default + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.triggers: + - type: cpu + metricType: Utilization + metadata: + value: "60" + templates: + - gateway/keda.yaml + - gateway/hpa.yaml + asserts: + - hasDocuments: + count: 0 + template: gateway/hpa.yaml + - isKind: + of: ScaledObject + template: gateway/keda.yaml + - equal: + path: metadata.name + value: rel-litellm-gateway + template: gateway/keda.yaml + - equal: + path: spec.scaleTargetRef + value: + apiVersion: apps/v1 + kind: Deployment + name: rel-litellm-gateway + template: gateway/keda.yaml + - equal: + path: spec.minReplicaCount + value: 1 + template: gateway/keda.yaml + - equal: + path: spec.maxReplicaCount + value: 10 + template: gateway/keda.yaml + - equal: + path: spec.triggers + value: + - type: cpu + metricType: Utilization + metadata: + value: "60" + template: gateway/keda.yaml + + - it: leaves spec.replicas off the Deployment so KEDA owns the replica count + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.replicaCount: 3 + template: gateway/deployment.yaml + asserts: + - notExists: + path: spec.replicas + + - it: scales on requests per second against the metrics Service job + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.prometheus.serverAddress: http://prometheus-operated.monitoring.svc:9090 + gateway.metricsServer.enabled: true + gateway.keda.prometheus.targetRequestsPerSecond: 90 + template: gateway/keda.yaml + asserts: + - lengthEqual: + path: spec.triggers + count: 1 + - equal: + path: spec.triggers[0] + value: + type: prometheus + metricType: AverageValue + metadata: + serverAddress: http://prometheus-operated.monitoring.svc:9090 + threshold: "90" + query: sum(rate(litellm_proxy_total_requests_metric_total{namespace="llm",job="rel-litellm-gateway-metrics"}[1m])) + + - it: scales on tokens per second on its own + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.prometheus.serverAddress: http://prom:9090 + gateway.metricsServer.enabled: true + gateway.keda.prometheus.targetTokensPerSecond: 6000000 + template: gateway/keda.yaml + asserts: + - lengthEqual: + path: spec.triggers + count: 1 + - equal: + path: spec.triggers[0].type + value: prometheus + - equal: + path: spec.triggers[0].metadata.threshold + value: "6000000" + - equal: + path: spec.triggers[0].metadata.query + value: sum(rate(litellm_total_tokens_metric_total{namespace="llm",job="rel-litellm-gateway-metrics"}[1m])) + + - it: appends the requests and tokens triggers after user triggers + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.metricsServer.enabled: true + gateway.keda.triggers: + - type: cpu + metricType: Utilization + metadata: + value: "60" + gateway.keda.prometheus.serverAddress: http://prom:9090 + gateway.keda.prometheus.targetRequestsPerSecond: 90 + gateway.keda.prometheus.targetTokensPerSecond: 6000000 + template: gateway/keda.yaml + asserts: + - lengthEqual: + path: spec.triggers + count: 3 + - equal: + path: spec.triggers[0].type + value: cpu + - equal: + path: spec.triggers[1].metadata.threshold + value: "90" + - equal: + path: spec.triggers[2].metadata.threshold + value: "6000000" + - notMatchRegexRaw: + pattern: "\\* *60|per_minute|PerMinute" + + - it: fills a partial fallback with the litellm-helm defaults + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.maxReplicaCount: 20 + gateway.keda.fallback: + replicas: 4 + template: gateway/keda.yaml + asserts: + - equal: + path: spec.fallback + value: + failureThreshold: 3 + replicas: 4 + + - it: defaults fallback replicas to maxReplicaCount and keeps an explicit zero threshold + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.maxReplicaCount: 20 + gateway.keda.fallback: + failureThreshold: 0 + template: gateway/keda.yaml + asserts: + - equal: + path: spec.fallback + value: + failureThreshold: 0 + replicas: 20 + + - it: renders advanced, fallback, polling and cooldown settings + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.minReplicaCount: 2 + gateway.keda.maxReplicaCount: 20 + gateway.keda.pollingInterval: 15 + gateway.keda.cooldownPeriod: 120 + gateway.keda.fallback: + failureThreshold: 3 + replicas: 4 + gateway.keda.advanced: + restoreToOriginalReplicaCount: true + template: gateway/keda.yaml + asserts: + - equal: + path: spec.minReplicaCount + value: 2 + - equal: + path: spec.maxReplicaCount + value: 20 + - equal: + path: spec.pollingInterval + value: 15 + - equal: + path: spec.cooldownPeriod + value: 120 + - equal: + path: spec.fallback + value: + failureThreshold: 3 + replicas: 4 + - equal: + path: spec.advanced.restoreToOriginalReplicaCount + value: true + + - it: refuses a workload target without a prometheus server address + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.prometheus.targetRequestsPerSecond: 90 + template: gateway/keda.yaml + asserts: + - failedTemplate: + errorMessage: gateway.keda.prometheus.serverAddress is required when gateway.keda.prometheus.targetRequestsPerSecond or targetTokensPerSecond is set + + - it: refuses a workload target without the metrics sidecar the trigger queries + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.prometheus.serverAddress: http://prom:9090 + gateway.keda.prometheus.targetRequestsPerSecond: 90 + template: gateway/keda.yaml + asserts: + - failedTemplate: + errorMessage: gateway.metricsServer.enabled is required when gateway.keda.prometheus.targetRequestsPerSecond or targetTokensPerSecond is set + + - it: refuses to run the HPA and KEDA against the same Deployment + set: + gateway.hpa.enabled: true + gateway.keda.enabled: true + template: gateway/deployment.yaml + asserts: + - failedTemplate: + errorPattern: gateway.hpa.enabled and gateway.keda.enabled are mutually exclusive + + - it: renders scaledObject annotations, such as the KEDA pause annotation, on the ScaledObject + template: gateway/keda.yaml + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.scaledObject.annotations: + autoscaling.keda.sh/paused-replicas: "2" + asserts: + - equal: + path: metadata.annotations + value: + autoscaling.keda.sh/paused-replicas: "2" + + - it: renders no ScaledObject annotations by default + template: gateway/keda.yaml + set: + gateway.hpa.enabled: false + gateway.keda.enabled: true + asserts: + - notExists: + path: metadata.annotations diff --git a/helm/litellm/tests/liteadmin_tests.yaml b/helm/litellm/tests/liteadmin_tests.yaml new file mode 100644 index 00000000000..429896ba9b4 --- /dev/null +++ b/helm/litellm/tests/liteadmin_tests.yaml @@ -0,0 +1,149 @@ +suite: test the LiteAdmin worker and its proxy wiring +templates: + - liteadmin.yaml + - gateway/configmap.yaml + - gateway/deployment.yaml + - backend/deployment.yaml + - monolith/deployment.yaml +values: + - ./values/required.yaml +tests: + - it: renders nothing when liteadmin is disabled + template: liteadmin.yaml + asserts: + - hasDocuments: + count: 0 + + - it: enabled renders a Deployment, Service and PVC on the shared image + set: + liteadmin.enabled: true + liteadmin.existingSecret: liteadmin-secret + liteadmin.gatewayUrl: http://gateway.example.com + liteadmin.model: gpt-4o + template: liteadmin.yaml + asserts: + - hasDocuments: + count: 3 + - isKind: + of: Deployment + documentIndex: 0 + - isKind: + of: Service + documentIndex: 1 + - isKind: + of: PersistentVolumeClaim + documentIndex: 2 + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0 + documentIndex: 0 + - equal: + path: spec.template.spec.containers[0].args + value: ["--admin-agent"] + documentIndex: 0 + + - it: refuses to render without liteadmin.existingSecret + set: + liteadmin.enabled: true + liteadmin.gatewayUrl: http://gateway.example.com + liteadmin.model: gpt-4o + templates: + - backend/deployment.yaml + template: backend/deployment.yaml + asserts: + - failedTemplate: + errorPattern: liteadmin.existingSecret is required + + - it: wires the liteadmin env onto the backend pod in componentized mode + set: + liteadmin.enabled: true + liteadmin.existingSecret: liteadmin-secret + liteadmin.gatewayUrl: http://gateway.example.com + liteadmin.model: gpt-4o + template: backend/deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_ADMIN_AGENT_URL + value: http://RELEASE-NAME-litellm-liteadmin:10000 + - contains: + path: spec.template.spec.containers[0].env + content: + name: ADMIN_AGENT_SERVICE_TOKEN + valueFrom: + secretKeyRef: + name: liteadmin-secret + key: ADMIN_AGENT_SERVICE_TOKEN + - contains: + path: spec.template.spec.containers[0].env + content: + name: PROXY_BASE_URL + value: http://gateway.example.com + + - it: leaves the liteadmin env off the gateway pod in componentized mode + set: + liteadmin.enabled: true + liteadmin.existingSecret: liteadmin-secret + liteadmin.gatewayUrl: http://gateway.example.com + liteadmin.model: gpt-4o + template: gateway/deployment.yaml + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_ADMIN_AGENT_URL + - notContains: + path: spec.template.spec.containers[0].env + content: + name: ADMIN_AGENT_SERVICE_TOKEN + + - it: wires the liteadmin env onto the monolith pod in monolith mode + set: + monolith.enabled: true + liteadmin.enabled: true + liteadmin.existingSecret: liteadmin-secret + liteadmin.gatewayUrl: http://gateway.example.com + liteadmin.model: gpt-4o + template: monolith/deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_ADMIN_AGENT_URL + value: http://RELEASE-NAME-litellm-liteadmin:10000 + - contains: + path: spec.template.spec.containers[0].env + content: + name: ADMIN_AGENT_SERVICE_TOKEN + valueFrom: + secretKeyRef: + name: liteadmin-secret + key: ADMIN_AGENT_SERVICE_TOKEN + - contains: + path: spec.template.spec.containers[0].env + content: + name: PROXY_BASE_URL + value: http://gateway.example.com + + - it: a user-supplied PROXY_BASE_URL in extraEnv wins over liteadmin.gatewayUrl + set: + liteadmin.enabled: true + liteadmin.existingSecret: liteadmin-secret + liteadmin.gatewayUrl: http://gateway.example.com + liteadmin.model: gpt-4o + backend.extraEnv: + - name: PROXY_BASE_URL + value: https://custom.example.com + template: backend/deployment.yaml + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: PROXY_BASE_URL + value: http://gateway.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: PROXY_BASE_URL + value: https://custom.example.com diff --git a/helm/litellm/tests/masterkey_secret_tests.yaml b/helm/litellm/tests/masterkey_secret_tests.yaml new file mode 100644 index 00000000000..8dd261053e4 --- /dev/null +++ b/helm/litellm/tests/masterkey_secret_tests.yaml @@ -0,0 +1,156 @@ +suite: test the generated master key Secret +templates: + - secret-masterkey.yaml + - gateway/deployment.yaml + - backend/deployment.yaml + - gateway/configmap.yaml + - monolith/deployment.yaml + - migrations-job.yaml +values: + - ./values/required.yaml +tests: + - it: renders no Secret when the operator supplies masterKey.secretName + template: secret-masterkey.yaml + asserts: + - hasDocuments: + count: 0 + + - it: refuses to render the workload without a Secret name when masterKey.generate is off + set: + masterKey.secretName: "" + template: gateway/deployment.yaml + asserts: + - failedTemplate: + errorPattern: masterKey.secretName is required + + - it: generates a Secret whose key starts with sk- (base64 c2st) and keeps it across uninstall + set: + masterKey.secretName: "" + masterKey.generate: true + template: secret-masterkey.yaml + asserts: + - isKind: + of: Secret + - equal: + path: metadata.name + value: RELEASE-NAME-litellm-masterkey + - equal: + path: metadata.annotations["helm.sh/resource-policy"] + value: keep + - matchRegex: + path: data["master-key"] + pattern: ^c2st + + - it: stores the value under masterKey.secretKey + set: + masterKey.secretName: "" + masterKey.generate: true + masterKey.secretKey: LITELLM_MASTER_KEY + template: secret-masterkey.yaml + asserts: + - matchRegex: + path: data.LITELLM_MASTER_KEY + pattern: ^c2st + + - it: reuses the master key already stored in the cluster instead of generating a new one on upgrade + set: + masterKey.secretName: "" + masterKey.generate: true + template: secret-masterkey.yaml + kubernetesProvider: + scheme: + "v1/Secret": + gvr: + version: "v1" + resource: "secrets" + namespaced: true + objects: + - kind: Secret + apiVersion: v1 + metadata: + name: RELEASE-NAME-litellm-masterkey + namespace: NAMESPACE + data: + master-key: c2stZXhpc3Rpbmcta2V5 + asserts: + - equal: + path: data["master-key"] + value: c2stZXhpc3Rpbmcta2V5 + + - it: points the app containers at the generated Secret and keeps it out of the pre-install migrations Job + set: + masterKey.secretName: "" + masterKey.generate: true + monolith.enabled: true + templates: + - monolith/deployment.yaml + - migrations-job.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_MASTER_KEY + valueFrom: + secretKeyRef: + name: RELEASE-NAME-litellm-masterkey + key: master-key + template: monolith/deployment.yaml + - equal: + path: metadata.annotations["helm.sh/hook"] + value: pre-install,pre-upgrade + template: migrations-job.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_MASTER_KEY + any: true + template: migrations-job.yaml + + - it: an explicit masterKey.secretName wins over masterKey.generate + set: + masterKey.secretName: operator-secret + masterKey.generate: true + templates: + - secret-masterkey.yaml + - gateway/deployment.yaml + asserts: + - hasDocuments: + count: 0 + template: secret-masterkey.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_MASTER_KEY + valueFrom: + secretKeyRef: + name: operator-secret + key: master-key + template: gateway/deployment.yaml + + - it: exposes the key as PROXY_MASTER_KEY too, so a proxy_config copied from litellm-helm keeps resolving + set: + monolith.enabled: true + templates: + - monolith/deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PROXY_MASTER_KEY + valueFrom: + secretKeyRef: + name: litellm-master-key-secret + key: master-key + - it: exposes PROXY_MASTER_KEY on the componentized gateway and backend + templates: + - gateway/deployment.yaml + - backend/deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PROXY_MASTER_KEY + valueFrom: + secretKeyRef: + name: litellm-master-key-secret + key: master-key diff --git a/helm/litellm/tests/metrics_server_tests.yaml b/helm/litellm/tests/metrics_server_tests.yaml index 0e7d9d9e9ee..1a48b7be671 100644 --- a/helm/litellm/tests/metrics_server_tests.yaml +++ b/helm/litellm/tests/metrics_server_tests.yaml @@ -38,7 +38,7 @@ tests: gateway.metricsServer.enabled: true gateway.metricsServer.port: 4101 gateway.service.type: LoadBalancer - gateway.image.tag: v1.101.0 + image.tag: v1.101.0 asserts: - contains: path: spec.template.spec.containers[0].env @@ -58,14 +58,12 @@ tests: template: gateway/deployment.yaml - equal: path: spec.template.spec.containers[1].image - value: ghcr.io/berriai/litellm-gateway:v1.101.0 + value: ghcr.io/berriai/litellm:v1.101.0 template: gateway/deployment.yaml - equal: - path: spec.template.spec.containers[1].command + path: spec.template.spec.containers[1].args value: - - python - - -m - - litellm.proxy.prometheus_metrics_server + - metrics - --port - "4101" template: gateway/deployment.yaml diff --git a/helm/litellm/tests/migration_job_tests.yaml b/helm/litellm/tests/migration_job_tests.yaml index b9e52ba7123..86251e8a1d2 100644 --- a/helm/litellm/tests/migration_job_tests.yaml +++ b/helm/litellm/tests/migration_job_tests.yaml @@ -91,7 +91,7 @@ tests: app.kubernetes.io/name: litellm app.kubernetes.io/instance: RELEASE-NAME app.kubernetes.io/managed-by: Helm - helm.sh/chart: litellm-0.1.1 + helm.sh/chart: litellm-1.0.0 app.kubernetes.io/component: migrations - it: renders pod-level and container-level securityContext in their own scopes @@ -254,3 +254,76 @@ tests: path: spec.template.spec.nodeSelector - isNull: path: spec.template.spec.tolerations + + - it: sets jobLabels and jobAnnotations on the Job metadata + set: + migrationJob.jobLabels: + environment: production + migrationJob.jobAnnotations: + example.com/cost-center: "1234" + asserts: + - equal: + path: metadata.labels.environment + value: production + - equal: + path: metadata.annotations['example.com/cost-center'] + value: "1234" + - equal: + path: metadata.annotations['helm.sh/hook'] + value: pre-install,pre-upgrade + + - it: renders jobAnnotations when both hooks are off + set: + migrationJob.hooks.helm.enabled: false + migrationJob.jobAnnotations: + example.com/cost-center: "1234" + asserts: + - equal: + path: metadata.annotations + value: + example.com/cost-center: "1234" + + - it: runs the image entrypoint with the migrations component by default + asserts: + - notExists: + path: spec.template.spec.containers[0].command + - equal: + path: spec.template.spec.containers[0].args + value: [migrations] + + - it: overrides the container command and args + set: + migrationJob.command: [sh] + migrationJob.args: [-c, echo migrating] + asserts: + - equal: + path: spec.template.spec.containers[0].command + value: [sh] + - equal: + path: spec.template.spec.containers[0].args + value: [-c, echo migrating] + + - it: clears the container args when only command is set + set: + migrationJob.command: [sh] + asserts: + - equal: + path: spec.template.spec.containers[0].command + value: [sh] + - equal: + path: spec.template.spec.containers[0].args + value: [] + + - it: puts migrationJob.annotations on the Job pod, as litellm-helm did + set: + migrationJob.annotations: + sidecar.istio.io/inject: "false" + asserts: + - equal: + path: spec.template.metadata.annotations + value: + sidecar.istio.io/inject: "false" + - it: renders no pod annotations by default + asserts: + - notExists: + path: spec.template.metadata.annotations diff --git a/helm/litellm/tests/monolith_tests.yaml b/helm/litellm/tests/monolith_tests.yaml new file mode 100644 index 00000000000..cb8aca608ec --- /dev/null +++ b/helm/litellm/tests/monolith_tests.yaml @@ -0,0 +1,539 @@ +suite: test monolith mode +templates: + - monolith/deployment.yaml + - monolith/service.yaml + - monolith/hpa.yaml + - monolith/keda.yaml + - monolith/poddisruptionbudget.yaml + - monolith/service-metrics.yaml + - monolith/servicemonitor.yaml + - gateway/deployment.yaml + - gateway/service.yaml + - gateway/hpa.yaml + - gateway/keda.yaml + - gateway/poddisruptionbudget.yaml + - gateway/service-metrics.yaml + - gateway/servicemonitor.yaml + - gateway/configmap.yaml + - backend/deployment.yaml + - backend/service.yaml + - backend/hpa.yaml + - backend/poddisruptionbudget.yaml + - ui/deployment.yaml + - ui/service.yaml + - ui/hpa.yaml + - ui/poddisruptionbudget.yaml + - serviceaccount.yaml + - migrations-job.yaml +values: + - ./values/required.yaml +tests: + - it: monolith ignores a leftover invalid UI public URL + set: + monolith.enabled: true + ui.publicUrl: not-an-origin + template: monolith/deployment.yaml + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_CORS_ORIGINS + any: true + + - it: renders no monolith resources by default + templates: + - monolith/deployment.yaml + - monolith/service.yaml + - monolith/hpa.yaml + - monolith/poddisruptionbudget.yaml + - monolith/service-metrics.yaml + - monolith/servicemonitor.yaml + asserts: + - hasDocuments: + count: 0 + + - it: componentized mode renders one Deployment per component + templates: + - gateway/deployment.yaml + - backend/deployment.yaml + - ui/deployment.yaml + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Deployment + + - it: monolith mode renders exactly one Deployment, running the proxy dispatcher + set: + monolith.enabled: true + templates: + - monolith/deployment.yaml + - gateway/deployment.yaml + - backend/deployment.yaml + - ui/deployment.yaml + asserts: + - hasDocuments: + count: 1 + template: monolith/deployment.yaml + - hasDocuments: + count: 0 + template: gateway/deployment.yaml + - hasDocuments: + count: 0 + template: backend/deployment.yaml + - hasDocuments: + count: 0 + template: ui/deployment.yaml + - isKind: + of: Deployment + template: monolith/deployment.yaml + - equal: + path: metadata.name + value: RELEASE-NAME-litellm + template: monolith/deployment.yaml + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: proxy + template: monolith/deployment.yaml + - equal: + path: spec.template.spec.containers[0].name + value: proxy + template: monolith/deployment.yaml + - equal: + path: spec.template.spec.containers[0].args[0] + value: proxy + template: monolith/deployment.yaml + - equal: + path: spec.template.spec.containers[0].args + value: + - proxy + - --port + - "4000" + - --config + - /app/config/config.yaml + template: monolith/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].command + template: monolith/deployment.yaml + + - it: monolith appends monolith.extraArgs after the chart's own proxy arguments + set: + monolith.enabled: true + monolith.extraArgs: + - --detailed_debug + - --run_granian + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].args + value: + - proxy + - --port + - "4000" + - --config + - /app/config/config.yaml + - --detailed_debug + - --run_granian + + - it: monolith drops the --config pair when the chart does not create the ConfigMap + set: + monolith.enabled: true + gateway.config.create: false + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].args + value: + - proxy + - --port + - "4000" + - notExists: + path: spec.template.spec.volumes + + - it: monolith mounts the proxy config ConfigMap the chart renders under the monolith name + set: + monolith.enabled: true + gateway.config.proxy_config: + model_list: + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + templates: + - monolith/deployment.yaml + - gateway/configmap.yaml + asserts: + - equal: + path: metadata.name + value: RELEASE-NAME-litellm-config + template: gateway/configmap.yaml + - contains: + path: spec.template.spec.volumes + content: + name: gateway-config + configMap: + name: RELEASE-NAME-litellm-config + template: monolith/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: CONFIG_FILE_PATH + value: /app/config/config.yaml + template: monolith/deployment.yaml + + - it: monolith mode renders exactly one Service and no component Services + set: + monolith.enabled: true + templates: + - monolith/service.yaml + - gateway/service.yaml + - backend/service.yaml + - ui/service.yaml + asserts: + - hasDocuments: + count: 1 + template: monolith/service.yaml + - hasDocuments: + count: 0 + template: gateway/service.yaml + - hasDocuments: + count: 0 + template: backend/service.yaml + - hasDocuments: + count: 0 + template: ui/service.yaml + - equal: + path: metadata.name + value: RELEASE-NAME-litellm + template: monolith/service.yaml + - equal: + path: spec.selector + value: + app.kubernetes.io/name: litellm + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: proxy + template: monolith/service.yaml + - equal: + path: spec.ports[0].port + value: 4000 + template: monolith/service.yaml + + - it: monolith Service selector matches the monolith pod labels + set: + monolith.enabled: true + templates: + - monolith/deployment.yaml + asserts: + - equal: + path: spec.selector.matchLabels + value: + app.kubernetes.io/name: litellm + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: proxy + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/component"] + value: proxy + + - it: monolith reuses gateway resources, replicas, numWorkers and probes + set: + monolith.enabled: true + gateway.replicaCount: 3 + gateway.hpa.enabled: false + gateway.numWorkers: 6 + gateway.resources: + requests: + cpu: "2" + memory: 4Gi + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.replicas + value: 3 + - equal: + path: spec.template.spec.containers[0].resources.requests.cpu + value: "2" + - contains: + path: spec.template.spec.containers[0].env + content: + name: NUM_WORKERS + value: "6" + - equal: + path: spec.template.spec.containers[0].readinessProbe.httpGet.path + value: /health/readiness + - equal: + path: spec.template.spec.containers[0].livenessProbe.httpGet.path + value: /health/liveliness + + - it: monolith takes the rollout settings a litellm-helm install carried from gateway.minReadySeconds and gateway.strategy + set: + monolith.enabled: true + gateway.minReadySeconds: 60 + gateway.strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + backend.minReadySeconds: 5 + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.minReadySeconds + value: 60 + - equal: + path: spec.strategy + value: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + + - it: monolith ignores backend and ui replica overrides + set: + monolith.enabled: true + gateway.hpa.enabled: false + gateway.replicaCount: 2 + backend.replicaCount: 9 + ui.replicaCount: 9 + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.replicas + value: 2 + + - it: monolith HPA targets the monolith Deployment and the gateway HPA is not rendered + set: + monolith.enabled: true + gateway.hpa.enabled: true + templates: + - monolith/hpa.yaml + - gateway/hpa.yaml + - backend/hpa.yaml + - ui/hpa.yaml + asserts: + - hasDocuments: + count: 1 + template: monolith/hpa.yaml + - hasDocuments: + count: 0 + template: gateway/hpa.yaml + - hasDocuments: + count: 0 + template: backend/hpa.yaml + - hasDocuments: + count: 0 + template: ui/hpa.yaml + - equal: + path: spec.scaleTargetRef + value: + apiVersion: apps/v1 + kind: Deployment + name: RELEASE-NAME-litellm + template: monolith/hpa.yaml + + - it: monolith KEDA ScaledObject targets the monolith Deployment + set: + monolith.enabled: true + gateway.hpa.enabled: false + gateway.keda.enabled: true + gateway.keda.prometheus.serverAddress: http://prometheus:9090 + gateway.metricsServer.enabled: true + gateway.keda.prometheus.targetRequestsPerSecond: 50 + templates: + - monolith/keda.yaml + - gateway/keda.yaml + asserts: + - hasDocuments: + count: 1 + template: monolith/keda.yaml + - hasDocuments: + count: 0 + template: gateway/keda.yaml + - equal: + path: spec.scaleTargetRef.name + value: RELEASE-NAME-litellm + template: monolith/keda.yaml + - equal: + path: spec.triggers[0].metadata.query + value: sum(rate(litellm_proxy_total_requests_metric_total{namespace="NAMESPACE",job="RELEASE-NAME-litellm-metrics"}[1m])) + template: monolith/keda.yaml + + - it: monolith PDB selects the monolith pods + set: + monolith.enabled: true + gateway.pdb.enabled: true + gateway.pdb.maxUnavailable: 1 + templates: + - monolith/poddisruptionbudget.yaml + - gateway/poddisruptionbudget.yaml + - backend/poddisruptionbudget.yaml + - ui/poddisruptionbudget.yaml + asserts: + - hasDocuments: + count: 1 + template: monolith/poddisruptionbudget.yaml + - hasDocuments: + count: 0 + template: gateway/poddisruptionbudget.yaml + - hasDocuments: + count: 0 + template: backend/poddisruptionbudget.yaml + - hasDocuments: + count: 0 + template: ui/poddisruptionbudget.yaml + - equal: + path: spec.maxUnavailable + value: 1 + template: monolith/poddisruptionbudget.yaml + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/component"] + value: proxy + template: monolith/poddisruptionbudget.yaml + + - it: monolith metrics sidecar, metrics Service and ServiceMonitor follow the monolith name + set: + monolith.enabled: true + gateway.metricsServer.enabled: true + gateway.serviceMonitor.enabled: true + templates: + - monolith/deployment.yaml + - monolith/service-metrics.yaml + - monolith/servicemonitor.yaml + - gateway/service-metrics.yaml + - gateway/servicemonitor.yaml + asserts: + - hasDocuments: + count: 0 + template: gateway/service-metrics.yaml + - hasDocuments: + count: 0 + template: gateway/servicemonitor.yaml + - equal: + path: metadata.name + value: RELEASE-NAME-litellm-metrics + template: monolith/service-metrics.yaml + - equal: + path: spec.selector["app.kubernetes.io/component"] + value: proxy + template: monolith/service-metrics.yaml + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/component"] + value: proxy + template: monolith/servicemonitor.yaml + - equal: + path: spec.template.spec.containers[1].name + value: metrics + template: monolith/deployment.yaml + - equal: + path: spec.template.spec.containers[1].args[0] + value: metrics + template: monolith/deployment.yaml + + - it: monolith collector sidecar runs the collector dispatcher from the same image + set: + monolith.enabled: true + gateway.collector.enabled: true + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[1].name + value: collector + - equal: + path: spec.template.spec.containers[1].args + value: + - collector + - equal: + path: spec.template.spec.containers[1].image + value: ghcr.io/berriai/litellm:1.104.0 + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_COLLECTOR_ENABLED + value: "true" + + - it: monolith renders only the gateway ServiceAccount and runs the pod with it + set: + monolith.enabled: true + serviceAccounts.gateway.create: true + serviceAccounts.backend.create: true + serviceAccounts.ui.create: true + templates: + - serviceaccount.yaml + - monolith/deployment.yaml + asserts: + - hasDocuments: + count: 1 + template: serviceaccount.yaml + - equal: + path: metadata.name + value: RELEASE-NAME-litellm-gateway + template: serviceaccount.yaml + - equal: + path: spec.template.spec.serviceAccountName + value: RELEASE-NAME-litellm-gateway + template: monolith/deployment.yaml + + - it: the migrations Job renders the same way in monolith mode + set: + monolith.enabled: true + template: migrations-job.yaml + asserts: + - isKind: + of: Job + - equal: + path: spec.template.spec.containers[0].args + value: + - migrations + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0 + + - it: monolith gets the writer user under both names the proxy CLI and the componentized entrypoints read + set: + monolith.enabled: true + template: monolith/deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DATABASE_USER + valueFrom: + secretKeyRef: + name: litellm-writer-secret + key: username + - contains: + path: spec.template.spec.containers[0].env + content: + name: DATABASE_USERNAME + valueFrom: + secretKeyRef: + name: litellm-writer-secret + key: username + - contains: + path: spec.template.spec.containers[0].env + content: + name: DATABASE_NAME + value: litellm + + - it: disabling every component without monolith renders no application workload + set: + gateway.enabled: false + backend.enabled: false + ui.enabled: false + templates: + - monolith/deployment.yaml + - gateway/deployment.yaml + - backend/deployment.yaml + - ui/deployment.yaml + asserts: + - hasDocuments: + count: 0 + + - it: monolith renders even when gateway.enabled is false + set: + monolith.enabled: true + gateway.enabled: false + templates: + - monolith/deployment.yaml + - monolith/service.yaml + asserts: + - hasDocuments: + count: 1 diff --git a/helm/litellm/tests/no_bundled_datastores_tests.yaml b/helm/litellm/tests/no_bundled_datastores_tests.yaml new file mode 100644 index 00000000000..dbafa5a9a11 --- /dev/null +++ b/helm/litellm/tests/no_bundled_datastores_tests.yaml @@ -0,0 +1,54 @@ +suite: the chart ships no PostgreSQL or Redis of its own +templates: + - gateway/deployment.yaml + - gateway/configmap.yaml + - migrations-job.yaml +tests: + - it: rejects the retired postgresql block instead of silently ignoring it + set: + postgresql.enabled: true + postgresql.auth.password: super-secret + asserts: + - failedTemplate: {} + + - it: rejects the retired redis.enabled flag instead of silently ignoring it + set: + redis.enabled: true + asserts: + - failedTemplate: {} + + - it: wires the writer straight from database.writer with no subchart in between + template: gateway/deployment.yaml + set: + database.writer.host: postgres.example.com + database.writer.dbname: proxydb + database.writer.passwordSecret.name: pg-secret + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DATABASE_HOST + value: postgres.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: DATABASE_NAME + value: proxydb + - contains: + path: spec.template.spec.containers[0].env + content: + name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: pg-secret + key: password + + - it: keeps the migrations Job a pre-install hook whenever the Helm hook is enabled + template: migrations-job.yaml + set: + database.writer.host: postgres.example.com + database.writer.dbname: litellm + asserts: + - equal: + path: metadata.annotations["helm.sh/hook"] + value: pre-install,pre-upgrade diff --git a/helm/litellm/tests/redis_env_tests.yaml b/helm/litellm/tests/redis_env_tests.yaml index 79a14dfc6e5..849f13e5438 100644 --- a/helm/litellm/tests/redis_env_tests.yaml +++ b/helm/litellm/tests/redis_env_tests.yaml @@ -225,10 +225,10 @@ tests: name: REDIS_CLUSTER_NODES value: '[{"host":"$(REDIS_HOST)","port":6379}]' - equal: - path: spec.template.spec.containers[0].env[8].name + path: spec.template.spec.containers[0].env[10].name value: REDIS_HOST - equal: - path: spec.template.spec.containers[0].env[11].name + path: spec.template.spec.containers[0].env[13].name value: REDIS_CLUSTER_NODES - it: gateway gets REDIS_URL from urlKey without discrete Redis env diff --git a/helm/litellm/tests/rollout_strategy_tests.yaml b/helm/litellm/tests/rollout_strategy_tests.yaml index b12e2073c7c..af6e41d038e 100644 --- a/helm/litellm/tests/rollout_strategy_tests.yaml +++ b/helm/litellm/tests/rollout_strategy_tests.yaml @@ -64,3 +64,39 @@ tests: - notExists: path: spec.strategy template: ui/deployment.yaml + + - it: leaves minReadySeconds to the Kubernetes default when unset + asserts: + - notExists: + path: spec.minReadySeconds + + - it: renders the configured minReadySeconds on each deployment + set: + gateway.minReadySeconds: 60 + backend.minReadySeconds: 30 + ui.minReadySeconds: 10 + asserts: + - equal: + path: spec.minReadySeconds + value: 60 + template: gateway/deployment.yaml + - equal: + path: spec.minReadySeconds + value: 30 + template: backend/deployment.yaml + - equal: + path: spec.minReadySeconds + value: 10 + template: ui/deployment.yaml + + - it: renders an explicit minReadySeconds of 0 instead of dropping it + set: + gateway.minReadySeconds: 0 + asserts: + - equal: + path: spec.minReadySeconds + value: 0 + template: gateway/deployment.yaml + - notExists: + path: spec.minReadySeconds + template: backend/deployment.yaml diff --git a/helm/litellm/tests/shared_image_tests.yaml b/helm/litellm/tests/shared_image_tests.yaml new file mode 100644 index 00000000000..1e7837b1973 --- /dev/null +++ b/helm/litellm/tests/shared_image_tests.yaml @@ -0,0 +1,264 @@ +suite: test the shared image and the entrypoint dispatcher args +templates: + - gateway/deployment.yaml + - gateway/configmap.yaml + - backend/deployment.yaml + - ui/deployment.yaml + - monolith/deployment.yaml + - migrations-job.yaml + - templates/tests/test-connection.yaml +values: + - ./values/required.yaml +tests: + - it: every componentized container runs the shared image at the chart appVersion + templates: + - gateway/deployment.yaml + - backend/deployment.yaml + - ui/deployment.yaml + - migrations-job.yaml + - templates/tests/test-connection.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0 + template: gateway/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0 + template: backend/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0 + template: ui/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0 + template: migrations-job.yaml + - equal: + path: spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0 + template: templates/tests/test-connection.yaml + + - it: image.repository and image.tag are applied to every container, sidecars included + set: + image.repository: registry.example.com/litellm + image.tag: v9.9.9 + image.pullPolicy: Always + gateway.metricsServer.enabled: true + gateway.collector.enabled: true + templates: + - gateway/deployment.yaml + - backend/deployment.yaml + - ui/deployment.yaml + - migrations-job.yaml + - templates/tests/test-connection.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: registry.example.com/litellm:v9.9.9 + template: gateway/deployment.yaml + - equal: + path: spec.template.spec.containers[1].image + value: registry.example.com/litellm:v9.9.9 + template: gateway/deployment.yaml + - equal: + path: spec.template.spec.containers[2].image + value: registry.example.com/litellm:v9.9.9 + template: gateway/deployment.yaml + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: Always + template: gateway/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: registry.example.com/litellm:v9.9.9 + template: backend/deployment.yaml + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: Always + template: backend/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: registry.example.com/litellm:v9.9.9 + template: ui/deployment.yaml + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: Always + template: ui/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: registry.example.com/litellm:v9.9.9 + template: migrations-job.yaml + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: Always + template: migrations-job.yaml + - equal: + path: spec.containers[0].image + value: registry.example.com/litellm:v9.9.9 + template: templates/tests/test-connection.yaml + + - it: the monolith and its sidecars run the shared image + set: + monolith.enabled: true + image.tag: v9.9.9 + gateway.metricsServer.enabled: true + gateway.collector.enabled: true + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:v9.9.9 + - equal: + path: spec.template.spec.containers[1].image + value: ghcr.io/berriai/litellm:v9.9.9 + - equal: + path: spec.template.spec.containers[2].image + value: ghcr.io/berriai/litellm:v9.9.9 + + - it: image.digest renders as repository:tag@digest everywhere + set: + image.tag: v9.9.9 + image.digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + templates: + - gateway/deployment.yaml + - backend/deployment.yaml + - ui/deployment.yaml + - migrations-job.yaml + - templates/tests/test-connection.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:v9.9.9@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + template: gateway/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:v9.9.9@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + template: backend/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:v9.9.9@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + template: ui/deployment.yaml + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:v9.9.9@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + template: migrations-job.yaml + - equal: + path: spec.containers[0].image + value: ghcr.io/berriai/litellm:v9.9.9@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + template: templates/tests/test-connection.yaml + + - it: image.digest with an empty tag falls back to the appVersion tag + set: + image.digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + monolith.enabled: true + template: monolith/deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/berriai/litellm:1.104.0@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + + - it: componentized containers select their component through args and never set command + set: + gateway.metricsServer.enabled: true + gateway.collector.enabled: true + templates: + - gateway/deployment.yaml + - backend/deployment.yaml + - ui/deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].args + value: + - gateway + - --host + - 0.0.0.0 + - --port + - "4000" + template: gateway/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].command + template: gateway/deployment.yaml + - notExists: + path: spec.template.spec.containers[1].command + template: gateway/deployment.yaml + - notExists: + path: spec.template.spec.containers[2].command + template: gateway/deployment.yaml + - equal: + path: spec.template.spec.containers[0].args + value: + - backend + - --host + - 0.0.0.0 + - --port + - "4001" + template: backend/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].command + template: backend/deployment.yaml + - equal: + path: spec.template.spec.containers[0].args + value: + - ui + template: ui/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].command + template: ui/deployment.yaml + + - it: the migrations Job runs the migrations dispatcher and nothing else + template: migrations-job.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].args + value: + - migrations + - notExists: + path: spec.template.spec.containers[0].command + + - it: the Helm test pod polls the workload Service readiness endpoint through the image entrypoint + template: templates/tests/test-connection.yaml + asserts: + - isKind: + of: Pod + - equal: + path: metadata.annotations["helm.sh/hook"] + value: test + - equal: + path: spec.containers[0].env[0] + value: + name: LITELLM_READINESS_URL + value: http://RELEASE-NAME-litellm-gateway:4000/health/readiness + - equal: + path: spec.containers[0].args[0] + value: python + - equal: + path: spec.containers[0].args[1] + value: -c + - matchRegex: + path: spec.containers[0].args[2] + pattern: LITELLM_READINESS_URL + - matchRegex: + path: spec.containers[0].args[2] + pattern: 'db != "connected"' + - matchRegex: + path: spec.containers[0].args[2] + pattern: sys\.exit\(1\) + - notExists: + path: spec.containers[0].command + + - it: the Helm test pod targets the monolith Service in monolith mode + set: + monolith.enabled: true + template: templates/tests/test-connection.yaml + asserts: + - equal: + path: spec.containers[0].env[0].value + value: http://RELEASE-NAME-litellm:4000/health/readiness + + - it: the removed per-component image blocks are rejected by the values schema + set: + gateway.image.tag: v1 + template: gateway/deployment.yaml + asserts: + - failedTemplate: {} diff --git a/helm/litellm/values.schema.json b/helm/litellm/values.schema.json new file mode 100644 index 00000000000..3aafbe68fe6 --- /dev/null +++ b/helm/litellm/values.schema.json @@ -0,0 +1,183 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LiteLLM chart values", + "type": "object", + "additionalProperties": true, + "properties": { + "image": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string" + }, + "pullPolicy": { + "type": "string", + "enum": [ + "Always", + "IfNotPresent", + "Never" + ] + }, + "digest": { + "type": "string", + "pattern": "^(sha256:[a-f0-9]{64})?$" + } + } + }, + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + } + }, + "monolith": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "extraArgs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "gateway": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean" + }, + "replicaCount": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "numWorkers": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "image": false + } + }, + "backend": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean" + }, + "replicaCount": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "image": false + } + }, + "ui": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean" + }, + "replicaCount": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "image": false + } + }, + "migrationJob": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": false + } + }, + "masterKey": { + "type": "object", + "additionalProperties": true, + "properties": { + "secretName": { + "type": "string" + }, + "secretKey": { + "type": "string" + }, + "generate": { + "type": "boolean" + } + } + }, + "postgresql": false, + "redis": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": false, + "cluster": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "port": { + "type": [ + "integer", + "string" + ] + } + } + }, + "ingress": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "extraResources": { + "type": "array", + "items": { + "type": "object" + } + } + } +} diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index cb37656cb77..9bfc3ca32e9 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -1,10 +1,64 @@ # LiteLLM helm chart values +# +# Two deployment modes, both from the single image below: +# +# componentized (default): `gateway.enabled`, `backend.enabled` and +# `ui.enabled` each render their own Deployment, Service, HPA and PDB, so +# the LLM data plane, the management API and the static dashboard scale +# independently. Their containers run `args: [gateway|backend|ui, ...]`. +# +# monolith: `monolith.enabled: true` renders ONE Deployment and Service +# running the full proxy (`args: [proxy, ...]`): gateway routes, +# management routes and the Admin UI served by the proxy itself. The +# gateway, backend and ui Deployments and Services are not rendered and +# the Ingress sends every path to the monolith Service. The monolith is +# configured through the `gateway.*` values (config, resources, probes, +# securityContext, hpa, pdb, keda, collector, metricsServer, volumes, +# env), so a monolith install only flips this flag. See `monolith` below +# for the precedence rules. nameOverride: "" fullnameOverride: "" +# The one image every container in this chart runs: gateway, backend, ui, +# monolith, the migrations Job, the metrics and collector sidecars and the +# Helm test pod. Its entrypoint dispatches on the first container argument +# (proxy, gateway, backend, ui, migrations, metrics, collector), which is why +# the templates only ever set `args` and never `command`: overriding the +# entrypoint would drop the wrappers it applies (ddtrace, Prometheus +# multiprocess dir cleanup). `tag` defaults to the chart's appVersion. When +# `digest` is set the image renders as `repository:tag@digest`, so the tag is +# informational and the digest pins the bytes. Also mirrored at +# docker.litellm.ai/berriai/litellm. +image: + repository: ghcr.io/berriai/litellm + tag: "" + pullPolicy: IfNotPresent + digest: "" + imagePullSecrets: [] +# Monolith mode. When enabled the chart renders a single Deployment named +# `-litellm` running `args: [proxy, --port, 4000, --config, +# /app/config/config.yaml]` (the --config pair only when gateway.config.create +# is true) plus `extraArgs`, and a Service of the same name. Precedence when +# this is on: +# - gateway.enabled / backend.enabled / ui.enabled are ignored: no +# component Deployment, Service, HPA, PDB or ServiceMonitor is rendered +# - every `backend.*` and `ui.*` value is ignored, including their +# ServiceAccounts; the monolith runs as serviceAccounts.gateway +# - every `gateway.*` value applies to the monolith pod instead of a gateway +# pod: config, numWorkers, resources, probes, securityContext, +# hpa / keda / pdb, metricsServer, collector, volumes, env, scheduling +# - ingress.extraPaths entries keep their `service` field but every path, +# built in or extra, targets the monolith Service +# - the migrations Job renders exactly as in componentized mode +monolith: + enabled: false + # Extra CLI arguments appended after the chart's own proxy arguments, e.g. + # ["--detailed_debug"] or ["--run_granian"]. + extraArgs: [] + # Optional Ingress wiring the three component Services behind a single L7 # entrypoint. Required when serving the static UI bundle over the network. ingress: @@ -86,10 +140,8 @@ serviceAccounts: # LiteLLM_VerificationToken, LiteLLM_SpendLogs, ...). Disable if your # pipeline runs migrations out-of-band. # -# Uses a dedicated `litellm-migrations` image (prisma CLI + the migration -# files from `litellm-proxy-extras`) instead of the backend image, so the -# Job doesn't drag in the rest of the proxy and doesn't run `prisma -# generate` — the migration engine doesn't need the generated client. +# Runs the shared image with `args: [migrations]`, which the entrypoint +# dispatches to the prisma migration runner without importing the proxy. migrationJob: enabled: true # Which controller is responsible for running the Job. @@ -150,8 +202,18 @@ migrationJob: # pod. Same shape as gateway.podSecurityContext / gateway.securityContext. podSecurityContext: {} securityContext: {} + # Extra labels and annotations on the Job object itself. + jobLabels: {} + jobAnnotations: {} # Extra pod labels on the Job pod, merged into the chart's common labels. podLabels: {} + # Annotations on the Job pod, e.g. sidecar.istio.io/inject: "false" so an + # injected sidecar cannot keep the pod running after the migration exits. + annotations: {} + # Override the container command and args. The default runs the image + # entrypoint with `migrations`; a command with no args clears the args. + command: [] + args: [] # Additional volumes on the Job pod and volumeMounts on its container, e.g. # the writable scratch space a read-only root filesystem needs. volumes: [] @@ -163,21 +225,26 @@ migrationJob: nodeSelector: {} tolerations: [] affinity: {} - image: - repository: ghcr.io/berriai/litellm-migrations - tag: "" # defaults to .Chart.AppVersion - pullPolicy: IfNotPresent + # Extra init containers and sidecars on the Job pod, rendered through `tpl`. + extraInitContainers: [] + extraContainers: [] # Extra env appended to the migration container. The migration entrypoint # uses the v2 resolver by default (no diff-and-force recovery — avoids the # schema thrashing seen during rolling deploys). To opt back into the v1 # resolver, append `- name: USE_V2_MIGRATION_RESOLVER` / `value: "false"`. extraEnv: [] -# Required: a master key used by gateway + backend to mint/verify proxy tokens. -# Must reference an existing Secret. +# Master key used by the proxy to mint and verify virtual keys. Reference an +# existing Secret through `secretName` / `secretKey`, or set `generate: true` +# and leave `secretName` empty to have the chart create +# `-litellm-masterkey` with a random `sk-...` key under `secretKey`. +# The generated Secret is looked up on upgrade so the key never rotates by +# accident (`helm template` cannot look anything up and renders a fresh key +# each time; that is expected). The chart never accepts an inline key. masterKey: secretName: litellm-master-key-secret # name of a Secret containing the master key secretKey: master-key + generate: false # Optional: enterprise billable-request metering. When enabled, the gateway and # backend count successful requests to inference, MCP, and A2A endpoints. Requires @@ -189,7 +256,8 @@ billingMetrics: secretName: litellm-billing-metrics exportIntervalMs: "" -# External Postgres connection. +# Postgres connection. The chart ships no database; point `writer` at your +# own PostgreSQL (managed or self hosted) and its credentials Secret. database: writer: host: "" @@ -249,24 +317,23 @@ database: maxDbConnections: 20 maxClientConn: 1000 -# Optional Redis. Leave host empty to disable. +# Redis is the proxy's coordination store: cross-pod tpm/rpm rate limits, +# spend tracking, and the pod lock manager. The chart emits REDIS_HOST / +# REDIS_PORT / REDIS_PASSWORD, which the proxy picks up through its +# coordination Redis env fallback. Response caching is separate and off unless +# you enable it in `proxy_config.litellm_settings.cache`. # -# This is the proxy's coordination store: cross-pod tpm/rpm rate limits, spend -# tracking, and the pod lock manager. The chart emits REDIS_HOST / REDIS_PORT / -# REDIS_PASSWORD, which the proxy picks up through its coordination Redis env -# fallback. Response caching is separate and off unless you enable it in -# `proxy_config.litellm_settings.cache`. +# Set `host` (and `passwordSecret.name` when auth is required). Set +# `cluster: true` for Redis Cluster mode (AWS ElastiCache Cluster, self-hosted +# Redis Cluster): the chart emits REDIS_CLUSTER_NODES from `host` / `port` as +# the single seed and the client discovers the rest from CLUSTER SLOTS. The +# chart ships no Redis of its own. # # For full control, define `general_settings.coordination_redis` in # `proxy_config` (host/port/password/username/url/ssl/startup_nodes/ # sentinel_nodes/sentinel_password/service_name, each accepting os.environ/VAR # refs). An explicit block overrides these env vars. # -# Set `cluster: true` for Redis Cluster mode (e.g. AWS ElastiCache Cluster, -# self-hosted Redis Cluster). The chart emits REDIS_CLUSTER_NODES from -# `host` / `port` as the single seed; the cluster client discovers the -# remaining nodes from CLUSTER SLOTS at startup. -# # For a TLS Redis endpoint set `ssl: true`, which the chart emits as REDIS_SSL. # `sslCertReqs` (none / optional / required) becomes REDIS_SSL_CERT_REQS and # `sslCaCerts` (path to a CA bundle mounted in the pod, e.g. via @@ -287,12 +354,17 @@ redis: hostKey: "" # Secret key containing the Redis host urlKey: "" # Secret key containing the complete Redis URL +# Arbitrary extra Kubernetes manifests rendered verbatim with the release, +# e.g. a NetworkPolicy or an ExternalSecret the proxy needs. +extraResources: [] + # ---------- gateway (LLM data plane) ---------- +# In monolith mode this whole block configures the monolith pod instead. gateway: enabled: true logLevel: INFO - # Number of uvicorn worker processes per gateway pod. Sets NUM_WORKERS, - # consumed by the gateway image entrypoint. Default is 1. + # Number of worker processes per pod. Sets NUM_WORKERS, read by the gateway + # launcher and by the proxy's --num_workers default. Default is 1. numWorkers: 1 extraEnv: [] # Add extra environment variables to the gateway envConfigMaps: [] # Add extra environment variables to the gateway from config maps @@ -311,7 +383,6 @@ gateway: # scrape never runs on an inference worker. Adds a `metrics` port to the pod # and a dedicated ClusterIP `-metrics` Service; point your scrape # config at it. The port has no virtual-key auth: keep it off public ingress. - # Needs the gateway image v1.101.0 or newer. metricsServer: enabled: false port: 4001 @@ -329,8 +400,13 @@ gateway: serviceMonitor: enabled: false labels: {} + annotations: {} interval: 15s scrapeTimeout: 10s + relabelings: [] + # Empty scrapes the release namespace. + namespaceSelector: + matchNames: [] # Opt-in `collector` sidecar (same image, `python -m litellm.proxy.collector`) # that runs the post-response spend pipeline (cost calculation, spend logs, # spend counters, budget reservation reconciliation) so the uvicorn workers @@ -367,13 +443,13 @@ gateway: # ContainerResource metric of the `gateway` container only, so the # sidecar's CPU never drives inference replicas. Needs Kubernetes 1.30+. scaleOnGatewayContainerCpu: true - image: - repository: ghcr.io/berriai/litellm-gateway - tag: "" # defaults to .Chart.AppVersion - pullPolicy: IfNotPresent service: type: ClusterIP port: 4000 + annotations: {} + # For LoadBalancer Services on clusters with several load balancer + # implementations. + loadBalancerClass: "" resources: requests: cpu: "1" @@ -447,6 +523,41 @@ gateway: # counted when a response completes, so TPS trails long streams. targetRequestsPerSecond: "" targetTokensPerSecond: "" + # KEDA ScaledObject as an alternative to the HPA above. Mutually exclusive + # with hpa.enabled (two autoscalers on one Deployment fight each other), so + # set `hpa.enabled: false` when turning this on. Needs KEDA installed in the + # cluster. `triggers` are rendered verbatim; the two optional Prometheus + # workload targets below add prometheus triggers on the same counters the + # HPA workload metrics use, scraped through the metrics Service (enable + # metricsServer and serviceMonitor). + keda: + enabled: false + minReplicaCount: 1 + maxReplicaCount: 10 + pollingInterval: 30 + cooldownPeriod: 300 + # Rendered on the ScaledObject, e.g. autoscaling.keda.sh/paused-replicas: "2" + # to pause scaling at a fixed replica count + scaledObject: + annotations: {} + # Optional; rendered verbatim under spec.advanced + advanced: {} + # Optional; rendered under spec.fallback. A missing `failureThreshold` + # defaults to 3 and a missing `replicas` to maxReplicaCount, as in litellm-helm. + fallback: {} + # Custom triggers, rendered verbatim, e.g. + # - type: cpu + # metricType: Utilization + # metadata: + # value: "70" + triggers: [] + prometheus: + # Prometheus server URL, required when either workload target is set. + serverAddress: "" + # Per pod averages, same units as hpa.targetRequestsPerSecond / + # hpa.targetTokensPerSecond. Empty leaves the trigger out. + targetRequestsPerSecond: "" + targetTokensPerSecond: "" # PodDisruptionBudget for the gateway pods. Set exactly one of # `minAvailable` / `maxUnavailable` (minAvailable wins if both are set; # enabling without either falls back to `maxUnavailable: 1`). Disabled by @@ -456,6 +567,13 @@ gateway: enabled: false minAvailable: "" maxUnavailable: "" + # Extra annotations and labels on the Deployment object itself (not the + # pods), e.g. for a GitOps controller or a reloader. + deploymentAnnotations: {} + deploymentLabels: {} + # Seconds a new pod must be ready before the rollout treats it as + # available. Empty inherits the Kubernetes default of 0. + minReadySeconds: "" podAnnotations: {} # Extra pod labels, merged into the chart's selector labels. Do not # re-declare `app.kubernetes.io/name` / `instance` / `component` here: they @@ -482,7 +600,16 @@ gateway: # egress proxy. Rendered through `tpl`, so entries may reference chart # values and release metadata. extraContainers: [] + # Init containers, rendered through `tpl` the same way. + extraInitContainers: [] # Container lifecycle hooks (postStart / preStop) for the gateway container. + # + # Prefer the proxy's /health/drain preStop hook over a fixed `sleep`: it + # marks the pod NotReady and blocks only until in-flight requests finish + # (bounded by GRACEFUL_SHUTDOWN_TIMEOUT). It is off by default; enable it + # with general_settings.enable_drain_endpoint: true and, when the health + # port is reachable from other pods, set general_settings.drain_endpoint_token + # and send the same value on the X-Drain-Token header from the hook. lifecycle: {} # Grace period the kubelet allows between SIGTERM and SIGKILL. Leave empty # to inherit the Kubernetes default of 30s. Set it a few seconds above the @@ -513,13 +640,11 @@ backend: volumes: [] # Additional volumeMounts on the backend container. volumeMounts: [] - image: - repository: ghcr.io/berriai/litellm-backend - tag: "" - pullPolicy: IfNotPresent service: type: ClusterIP port: 4001 + annotations: {} + loadBalancerClass: "" resources: requests: cpu: "1" @@ -556,12 +681,16 @@ backend: enabled: false minAvailable: "" maxUnavailable: "" + deploymentAnnotations: {} + deploymentLabels: {} + minReadySeconds: "" podAnnotations: {} # Same shape as the gateway blocks of the same name. podLabels: {} podSecurityContext: {} securityContext: {} extraContainers: [] + extraInitContainers: [] lifecycle: {} terminationGracePeriodSeconds: "" nodeSelector: {} @@ -581,13 +710,13 @@ ui: volumes: [] # Additional volumeMounts on the ui container. volumeMounts: [] - image: - repository: ghcr.io/berriai/litellm-ui - tag: "" - pullPolicy: IfNotPresent service: type: ClusterIP port: 3000 + annotations: {} + loadBalancerClass: "" + # The dashboard expects to know where to reach the backend API. Set this to + # the externally-routable URL (typically the ingress host + /api or similar). # The backend's public origin, passed to the UI only when ingress.componentHosts is configured. backendUrl: "" # Public origin of the UI, used for backend and gateway CORS. @@ -625,17 +754,19 @@ ui: enabled: false minAvailable: "" maxUnavailable: "" + deploymentAnnotations: {} + deploymentLabels: {} + minReadySeconds: "" podAnnotations: {} # Same shape as the gateway blocks of the same name. The nginx runtime # writes its pid, cache, and proxy temp files under /tmp, so it boots as # any (arbitrary, non-root) uid; `securityContext.readOnlyRootFilesystem: - # true` here needs an emptyDir volume mounted over /tmp. Images before - # the /tmp move instead need emptyDirs over /var/cache/nginx and /run to - # run as a non-root uid at all. + # true` here needs an emptyDir volume mounted over /tmp. podLabels: {} podSecurityContext: {} securityContext: {} extraContainers: [] + extraInitContainers: [] lifecycle: {} terminationGracePeriodSeconds: "" nodeSelector: {} @@ -644,6 +775,24 @@ ui: # Same shape as gateway.topologySpreadConstraints. topologySpreadConstraints: [] +# Optional: bundled LiteAdmin worker. Renders one Deployment, Service and PVC +# running the shared image with `args: ["--admin-agent"]`, and points the pod +# that serves the /liteadmin routes (the backend in componentized mode, the +# monolith in monolith mode) at it via LITELLM_ADMIN_AGENT_URL. +liteadmin: + enabled: false + existingSecret: "" + gatewayUrl: "" + model: "" + readOnly: false + storageSize: 1Gi + storageClassName: "" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi lens: enabled: false url: "" diff --git a/scripts/tpm_headline_test.sh b/scripts/tpm_headline_test.sh index a2f4063d311..ef6750e2433 100755 --- a/scripts/tpm_headline_test.sh +++ b/scripts/tpm_headline_test.sh @@ -12,7 +12,7 @@ # Post-PR: only ~1–2 fit under tpm_limit=100, rest return 429. # # Setup (separate terminal): -# kubectl port-forward -n litellm svc/yassin-veks-litellm-helm 4000:4000 +# kubectl port-forward -n litellm svc/litellm-gateway 4000:4000 # # Run: # bash scripts/tpm_headline_test.sh diff --git a/tests/unit/proxy/test_proxy_cli.py b/tests/unit/proxy/test_proxy_cli.py index 6347c165865..9a091959631 100644 --- a/tests/unit/proxy/test_proxy_cli.py +++ b/tests/unit/proxy/test_proxy_cli.py @@ -2492,17 +2492,15 @@ class TestProxyInitializationHelpers: class TestQueryEngineReaperWiring: - def _invoke_run_server(self, args): + def _invoke_run_server(self, args, env: dict[str, str] | None = None): from click.testing import CliRunner from litellm.proxy.proxy_cli import run_server runner = CliRunner() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL", "NUM_WORKERS")} + if env: + clean_env.update(env) with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -2548,6 +2546,20 @@ class TestQueryEngineReaperWiring: mock_uvicorn_run.assert_called_once() mock_start_reaper.assert_not_called() + def test_num_workers_env_var_sets_uvicorn_workers(self): + result, mock_uvicorn_run, _ = self._invoke_run_server(["--local"], env={"NUM_WORKERS": "3"}) + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" + mock_uvicorn_run.assert_called_once() + assert mock_uvicorn_run.call_args.kwargs["workers"] == 3 + + def test_num_workers_flag_beats_env_var(self): + result, mock_uvicorn_run, _ = self._invoke_run_server( + ["--local", "--num_workers", "2"], env={"NUM_WORKERS": "3"} + ) + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" + mock_uvicorn_run.assert_called_once() + assert mock_uvicorn_run.call_args.kwargs["workers"] == 2 + @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") def test_gunicorn_arbiter_starts_reaper(self): pytest.importorskip("gunicorn") diff --git a/tests/unit/test_docker_entrypoint.py b/tests/unit/test_docker_entrypoint.py index 1d8da4a9b62..bd0b9b0fd4b 100644 --- a/tests/unit/test_docker_entrypoint.py +++ b/tests/unit/test_docker_entrypoint.py @@ -4,8 +4,10 @@ import re import shlex import stat import subprocess +import sys from pathlib import Path from typing import Final +from urllib.parse import parse_qs, urlencode, urlsplit import pytest @@ -555,3 +557,145 @@ def test_entrypoint_script_is_executable() -> None: def test_entrypoint_script_has_no_carriage_returns() -> None: assert b"\r" not in DOCKER_ENTRYPOINT.read_bytes() + + +def _run_proxy_with_discrete_database( + tmp_path: Path, extra_env: dict[str, str | None], extra_pythonpath: Path | None = None +) -> list[str]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "litellm").write_text( + '#!/bin/sh\n{ echo "args=$*"; echo "DATABASE_URL=$DATABASE_URL"; ' + 'echo "DATABASE_URL_READ_REPLICA=${DATABASE_URL_READ_REPLICA-}"; } >> "$RECORD"\n' + ) + (bin_dir / "litellm").chmod(0o755) + (bin_dir / "python").write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n') + (bin_dir / "python").chmod(0o755) + record = tmp_path / "record.txt" + env = _entrypoint_env( + bin_dir, + record, + { + "DATABASE_URL": None, + "DATABASE_URL_READ_REPLICA": None, + "DIRECT_URL": None, + "DATABASE_MAX_IDLE_CONNECTION_LIFETIME": None, + "USE_AWS_KMS": None, + "DATABASE_HOST": "writer.db", + "DATABASE_PORT": "6543", + "DATABASE_USER": "app", + "DATABASE_PASSWORD": "p@ss", + "DATABASE_NAME": "litellm", + "DATABASE_HOST_READ_REPLICA": "reader.db", + "DATABASE_PORT_READ_REPLICA": "6544", + **extra_env, + }, + ) + env["PYTHONPATH"] = os.pathsep.join( + [str(extra_pythonpath), str(REPO_ROOT)] if extra_pythonpath else [str(REPO_ROOT)] + ) + + result = subprocess.run( + ["sh", str(DOCKER_ENTRYPOINT), "proxy", "--port", "4000"], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" + return record.read_text().splitlines() + + +def test_proxy_assembles_database_urls_from_discrete_variables(tmp_path: Path) -> None: + lines = _run_proxy_with_discrete_database(tmp_path, {}) + + assert lines[0] == "args=--port 4000" + assert lines[1].startswith("DATABASE_URL=postgresql://app:p%40ss@writer.db:6543/litellm") + assert lines[2].startswith("DATABASE_URL_READ_REPLICA=postgresql://app:p%40ss@reader.db:6544/litellm") + assert "max_idle_connection_lifetime" not in parse_qs(urlsplit(lines[1]).query) + assert "max_idle_connection_lifetime" not in parse_qs(urlsplit(lines[2]).query) + + +def test_proxy_decrypts_a_kms_database_password_before_assembling(tmp_path: Path) -> None: + site_dir = tmp_path / "site" + site_dir.mkdir() + (site_dir / "sitecustomize.py").write_text( + "import os\n" + "import litellm.secret_managers.aws_secret_manager as kms\n" + "kms.decrypt_env_var = lambda: {'DATABASE_PASSWORD': 'p@ss'} " + "if os.environ.get('DATABASE_PASSWORD') == 'aws_kms/ciphertext' else {}\n" + ) + lines = _run_proxy_with_discrete_database( + tmp_path, + {"USE_AWS_KMS": "True", "DATABASE_PASSWORD": "aws_kms/ciphertext"}, + extra_pythonpath=site_dir, + ) + + assert lines[1].startswith("DATABASE_URL=postgresql://app:p%40ss@writer.db:6543/litellm") + assert lines[2].startswith("DATABASE_URL_READ_REPLICA=postgresql://app:p%40ss@reader.db:6544/litellm") + + +def test_proxy_pins_an_env_configured_idle_lifetime(tmp_path: Path) -> None: + lines = _run_proxy_with_discrete_database(tmp_path, {"DATABASE_MAX_IDLE_CONNECTION_LIFETIME": "120"}) + + assert "max_idle_connection_lifetime=120" in lines[1] + assert "max_idle_connection_lifetime=120" in lines[2] + + +def test_proxy_keeps_a_reader_url_pinned_idle_lifetime(tmp_path: Path) -> None: + lines: Final = _run_proxy_with_discrete_database( + tmp_path, {"DATABASE_URL_READ_REPLICA": "postgresql://app:pass@reader.db/litellm?max_idle_connection_lifetime=300"} + ) + assert "max_idle_connection_lifetime" not in parse_qs(urlsplit(lines[1]).query) + assert "max_idle_connection_lifetime=300" in lines[2] + assert "max_idle_connection_lifetime=60" not in lines[2] + + +@pytest.mark.parametrize("pinned_writer", (False, True)) +def test_proxy_reader_uses_prisma_tls_and_writer_pool_settings(tmp_path: Path, pinned_writer: bool) -> None: + root_cert: Final = str(tmp_path / "absent-ca.pem") + writer_query: Final = urlencode( + { + "sslmode": "verify-full", + "sslrootcert": root_cert, + "connection_limit": "7", + "socket_timeout": "9", + "max_idle_connection_lifetime": "300", + } + ) + lines: Final = _run_proxy_with_discrete_database( + tmp_path, + { + "DATABASE_URL": f"postgresql://app:pass@writer.db/litellm?{writer_query}" if pinned_writer else None, + "DATABASE_HOST": None if pinned_writer else "writer.db", + "DATABASE_USER_READ_REPLICA": "reader", + "DATABASE_PASSWORD_READ_REPLICA": "rpass", + "DATABASE_NAME_READ_REPLICA": "replica", + "DATABASE_SSLMODE": "verify-full", + "DATABASE_SSLROOTCERT": root_cert, + }, + ) + reader: Final = urlsplit(lines[2].removeprefix("DATABASE_URL_READ_REPLICA=")) + assert (reader.hostname, reader.username, reader.path) == ("reader.db", "reader", "/replica") + assert parse_qs(reader.query) == { + "sslmode": ["require"], + "sslcert": [root_cert], + "sslaccept": ["strict"], + **( + {"connection_limit": ["7"], "socket_timeout": ["9"], "max_idle_connection_lifetime": ["300"]} + if pinned_writer + else {} + ), + } + + +def test_proxy_keeps_an_operator_database_url(tmp_path: Path) -> None: + record = _run_entrypoint( + ("proxy",), + tmp_path, + DATABASE_URL="postgresql://pinned/db", + DATABASE_HOST="writer.db", + ) + + assert record[0] == "exec=litellm" From ec8d96d7d4016815f6ed90435f616e7489c72f97 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:03:33 -0800 Subject: [PATCH 56/86] feat(terraform): run every AWS and GCP workload from the one litellm image (#43358) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- terraform/litellm/README.md | 31 ++-- terraform/litellm/aws/README.md | 43 +++--- terraform/litellm/aws/ecs.tf | 72 ++++------ terraform/litellm/aws/locals.tf | 5 +- terraform/litellm/aws/migrations.tf | 22 +-- .../litellm/aws/tests/collector.tftest.hcl | 11 +- .../aws/tests/connection_pool.tftest.hcl | 8 +- terraform/litellm/aws/tests/images.tftest.hcl | 50 +++---- .../aws/tests/metrics_sidecar.tftest.hcl | 7 +- .../aws/tests/unified_image.tftest.hcl | 73 ++++++++++ terraform/litellm/aws/variables.tf | 55 +++---- terraform/litellm/gcp/README.md | 42 +++--- terraform/litellm/gcp/bootstrap.tf | 6 +- terraform/litellm/gcp/cloudrun.tf | 41 +++--- .../litellm/gcp/examples/default/TUTORIAL.md | 2 +- .../gcp/examples/default/deploystack.json | 4 +- .../litellm/gcp/examples/default/main.tf | 2 +- .../examples/default/terraform.tfvars.example | 11 +- .../litellm/gcp/examples/default/variables.tf | 8 +- terraform/litellm/gcp/locals.tf | 8 +- .../litellm/gcp/tests/collector.tftest.hcl | 6 +- .../gcp/tests/connection_pool.tftest.hcl | 12 +- .../gcp/tests/metrics_sidecar.tftest.hcl | 8 +- .../gcp/tests/unified_image.tftest.hcl | 72 ++++++++++ terraform/litellm/gcp/variables.tf | 71 ++++----- tests/unit/test_docker_entrypoint.py | 136 +++++++----------- 26 files changed, 416 insertions(+), 390 deletions(-) create mode 100644 terraform/litellm/aws/tests/unified_image.tftest.hcl create mode 100644 terraform/litellm/gcp/tests/unified_image.tftest.hcl diff --git a/terraform/litellm/README.md b/terraform/litellm/README.md index 7cc6e4c08ba..1c2ad1d9b25 100644 --- a/terraform/litellm/README.md +++ b/terraform/litellm/README.md @@ -34,11 +34,16 @@ secret-manager refs. The proxy is split into three deployables: -| Component | Default image | Port | Role | -| --------- | ---------------------------------------- | ---- | -------------------------------------------------------------------- | -| `gateway` | `ghcr.io/berriai/litellm-gateway:main-stable` | 4000 | LLM data plane (`/v1/chat/completions`, `/v1/embeddings`, …) | -| `backend` | `ghcr.io/berriai/litellm-backend:main-stable` | 4001 | Management API (`/key/*`, `/user/*`, `/team/*`, `/model/*`, …) | -| `ui` | `ghcr.io/berriai/litellm-ui:main-stable` | 3000 | Static Next.js dashboard served by nginx | +| Component | Entrypoint argument | Port | Role | +| --------- | ------------------- | ---- | -------------------------------------------------------------------- | +| `gateway` | `gateway` | 4000 | LLM data plane (`/v1/chat/completions`, `/v1/embeddings`, …) | +| `backend` | `backend` | 4001 | Management API (`/key/*`, `/user/*`, `/team/*`, `/model/*`, …) | +| `ui` | `ui` | 3000 | Static Next.js dashboard served by nginx | + +All of them run the same `ghcr.io/berriai/litellm` image (`image` on AWS, +`image_registry` + `image_tag` or `image` on GCP). The image entrypoint +starts the process named by its first argument, so each workload only +differs in the argument the module passes The load balancer routes gateway path prefixes (mirrored verbatim from `gateway/routes/allowlist.py`) to the gateway, UI asset paths (`/`, @@ -152,8 +157,8 @@ pin to a specific tag for production: LiteLLM's proxy runs `prisma migrate deploy` at startup, but on first apply the gateway/backend can race the empty database. Both stacks expose a -one-off migration task that runs `python litellm/proxy/prisma_migration.py` -against the backend image: +one-off migration task that runs the `migrations` component of the same +image: - AWS: an `aws_ecs_task_definition` (`litellm-migrations`). Run with `aws ecs run-task` — the command is printed in `terraform output`. @@ -234,19 +239,17 @@ dynamic-credentials OIDC). Required overrides the launcher must supply per stack: - **AWS** (`terraform/litellm/aws`): `region`, `azs`, `tenant`, `env`. - The image vars (`gateway_image`, `backend_image`, `ui_image`, - `migrations_image`) can be left at their defaults — the GHCR images - are anonymous-readable and ECS Fargate pulls them without extra - credentials. + `image` can be left at its default: the GHCR image is + anonymous-readable and ECS Fargate pulls it without extra credentials. - **GCP** (`terraform/litellm/gcp`): `project`, `tenant`, `env`, **and one of**: - `image_registry` pointed at an Artifact Registry **remote** repository backed by `https://ghcr.io` (e.g. `us-central1-docker.pkg.dev//litellm/berriai`), so Cloud Run - pulls the four upstream `litellm-*` images through it; or - - all four per-component `*_image` URIs pointing at images mirrored - into a regular Artifact Registry repo. + pulls the upstream `litellm` image through it; or + - `image` pointing at a copy mirrored into a regular Artifact Registry + repo. The defaults (`ghcr.io/berriai`) cause Cloud Run admission to reject the service spec — Cloud Run only authenticates against Artifact diff --git a/terraform/litellm/aws/README.md b/terraform/litellm/aws/README.md index 6f0450c0091..7aebfe2acc9 100644 --- a/terraform/litellm/aws/README.md +++ b/terraform/litellm/aws/README.md @@ -12,7 +12,7 @@ Deploys the componentized LiteLLM proxy on AWS: - LLM data-plane prefixes (`/v1/chat/*`, `/v1/embeddings`, …) → `gateway` - UI assets (`/`, `/_next/*`, `/litellm-asset-prefix/*`, …) → `ui` - Everything else (management API: `/key/*`, `/user/*`, …) → `backend` -- **One-off migration task** (`litellm-migrations`) that runs `prisma migrate deploy` from the dedicated `ghcr.io/berriai/litellm-migrations` image +- **One-off migration task** (`litellm-migrations`) that runs `prisma migrate deploy` through the `migrations` component of the same image ## Bring your own networking, database, and Redis @@ -238,8 +238,7 @@ Metering also requires an enterprise license. To tune the export cadence, set (`python -m litellm.proxy.prometheus_metrics_server`) to the gateway task that aggregates the workers' samples over a shared task volume, so a scrape never runs on an inference worker. The ALB never routes to that port and the tasks -security group only opens it to `gateway_metrics_scrape_cidrs`. Needs -`gateway_image` v1.101.0 or newer. See +security group only opens it to `gateway_metrics_scrape_cidrs`. See [Prometheus metrics](https://docs.litellm.ai/docs/proxy/prometheus) for the metrics themselves. @@ -275,10 +274,10 @@ tokens the workers used to (see [Aurora + IAM auth](#aurora--iam-auth)): the pooler mints a token from the task role, renews it before it expires and hands the workers a loopback URL with a static password instead -The componentized `gateway_image` starts through `python -m gateway.launch`, +The image's `gateway` component starts through `python -m gateway.launch`, which reads these variables, starts the pooler once per task and hands the -workers its loopback URL; the classic `litellm` image honours them the same -way. +workers its loopback URL; the monolithic `proxy` component honours them the +same way. ### Scaling the gateway on requests and tokens @@ -492,20 +491,26 @@ top — set org-wide tags there, per-deployment tags via the `tags` input. ## Image pulls -The defaults pull `ghcr.io/berriai/litellm-:${image_tag}`, -which is anonymous-readable. There are four images: `litellm-gateway`, -`litellm-backend`, `litellm-ui`, and `litellm-migrations` (slim image used -only by the one-off migration task — runs `prisma migrate deploy` against -the writer DB and exits). A published module version (Terraform Registry -`BerriAI/litellm/aws`) ships with `image_tag` stamped to its own release -tag, so bumping the module version bumps all four images with it. A git -ref of this monorepo has no published images of its own, so there +The default pulls `ghcr.io/berriai/litellm:${image_tag}`, which is +anonymous-readable. The gateway, backend and UI services, the metrics and +collector sidecars and the one-off migration task all run that one image +and pick their process through the entrypoint's first argument, so bumping +LiteLLM is a single variable change. A published module version (Terraform +Registry `BerriAI/litellm/aws`) ships with `image_tag` stamped to its own +release tag, so bumping the module version bumps every workload with it. A +git ref of this monorepo has no published images of its own, so there `image_tag` is required. Set `image_tag` to pin a different LiteLLM -release, or -`image_registry` to pull the same four images from a mirror. Set -`gateway_image` / `backend_image` / `ui_image` / `migrations_image` only -for a per-component override (custom build, different tag). To pull from a -private registry: +release, `image_registry` to pull from a mirror, or `image` for a full +override (custom build, digest pin). A tag older than the one this module +version ships with has no component entrypoint and only knows how to run +the monolithic proxy, so pinning one fails at container start. + +Migrating from the retired `gateway_image`, `backend_image`, `ui_image` +and `migrations_image` inputs: drop them and set `image_tag` (or `image`) +once. Terraform +rejects the old names at plan time (`An argument named "gateway_image" +is not expected here`), so a stale `.tfvars` cannot silently keep pulling +the per-component images. To pull from a private registry: - **ECR (same account)**: the execution role already has `AmazonECSTaskExecutionRolePolicy`, which grants ECR pull for repos in diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index dec7f599ced..bc40d78413e 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -194,11 +194,18 @@ locals { proxy_config_fetch_cmd = "python -c \"import os, boto3; boto3.client('s3', region_name=os.environ['AWS_REGION']).download_file(os.environ['LITELLM_PROXY_CONFIG_S3_BUCKET'], os.environ['LITELLM_PROXY_CONFIG_S3_KEY'], os.environ['CONFIG_FILE_PATH'])\"" - # Gateway always needs --workers wired in (no NUM_WORKERS env var support - # in the image entrypoint). When proxy_config is enabled we also have to - # pull the config from S3 first, so the command goes through `sh -c`; - # otherwise we keep the image's ENTRYPOINT and only override `command`. - gateway_uvicorn_args = "--host 0.0.0.0 --port 4000 --workers ${var.gateway_num_workers}" + image_entrypoint = "/app/docker-entrypoint.sh" + component_args = { + gateway = ["gateway", "--workers", tostring(var.gateway_num_workers)] + backend = ["backend"] + collector = ["collector"] + } + component_overrides = { + for component, args in local.component_args : component => local.proxy_config_enabled ? { + entryPoint = ["sh", "-c"] + command = ["${local.proxy_config_fetch_cmd} && exec ${local.image_entrypoint} ${join(" ", args)}"] + } : { command = args } + } gateway_pool_env = var.gateway_connection_pool_enabled ? [ { name = "LITELLM_PGBOUNCER_ENABLED", value = "true" }, @@ -215,11 +222,10 @@ locals { gateway_metrics_container = local.metrics_enabled ? [ { - name = "metrics" - image = local.gateway_image - essential = false - entryPoint = ["python", "-m", "litellm.proxy.prometheus_metrics_server"] - command = ["--port", tostring(var.gateway_metrics_port)] + name = "metrics" + image = local.image + essential = false + command = ["metrics", "--port", tostring(var.gateway_metrics_port)] portMappings = [{ containerPort = var.gateway_metrics_port, protocol = "tcp" }] environment = local.metrics_env @@ -244,28 +250,6 @@ locals { } ] : [] - backend_uvicorn_args = "--host 0.0.0.0 --port 4001" - - gateway_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run python -m gateway.launch ${local.gateway_uvicorn_args};; *) exec python -m gateway.launch ${local.gateway_uvicorn_args};; esac" - backend_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn backend.main:app ${local.backend_uvicorn_args};; *) exec uvicorn backend.main:app ${local.backend_uvicorn_args};; esac" - - gateway_proxy_overrides = local.proxy_config_enabled ? { - entryPoint = ["sh", "-c"] - command = [ - "${local.proxy_config_fetch_cmd} && ${local.gateway_launch_cmd}" - ] - } : { - entryPoint = ["sh", "-c"] - command = [local.gateway_launch_cmd] - } - - backend_proxy_overrides = local.proxy_config_enabled ? { - entryPoint = ["sh", "-c"] - command = [ - "${local.proxy_config_fetch_cmd} && ${local.backend_launch_cmd}" - ] - } : {} - collector_address = "tcp://127.0.0.1:${var.collector_port}" collector_env = var.collector_enabled ? [ { name = "LITELLM_COLLECTOR_ENABLED", value = "true" }, @@ -286,22 +270,15 @@ locals { local.collector_env, ) - collector_launch_cmd = "exec python -m litellm.proxy.collector" - collector_command = [ - local.proxy_config_enabled ? "${local.proxy_config_fetch_cmd} && ${local.collector_launch_cmd}" : local.collector_launch_cmd - ] - - collector_container = var.collector_enabled ? [{ + collector_container = var.collector_enabled ? [merge({ name = "collector" - image = local.gateway_image + image = local.image essential = false cpu = var.collector_cpu memory = var.collector_memory restartPolicy = { enabled = true } - entryPoint = ["sh", "-c"] - command = local.collector_command environment = concat( local.shared_env, local.gateway_extra_env_list, @@ -320,7 +297,7 @@ locals { awslogs-stream-prefix = "collector" } } - }] : [] + }, local.component_overrides.collector)] : [] } # ---------- Gateway ---------- @@ -361,7 +338,7 @@ resource "aws_ecs_task_definition" "gateway" { merge( { name = "gateway" - image = local.gateway_image + image = local.image essential = true portMappings = [{ containerPort = 4000, protocol = "tcp" }] @@ -382,7 +359,7 @@ resource "aws_ecs_task_definition" "gateway" { } } }, - local.gateway_proxy_overrides, + local.component_overrides.gateway, ) ], local.gateway_metrics_container, local.collector_container)) @@ -469,7 +446,7 @@ resource "aws_ecs_task_definition" "backend" { merge( { name = "backend" - image = local.backend_image + image = local.image essential = true portMappings = [{ containerPort = 4001, protocol = "tcp" }] @@ -492,7 +469,7 @@ resource "aws_ecs_task_definition" "backend" { } } }, - local.backend_proxy_overrides, + local.component_overrides.backend, ) ]) @@ -559,8 +536,9 @@ resource "aws_ecs_task_definition" "ui" { container_definitions = jsonencode([ { name = "ui" - image = local.ui_image + image = local.image essential = true + command = ["ui"] portMappings = [{ containerPort = 3000, protocol = "tcp" }] logConfiguration = { diff --git a/terraform/litellm/aws/locals.tf b/terraform/litellm/aws/locals.tf index c06b171d6bd..e31e2b3c11b 100644 --- a/terraform/litellm/aws/locals.tf +++ b/terraform/litellm/aws/locals.tf @@ -11,10 +11,7 @@ locals { # the stack can reference local.name. name = "${var.tenant}-litellm-${var.env}" - gateway_image = var.gateway_image != "" ? var.gateway_image : "${var.image_registry}/litellm-gateway:${var.image_tag}" - backend_image = var.backend_image != "" ? var.backend_image : "${var.image_registry}/litellm-backend:${var.image_tag}" - ui_image = var.ui_image != "" ? var.ui_image : "${var.image_registry}/litellm-ui:${var.image_tag}" - migrations_image = var.migrations_image != "" ? var.migrations_image : "${var.image_registry}/litellm-migrations:${var.image_tag}" + image = var.image != "" ? var.image : "${var.image_registry}/litellm:${var.image_tag}" # This is a reusable module — it declares no `provider` block, so the AWS # provider's `default_tags` is the caller's concern, not ours. To keep the diff --git a/terraform/litellm/aws/migrations.tf b/terraform/litellm/aws/migrations.tf index 57c33f69a8b..43ae33b5b83 100644 --- a/terraform/litellm/aws/migrations.tf +++ b/terraform/litellm/aws/migrations.tf @@ -1,16 +1,8 @@ -# Task definition for the dedicated litellm-migrations image. Mirrors the -# pre-install/pre-upgrade Helm hook in helm/litellm/templates/migrations-job.yaml. -# -# The image (built from migrations/Dockerfile) ships with -# `ENTRYPOINT ["python3", "/app/run.py"]`. run.py assembles DATABASE_URL from -# the discrete DATABASE_* env vars (IAM auth here) via DatabaseURLSettings, -# then calls ProxyExtrasDBManager.setup_database() — i.e. `prisma migrate -# deploy` with the v2 resolver and P3005/P3009/P3018 recovery. It does NOT -# read CONFIG_FILE_PATH, the master key, or DISABLE_SCHEMA_UPDATE, so we -# don't pass them. -# -# Invoked automatically by `terraform_data.migration` in bootstrap.tf during -# every apply (after the IAM-authed user has been created). The +# Runs the `migrations` component of local.image: run.py assembles DATABASE_URL +# from the discrete DATABASE_* env vars (IAM auth here) and runs `prisma +# migrate deploy`. It does not read CONFIG_FILE_PATH, the master key, or +# DISABLE_SCHEMA_UPDATE, so we don't pass them. Invoked automatically by +# `terraform_data.migration` in bootstrap.tf during every apply; the # `migration_run_command` output is preserved for break-glass manual re-runs. resource "aws_ecs_task_definition" "migrations" { count = local.database_enabled ? 1 : 0 @@ -28,10 +20,10 @@ resource "aws_ecs_task_definition" "migrations" { container_definitions = jsonencode([{ name = "migrations" - image = local.migrations_image + image = local.image essential = true + command = ["migrations"] - # No entryPoint/command override — the image's ENTRYPOINT runs run.py. environment = local.shared_env secrets = local.byo_database_secrets diff --git a/terraform/litellm/aws/tests/collector.tftest.hcl b/terraform/litellm/aws/tests/collector.tftest.hcl index 082f17b2c08..d38c3882a1b 100644 --- a/terraform/litellm/aws/tests/collector.tftest.hcl +++ b/terraform/litellm/aws/tests/collector.tftest.hcl @@ -67,14 +67,14 @@ run "enabled_adds_a_sidecar_that_shares_the_gateway_transport" { assert { condition = ( - local.collector_container[0].image == local.gateway_image && - local.collector_container[0].entryPoint == ["sh", "-c"] && - local.collector_container[0].command == ["exec python -m litellm.proxy.collector"] && + local.collector_container[0].image == local.image && + !can(local.collector_container[0].entryPoint) && + local.collector_container[0].command == tolist(["collector"]) && local.collector_container[0].essential == false && local.collector_container[0].restartPolicy.enabled == true && { for e in local.collector_container[0].environment : e.name => e.value }["LITELLM_JOB_ROLE"] == "collector" ) - error_message = "The sidecar must run litellm.proxy.collector from the gateway image as a restartable, non-essential collector." + error_message = "The sidecar must run the collector component of the shared image as a restartable, non-essential collector." } assert { @@ -109,8 +109,9 @@ run "proxy_config_is_fetched_by_the_sidecar_too" { assert { condition = ( + local.collector_container[0].entryPoint == tolist(["sh", "-c"]) && startswith(local.collector_container[0].command[0], local.proxy_config_fetch_cmd) && - endswith(local.collector_container[0].command[0], "exec python -m litellm.proxy.collector") && + endswith(local.collector_container[0].command[0], "exec /app/docker-entrypoint.sh collector") && contains([for e in local.collector_container[0].environment : e.name], "CONFIG_FILE_PATH") ) error_message = "The sidecar must pull the proxy config from S3 before starting, like the gateway does." diff --git a/terraform/litellm/aws/tests/connection_pool.tftest.hcl b/terraform/litellm/aws/tests/connection_pool.tftest.hcl index c618bd11768..c22f0a11fc1 100644 --- a/terraform/litellm/aws/tests/connection_pool.tftest.hcl +++ b/terraform/litellm/aws/tests/connection_pool.tftest.hcl @@ -117,12 +117,10 @@ run "gateway_starts_through_the_pool_aware_launcher" { assert { condition = alltrue([ - strcontains(local.gateway_launch_cmd, "exec python -m gateway.launch --host 0.0.0.0 --port 4000 --workers 4"), - strcontains(local.gateway_launch_cmd, "exec ddtrace-run python -m gateway.launch --host 0.0.0.0 --port 4000 --workers 4"), - !strcontains(local.gateway_launch_cmd, "uvicorn gateway.main:app"), - local.gateway_proxy_overrides.command[0] == local.gateway_launch_cmd, + local.component_overrides.gateway.command == tolist(["gateway", "--workers", "4"]), + !can(local.component_overrides.gateway.entryPoint), ]) - error_message = "The gateway must start through gateway.launch (with and without ddtrace) so the pooler starts once before uvicorn forks the workers." + error_message = "The gateway must start through the image's gateway component with the configured worker count so the pooler starts once before uvicorn forks the workers." } } diff --git a/terraform/litellm/aws/tests/images.tftest.hcl b/terraform/litellm/aws/tests/images.tftest.hcl index 2d03a46b7a3..800b989af8a 100644 --- a/terraform/litellm/aws/tests/images.tftest.hcl +++ b/terraform/litellm/aws/tests/images.tftest.hcl @@ -16,7 +16,7 @@ variables { azs = ["us-east-1a", "us-east-1b"] } -run "every_component_follows_image_registry_and_image_tag" { +run "the_image_follows_image_registry_and_image_tag" { command = plan variables { @@ -25,17 +25,12 @@ run "every_component_follows_image_registry_and_image_tag" { } assert { - condition = ( - local.gateway_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-gateway:v9.9.9" && - local.backend_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-backend:v9.9.9" && - local.ui_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-ui:v9.9.9" && - local.migrations_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-migrations:v9.9.9" - ) - error_message = "All four images must be composed as /litellm-:." + condition = local.image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm:v9.9.9" + error_message = "The image must be composed as /litellm:." } } -run "an_empty_image_tag_fails_closed_unless_every_image_is_overridden" { +run "an_empty_image_tag_fails_closed_unless_image_is_set" { command = plan variables { @@ -55,50 +50,39 @@ run "a_null_image_tag_takes_the_module_default" { expect_failures = [var.image_tag] } -run "four_explicit_uris_need_no_image_tag" { +run "an_explicit_uri_needs_no_image_tag" { command = plan variables { - image_tag = "" - gateway_image = "example.com/custom/gateway:nightly" - backend_image = "example.com/custom/backend:nightly" - ui_image = "example.com/custom/ui:nightly" - migrations_image = "example.com/custom/migrations:nightly" + image_tag = "" + image = "example.com/custom/litellm:nightly" } assert { - condition = local.migrations_image == "example.com/custom/migrations:nightly" - error_message = "Explicit per-component URIs must satisfy the module without an image_tag." + condition = local.image == "example.com/custom/litellm:nightly" + error_message = "An explicit image URI must satisfy the module without an image_tag." } } -run "composed_defaults_pull_from_ghcr_at_the_image_tag" { +run "the_composed_default_pulls_from_ghcr_at_the_image_tag" { command = plan assert { - condition = alltrue([ - for image in [local.gateway_image, local.backend_image, local.ui_image, local.migrations_image] : - startswith(image, "ghcr.io/berriai/litellm-") && endswith(image, ":${var.image_tag}") - ]) - error_message = "With no overrides every component must come from ghcr.io/berriai at image_tag." + condition = local.image == "ghcr.io/berriai/litellm:${var.image_tag}" + error_message = "With no override the image must come from ghcr.io/berriai at image_tag." } } -run "a_per_component_uri_wins_over_the_composed_image" { +run "an_explicit_uri_wins_over_the_composed_image" { command = plan variables { - image_tag = "v9.9.9" - gateway_image = "example.com/custom/gateway:nightly" + image_tag = "v9.9.9" + image = "example.com/custom/litellm:nightly" } assert { - condition = ( - local.gateway_image == "example.com/custom/gateway:nightly" && - local.backend_image == "ghcr.io/berriai/litellm-backend:v9.9.9" && - local.ui_image == "ghcr.io/berriai/litellm-ui:v9.9.9" && - local.migrations_image == "ghcr.io/berriai/litellm-migrations:v9.9.9" - ) - error_message = "A per-component override must replace only that component's image." + condition = local.image == "example.com/custom/litellm:nightly" + error_message = "A full image URI must replace the composed image." } } diff --git a/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl b/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl index b330d4b91c3..ab1a94247f2 100644 --- a/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl +++ b/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl @@ -56,14 +56,15 @@ run "metrics_port_adds_a_sidecar_volume_and_scrape_rule" { length(local.gateway_metrics_container) == 1, local.gateway_metrics_container[0].name == "metrics", local.gateway_metrics_container[0].essential == false, - join(" ", local.gateway_metrics_container[0].entryPoint) == "python -m litellm.proxy.prometheus_metrics_server", - join(" ", local.gateway_metrics_container[0].command) == "--port 9464", + local.gateway_metrics_container[0].image == local.image, + !can(local.gateway_metrics_container[0].entryPoint), + join(" ", local.gateway_metrics_container[0].command) == "metrics --port 9464", one(local.gateway_metrics_container[0].portMappings).containerPort == 9464, one(local.gateway_metrics_container[0].environment).value == "/tmp/litellm_prometheus_multiproc", one(local.gateway_metrics_container[0].mountPoints).sourceVolume == "prometheus-multiproc", strcontains(local.gateway_metrics_container[0].healthCheck.command[3], "9464"), ]) - error_message = "The metrics sidecar must run prometheus_metrics_server on the configured port, share the multiproc volume, and health-check that port." + error_message = "The metrics sidecar must run the shared image's metrics component on the configured port, share the multiproc volume, and health-check that port." } assert { diff --git a/terraform/litellm/aws/tests/unified_image.tftest.hcl b/terraform/litellm/aws/tests/unified_image.tftest.hcl new file mode 100644 index 00000000000..2aead6b474c --- /dev/null +++ b/terraform/litellm/aws/tests/unified_image.tftest.hcl @@ -0,0 +1,73 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } +} +mock_provider "random" {} + +variables { + region = "us-east-1" + tenant = "acme" + env = "test" + allow_plaintext_alb = true + azs = ["us-east-1a", "us-east-1b"] + image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/litellm:test" + gateway_num_workers = 3 + gateway_metrics_port = 9464 + collector_enabled = true +} + +run "every_container_runs_a_component_of_the_one_image" { + command = plan + + assert { + condition = alltrue([ + jsondecode(aws_ecs_task_definition.ui.container_definitions)[0].image == var.image, + join(" ", jsondecode(aws_ecs_task_definition.ui.container_definitions)[0].command) == "ui", + !can(jsondecode(aws_ecs_task_definition.ui.container_definitions)[0].entryPoint), + ]) + error_message = "The UI task must run the ui component of var.image through the image entrypoint." + } + + assert { + condition = alltrue([ + join(" ", local.component_overrides.gateway.command) == "gateway --workers 3", + join(" ", local.component_overrides.backend.command) == "backend", + join(" ", local.component_overrides.collector.command) == "collector", + alltrue([for component in keys(local.component_args) : !can(local.component_overrides[component].entryPoint)]), + local.gateway_metrics_container[0].image == var.image, + join(" ", local.gateway_metrics_container[0].command) == "metrics --port 9464", + !can(local.gateway_metrics_container[0].entryPoint), + local.collector_container[0].image == var.image, + ]) + error_message = "Gateway, backend and the sidecars must keep the image entrypoint and select their component (plus the gateway worker count) through command." + } +} + +run "proxy_config_wraps_the_fetch_around_the_same_component" { + command = plan + + variables { + proxy_config = { model_list = [] } + } + + assert { + condition = alltrue([ + for component, args in local.component_args : + join(" ", local.component_overrides[component].entryPoint) == "sh -c" && + startswith(local.component_overrides[component].command[0], local.proxy_config_fetch_cmd) && + endswith(local.component_overrides[component].command[0], " && exec /app/docker-entrypoint.sh ${join(" ", args)}") + ]) + error_message = "With proxy_config, gateway, backend and collector must download the config and then exec the image entrypoint with their component." + } + + assert { + condition = alltrue([ + !can(local.gateway_metrics_container[0].entryPoint), + !can(jsondecode(aws_ecs_task_definition.ui.container_definitions)[0].entryPoint), + ]) + error_message = "The metrics sidecar and the UI never read the proxy config and must keep the plain image entrypoint." + } +} diff --git a/terraform/litellm/aws/variables.tf b/terraform/litellm/aws/variables.tf index be7656c2d48..fb5460ff56d 100644 --- a/terraform/litellm/aws/variables.tf +++ b/terraform/litellm/aws/variables.tf @@ -133,52 +133,33 @@ variable "azs" { default = [] } -# ---------- Component images ---------- +# ---------- Image ---------- # variable "image_registry" { - description = "Registry path prefix used to compose the four LiteLLM image URIs as `/litellm-:`. Per-component overrides (`gateway_image`, `backend_image`, `ui_image`, `migrations_image`) bypass this entirely when set." + description = "Registry path prefix used to compose the LiteLLM image URI as `/litellm:`. `image` bypasses this entirely when set." type = string default = "ghcr.io/berriai" } variable "image_tag" { - description = "Tag applied to all four litellm-* images when composed from `image_registry`. A published module version ships with this stamped to its own release tag, which matches the `v`-prefixed tag published to GHCR for the same LiteLLM release. Set it to pin a different LiteLLM release. When sourcing the module from a git ref of the monorepo it has no default and must be set." + description = "Tag of the LiteLLM image when composed from `image_registry`. A published module version ships with this stamped to its own release tag, which matches the `v`-prefixed tag published to GHCR for the same LiteLLM release. Set it to pin a different LiteLLM release. When sourcing the module from a git ref of the monorepo it has no default and must be set. Tags older than the one a module version ships with have no component entrypoint and only run the monolithic proxy." type = string default = "" nullable = false validation { - condition = var.image_tag != "" || (var.gateway_image != "" && var.backend_image != "" && var.ui_image != "" && var.migrations_image != "") - error_message = "image_tag is empty. Published module versions (registry.terraform.io/modules/BerriAI/litellm) ship with it stamped to their own release. A git ref of the monorepo has no published images of its own, so set image_tag to a published LiteLLM release tag or set all four *_image URIs." + condition = var.image_tag != "" || var.image != "" + error_message = "image_tag is empty. Published module versions (registry.terraform.io/modules/BerriAI/litellm) ship with it stamped to their own release. A git ref of the monorepo has no published images of its own, so set image_tag to a published LiteLLM release tag or set image to a full URI." } } -variable "gateway_image" { - description = "Full image URI for the gateway (data plane, port 4000). Empty (default) composes from `image_registry` + `image_tag`." - type = string - default = "" -} - -variable "backend_image" { - description = "Full image URI for the backend (management API, port 4001). Empty (default) composes from `image_registry` + `image_tag`." - type = string - default = "" -} - -variable "ui_image" { - description = "Full image URI for the UI (nginx static export, port 3000). Empty (default) composes from `image_registry` + `image_tag`." - type = string - default = "" -} - -variable "migrations_image" { +variable "image" { description = <<-EOT - Full image URI for the one-off prisma migration task. Empty (default) - composes from `image_registry` + `image_tag`. Built from - `migrations/Dockerfile` — slim image whose ENTRYPOINT runs - `python3 /app/run.py` (assembles DATABASE_URL from DATABASE_* env vars - via DatabaseURLSettings, then runs `prisma migrate deploy`). Should track - the same release tag as gateway/backend/ui. + Full URI of the one LiteLLM image every task runs. Its entrypoint picks + the process from the first argument (`gateway`, `backend`, `ui`, + `migrations`, `metrics`, `collector`), so the gateway, backend, UI and + migration task definitions and the sidecars all pull this URI. Empty + (default) composes from `image_registry` + `image_tag`. EOT type = string default = "" @@ -649,13 +630,11 @@ variable "gateway_metrics_port" { description = <<-EOT Serve Prometheus /metrics from a `metrics` sidecar container in the gateway task on this port (1-65535, not 4000), so a scrape never runs on - an inference worker. The sidecar runs the gateway image with - `python -m litellm.proxy.prometheus_metrics_server` and aggregates the - workers' PROMETHEUS_MULTIPROC_DIR samples over a task volume. Null (the - default) leaves /metrics on the gateway port only. The sidecar port has - no virtual-key auth and is not routed through the ALB; open it to your - scrapers with gateway_metrics_scrape_cidrs. Needs gateway_image v1.101.0 - or newer. + an inference worker. The sidecar runs the `metrics` component of `image` + and aggregates the workers' PROMETHEUS_MULTIPROC_DIR samples over a task + volume. Null (the default) leaves /metrics on the gateway port only. The + sidecar port has no virtual-key auth and is not routed through the ALB; + open it to your scrapers with gateway_metrics_scrape_cidrs. EOT type = number default = null @@ -811,7 +790,7 @@ variable "billing_metrics_token_url" { # ---------- Collector sidecar ---------- # # Opt-in offload of spend tracking from the gateway's uvicorn workers to a -# `python -m litellm.proxy.collector` sidecar in the same Fargate task (helm's +# `collector` component sidecar in the same Fargate task (helm's # `gateway.collector`). Fargate awsvpc tasks share one network namespace, # so the sidecar listens on loopback TCP. Disabled (the default) adds nothing # to the task definition. diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index 9e21ba14543..32bbd1533f9 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -15,7 +15,7 @@ Deploys the componentized LiteLLM proxy on GCP: - **Secret Manager** entries for `LITELLM_MASTER_KEY` and `DATABASE_PASSWORD` - **Cloud Run v2** services for `gateway` (port 4000), `backend` (port 4001), and `ui` (port 3000), all using a shared runtime service account -- **Cloud Run Job** (`litellm-migrations`) that runs `prisma migrate deploy` from the dedicated `ghcr.io/berriai/litellm-migrations` image +- **Cloud Run Job** (`litellm-migrations`) that runs `prisma migrate deploy` through the `migrations` component of the same image - **External global HTTP(S) load balancer** with serverless NEGs and a URL map mirroring the helm-chart ingress path routing: - LLM data-plane prefixes → `gateway` @@ -24,18 +24,18 @@ Deploys the componentized LiteLLM proxy on GCP: ## Image pulls -There are four images: `litellm-gateway`, `litellm-backend`, `litellm-ui`, -and `litellm-migrations` (slim image used only by the one-off Cloud Run -Job — runs `prisma migrate deploy` against the writer DB and exits). -Bump them together when bumping LiteLLM. +Every Cloud Run service, sidecar and the migrations Job runs the one +`litellm` image and picks its process through the entrypoint's first +argument (`gateway`, `backend`, `ui`, `migrations`, `metrics`, +`collector`), so bumping LiteLLM is a single `image_tag` change. **Required override.** The `image_registry` default (`ghcr.io/berriai`) does **not** work as-is — Cloud Run only accepts images from Artifact Registry, `[region.]gcr.io`, or `docker.io`, and rejects `ghcr.io` URIs at apply time. Every deploy (including HCP Terraform 1-click) must supply either `image_registry` pointed at an Artifact Registry remote -repo backed by GHCR, or full per-component `*_image` URIs against -images you've already mirrored. The default is present only so +repo backed by GHCR, or a full `image` URI for a copy you've already +mirrored. The default is present only so `terraform plan` succeeds during local iteration. **One-time setup (per project):** create a remote repo and let Cloud Run @@ -56,15 +56,22 @@ Then point the stack at it via `image_registry`: image_registry = "us-central1-docker.pkg.dev/my-gcp-project/litellm/berriai" ``` -The four `litellm-:${image_tag}` URIs are composed from -`image_registry` and `image_tag`. A published module version (Terraform -Registry `BerriAI/litellm/google`) ships with `image_tag` stamped to its -own release tag, so bumping the module version bumps all four images with -it. A git ref of this monorepo has no published images of its own, so -there `image_tag` is required. Set `image_tag` to pin a different LiteLLM -release. Set `gateway_image` / `backend_image` / `ui_image` / -`migrations_image` only if you need a per-component override (custom -build, different tag). +The `/litellm:` URI is composed from those two +vars. A published module version (Terraform Registry `BerriAI/litellm/google`) +ships with `image_tag` stamped to its own release tag, so bumping the module +version bumps every workload with it. A git ref of this monorepo has no +published images of its own, so there `image_tag` is required. Set `image` +only if you need a full override (custom build, digest pin). + +A tag older than the one this module version ships with has no component +entrypoint and only knows how to run the monolithic proxy, so pinning one +fails at container start. + +Migrating from the retired `gateway_image`, `backend_image`, `ui_image` +and `migrations_image` inputs: drop them and set `image_registry` + +`image_tag` (or `image`) once. Terraform rejects the old names at plan +time, so a stale `.tfvars` cannot silently keep pulling the per-component +images. Two further notes: @@ -248,8 +255,7 @@ stack also adds Google's Secret Manager that scrapes `localhost:/metrics` every 30s and writes to Cloud Monitoring as `prometheus.googleapis.com/...` metrics. Enabling it grants the runtime service account `roles/monitoring.metricWriter` and -`roles/logging.logWriter` on the project. Needs `gateway_image` v1.101.0 or -newer. See [Prometheus metrics](https://docs.litellm.ai/docs/proxy/prometheus) +`roles/logging.logWriter` on the project. See [Prometheus metrics](https://docs.litellm.ai/docs/proxy/prometheus) for the metrics themselves ```hcl diff --git a/terraform/litellm/gcp/bootstrap.tf b/terraform/litellm/gcp/bootstrap.tf index dead5c41f6b..deaf32ad153 100644 --- a/terraform/litellm/gcp/bootstrap.tf +++ b/terraform/litellm/gcp/bootstrap.tf @@ -7,8 +7,8 @@ # so they don't go live until the schema is in place. # # Triggers: -# - re-runs if the migrations image changes (new release ships new prisma -# migration files). +# - re-runs if the image changes (new release ships new prisma migration +# files). # - re-runs if the migration job is recreated. # # Requires `gcloud` on the machine running terraform, with user creds live @@ -19,7 +19,7 @@ resource "terraform_data" "migration" { triggers_replace = { job_id = google_cloud_run_v2_job.migrations[0].id - job_image = local.migrations_image + job_image = local.image } provisioner "local-exec" { diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 07bf68b3a62..27877c7f928 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -131,16 +131,12 @@ locals { { name = "LITELLM_PGBOUNCER_MAX_CLIENT_CONN", value = tostring(var.gateway_pool_max_client_conn) }, ] : [] - gateway_uvicorn_args = "--host 0.0.0.0 --port 4000 --workers ${var.gateway_num_workers}" - backend_uvicorn_args = "--host 0.0.0.0 --port 4001" - - gateway_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run python -m gateway.launch ${local.gateway_uvicorn_args};; *) exec python -m gateway.launch ${local.gateway_uvicorn_args};; esac" - backend_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn backend.main:app ${local.backend_uvicorn_args};; *) exec uvicorn backend.main:app ${local.backend_uvicorn_args};; esac" + image_entrypoint = "/app/docker-entrypoint.sh" gateway_args = join(" && ", concat( local.redis_ca_fragment, local.database_url_fragment, - [local.gateway_launch_cmd], + ["exec ${local.image_entrypoint} gateway --workers ${var.gateway_num_workers}"], )) metrics_enabled = var.create_runtime && var.gateway_metrics_port != null @@ -161,7 +157,7 @@ locals { backend_args = join(" && ", concat( local.redis_ca_fragment, local.database_url_fragment, - [local.backend_launch_cmd], + ["exec ${local.image_entrypoint} backend"], )) collector_address = "tcp://127.0.0.1:${var.collector_port}" @@ -189,13 +185,12 @@ locals { collector_args = join(" && ", concat( local.redis_ca_fragment, local.database_url_fragment, - ["exec python -m litellm.proxy.collector"], + ["exec ${local.image_entrypoint} collector"], )) - # Env shipped to the migrations Job. The migrations image runs run.py - # which assembles DATABASE_URL from these discrete vars itself, so we - # only need writer-side DB env (no read replica, no proxy_config, no - # master key). + # run.py assembles DATABASE_URL from these discrete vars itself, so the + # migrations Job only needs writer-side DB env (no read replica, no + # proxy_config, no master key). migrations_env_kv = [ { name = "DATABASE_HOST", value = google_sql_database_instance.writer.private_ip_address }, { name = "DATABASE_PORT", value = "5432" }, @@ -241,7 +236,7 @@ resource "google_cloud_run_v2_service" "gateway" { containers { name = "gateway" - image = local.gateway_image + image = local.image command = ["sh", "-c"] args = [local.gateway_args] @@ -317,10 +312,9 @@ resource "google_cloud_run_v2_service" "gateway" { dynamic "containers" { for_each = local.metrics_enabled ? [1] : [] content { - name = "metrics" - image = local.gateway_image - command = ["python", "-m", "litellm.proxy.prometheus_metrics_server"] - args = ["--port", tostring(var.gateway_metrics_port)] + name = "metrics" + image = local.image + args = ["metrics", "--port", tostring(var.gateway_metrics_port)] dynamic "env" { for_each = local.metrics_env_kv @@ -383,7 +377,7 @@ resource "google_cloud_run_v2_service" "gateway" { for_each = var.collector_enabled ? [1] : [] content { name = "spend-collector" - image = local.gateway_image + image = local.image command = ["sh", "-c"] args = [local.collector_args] @@ -504,7 +498,7 @@ resource "google_cloud_run_v2_service" "backend" { } containers { - image = local.backend_image + image = local.image command = ["sh", "-c"] args = [local.backend_args] @@ -618,7 +612,8 @@ resource "google_cloud_run_v2_service" "ui" { } containers { - image = local.ui_image + image = local.image + args = ["ui"] ports { container_port = 3000 @@ -680,9 +675,6 @@ resource "google_cloud_run_v2_service_iam_member" "ui_allusers" { } # ---------- Migrations job ---------- -# Dedicated litellm-migrations image — slim, ENTRYPOINT runs run.py which -# assembles DATABASE_URL from the DATABASE_* env vars and runs `prisma -# migrate deploy`. No proxy_config, no master key, no shell wrapper. resource "google_cloud_run_v2_job" "migrations" { count = var.create_runtime ? 1 : 0 @@ -701,7 +693,8 @@ resource "google_cloud_run_v2_job" "migrations" { } containers { - image = local.migrations_image + image = local.image + args = ["migrations"] # Prisma's Node + Rust engine plus the v2 migration resolver # routinely peaks above 1 GiB while applying the schema, so 2 GiB diff --git a/terraform/litellm/gcp/examples/default/TUTORIAL.md b/terraform/litellm/gcp/examples/default/TUTORIAL.md index ec82feb739a..6e816167aeb 100644 --- a/terraform/litellm/gcp/examples/default/TUTORIAL.md +++ b/terraform/litellm/gcp/examples/default/TUTORIAL.md @@ -31,7 +31,7 @@ gcloud services enable \ ## Create the Artifact Registry passthrough to GHCR -Cloud Run only pulls from Artifact Registry, `gcr.io`, or `docker.io`; it rejects `ghcr.io` URIs at apply time. The four LiteLLM images live on GHCR, so the stack needs a remote Artifact Registry repo pointed at GHCR. This is a one-time setup per project. +Cloud Run only pulls from Artifact Registry, `gcr.io`, or `docker.io`; it rejects `ghcr.io` URIs at apply time. The LiteLLM image lives on GHCR, so the stack needs a remote Artifact Registry repo pointed at GHCR. This is a one-time setup per project. ```bash gcloud artifacts repositories create litellm \ diff --git a/terraform/litellm/gcp/examples/default/deploystack.json b/terraform/litellm/gcp/examples/default/deploystack.json index 732d4a187b1..2b80084ffa3 100644 --- a/terraform/litellm/gcp/examples/default/deploystack.json +++ b/terraform/litellm/gcp/examples/default/deploystack.json @@ -24,12 +24,12 @@ }, { "name": "image_tag", - "description": "Tag for the four litellm-* images (gateway, backend, ui, migrations). Bump together when bumping LiteLLM", + "description": "Tag of the litellm image every workload (gateway, backend, ui, migrations) runs", "default": "" }, { "name": "image_registry", - "description": "Artifact Registry path prefix for the four litellm-* images. Format: -docker.pkg.dev//litellm/berriai, pointing at the remote repo you created above. Substitute BOTH REGION and PROJECT_ID in the default to match the AR repo you just created (REGION must match the region you picked above). The ghcr.io/berriai default in the module does NOT work; Cloud Run rejects ghcr.io URIs at apply time", + "description": "Artifact Registry path prefix for the litellm image. Format: -docker.pkg.dev//litellm/berriai, pointing at the remote repo you created above. Substitute BOTH REGION and PROJECT_ID in the default to match the AR repo you just created (REGION must match the region you picked above). The ghcr.io/berriai default in the module does NOT work; Cloud Run rejects ghcr.io URIs at apply time", "default": "REGION-docker.pkg.dev/PROJECT_ID/litellm/berriai" }, { diff --git a/terraform/litellm/gcp/examples/default/main.tf b/terraform/litellm/gcp/examples/default/main.tf index 9b18a6df863..b1462fd2f95 100644 --- a/terraform/litellm/gcp/examples/default/main.tf +++ b/terraform/litellm/gcp/examples/default/main.tf @@ -21,7 +21,7 @@ # "Using as a module" section. # # Knobs not surfaced as variables here (per-component sizing/instances, -# Cloud SQL tier/edition, Memorystore tier, per-component image overrides) +# Cloud SQL tier/edition, Memorystore tier, a full `image` override) # can be set directly on this block — see ../../variables.tf. module "litellm" { source = "../../" diff --git a/terraform/litellm/gcp/examples/default/terraform.tfvars.example b/terraform/litellm/gcp/examples/default/terraform.tfvars.example index 6456b525d08..07901d10186 100644 --- a/terraform/litellm/gcp/examples/default/terraform.tfvars.example +++ b/terraform/litellm/gcp/examples/default/terraform.tfvars.example @@ -38,11 +38,12 @@ env = "stage" # Images. Cloud Run rejects ghcr.io, so a real deploy must point # image_registry at an Artifact Registry remote repo (see README "Image -# pulls"); image_tag is applied to all four litellm-* images. A published -# module version ships with it stamped to its own release tag, a checkout of -# the monorepo has no default and needs it set. Per-component *_image overrides are -# NOT exposed here. Set them directly on the `module "litellm"` block in -# main.tf (see ../../variables.tf) if you need to mix-and-match versions. +# pulls"); every workload runs /litellm:. A +# published module version ships with image_tag stamped to its own release +# tag, a checkout of the monorepo has no default and needs it set. The full +# `image` override is NOT exposed here: set it directly on the +# `module "litellm"` block in main.tf (see ../../variables.tf) if you need +# a custom build. # image_registry = "us-central1-docker.pkg.dev/my-gcp-project/litellm/berriai" # image_tag = "" diff --git a/terraform/litellm/gcp/examples/default/variables.tf b/terraform/litellm/gcp/examples/default/variables.tf index dcf84572fd7..d77a29b9899 100644 --- a/terraform/litellm/gcp/examples/default/variables.tf +++ b/terraform/litellm/gcp/examples/default/variables.tf @@ -1,6 +1,6 @@ # Curated surface for the one-command deploy path. The module (../../) # exposes far more knobs (per-component CPU/memory/instances, Cloud SQL -# tier/edition, Memorystore tier, per-component image overrides, …). To +# tier/edition, Memorystore tier, a full `image` override, …). To # tune those, set them directly on the `module "litellm"` block in # main.tf, or call the module from your own root config. Full per-variable # docs live in ../../variables.tf — the module is the source of truth. @@ -75,15 +75,15 @@ variable "ui_password" { # Image source. Cloud Run rejects ghcr.io, so a real deploy must point # image_registry at an Artifact Registry remote repo (see README "Image -# pulls"). Per-component overrides live in ../../variables.tf. +# pulls"). The full `image` override lives in ../../variables.tf. variable "image_registry" { - description = "Registry path prefix; images composed as /litellm-:." + description = "Registry path prefix; the image is composed as /litellm:." type = string default = "ghcr.io/berriai" } variable "image_tag" { - description = "Tag applied to all four litellm-* images. Bump in lockstep." + description = "Tag of the litellm image every workload runs." type = string default = "" } diff --git a/terraform/litellm/gcp/locals.tf b/terraform/litellm/gcp/locals.tf index e82892b27cb..270036590c5 100644 --- a/terraform/litellm/gcp/locals.tf +++ b/terraform/litellm/gcp/locals.tf @@ -92,11 +92,5 @@ locals { { name = "PROXY_CONFIG_HASH", value = md5(local.proxy_config_yaml) }, ] : [] - # Resolved image URIs: per-component override wins, otherwise compose - # from image_registry + image_tag. Cloud Run only accepts AR / gcr.io / - # docker.io paths — see variables.tf for the full constraint list. - gateway_image = var.gateway_image != "" ? var.gateway_image : "${var.image_registry}/litellm-gateway:${var.image_tag}" - backend_image = var.backend_image != "" ? var.backend_image : "${var.image_registry}/litellm-backend:${var.image_tag}" - ui_image = var.ui_image != "" ? var.ui_image : "${var.image_registry}/litellm-ui:${var.image_tag}" - migrations_image = var.migrations_image != "" ? var.migrations_image : "${var.image_registry}/litellm-migrations:${var.image_tag}" + image = var.image != "" ? var.image : "${var.image_registry}/litellm:${var.image_tag}" } diff --git a/terraform/litellm/gcp/tests/collector.tftest.hcl b/terraform/litellm/gcp/tests/collector.tftest.hcl index f16d02d9c7e..6c7810454df 100644 --- a/terraform/litellm/gcp/tests/collector.tftest.hcl +++ b/terraform/litellm/gcp/tests/collector.tftest.hcl @@ -65,14 +65,14 @@ run "enabled_adds_a_sidecar_that_shares_the_gateway_transport" { assert { condition = ( - google_cloud_run_v2_service.gateway[0].template[0].containers[1].image == local.gateway_image && + google_cloud_run_v2_service.gateway[0].template[0].containers[1].image == google_cloud_run_v2_service.gateway[0].template[0].containers[0].image && google_cloud_run_v2_service.gateway[0].template[0].containers[1].command == tolist(["sh", "-c"]) && - endswith(google_cloud_run_v2_service.gateway[0].template[0].containers[1].args[0], " && exec python -m litellm.proxy.collector") && + endswith(google_cloud_run_v2_service.gateway[0].template[0].containers[1].args[0], " && exec /app/docker-entrypoint.sh collector") && strcontains(google_cloud_run_v2_service.gateway[0].template[0].containers[1].args[0], "export DATABASE_URL=") && strcontains(google_cloud_run_v2_service.gateway[0].template[0].containers[1].args[0], "REDIS_SSL_CA_CERTS") && { for e in google_cloud_run_v2_service.gateway[0].template[0].containers[1].env : e.name => e.value }["LITELLM_JOB_ROLE"] == "collector" ) - error_message = "The sidecar must run litellm.proxy.collector from the gateway image with the same Redis CA + DATABASE_URL bootstrap as the gateway." + error_message = "The sidecar must run the collector component of the gateway's image with the same Redis CA + DATABASE_URL bootstrap as the gateway." } assert { diff --git a/terraform/litellm/gcp/tests/connection_pool.tftest.hcl b/terraform/litellm/gcp/tests/connection_pool.tftest.hcl index c0ad842ff95..98ef6f7fe5d 100644 --- a/terraform/litellm/gcp/tests/connection_pool.tftest.hcl +++ b/terraform/litellm/gcp/tests/connection_pool.tftest.hcl @@ -145,17 +145,15 @@ run "gateway_starts_through_the_pool_aware_launcher" { assert { condition = alltrue([ - strcontains(local.gateway_launch_cmd, "exec python -m gateway.launch --host 0.0.0.0 --port 4000 --workers 4"), - strcontains(local.gateway_launch_cmd, "exec ddtrace-run python -m gateway.launch --host 0.0.0.0 --port 4000 --workers 4"), - !strcontains(local.gateway_launch_cmd, "uvicorn gateway.main:app"), - endswith(google_cloud_run_v2_service.gateway[0].template[0].containers[0].args[0], local.gateway_launch_cmd), + google_cloud_run_v2_service.gateway[0].template[0].containers[0].command == tolist(["sh", "-c"]), + endswith(google_cloud_run_v2_service.gateway[0].template[0].containers[0].args[0], " && exec /app/docker-entrypoint.sh gateway --workers 4"), ]) - error_message = "The gateway must start through gateway.launch (with and without ddtrace) so the pooler starts once before uvicorn forks the workers." + error_message = "The gateway must start through the image's gateway component with the configured worker count so the pooler starts once before uvicorn forks the workers." } assert { - condition = strcontains(local.backend_launch_cmd, "uvicorn backend.main:app") - error_message = "The backend has no workers to share a pooler and keeps starting uvicorn directly." + condition = endswith(google_cloud_run_v2_service.backend[0].template[0].containers[0].args[0], " && exec /app/docker-entrypoint.sh backend") + error_message = "The backend has no workers to share a pooler and starts the plain backend component." } } diff --git a/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl b/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl index fb5446b4c56..449a048428a 100644 --- a/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl +++ b/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl @@ -66,11 +66,11 @@ run "metrics_sidecar_enabled" { assert { condition = alltrue([ - google_cloud_run_v2_service.gateway[0].template[0].containers[1].image == local.gateway_image, - join(" ", google_cloud_run_v2_service.gateway[0].template[0].containers[1].command) == "python -m litellm.proxy.prometheus_metrics_server", - join(" ", google_cloud_run_v2_service.gateway[0].template[0].containers[1].args) == "--port 4001", + google_cloud_run_v2_service.gateway[0].template[0].containers[1].image == google_cloud_run_v2_service.gateway[0].template[0].containers[0].image, + google_cloud_run_v2_service.gateway[0].template[0].containers[1].command == null, + join(" ", google_cloud_run_v2_service.gateway[0].template[0].containers[1].args) == "metrics --port 4001", ]) - error_message = "The metrics sidecar must run the gateway image's prometheus_metrics_server on the configured port." + error_message = "The metrics sidecar must run the gateway image's metrics component on the configured port." } assert { diff --git a/terraform/litellm/gcp/tests/unified_image.tftest.hcl b/terraform/litellm/gcp/tests/unified_image.tftest.hcl new file mode 100644 index 00000000000..1c7652a7110 --- /dev/null +++ b/terraform/litellm/gcp/tests/unified_image.tftest.hcl @@ -0,0 +1,72 @@ +mock_provider "google" { + mock_resource "google_redis_instance" { + defaults = { + host = "10.0.0.4" + port = 6379 + server_ca_certs = [{ + cert = "-----BEGIN CERTIFICATE-----\nmock\n-----END CERTIFICATE-----" + }] + } + } +} + +mock_provider "google-beta" {} +mock_provider "random" {} + +variables { + project_id = "test-project" + tenant = "tenant" + env = "test" + allow_plaintext_lb = true + image_registry = "us-central1-docker.pkg.dev/test-project/litellm/berriai" + image_tag = "v9.9.9-stable" + gateway_num_workers = 3 +} + +run "registry_and_tag_compose_one_image_for_every_workload" { + command = plan + + assert { + condition = alltrue([ + google_cloud_run_v2_service.gateway[0].template[0].containers[0].image == "us-central1-docker.pkg.dev/test-project/litellm/berriai/litellm:v9.9.9-stable", + google_cloud_run_v2_service.backend[0].template[0].containers[0].image == "us-central1-docker.pkg.dev/test-project/litellm/berriai/litellm:v9.9.9-stable", + google_cloud_run_v2_service.ui[0].template[0].containers[0].image == "us-central1-docker.pkg.dev/test-project/litellm/berriai/litellm:v9.9.9-stable", + google_cloud_run_v2_job.migrations[0].template[0].template[0].containers[0].image == "us-central1-docker.pkg.dev/test-project/litellm/berriai/litellm:v9.9.9-stable", + terraform_data.migration[0].triggers_replace.job_image == "us-central1-docker.pkg.dev/test-project/litellm/berriai/litellm:v9.9.9-stable", + ]) + error_message = "All workloads must pull /litellm: and the migration re-run trigger must follow it." + } + + assert { + condition = alltrue([ + google_cloud_run_v2_service.ui[0].template[0].containers[0].command == null, + google_cloud_run_v2_service.ui[0].template[0].containers[0].args == tolist(["ui"]), + google_cloud_run_v2_job.migrations[0].template[0].template[0].containers[0].command == null, + google_cloud_run_v2_job.migrations[0].template[0].template[0].containers[0].args == tolist(["migrations"]), + google_cloud_run_v2_service.gateway[0].template[0].containers[0].command == tolist(["sh", "-c"]), + endswith(google_cloud_run_v2_service.gateway[0].template[0].containers[0].args[0], " && exec /app/docker-entrypoint.sh gateway --workers 3"), + google_cloud_run_v2_service.backend[0].template[0].containers[0].command == tolist(["sh", "-c"]), + endswith(google_cloud_run_v2_service.backend[0].template[0].containers[0].args[0], " && exec /app/docker-entrypoint.sh backend"), + ]) + error_message = "UI and migrations must keep the image entrypoint with their component as args; gateway and backend must exec it with their component after the DATABASE_URL bootstrap." + } +} + +run "image_overrides_the_composed_uri_everywhere" { + command = plan + + variables { + image = "us-central1-docker.pkg.dev/test-project/mirror/litellm:custom" + } + + assert { + condition = alltrue([ + google_cloud_run_v2_service.gateway[0].template[0].containers[0].image == var.image, + google_cloud_run_v2_service.backend[0].template[0].containers[0].image == var.image, + google_cloud_run_v2_service.ui[0].template[0].containers[0].image == var.image, + google_cloud_run_v2_job.migrations[0].template[0].template[0].containers[0].image == var.image, + terraform_data.migration[0].triggers_replace.job_image == var.image, + ]) + error_message = "A full image URI must win over image_registry + image_tag for every workload." + } +} diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index e40ee36fa18..21d80eb2083 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -119,69 +119,49 @@ variable "vpc_connector_cidr" { default = "10.41.0.0/28" } -# ---------- Component images ---------- +# ---------- Image ---------- # # Cloud Run only pulls from Artifact Registry, [region.]gcr.io, or -# docker.io — it rejects arbitrary registries (notably ghcr.io) at apply -# time. The four images live on GHCR upstream, so any real deploy must -# either set `image_registry` to an Artifact Registry remote repository -# pointed at ghcr.io (e.g. `us-central1-docker.pkg.dev/my-proj/litellm/berriai`) -# or override the per-component `*_image` vars individually with full URIs. +# docker.io and rejects arbitrary registries (notably ghcr.io) at apply +# time. The image lives on GHCR upstream, so any real deploy must either +# set `image_registry` to an Artifact Registry remote repository pointed at +# ghcr.io (e.g. `us-central1-docker.pkg.dev/my-proj/litellm/berriai`) or +# set `image` to the full URI of a copy you mirrored yourself. variable "image_registry" { description = <<-EOT - Registry path prefix used to compose the four LiteLLM image URIs as - `/litellm-:`. The default - (`ghcr.io/berriai`) only works on registries Cloud Run accepts — for - GHCR-backed deploys, create an Artifact Registry remote repository - pointed at `https://ghcr.io` and set this to that repo's path + Registry path prefix used to compose the LiteLLM image URI as + `/litellm:`. The default (`ghcr.io/berriai`) + only works on registries Cloud Run accepts: for GHCR-backed deploys, + create an Artifact Registry remote repository pointed at + `https://ghcr.io` and set this to that repo's path (e.g. `us-central1-docker.pkg.dev///berriai`). - Per-component overrides (`gateway_image`, `backend_image`, `ui_image`, - `migrations_image`) bypass this entirely when set. + `image` bypasses this entirely when set. EOT type = string default = "ghcr.io/berriai" } variable "image_tag" { - description = "Tag applied to all four litellm-* images when composed from `image_registry`. A published module version ships with this stamped to its own release tag, which matches the `v`-prefixed tag published to GHCR for the same LiteLLM release. Set it to pin a different LiteLLM release. When sourcing the module from a git ref of the monorepo it has no default and must be set." + description = "Tag of the LiteLLM image when composed from `image_registry`. A published module version ships with this stamped to its own release tag, which matches the `v`-prefixed tag published to GHCR for the same LiteLLM release. Set it to pin a different LiteLLM release. When sourcing the module from a git ref of the monorepo it has no default and must be set. Tags older than the one a module version ships with have no component entrypoint and only run the monolithic proxy." type = string default = "" nullable = false validation { - condition = !var.create_runtime || var.image_tag != "" || (var.gateway_image != "" && var.backend_image != "" && var.ui_image != "" && var.migrations_image != "") - error_message = "image_tag is empty. Published module versions (registry.terraform.io/modules/BerriAI/litellm) ship with it stamped to their own release. A git ref of the monorepo has no published images of its own, so set image_tag to a published LiteLLM release tag or set all four *_image URIs." + condition = !var.create_runtime || var.image_tag != "" || var.image != "" + error_message = "image_tag is empty. Published module versions (registry.terraform.io/modules/BerriAI/litellm) ship with it stamped to their own release. A git ref of the monorepo has no published images of its own, so set image_tag to a published LiteLLM release tag or set image to a full URI." } } -variable "gateway_image" { - description = "Full image URI for the gateway. Empty (default) composes from `image_registry` + `image_tag`. Public images or Artifact Registry only — Cloud Run won't authenticate against arbitrary private registries." - type = string - default = "" -} - -variable "backend_image" { - description = "Full image URI for the backend. Empty (default) composes from `image_registry` + `image_tag`." - type = string - default = "" -} - -variable "ui_image" { - description = "Full image URI for the UI. Empty (default) composes from `image_registry` + `image_tag`." - type = string - default = "" -} - -variable "migrations_image" { +variable "image" { description = <<-EOT - Full image URI for the one-off prisma migration Cloud Run Job. Empty - (default) composes from `image_registry` + `image_tag` as - `litellm-migrations`. Built from `migrations/Dockerfile` — slim image - whose ENTRYPOINT runs `python3 /app/run.py` (assembles DATABASE_URL - from DATABASE_* env vars via DatabaseURLSettings, then runs - `prisma migrate deploy`). Should track the same release tag as - gateway/backend/ui. + Full URI of the one LiteLLM image every Cloud Run service and the + migrations Job run. Its entrypoint picks the process from the first + argument (`gateway`, `backend`, `ui`, `migrations`, `metrics`, + `collector`). Empty (default) composes from `image_registry` + + `image_tag`. Public images or Artifact Registry only: Cloud Run won't + authenticate against arbitrary private registries. EOT type = string default = "" @@ -569,8 +549,7 @@ variable "gateway_metrics_port" { Serve Prometheus /metrics from a `metrics` sidecar container in the gateway Cloud Run service on this port (a whole number 1-65535, not 4000 or 13133), so the collector's scrape never runs on an inference worker. - The sidecar runs the gateway image with - `python -m litellm.proxy.prometheus_metrics_server` and aggregates the + The sidecar runs the `metrics` component of the image and aggregates the workers' PROMETHEUS_MULTIPROC_DIR samples over an in-memory volume shared with the gateway container. Cloud Run only routes ingress to the gateway container, so the sidecar port is reachable on localhost inside the @@ -578,7 +557,7 @@ variable "gateway_metrics_port" { (gateway_metrics_collector_image) scrapes it and writes the series to Cloud Monitoring. The load balancer keeps serving the authenticated /metrics on the gateway port as before. Null (the default) leaves /metrics - on the gateway port only. Needs gateway_image v1.101.0 or newer. + on the gateway port only. EOT type = number default = null @@ -649,7 +628,7 @@ variable "billing_metrics_token_url" { # ---------- Collector sidecar ---------- # # Opt-in offload of spend tracking from the gateway's uvicorn workers to a -# `python -m litellm.proxy.collector` sidecar container in the same Cloud Run +# `collector` component sidecar container in the same Cloud Run # instance (helm's `gateway.collector`, mirrors the AWS stack). Containers # in one instance share localhost, so the sidecar listens on loopback TCP. # Disabled (the default) adds nothing to the service. diff --git a/tests/unit/test_docker_entrypoint.py b/tests/unit/test_docker_entrypoint.py index bd0b9b0fd4b..4364fcc6203 100644 --- a/tests/unit/test_docker_entrypoint.py +++ b/tests/unit/test_docker_entrypoint.py @@ -16,8 +16,10 @@ from litellm.constants import PROMETHEUS_ADMITTED_SERIES_FILE_PREFIX REPO_ROOT: Final = Path(__file__).resolve().parents[2] DOCKER_ENTRYPOINT: Final = REPO_ROOT / "docker-entrypoint.sh" DOCKERFILE: Final = REPO_ROOT / "Dockerfile" -TERRAFORM_ECS: Final = REPO_ROOT / "terraform" / "litellm" / "aws" / "ecs.tf" -TERRAFORM_CLOUDRUN: Final = REPO_ROOT / "terraform" / "litellm" / "gcp" / "cloudrun.tf" +TERRAFORM_MODULES: Final = { + "aws": REPO_ROOT / "terraform" / "litellm" / "aws", + "gcp": REPO_ROOT / "terraform" / "litellm" / "gcp", +} IMAGE_ENTRYPOINT_PATH: Final = "/app/docker-entrypoint.sh" STUBBED_EXECUTABLES: Final = ("ddtrace-run", "uvicorn", "python", "litellm", "nginx") @@ -38,17 +40,25 @@ _STUB_TEMPLATE: Final = """#!/bin/sh _ENTRYPOINT_RE: Final = re.compile(r"^ENTRYPOINT\s+(\[.*\])\s*$", re.MULTILINE) _CMD_RE: Final = re.compile(r"^CMD\s+(\[.*\])\s*$", re.MULTILINE) -_APP_TARGET_RE: Final = re.compile(r"(?:gateway|backend)\.main:app|gateway\.launch") -_TF_STRING_LOCAL_RE: Final = re.compile(r'^\s*(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$', re.MULTILINE) -_TF_INTERPOLATION_RE: Final = re.compile(r"\$\{(local|var)\.(\w+)\}") +_TF_ENTRYPOINT_LOCAL_RE: Final = re.compile(r'^\s*image_entrypoint\s*=\s*"([^"]*)"\s*$', re.MULTILINE) +_TF_WORD: Final = r'(?:"[^"\s]*"|tostring\(var\.\w+\)|\$\{var\.\w+\}|[\w.-]+)' +_TF_ENTRYPOINT_CALL_RE: Final = re.compile(rf"exec \$\{{local\.image_entrypoint\}}((?: {_TF_WORD})+)") +_TF_COMPONENT_LIST_RE: Final = re.compile( + rf"(?:command|args|gateway|backend|ui|migrations|metrics|collector)\s*=\s*" + rf'\[\s*("(?:proxy|gateway|backend|ui|migrations|metrics|collector)"(?:\s*,\s*{_TF_WORD})*)\s*\]' +) +_TF_VAR_RE: Final = re.compile(r"tostring\(var\.(\w+)\)|\$\{var\.(\w+)\}") -TERRAFORM_LAUNCH_SITES: Final = {TERRAFORM_ECS: 2, TERRAFORM_CLOUDRUN: 2} -TERRAFORM_VAR_STUBS: Final = {"gateway_num_workers": "2"} -COMPONENT_LAUNCHERS: Final = { - "gateway": ("python", "-m", "gateway.launch"), - "backend": ("uvicorn", "backend.main:app"), +TERRAFORM_VAR_STUBS: Final = {"gateway_num_workers": "2", "gateway_metrics_port": "9464"} +TERRAFORM_WORKLOADS: Final = frozenset({"gateway", "backend", "ui", "migrations", "metrics", "collector"}) +COMPONENT_BINARIES: Final = { + "gateway": "python", + "backend": "uvicorn", + "ui": "nginx", + "migrations": "python", + "metrics": "python", + "collector": "python", } -_MAX_INTERPOLATION_PASSES: Final = 5 def _write_stubs(bin_dir: Path, names: tuple[str, ...]) -> None: @@ -124,28 +134,22 @@ def test_backend_leaves_ui_runtime_config_unchanged(tmp_path: Path) -> None: assert json.loads(runtime_file.read_text()) == {"backend_url": "https://stale.example.com"} -def _run_shell_command(command: str, bin_dir: Path, record: Path, use_ddtrace: str | None) -> tuple[str, ...]: - env = _entrypoint_env(bin_dir, record, {"USE_DDTRACE": use_ddtrace}) - result = subprocess.run(["sh", "-c", command], env=env, capture_output=True, text=True, check=False) - assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}" - return tuple(record.read_text().splitlines()) if record.exists() else () +def _tf_word(word: str) -> str: + var: Final = _TF_VAR_RE.fullmatch(word) + if var is not None: + return TERRAFORM_VAR_STUBS[var.group(1) or var.group(2)] + return word.strip('"') -def _resolve_tf_local(terraform_file: Path, name: str) -> str: - values = { - m.group(1): m.group(2).replace('\\"', '"') for m in _TF_STRING_LOCAL_RE.finditer(terraform_file.read_text()) - } - - assert name in values, f"{terraform_file} defines no {name} local" - resolved = values[name] - for _ in range(_MAX_INTERPOLATION_PASSES): - if "${" not in resolved: - return resolved - resolved = _TF_INTERPOLATION_RE.sub( - lambda m: values[m.group(2)] if m.group(1) == "local" else TERRAFORM_VAR_STUBS[m.group(2)], - resolved, +def _terraform_component_argvs(module_dir: Path) -> tuple[tuple[str, tuple[str, ...]], ...]: + return tuple( + (tf.name, tuple(_tf_word(word) for word in words)) + for tf in sorted(module_dir.glob("*.tf")) + for words in ( + *(m.group(1).split() for m in _TF_ENTRYPOINT_CALL_RE.finditer(tf.read_text())), + *(re.split(r"\s*,\s*", m.group(1)) for m in _TF_COMPONENT_LIST_RE.finditer(tf.read_text())), ) - raise AssertionError(f"{terraform_file}:{name} still has unresolved interpolations: {resolved}") + ) # comprehension-ok: the two regex scans over one file read are clearer inline than as a generator helper def _entrypoint_argv(dockerfile: Path) -> tuple[str, ...]: @@ -474,62 +478,30 @@ def test_creates_a_missing_prometheus_multiproc_dir(tmp_path: Path) -> None: assert missing.is_dir() -@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) -@pytest.mark.parametrize("component", ["gateway", "backend"]) -@pytest.mark.parametrize("use_ddtrace", [*TRUTHY_USE_DDTRACE, *FALSY_USE_DDTRACE]) -def test_terraform_launch_command_matches_the_script_contract( - terraform_file: Path, component: str, use_ddtrace: str | None, tmp_path: Path -) -> None: - launcher = COMPONENT_LAUNCHERS[component] - app_target = " ".join(launcher[1:]) - command = _resolve_tf_local(terraform_file, f"{component}_launch_cmd") +@pytest.mark.parametrize("module", TERRAFORM_MODULES, ids=str) +def test_terraform_hands_every_workload_to_the_image_entrypoint(module: str) -> None: + argvs: Final = _terraform_component_argvs(TERRAFORM_MODULES[module]) - bin_dir = tmp_path / "bin" - bin_dir.mkdir(parents=True) - _write_stubs(bin_dir, ("ddtrace-run", "uvicorn", "python")) - from_terraform = _run_shell_command(command, bin_dir, tmp_path / "terraform.txt", use_ddtrace) - - from_script = _run_entrypoint((component,), tmp_path / "script", use_ddtrace=use_ddtrace) - - assert from_terraform[0] == from_script[0], ( - f"{terraform_file} disagrees with the script on USE_DDTRACE={use_ddtrace}" - ) - assert from_terraform[2] == from_script[2], f"{terraform_file} disagrees with the script on the openai integration" - assert app_target in from_terraform[1] - assert app_target in from_script[1] - assert "gateway.main:app" not in from_terraform[1], f"{terraform_file} bypasses the gateway.launch supervisor" - - if use_ddtrace in TRUTHY_USE_DDTRACE: - assert from_terraform[0] == "exec=ddtrace-run" - assert from_terraform[1].startswith(f"args={launcher[0]} ") - assert from_terraform[2] == "DD_TRACE_OPENAI_ENABLED=False" - else: - assert from_terraform[0] == f"exec={launcher[0]}" - assert from_terraform[2] == "DD_TRACE_OPENAI_ENABLED=" + assert {argv[0] for _, argv in argvs} == TERRAFORM_WORKLOADS, f"{module} workloads: {argvs}" + for tf in TERRAFORM_MODULES[module].glob("*.tf"): + for entrypoint_path in _TF_ENTRYPOINT_LOCAL_RE.findall(tf.read_text()): + assert entrypoint_path == _entrypoint_argv(DOCKERFILE)[0], f"{tf} execs a path the image does not ship" -@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) -def test_terraform_routes_every_launch_site_through_a_traced_command(terraform_file: Path) -> None: - launch_sites = tuple(line for line in terraform_file.read_text().splitlines() if _APP_TARGET_RE.search(line)) +@pytest.mark.parametrize("module", TERRAFORM_MODULES, ids=str) +@pytest.mark.parametrize("use_ddtrace", ["true", None]) +def test_the_entrypoint_accepts_every_terraform_argv(module: str, use_ddtrace: str | None, tmp_path: Path) -> None: + for index, (tf, argv) in enumerate(_terraform_component_argvs(TERRAFORM_MODULES[module])): + component, *extra = argv + recorded: Final = _run_entrypoint(argv, tmp_path / str(index), use_ddtrace=use_ddtrace) - assert len(launch_sites) == TERRAFORM_LAUNCH_SITES[terraform_file], ( - f"{terraform_file} launch-site count changed; re-check each one honors USE_DDTRACE" - ) - for line in launch_sites: - assert "ddtrace-run" in line, f"{terraform_file} launches uvicorn without honoring USE_DDTRACE: {line.strip()}" - - body = terraform_file.read_text() - for component in ("gateway", "backend"): - assert f"local.{component}_launch_cmd" in body, ( - f"{terraform_file} defines a {component} launch command but never uses it" - ) - - -@pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) -def test_terraform_does_not_depend_on_the_entrypoint_script(terraform_file: Path) -> None: - assert IMAGE_ENTRYPOINT_PATH not in terraform_file.read_text(), ( - f"{terraform_file} depends on a script that images predating it do not ship" - ) + expected_exec: Final = "ddtrace-run" if use_ddtrace and component != "ui" else COMPONENT_BINARIES[component] + assert recorded[0] == f"exec={expected_exec}", f"{module}/{tf} {argv}: {recorded}" + assert recorded[1].endswith(" ".join(extra)), f"{module}/{tf} {argv} drops its flags: {recorded[1]}" + if component == "gateway": + assert extra == ["--workers", TERRAFORM_VAR_STUBS["gateway_num_workers"]], f"{module}/{tf}: {argv}" + if component == "metrics": + assert extra == ["--port", TERRAFORM_VAR_STUBS["gateway_metrics_port"]], f"{module}/{tf}: {argv}" def test_help_prints_the_header_comment_block_without_shell_source(tmp_path: Path) -> None: From 3622831fbab8fb7f14af7aff673305e1b52ab25f Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:06:50 -0800 Subject: [PATCH 57/86] fix(spend): keep empty-key rows in owner-scoped spend log reads (#51127) * fix(spend): preserve empty-key rows in owner-scoped log reads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): isolate passthrough tests without reloading shared modules Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../spend_tracking/spend_log_api_key_scope.py | 4 +- .../test_team_endpoints.py | 6 +-- .../test_llm_passthrough_endpoints.py | 27 ++---------- .../test_spend_management_endpoints.py | 43 ++++++++++++++++++- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_log_api_key_scope.py b/litellm/proxy/spend_tracking/spend_log_api_key_scope.py index 6507011a977..8b5c899553a 100644 --- a/litellm/proxy/spend_tracking/spend_log_api_key_scope.py +++ b/litellm/proxy/spend_tracking/spend_log_api_key_scope.py @@ -47,7 +47,9 @@ _API_KEY_ROWS: Final = TypeAdapter(tuple[_ApiKeyRow, ...]) def _narrowing_keys(rows: Sequence[Mapping[str, object]]) -> tuple[str, ...] | None: """None when the list cannot narrow the read: nothing known about the owner, or too many keys to help.""" keys: Final = tuple(row.api_key for row in _API_KEY_ROWS.validate_python(rows)) - return None if not keys or len(keys) > SPEND_LOG_API_KEY_SCOPE_MAX_KEYS else keys + if not keys or len(keys) > SPEND_LOG_API_KEY_SCOPE_MAX_KEYS: + return None + return keys if "" in keys else (*keys, "") def _daily_dates_around(start: date, end: date) -> tuple[str, str]: diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index ac46ccc1cfc..29e9be69776 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -17400,7 +17400,7 @@ async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(m assert any("statement_timeout = 30000" in statement for statement in transaction.statements) mock_db_client.db.tx.assert_not_called() sql, *params = transaction.query_raw.call_args.args - assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta", ["sk-alpha", "sk-beta"]] + assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta", ["sk-alpha", "sk-beta", ""]] assert 'FROM "LiteLLM_SpendLogs" sl' in sql assert 'sl."startTime" >= $1::timestamp' in sql assert "sl.\"startTime\" < $2::timestamp + INTERVAL '1 day'" in sql @@ -17440,7 +17440,7 @@ async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_clien ) sql, *params = transaction.query_raw.call_args.args - assert params == ["2026-09-01", "2026-09-04", "team-alpha", ["sk-alpha"]] + assert params == ["2026-09-01", "2026-09-04", "team-alpha", ["sk-alpha", ""]] assert 'sl."user" = $' not in sql assert "sl.api_key = ANY($4::text[])" in sql @@ -17465,7 +17465,7 @@ async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_cli ) sql, *params = transaction.query_raw.call_args.args - assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob", ["sk-bob"]] + assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob", ["sk-bob", ""]] assert "sl.team_id IN ($3)" in sql assert 'AND sl."user" = $4' in sql assert "sl.api_key = ANY($5::text[])" in sql diff --git a/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py index 60b263948b6..2e196d6a8c2 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py @@ -1,5 +1,3 @@ -import asyncio -import importlib import json from collections.abc import Iterator from typing import Final @@ -18,6 +16,9 @@ from litellm.router import Deployment, LiteLLM_Params from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome +pytestmark: Final = pytest.mark.usefixtures("drained_logging_worker") + + @pytest.fixture def isolate_passthrough_endpoint_router_state(monkeypatch: pytest.MonkeyPatch) -> None: from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( @@ -177,28 +178,6 @@ def _vcr_outcome_gate(request, vcr): record_vcr_outcome(request, vcr) -@pytest.fixture(scope="function", autouse=True) -def setup_and_teardown(): - """ - This fixture reloads litellm before every function. To speed up testing by removing callbacks being chained. - """ - from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER - - asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue()) - importlib.reload(litellm) - try: - if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"): - importlib.reload(litellm.proxy.proxy_server) - except Exception as e: - print(f"Error reloading litellm.proxy.proxy_server: {e}") - loop = asyncio.get_event_loop_policy().new_event_loop() - asyncio.set_event_loop(loop) - print(litellm) - yield - loop.close() - asyncio.set_event_loop(None) - - @pytest.mark.asyncio async def test_gemini_pass_through_endpoint_forwards_key_and_body( respx_mock: "respx.MockRouter", httpx_transport: None, monkeypatch: pytest.MonkeyPatch diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index 29e2f97d11c..337aa2939dd 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -944,10 +944,49 @@ async def test_ui_view_spend_logs_user_filter_reads_spend_logs_through_the_users assert '"LiteLLM_VerificationToken"' in lookup_sql assert lookup_params == ("alice", "2026-08-31", "2026-09-04", SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + 1) page_sql, _, scoped_api_keys = _spend_logs_page_query(queries) - assert scoped_api_keys == ["sk-deleted", "sk-live"] + assert scoped_api_keys == ["sk-deleted", "sk-live", ""] assert '"user" = $' in page_sql +@pytest.mark.asyncio +@pytest.mark.parametrize("owner_filter", ["user_id", "team_id"]) +async def test_ui_view_spend_logs_owner_filter_preserves_empty_key_rows( + client: TestClient, monkeypatch: pytest.MonkeyPatch, owner_filter: str +) -> None: + logs: Final = [ + _owned_log("known", "sk-live", "alice", "team-alpha"), + _owned_log("unknown-key", "", "alice", "team-alpha"), + _owned_log("foreign", "", "bob", "team-beta"), + ] + monkeypatch.setattr( + ps, + "prisma_client", + make_ui_spend_logs_mock_prisma( + logs, + _owner_filtered_logs(logs), + owner_api_keys_fn=_owner_api_keys( + daily_rows={}, live_keys={"alice": {"sk-live"}, "team-alpha": {"sk-live"}} + ), + ), + ) + monkeypatch.setitem( + app.dependency_overrides, + ps.user_api_key_auth, + lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"), + ) + response: Final = client.get( + "/spend/logs/ui", + params={ + owner_filter: "alice" if owner_filter == "user_id" else "team-alpha", + "start_date": "2026-09-01 00:00:00", + "end_date": "2026-09-03 23:59:59", + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + assert {row["request_id"] for row in response.json()["data"]} == {"known", "unknown-key"} + + @pytest.mark.asyncio async def test_ui_view_spend_logs_team_filter_reads_spend_logs_through_the_teams_api_keys(client, monkeypatch): logs = [ @@ -989,7 +1028,7 @@ async def test_ui_view_spend_logs_team_filter_reads_spend_logs_through_the_teams _, lookup_params = next((sql, params) for sql, params in queries if '"LiteLLM_DailyTeamSpend"' in sql) assert lookup_params[:3] == (["team-alpha"], "2026-08-31", "2026-09-04") page_sql, _, scoped_api_keys = _spend_logs_page_query(queries) - assert scoped_api_keys == ["sk-alpha-1", "sk-alpha-2"] + assert scoped_api_keys == ["sk-alpha-1", "sk-alpha-2", ""] assert "team_id = $" in page_sql From ac6e78cb1f1202b45c51854315b88c111f63e7ad Mon Sep 17 00:00:00 2001 From: quietjules <349048734+quietjules@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:20:06 -0800 Subject: [PATCH 58/86] fix(proxy): list callable ids for partial aliases over a catch-all deployment (#50963) * fix(proxy): list ids that route back for a prefixed partial alias over a catch-all deployment * test(proxy): call the router registry in the catch-all alias listing test * fix(proxy): keep main's listing for whole-name aliases over a catch-all Only partial aliases like team1/gpt-* over a bare * run the per-id route check. A whole-name alias like team1/* keeps the string-only listing it had, so it lists the same ids at the same cost. Adds a test that a more specific sibling alias's ids are left out of the partial alias's list. * fix(proxy): list catch-all aliases fast and skip ids a sibling alias takes * fix(proxy): check team wildcard aliases without a router call per id and skip suffix ids that name another catalog model Team keys now read the team's own wildcard patterns the way the router does (any team pattern first, then the provider form when its prefix can be a provider), so ids no rival can take skip the router call. A suffix alias such as team1/*-mini no longer lists team1/gpt-5.4-mini for gpt-5.4. * fix(proxy): keep team key listings fast when aliases overlap and honor a pinned provider on partial aliases A team key whose team defines the same alias as the global config, or a team pattern that can take a global alias's ids, now checks only the team's own patterns before calling the router, so the listing no longer pays a router call per catalog id. The router also tries the provider-prefixed form of an id only when the bare form matches nothing. A partial alias over `*` with `custom_llm_provider` set now lists only that provider's catalog, so `team1/gpt-*` pinned to OpenAI no longer lists `team1/gpt-claude-*` ids that answer 404. * fix(proxy): ask a pinned catch-all's provider for its models once per listing A partial alias over a `*` deployment pinned with custom_llm_provider fetched the whole `*` catalog before taking the pinned branch, so with check_provider_endpoint on each listing called the provider's /v1/models one extra time. The pinned branch now runs before that fetch. Tests also cover a provider-less team alias that takes ids under their provider and a wildcard model_group_alias --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/proxy/auth/model_checks.py | 161 ++++++++- litellm/router_rust/surface/indexes.py | 5 +- tests/unit/proxy/auth/test_model_checks.py | 370 ++++++++++++++++++++- 3 files changed, 520 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index 7f2aa33246d..07a0b3e9377 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -6,18 +6,19 @@ import copy import functools import itertools import re -from collections.abc import Callable, Collection, Mapping, Sequence +from collections.abc import Callable, Collection, Iterable, Mapping, Sequence from collections.abc import Set as AbstractSet from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, replace from types import MappingProxyType -from typing import Final, TypeVar +from typing import Final, TypeAlias, TypeVar import litellm from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.credential_accessor import CredentialAccessor +from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model -from litellm.litellm_core_utils.pattern_match_deployments import PatternMatchRouter +from litellm.litellm_core_utils.pattern_match_deployments import PatternMatchRouter, PatternUtils from litellm.llms.azure.ptu_capacity import is_str_keyed_mapping from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth from litellm.proxy.common_utils.model_listing_utils import CallerAliases, alias_map, dispatched_model @@ -429,6 +430,71 @@ def _ids_routing_to_catalog_models( ) +def _names_whole_models(name_pattern: str) -> bool: + return name_pattern.count("*") == 1 and name_pattern.endswith("/*") + + +@dataclass(frozen=True, slots=True) +class _Rivals: + direct: tuple[re.Pattern[str], ...] + under_a_provider: tuple[re.Pattern[str], ...] + + +RivalPatterns: TypeAlias = Callable[[str], _Rivals | None] + + +def _rival_may_take(listed_id: str, rivals: _Rivals) -> bool: + if any(rival.match(listed_id) for rival in rivals.direct): + return True + if not rivals.under_a_provider: + return False + provider_request: Final = f"{inferred_provider(listed_id)}/{listed_id}" + return any(rival.match(provider_request) for rival in rivals.under_a_provider) + + +def _routes_through_alias( + name_pattern: str, + rivals: _Rivals | None, + routes_to_model: Callable[[str, str], bool], +) -> Callable[[str], bool]: + alias_regex: Final = _wildcard_name_regex(name_pattern) + + def routes_through(listed_id: str) -> bool: + alias_match: Final = alias_regex.match(listed_id) + if alias_match is None or alias_match.group(1) == AZURE_MODEL_PLACEHOLDER: + return False + no_rival_matches: Final = rivals is not None and not _rival_may_take(listed_id, rivals) + return no_rival_matches or routes_to_model(listed_id, alias_match.group(1)) + + return routes_through + + +def _reads_as_another_catalog_id(listed_id: str, catalog_id: str, name_scope: str, catalog: AbstractSet[str]) -> bool: + read_as: Final = listed_id.removeprefix(name_scope) + return read_as != catalog_id and read_as in catalog + + +def _ids_routing_through_catch_all( + name_pattern: str, catalog_ids: Sequence[str], routes_through: Callable[[str], bool] +) -> tuple[str, ...]: + if name_pattern.count("*") != 1: + return () + name_scope: Final = "".join(name_pattern.partition("*")[0].rpartition("/")[:2]) + catalog: Final = frozenset(catalog_ids) + named_ids: Final = ((name_pattern.replace("*", catalog_id), catalog_id) for catalog_id in catalog_ids) + listed_ids: Final = ( + listed_id + for listed_id, catalog_id in named_ids + if not _reads_as_another_catalog_id(listed_id, catalog_id, name_scope, catalog) and routes_through(listed_id) + ) + return tuple(dict.fromkeys(listed_ids)) + + +def _catch_all_catalog(litellm_params: LiteLLM_Params | None) -> Sequence[str]: + pinned_provider: Final = litellm_params.custom_llm_provider if litellm_params is not None else None + return get_provider_models(provider=pinned_provider or "*", litellm_params=litellm_params) or () + + def _catalog_ids_a_wider_name_matches( name_pattern: str, deployment_pattern: str, @@ -477,6 +543,71 @@ def _deployed_by_name(llm_router: Router | None) -> Callable[[str, str | None], return lambda model, team_id: model in deployed_names or (team_id, model) in team_deployed_names +def _literal_start(regex: str) -> str: + return regex.partition("(.*)")[0] + + +def _prefixes_overlap(first: str, second: str) -> bool: + return first.startswith(second) or second.startswith(first) + + +def _may_match_directly(own_regex: str, regex: str) -> bool: + return regex != own_regex and _prefixes_overlap(_literal_start(regex), _literal_start(own_regex)) + + +_UNINFERRED_PROVIDER: Final = str(None) + + +def _may_name_a_provider(escaped_provider: str) -> bool: + provider: Final = re.sub(r"\\(.)", r"\1", escaped_provider) + return provider == _UNINFERRED_PROVIDER or inferred_provider(f"{provider}/{provider}") == provider + + +def _may_match_under_a_provider(own_regex: str, regex: str) -> bool: + provider, has_provider, rest = _literal_start(regex).partition("/") + if not has_provider: + return True + return _prefixes_overlap(rest, _literal_start(own_regex)) and _may_name_a_provider(provider) + + +def _can_take_ids_of(own_regex: str, regex: str) -> bool: + return _may_match_directly(own_regex, regex) and PatternUtils.calculate_pattern_specificity( + regex + ) >= PatternUtils.calculate_pattern_specificity(own_regex) + + +def _compiled(regexes: Iterable[str]) -> tuple[re.Pattern[str], ...]: + return tuple(re.compile(regex) for regex in regexes) + + +def _pattern_regexes(pattern_router: PatternMatchRouter | None) -> tuple[str, ...]: + if pattern_router is None: + return () + return tuple(pattern_router.patterns) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # untyped deployment lists + + +def _rival_patterns(llm_router: Router | None, team_id: str | None) -> RivalPatterns: + if llm_router is None: + return lambda _name_pattern: None + registry: Final = llm_router.registry() + router_regexes: Final = _pattern_regexes(registry.patterns()) + team_regexes: Final = _pattern_regexes(registry.patterns(team=True).get(team_id) if team_id is not None else None) + + def rivals(name_pattern: str) -> _Rivals | None: + own_regex: Final = PatternMatchRouter().pattern_to_regex(name_pattern) + if own_regex in team_regexes: + return _Rivals(_compiled(regex for regex in team_regexes if _can_take_ids_of(own_regex, regex)), ()) + if own_regex not in router_regexes: + return None + return _Rivals( + _compiled(regex for regex in router_regexes if _can_take_ids_of(own_regex, regex)) + + _compiled(regex for regex in team_regexes if _may_match_directly(own_regex, regex)), + _compiled(regex for regex in team_regexes if _may_match_under_a_provider(own_regex, regex)), + ) + + return functools.cache(rivals) + + def _routes_to_model( routed_models: Callable[[str, str | None], frozenset[str]] | None, team_id: str | None ) -> Callable[[str, str], bool]: @@ -495,10 +626,11 @@ def get_known_models_from_wildcard( wildcard_model: str, litellm_params: LiteLLM_Params | None = None, routes_to_model: Callable[[str, str], bool] = lambda _listed_id, _model: True, + rival_patterns: RivalPatterns = lambda _name_pattern: None, ) -> list[str]: provider_id_prefix: Final = _custom_llm_provider_prefix(wildcard_model, litellm_params) if litellm_params is None or provider_id_prefix is None: - return list(_known_models_from_wildcard(wildcard_model, litellm_params, routes_to_model)) + return list(_known_models_from_wildcard(wildcard_model, litellm_params, routes_to_model, rival_patterns)) return [ listed_id.removeprefix(provider_id_prefix) for listed_id in _known_models_from_wildcard( @@ -507,6 +639,7 @@ def get_known_models_from_wildcard( lambda listed_id, model: routes_to_model( listed_id.removeprefix(provider_id_prefix), model.removeprefix(provider_id_prefix) ), + rival_patterns, ) ] @@ -532,6 +665,7 @@ def _known_models_from_wildcard( wildcard_model: str, litellm_params: LiteLLM_Params | None, routes_to_model: Callable[[str, str], bool], + rival_patterns: RivalPatterns, ) -> tuple[str, ...]: wildcard_model_to_expand: Final = ( litellm_params.model @@ -562,6 +696,16 @@ def _known_models_from_wildcard( litellm_params = _hydrate_litellm_credential_name(litellm_params) + routes_through_catch_all: Final = ( + _routes_through_alias(wildcard_model, rival_patterns(wildcard_model), routes_to_model) + if litellm_params is not None and litellm_params.model == "*" + else None + ) + if routes_through_catch_all is not None and not _names_whole_models(wildcard_model): + return _ids_routing_through_catch_all( + wildcard_model, _catch_all_catalog(litellm_params), routes_through_catch_all + ) + wildcard_models = get_provider_models(provider=provider, litellm_params=litellm_params) if wildcard_models is None: @@ -588,9 +732,12 @@ def _known_models_from_wildcard( # add model prefix to wildcard models wildcard_models = [f"{model_prefix}{model.removeprefix(provider_id_prefix)}" for model in wildcard_models] - return tuple( + listed_ids: Final = tuple( dict.fromkeys(_under_wildcard_prefix(model, wildcard_provider_prefix, provider) for model in wildcard_models) ) + if routes_through_catch_all is None: + return listed_ids + return tuple(listed_id for listed_id in listed_ids if routes_through_catch_all(listed_id)) def _under_wildcard_prefix(model: str, wildcard_provider_prefix: str, listed_provider: str) -> str: @@ -647,6 +794,7 @@ def expand_wildcard_deployments_for_model_info( """ expanded: Final[list[dict[str, object]]] = [] routed_models: Final = _cached_wildcard_routing(llm_router) + rival_patterns_of_team: Final = functools.cache(functools.partial(_rival_patterns, llm_router)) deployed_by_name: Final = _deployed_by_name(llm_router) for deployment in deployments: model_name = str(deployment.get("model_name") or "") @@ -668,6 +816,7 @@ def expand_wildcard_deployments_for_model_info( wildcard_model=model_name, litellm_params=litellm_params, routes_to_model=_routes_to_model(routed_models, _deployment_team_id(deployment)), + rival_patterns=rival_patterns_of_team(_deployment_team_id(deployment)), ) if not expanded_names: expanded.append(deployment) @@ -826,6 +975,7 @@ def _get_wildcard_models( ) -> list[str]: models_to_remove: Final = set() routes_to_model: Final = _routes_to_model(_cached_wildcard_routing(llm_router), team_id) + rival_patterns: Final = _rival_patterns(llm_router, team_id) all_wildcard_models: Final = [] for model in unique_models: if _check_wildcard_routing(model=model): @@ -844,6 +994,7 @@ def _get_wildcard_models( wildcard_model=model, litellm_params=LiteLLM_Params(**router_model["litellm_params"]), routes_to_model=routes_to_model, + rival_patterns=rival_patterns, ) ) else: diff --git a/litellm/router_rust/surface/indexes.py b/litellm/router_rust/surface/indexes.py index f5aca3e1bad..32f63bc593b 100644 --- a/litellm/router_rust/surface/indexes.py +++ b/litellm/router_rust/surface/indexes.py @@ -308,8 +308,9 @@ class HostedPatternRouter(PatternMatchRouter): if not isinstance(model, str): # pyright: ignore[reportUnnecessaryIsInstance] # a caller's non-string model goes to the base class return super().get_models_by_pattern(model, custom_llm_provider) - provider: Final = custom_llm_provider or inferred_provider(model) - return self._owner.models_by_pattern(self._team, [model, f"{provider}/{model}"]) + return self._owner.models_by_pattern(self._team, [model]) or self._owner.models_by_pattern( + self._team, [f"{custom_llm_provider or inferred_provider(model)}/{model}"] + ) class _TeamRouters(dict[str, HostedPatternRouter]): diff --git a/tests/unit/proxy/auth/test_model_checks.py b/tests/unit/proxy/auth/test_model_checks.py index 1c576b1ee24..83fc81815d8 100644 --- a/tests/unit/proxy/auth/test_model_checks.py +++ b/tests/unit/proxy/auth/test_model_checks.py @@ -2463,27 +2463,379 @@ def test_wildcard_lists_an_id_a_more_specific_sibling_serves_with_the_same_model assert "openai/gpt-partial-route-9*" not in model_info_names -def test_prefixed_alias_over_a_bare_catch_all_lists_ids_that_route_through_it(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize( + ("model_name", "expected_id"), + [ + pytest.param("team-partial-route/*", f"team-partial-route/{_ROUTE_OPENAI_ID}", id="whole-name"), + pytest.param("team-partial-route/gpt-*", f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}", id="partial-name"), + pytest.param("team-partial-route/*-*", None, id="more-wildcards-than-the-deployment"), + ], +) +def test_prefixed_alias_over_a_bare_catch_all_lists_ids_that_route_to_catalog_models( + monkeypatch: pytest.MonkeyPatch, model_name: str, expected_id: str | None +) -> None: + from litellm import Router + from litellm.proxy.auth.model_checks import expand_wildcard_deployments_for_model_info, get_complete_model_list + from litellm.utils import get_valid_models + + monkeypatch.setenv("OPENAI_API_KEY", "sk-partial-route-test") + monkeypatch.setenv("AZURE_API_KEY", "azure-partial-route-test") + router: Final = Router(model_list=[{"model_name": model_name, "litellm_params": {"model": "*"}}]) + + def routed_model(listed_id: str) -> object: + routed: Final = router.registry().deployments(listed_id, source="wildcard") + return routed[0]["litellm_params"]["model"] if routed else None + + with _registered_chat_models({_ROUTE_OPENAI_ID: "openai"}): + catalog_ids: Final = frozenset(get_valid_models()) - {AZURE_MODEL_PLACEHOLDER} + v1_models: Final = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=[model_name], + user_model=None, + infer_model_from_keys=False, + llm_router=router, + ) + model_info_names: Final = [ + str(row["model_name"]) + for row in expand_wildcard_deployments_for_model_info( + [dict(deployment) for deployment in router.registry().deployments() or []], router + ) + if row["model_name"] != model_name + ] + + listed_ids: Final = [*v1_models, *model_info_names] + assert [listed_id for listed_id in listed_ids if routed_model(listed_id) not in catalog_ids] == [] + if expected_id is None: + assert listed_ids == [] + else: + assert expected_id in v1_models + assert expected_id in model_info_names + assert routed_model(expected_id) == _ROUTE_OPENAI_ID + + +_ROUTE_OPENAI_CHAT_ID: Final = "partial-route-9-chat" + + +@pytest.mark.parametrize( + ("alias_names", "stolen_id", "kept_ids"), + [ + pytest.param( + ("team-partial-route/gpt-*", "team-partial-route/gpt-gpt-partial-route-*"), + f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}", + ( + f"team-partial-route/gpt-gpt-partial-route-{_ROUTE_OPENAI_ID}", + f"team-partial-route/gpt-{_ROUTE_OPENAI_CHAT_ID}", + ), + id="partial-name-next-to-a-longer-partial-name", + ), + pytest.param( + ("team-partial-route/*", "team-partial-route/gpt-*"), + f"team-partial-route/{_ROUTE_OPENAI_ID}", + (f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}", f"team-partial-route/{_ROUTE_OPENAI_CHAT_ID}"), + id="whole-name-next-to-a-partial-name", + ), + ], +) +def test_alias_over_a_bare_catch_all_skips_ids_a_more_specific_sibling_routes_elsewhere( + monkeypatch: pytest.MonkeyPatch, alias_names: tuple[str, ...], stolen_id: str, kept_ids: tuple[str, ...] +) -> None: + from litellm import Router + from litellm.proxy.auth.model_checks import expand_wildcard_deployments_for_model_info, get_complete_model_list + from litellm.utils import get_valid_models + + monkeypatch.setenv("OPENAI_API_KEY", "sk-partial-route-test") + router: Final = Router(model_list=[{"model_name": name, "litellm_params": {"model": "*"}} for name in alias_names]) + + def routed_model(listed_id: str) -> object: + routed: Final = router.registry().deployments(listed_id, source="wildcard") + return routed[0]["litellm_params"]["model"] if routed else None + + with _registered_chat_models({_ROUTE_OPENAI_ID: "openai", _ROUTE_OPENAI_CHAT_ID: "openai"}): + catalog_ids: Final = frozenset(get_valid_models()) + v1_models: Final = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=list(alias_names), + user_model=None, + infer_model_from_keys=False, + llm_router=router, + ) + model_info_names: Final = [ + str(row["model_name"]) + for row in expand_wildcard_deployments_for_model_info( + [dict(deployment) for deployment in router.registry().deployments() or []], router + ) + if row["model_name"] not in alias_names + ] + + listed_ids: Final = [*v1_models, *model_info_names] + assert routed_model(stolen_id) not in catalog_ids + assert [listed_id for listed_id in listed_ids if routed_model(listed_id) not in catalog_ids] == [] + assert [kept_id for kept_id in kept_ids if kept_id not in v1_models or kept_id not in model_info_names] == [] + + +def test_suffix_alias_over_a_bare_catch_all_lists_no_id_that_names_another_catalog_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm import Router + from litellm.proxy.auth.model_checks import expand_wildcard_deployments_for_model_info, get_complete_model_list + from litellm.utils import get_valid_models + + monkeypatch.setenv("OPENAI_API_KEY", "sk-partial-route-test") + alias: Final = "team-partial-route/*-mini" + full_size_id: Final = _ROUTE_OPENAI_ID.removesuffix("-mini") + router: Final = Router(model_list=[{"model_name": alias, "litellm_params": {"model": "*"}}]) + + def routed_model(listed_id: str) -> object: + routed: Final = router.registry().deployments(listed_id, source="wildcard") + return routed[0]["litellm_params"]["model"] if routed else None + + with _registered_chat_models({_ROUTE_OPENAI_ID: "openai", full_size_id: "openai"}): + catalog_ids: Final = frozenset(get_valid_models()) + v1_models: Final = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=[alias], + user_model=None, + infer_model_from_keys=False, + llm_router=router, + ) + model_info_names: Final = [ + str(row["model_name"]) + for row in expand_wildcard_deployments_for_model_info( + [dict(deployment) for deployment in router.registry().deployments() or []], router + ) + if row["model_name"] != alias + ] + + listed_ids: Final = [*v1_models, *model_info_names] + named_ids: Final = {listed_id: listed_id.removeprefix("team-partial-route/") for listed_id in listed_ids} + assert routed_model(f"team-partial-route/{_ROUTE_OPENAI_ID}") == full_size_id + assert { + listed_id: routed_model(listed_id) + for listed_id, named_id in named_ids.items() + if named_id in catalog_ids and routed_model(listed_id) != named_id + } == {} + assert f"team-partial-route/{_ROUTE_OPENAI_ID}-mini" in v1_models + assert f"team-partial-route/{_ROUTE_OPENAI_ID}-mini" in model_info_names + + +@pytest.mark.parametrize( + ("global_alias", "team_alias", "team_alias_model", "stolen_id", "named_model", "kept_ids"), + [ + pytest.param( + "team-partial-route/*", + "team-partial-route/gpt-*", + "*", + f"team-partial-route/{_ROUTE_OPENAI_ID}", + _ROUTE_OPENAI_ID, + (f"team-partial-route/{_ROUTE_OPENAI_CHAT_ID}",), + id="team-alias-matching-the-id", + ), + pytest.param( + "team-partial-route/*", + "openai/*", + "openai/*", + f"team-partial-route/{_ROUTE_OPENAI_CHAT_ID}", + _ROUTE_OPENAI_CHAT_ID, + (f"team-partial-route/{_ROUTE_OPENAI_ID}",), + id="team-alias-matching-the-id-under-its-provider", + ), + pytest.param( + "team-partial-route/gpt-*", + "team-partial-route/*", + "*", + f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}", + _ROUTE_OPENAI_ID, + (), + id="team-alias-broader-than-the-global-alias", + ), + pytest.param( + "team-partial-route/*", + "o*", + "*", + f"team-partial-route/{_ROUTE_OPENAI_ID}", + _ROUTE_OPENAI_ID, + (), + id="provider-less-team-alias-matching-the-id-under-its-provider", + ), + ], +) +def test_team_key_listing_skips_ids_the_team_wildcard_routes_elsewhere( + monkeypatch: pytest.MonkeyPatch, + global_alias: str, + team_alias: str, + team_alias_model: str, + stolen_id: str, + named_model: str, + kept_ids: tuple[str, ...], +) -> None: from litellm import Router from litellm.proxy.auth.model_checks import get_complete_model_list monkeypatch.setenv("OPENAI_API_KEY", "sk-partial-route-test") - router: Final = Router(model_list=[{"model_name": "team-partial-route/*", "litellm_params": {"model": "*"}}]) - with _registered_chat_models({_ROUTE_OPENAI_ID: "openai"}): - v1_models: Final = get_complete_model_list( + router: Final = Router( + model_list=[ + {"model_name": global_alias, "litellm_params": {"model": "*"}}, + { + "model_name": "model_name_team-1_partial-route", + "litellm_params": {"model": team_alias_model}, + "model_info": {"id": "team-partial-route", "team_id": "team-1", "team_public_model_name": team_alias}, + }, + ] + ) + + def listed_for(team_id: str | None) -> list[str]: + return get_complete_model_list( key_models=[], team_models=[], + proxy_model_list=[global_alias], + user_model=None, + infer_model_from_keys=False, + llm_router=router, + team_id=team_id, + ) + + with _registered_chat_models({_ROUTE_OPENAI_ID: "openai", _ROUTE_OPENAI_CHAT_ID: "openai", stolen_id: "openai"}): + listed_without_team: Final = listed_for(None) + listed_for_team: Final = listed_for("team-1") + stolen_route: Final = router.registry().names(stolen_id, kind="wildcard", team_id="team-1") + + assert named_model not in stolen_route + assert stolen_id in listed_without_team + assert stolen_id not in listed_for_team + assert set(kept_ids) <= set(listed_for_team) + + +def test_wildcard_model_group_alias_skips_ids_its_target_sends_to_another_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm import Router + from litellm.proxy.auth.model_checks import get_complete_model_list + + monkeypatch.setenv("OPENAI_API_KEY", "sk-partial-route-test") + router: Final = Router( + model_list=[{"model_name": "team-partial-route/*", "litellm_params": {"model": "*"}}], + model_group_alias={"team-partial-route/gpt-*": "team-partial-route/*"}, + ) + misnamed_id: Final = f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}" + + with _registered_chat_models({_ROUTE_OPENAI_ID: "openai"}): + listed: Final = get_complete_model_list( + key_models=["team-partial-route/gpt-*"], + team_models=[], proxy_model_list=["team-partial-route/*"], user_model=None, infer_model_from_keys=False, llm_router=router, ) + misnamed_route: Final = router.registry().names(misnamed_id, kind="wildcard") - unrouted_ids: Final = [ - listed_id for listed_id in v1_models if not router.wildcard_route_deployments(listed_id, None) - ] - assert f"team-partial-route/{_ROUTE_OPENAI_ID}" in v1_models - assert unrouted_ids == [] + assert _ROUTE_OPENAI_ID not in misnamed_route + assert misnamed_id not in listed + + +def test_team_copy_of_a_partial_alias_lists_ids_a_global_sibling_would_take(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm import Router + from litellm.proxy.auth.model_checks import get_complete_model_list + + monkeypatch.setenv("OPENAI_API_KEY", "sk-partial-route-test") + alias: Final = "team-partial-route/gpt-*" + sibling_taken_id: Final = f"team-partial-route/gpt-{_ROUTE_OPENAI_CHAT_ID}" + router: Final = Router( + model_list=[ + {"model_name": alias, "litellm_params": {"model": "*"}}, + {"model_name": "team-partial-route/gpt-partial-*", "litellm_params": {"model": "openai/*"}}, + { + "model_name": "model_name_team-1_partial-route", + "litellm_params": {"model": "*"}, + "model_info": {"id": "team-partial-route", "team_id": "team-1", "team_public_model_name": alias}, + }, + ] + ) + + def listed_for(team_id: str | None) -> list[str]: + return get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=[alias], + user_model=None, + infer_model_from_keys=False, + llm_router=router, + team_id=team_id, + ) + + with _registered_chat_models({_ROUTE_OPENAI_ID: "openai", _ROUTE_OPENAI_CHAT_ID: "openai"}): + listed_without_team: Final = listed_for(None) + listed_for_team: Final = listed_for("team-1") + team_route: Final = router.registry().names(sibling_taken_id, kind="wildcard", team_id="team-1") + + assert team_route == {_ROUTE_OPENAI_CHAT_ID} + assert sibling_taken_id not in listed_without_team + assert sibling_taken_id in listed_for_team + assert f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}" in listed_for_team + + +def test_partial_alias_over_a_catch_all_pinned_to_a_provider_lists_only_that_providers_models( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm import Router + from litellm.proxy.auth.model_checks import expand_wildcard_deployments_for_model_info, get_complete_model_list + + monkeypatch.setenv("OPENAI_API_KEY", "sk-partial-route-test") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-partial-route-test") + alias: Final = "team-partial-route/gpt-*" + anthropic_id: Final = "claude-partial-route-9" + router: Final = Router( + model_list=[{"model_name": alias, "litellm_params": {"model": "*", "custom_llm_provider": "openai"}}] + ) + + with _registered_chat_models({_ROUTE_OPENAI_ID: "openai", anthropic_id: "anthropic"}): + v1_models: Final = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=[alias], + user_model=None, + infer_model_from_keys=False, + llm_router=router, + ) + model_info_names: Final = [ + str(row["model_name"]) + for row in expand_wildcard_deployments_for_model_info( + [dict(deployment) for deployment in router.registry().deployments() or []], router + ) + ] + + assert f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}" in v1_models + assert f"team-partial-route/gpt-{_ROUTE_OPENAI_ID}" in model_info_names + assert f"team-partial-route/gpt-{anthropic_id}" not in v1_models + assert f"team-partial-route/gpt-{anthropic_id}" not in model_info_names + + +@respx.mock +def test_partial_alias_over_a_pinned_catch_all_asks_the_provider_for_its_models_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import litellm + from litellm.proxy.auth.model_checks import get_known_models_from_wildcard + from litellm.types.router import LiteLLM_Params + + monkeypatch.setattr(litellm, "check_provider_endpoint", True) + monkeypatch.setenv("OPENAI_API_KEY", "sk-pinned") + models_route: Final = respx.get("http://pinned-catch-all.test/v1/models").mock( + return_value=httpx.Response(200, json={"data": [{"id": "pinned-route-model"}]}) + ) + + listed: Final = get_known_models_from_wildcard( + "team-pinned-route/gpt-*", + LiteLLM_Params( + model="*", custom_llm_provider="openai", api_base="http://pinned-catch-all.test/v1", api_key="sk-pinned" + ), + ) + + assert "team-pinned-route/gpt-pinned-route-model" in listed + assert models_route.call_count == 1 @pytest.mark.asyncio From 97f07af044c854d861347181c7a118f9dd78e285 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:27:12 +0000 Subject: [PATCH 59/86] test(providers): preserve unspecified Gemini image output modality (#51149) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../providers/test_gemini_image_config_and_usage_wire.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/providers/test_gemini_image_config_and_usage_wire.py b/tests/integration/providers/test_gemini_image_config_and_usage_wire.py index 27b408b27aa..c0957c5c3cb 100644 --- a/tests/integration/providers/test_gemini_image_config_and_usage_wire.py +++ b/tests/integration/providers/test_gemini_image_config_and_usage_wire.py @@ -34,7 +34,7 @@ _COUNTS_ONLY_REPLY: Final[dict[str, JsonValue]] = { "input_tokens": 10, "input_tokens_details": {"image_tokens": 0, "text_tokens": 0}, "output_tokens": 20, - "output_tokens_details": {"image_tokens": 20, "text_tokens": 0}, + "output_tokens_details": {}, } @@ -167,7 +167,7 @@ def test_gemini_image_edit_rejects_an_image_config_string_that_is_not_json( "input_tokens": 0, "input_tokens_details": {"image_tokens": 0, "text_tokens": 0}, "output_tokens": 0, - "output_tokens_details": {"image_tokens": 0, "text_tokens": 0}, + "output_tokens_details": {}, }, id="empty-usage-object", ), From 525df3f9a113d750e49e1ec865104c5c5d40bc67 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:27:58 +0000 Subject: [PATCH 60/86] test(spend): expect token billing for Vertex image edits (#51148) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/integration/spend/test_vertex_regional_uplift_spend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/spend/test_vertex_regional_uplift_spend.py b/tests/integration/spend/test_vertex_regional_uplift_spend.py index fc592ba2a99..40b6aaeb96b 100644 --- a/tests/integration/spend/test_vertex_regional_uplift_spend.py +++ b/tests/integration/spend/test_vertex_regional_uplift_spend.py @@ -393,7 +393,7 @@ def test_image_edit_spend_follows_the_vertex_location(gateway: Gateway, location images: Final = raw.parse() assert images.data is not None and [image.b64_json for image in images.data] == ["aW1n"], images prices: Final = _published_prices(gateway, model) - expected: Final = _rate(prices, "output_cost_per_image") * _uplift(prices, placed) + expected: Final = _standard_cost(prices) * _uplift(prices, placed) assert float(raw.headers["x-litellm-response-cost"]) == pytest.approx(expected), raw.headers (sent,) = _only_generate_calls(wire, 1) forwarded: Final = sent.body.decode() From 606490a52dbafa822a24f943d0f8a14a1bc29940 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:31:49 +0000 Subject: [PATCH 61/86] fix(gemini): batch and flex tier fields the -latest alias rows were missing (#51150) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...model_prices_and_context_window_backup.json | 18 ++++++++++++++++++ model_prices_and_context_window.json | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 3210f3e080b..ead85584c33 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -32280,6 +32280,7 @@ }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "input_cost_per_token": 7.5e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -32338,6 +32339,7 @@ }, "gemini/gemini-flash-lite-latest": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -60939,6 +60941,7 @@ }, "gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "input_cost_per_token": 7.5e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -60997,6 +61000,7 @@ }, "gemini-flash-lite-latest": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -61055,6 +61059,8 @@ "gemini-pro-latest": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_batches": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_flex": 4e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "litellm_provider": "gemini", @@ -61098,6 +61104,7 @@ }, "google_maps_grounding_cost_per_query": 0.014, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_priority": 3.6e-07, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, @@ -61110,12 +61117,18 @@ "supports_url_context": true, "web_search_billing_unit": "per_query", "cache_read_input_token_cost_flex": 2e-07, + "input_cost_per_token_above_200k_tokens_batches": 2e-06, + "input_cost_per_token_above_200k_tokens_flex": 2e-06, "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_above_200k_tokens_batches": 9e-06, + "output_cost_per_token_above_200k_tokens_flex": 9e-06, "output_cost_per_token_flex": 6e-06 }, "gemini/gemini-pro-latest": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_batches": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_flex": 4e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "litellm_provider": "gemini", @@ -61159,6 +61172,7 @@ }, "google_maps_grounding_cost_per_query": 0.014, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_priority": 3.6e-07, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, @@ -61171,7 +61185,11 @@ "supports_url_context": true, "web_search_billing_unit": "per_query", "cache_read_input_token_cost_flex": 2e-07, + "input_cost_per_token_above_200k_tokens_batches": 2e-06, + "input_cost_per_token_above_200k_tokens_flex": 2e-06, "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_above_200k_tokens_batches": 9e-06, + "output_cost_per_token_above_200k_tokens_flex": 9e-06, "output_cost_per_token_flex": 6e-06 }, "gemini-exp-1206": { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 3210f3e080b..ead85584c33 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -32280,6 +32280,7 @@ }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "input_cost_per_token": 7.5e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -32338,6 +32339,7 @@ }, "gemini/gemini-flash-lite-latest": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -60939,6 +60941,7 @@ }, "gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "input_cost_per_token": 7.5e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -60997,6 +61000,7 @@ }, "gemini-flash-lite-latest": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -61055,6 +61059,8 @@ "gemini-pro-latest": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_batches": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_flex": 4e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "litellm_provider": "gemini", @@ -61098,6 +61104,7 @@ }, "google_maps_grounding_cost_per_query": 0.014, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_priority": 3.6e-07, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, @@ -61110,12 +61117,18 @@ "supports_url_context": true, "web_search_billing_unit": "per_query", "cache_read_input_token_cost_flex": 2e-07, + "input_cost_per_token_above_200k_tokens_batches": 2e-06, + "input_cost_per_token_above_200k_tokens_flex": 2e-06, "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_above_200k_tokens_batches": 9e-06, + "output_cost_per_token_above_200k_tokens_flex": 9e-06, "output_cost_per_token_flex": 6e-06 }, "gemini/gemini-pro-latest": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_batches": 4e-07, + "cache_read_input_token_cost_above_200k_tokens_flex": 4e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "litellm_provider": "gemini", @@ -61159,6 +61172,7 @@ }, "google_maps_grounding_cost_per_query": 0.014, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_priority": 3.6e-07, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, @@ -61171,7 +61185,11 @@ "supports_url_context": true, "web_search_billing_unit": "per_query", "cache_read_input_token_cost_flex": 2e-07, + "input_cost_per_token_above_200k_tokens_batches": 2e-06, + "input_cost_per_token_above_200k_tokens_flex": 2e-06, "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_above_200k_tokens_batches": 9e-06, + "output_cost_per_token_above_200k_tokens_flex": 9e-06, "output_cost_per_token_flex": 6e-06 }, "gemini-exp-1206": { From c2afb07f93b47e982ae57724bbf94100c1aefd4e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:02:00 +0000 Subject: [PATCH 62/86] fix(spend): keep free response reads and cancels off the creation spend row (#51110) * fix(spend): track self-heal for test_with_id_security_off_reads_and_cancels_add_no_rows_and_no_replayed_turns Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(spend): preserve response turn rows when id security is disabled Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/db/db_spend_update_writer.py | 7 +++ .../test_responses_stream_spend_row_id.py | 6 +++ .../proxy/db/test_db_spend_update_writer.py | 51 +++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index fd47b2fbb6b..12267120965 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -771,6 +771,13 @@ class DBSpendUpdateWriter: llm_router=llm_router, ) billable_cost: Final = _billable_cost(response_cost, payload) + if ( + proxy_settings.get("disable_responses_id_security") is True + and payload["call_type"] in {"aget_responses", "acancel_responses"} + and payload["status"] == "success" + and not billable_cost + ): + return True payload["spend"] = billable_cost or 0.0 payload["startTime"] = _iso_timestamp(payload["startTime"]) payload["endTime"] = _iso_timestamp(payload["endTime"]) diff --git a/tests/integration/spend/test_responses_stream_spend_row_id.py b/tests/integration/spend/test_responses_stream_spend_row_id.py index 58ed9f7785f..c13ef59fee3 100644 --- a/tests/integration/spend/test_responses_stream_spend_row_id.py +++ b/tests/integration/spend/test_responses_stream_spend_row_id.py @@ -448,6 +448,12 @@ def test_with_id_security_off_reads_and_cancels_add_no_rows_and_no_replayed_turn assert [string_value(JSON_OBJECT.validate_json(read.content)["id"]) for read in reads] == [first_id] * 3 later_id: Final = _created_response_id(insecure, key, {"model": native, "input": "a later request"}) assert _request_ids_for_key(insecure, key, 2) == [first_id, later_id] + (first_turn,) = insecure.rows( + 'SELECT request_id, proxy_server_request, messages, call_type FROM "LiteLLM_SpendLogs" WHERE request_id=%s', + (first_id,), + ) + assert first_turn["call_type"] == "aresponses", first_turn + assert object_value(first_turn["proxy_server_request"])["input"] == first_prompt, first_turn wire.drain() _terminal_response_id( diff --git a/tests/unit/proxy/db/test_db_spend_update_writer.py b/tests/unit/proxy/db/test_db_spend_update_writer.py index 8bf7cd36a8c..36aeacd8476 100644 --- a/tests/unit/proxy/db/test_db_spend_update_writer.py +++ b/tests/unit/proxy/db/test_db_spend_update_writer.py @@ -5890,6 +5890,57 @@ async def test_request_error_counts_survive_the_ledger_path_switch(monkeypatch: ): 1} +@pytest.mark.asyncio +@pytest.mark.parametrize("call_type", ["aresponses", "aget_responses", "acancel_responses"]) +@pytest.mark.parametrize( + ("security_disabled", "status", "cost", "keep_read"), + [ + (False, "success", 0.0, True), + (True, "success", 0.0, False), + (True, "success", None, False), + (True, "success", 0.23, True), + (True, "failure", 0.0, True), + ], +) +async def test_unsecured_free_response_reads_leave_the_creation_spend_row_alone( + monkeypatch: pytest.MonkeyPatch, + call_type: str, + security_disabled: bool, + status: str, + cost: float | None, + keep_read: bool, +) -> None: + from litellm.proxy import proxy_server + + writer: Final = DBSpendUpdateWriter() + prisma: Final = _tool_usage_prisma() + prisma.get_request_status.return_value = status + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "disable_spend_logs", False) + monkeypatch.setitem(proxy_server.general_settings, "disable_responses_id_security", security_disabled) + now: Final = datetime(2026, 11, 11, tzinfo=timezone.utc) + assert await writer.update_database( + token="test-token", + user_id="test-user", + end_user_id=None, + team_id=None, + org_id=None, + kwargs={ + "model": "response-model", + "call_type": call_type, + "litellm_call_id": "read-call", + "litellm_params": {"metadata": {"user_api_key": "test-token", "status": status}}, + }, + completion_response={"id": "resp-shared"}, + start_time=now, + end_time=now, + response_cost=cost, + ) + await drain_logging_tasks() + expected: Final = [call_type] if call_type == "aresponses" or keep_read else [] + assert [row["call_type"] for row in prisma.spend_log_transactions] == expected + + class _SessionTotalsRedis: def __init__(self) -> None: self.values: dict[str, float] = {} # mutable-ok: stands in for Redis state From 3616014548d40020f508566620c5cd8745721f3c Mon Sep 17 00:00:00 2001 From: Satwik Bankapur Date: Wed, 11 Nov 2026 12:39:43 +0530 Subject: [PATCH 63/86] fix(proxy): add tzdata to the proxy extra so a legacy TZ doesn't stop startup (#49914) * fix(proxy): add tzdata to the proxy extra so a legacy TZ doesn't stop startup * style(proxy): drop the inline comment on the tzdata requirement * test(proxy): check tzdata is installed with the proxy extra on every platform * fix(proxy): set the first spend report run in UTC so a scheduler zone that differs from the system clock can't skip it --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 2 +- pyproject.toml | 1 + .../unit/proxy/proxy_server/test_lifecycle.py | 20 +++++++++++++ tests/unit/test_package_metadata.py | 29 +++++++++++++++++++ uv.lock | 2 ++ 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f7a27e4896c..de98a4d571b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12453,7 +12453,7 @@ class ProxyStartupEvent: _scheduled_weekly_spend_report, "interval", days=days, - next_run_time=datetime.now() + timedelta(seconds=10 + random.randint(0, 300)), + next_run_time=datetime.now(timezone.utc) + timedelta(seconds=10 + random.randint(0, 300)), id=WEEKLY_SPEND_REPORT_JOB_ID, replace_existing=True, misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, diff --git a/pyproject.toml b/pyproject.toml index e80ba2d5d44..76bb3a54223 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ proxy = [ "hiredis>=3.0.0,<4.0; python_version < '3.15'", "hiredis>=3.5.0,<4.0; python_version >= '3.15'", "apscheduler>=3.11.2,<4.0", + "tzdata>=2024.1", "fastapi-sso>=0.19.0,<1.0", "PyJWT>=2.13.0,<3.0", "python-multipart>=0.0.27,<1.0", diff --git a/tests/unit/proxy/proxy_server/test_lifecycle.py b/tests/unit/proxy/proxy_server/test_lifecycle.py index db5be07fb6f..7f99d12c868 100644 --- a/tests/unit/proxy/proxy_server/test_lifecycle.py +++ b/tests/unit/proxy/proxy_server/test_lifecycle.py @@ -25,6 +25,7 @@ import os import subprocess from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -1393,6 +1394,25 @@ async def test_monthly_spend_report_sent_when_the_lock_is_free_or_absent(acquire proxy_logging_obj.slack_alerting_instance.send_monthly_spend_report.assert_awaited_once_with() +@pytest.mark.asyncio +async def test_weekly_spend_report_first_run_is_minutes_ahead_when_the_scheduler_zone_differs_from_the_clock(): + """TZ=Asia/Calcutta on a host without the legacy alias puts the scheduler on IST through tzdata while + datetime.now() stays on UTC, so a naive first run landed hours in the past, past the misfire grace.""" + local_offset: Final = datetime.now().astimezone().utcoffset() or timedelta(0) + scheduler: Final = AsyncIOScheduler(timezone=timezone(local_offset + timedelta(hours=5, minutes=30))) + registered_after: Final = datetime.now(timezone.utc) + + await ProxyStartupEvent._initialize_slack_alerting_jobs( + scheduler=scheduler, + general_settings={}, + proxy_logging_obj=_make_slack_alerting_proxy_logging(acquire_lock_result=True), + prisma_client=MagicMock(), + ) + + first_run: Final = scheduler.get_job("weekly_spend_report_job").next_run_time + assert registered_after < first_run < datetime.now(timezone.utc) + timedelta(seconds=311) + + @pytest.mark.asyncio async def test_spend_report_locks_are_never_released(): """The lock is a per-window marker, not a mutex: releasing it lets the next pod re-send.""" diff --git a/tests/unit/test_package_metadata.py b/tests/unit/test_package_metadata.py index dc6e3dca64e..d7dce33bd74 100644 --- a/tests/unit/test_package_metadata.py +++ b/tests/unit/test_package_metadata.py @@ -2,6 +2,7 @@ import sys from pathlib import Path from typing import Final +import pytest from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet from packaging.utils import NormalizedName, canonicalize_name @@ -22,6 +23,11 @@ AIOHTTP_POOL_POISONING_SOURCE: Final = "aio-libs/aiohttp#12953, fixed in aiohttp ANYIO_STRANDED_LOCK_WAITER_FIX: Final = Version("4.14.0") ANYIO_STRANDED_LOCK_WAITER_SOURCE: Final = "agronholm/anyio#1145, fixed in anyio 4.14.0 released 2026-06-15" CLI_RUNTIME_DEPENDENCIES: Final = frozenset({"rich", "pyyaml", "requests"}) +PROXY_INSTALL_PLATFORMS: Final = ( + ("linux", "Linux", "posix"), + ("darwin", "Darwin", "posix"), + ("win32", "Windows", "nt"), +) PROXY_SERVER_ONLY_DEPENDENCIES: Final = frozenset( { "fastapi", @@ -50,6 +56,20 @@ def _requirement_names(requirements: list[str]) -> frozenset[NormalizedName]: return frozenset(canonicalize_name(Requirement(requirement).name) for requirement in requirements) +def _proxy_requirements_installed_on(sys_platform: str, platform_system: str, os_name: str) -> frozenset[NormalizedName]: + environment: Final = { + "sys_platform": sys_platform, + "platform_system": platform_system, + "os_name": os_name, + "extra": "proxy", + } + return frozenset( + canonicalize_name(requirement.name) + for requirement in map(Requirement, OPTIONAL_DEPENDENCIES["proxy"]) + if requirement.marker is None or requirement.marker.evaluate(environment) + ) + + def test_importing_litellm_reports_the_declared_release_version() -> None: import litellm @@ -79,6 +99,15 @@ def test_cli_extra_is_a_thin_client_install() -> None: assert cli & PROXY_SERVER_ONLY_DEPENDENCIES == frozenset() +@pytest.mark.parametrize(("sys_platform", "platform_system", "os_name"), PROXY_INSTALL_PLATFORMS) +def test_proxy_extra_installs_tzdata_beside_its_scheduler_on_every_platform( + sys_platform: str, platform_system: str, os_name: str +) -> None: + installed: Final = _proxy_requirements_installed_on(sys_platform, platform_system, os_name) + assert canonicalize_name("apscheduler") in installed + assert canonicalize_name("tzdata") in installed + + def test_declared_aiohttp_floor_excludes_pool_poisoning_releases() -> None: declared: Final = tuple( Requirement(requirement) diff --git a/uv.lock b/uv.lock index 2dec937800c..386f4339b3f 100644 --- a/uv.lock +++ b/uv.lock @@ -5138,6 +5138,7 @@ proxy = [ { name = "soundfile" }, { name = "starlette" }, { name = "tomlkit" }, + { name = "tzdata" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, { name = "websockets" }, @@ -5423,6 +5424,7 @@ requires-dist = [ { name = "tomlkit", marker = "extra == 'cli'", specifier = ">=0.13.3,<1.0" }, { name = "tomlkit", marker = "extra == 'proxy'", specifier = ">=0.13.3,<1.0" }, { name = "typing-extensions", specifier = ">=4.13.0,<5.0" }, + { name = "tzdata", marker = "extra == 'proxy'", specifier = ">=2024.1" }, { name = "uvicorn", marker = "extra == 'proxy'", specifier = ">=0.33.0,<1.0" }, { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.22.1,<1.0" }, { name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" }, From eba880e108109fec9a9a131c6e26c71b9c2b5828 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Wed, 11 Nov 2026 00:48:11 -0800 Subject: [PATCH 64/86] fix(tls)!: exclude RSA key exchange and ChaCha20 from the outbound cipher list in FIPS mode (#50671) * fix(tls): exclude RSA key exchange from outbound cipher list in FIPS mode * fix(tls): apply the FIPS cipher filter to SSL contexts clients already hold enforce_fips_mode runs after load_config, so guardrail and logger clients built during config load held an SSL context without the RSA key exchange exclusion. enforce_fips_mode now updates every cached context in place, and the SDK no longer imports the proxy's FIPS module. * fix(tls): refuse to start in FIPS mode when SSL_SECURITY_LEVEL has only RSA key exchange Excluding RSA key exchange from an SSL_SECURITY_LEVEL that lists only RSA key-exchange suites leaves no TLS 1.2 suite, so set_ciphers raised on every client build. enforce_fips_mode now checks the configured list first and refuses to start with a FipsModeError that names the setting. The cipher list test no longer assumes the host has RSA key-exchange suites. * fix(tls): offer only TLS 1.3 instead of refusing to start when no TLS 1.2 suite without RSA key exchange is left * chore(tls): warn when fips_mode leaves a cipher list with only TLS 1.3 * style(tls): split the TLS 1.3 fallback warning to fit the line limit * fix(proxy): exclude RSA key exchange before config-load secret reads in FIPS mode get_config reads secrets (AWS, GCP, HashiCorp, CyberArk secret managers) over the shared outbound SSL context before enforce_fips_mode runs, so with general_settings.fips_mode true those first TLS handshakes still offered RSA key exchange. Turn the exclusion on in get_config from the raw general_settings, right before the secret manager starts, reusing the existing fips_mode_setting (now public) and exclude_rsa_key_exchange. enforce_fips_mode still owns the refusal and the final state; fips_mode given as an os.environ/ reference is still handled there. * test(proxy): skip the config-load RSA key exchange test on an OpenSSL without RSA suites On an OpenSSL whose default cipher list offers no RSA key exchange suite, fips and standard mode read secrets the same way, so the test has nothing to tell apart and skips, as the http_handler fallback test already does. * fix(tls): exclude TLS 1.2 ChaCha20 suites from the outbound cipher list in FIPS mode ChaCha20-Poly1305 is not a FIPS approved algorithm. FIPS providers already refuse it, so on the Chainguard image this changes nothing, but fips_mode now drops it from TLS 1.2 explicitly next to RSA key exchange. Python's set_ciphers cannot touch TLS 1.3 suites, which still come from OpenSSL's config. exclude_rsa_key_exchange is renamed to exclude_rsa_key_exchange_and_chacha20 to match. --- litellm/llms/custom_httpx/http_handler.py | 55 +++++++-- litellm/proxy/common_utils/fips.py | 9 +- litellm/proxy/proxy_server.py | 9 +- .../llms/custom_httpx/test_http_handler.py | 108 ++++++++++++++++++ .../proxy/proxy_server/test_proxy_config.py | 70 ++++++++++++ 5 files changed, 234 insertions(+), 17 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 307d67bab21..011551faf0c 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -316,6 +316,48 @@ def _prepare_request_data_and_content( # Cache for SSL contexts to avoid creating duplicate contexts with the same configuration _ssl_context_cache: Final[dict[tuple[str | None, str | None, str | None, bool], ssl.SSLContext]] = {} +_rsa_key_exchange_and_chacha20_excluded: Final = threading.Event() + + +def _set_ciphers( + context: ssl.SSLContext, ssl_security_level: str | None, rsa_key_exchange_and_chacha20_excluded: bool +) -> None: + """ + Offer TLS 1.2 and up with SSL_SECURITY_LEVEL's cipher string if set, else DEFAULT_SSL_CIPHERS. + With RSA key exchange and ChaCha20 excluded, drop their TLS 1.2 suites, or offer only TLS 1.3 and log a warning + when the string has no other TLS 1.2 suite. TLS 1.3 suites come from OpenSSL's config, which set_ciphers can't + change. + """ + ciphers: Final = ( + ssl_security_level if ssl_security_level and isinstance(ssl_security_level, str) else DEFAULT_SSL_CIPHERS + ) + context.minimum_version = ssl.TLSVersion.TLSv1_2 # rebind-ok: sets up the caller's context in place + context.set_ciphers(ciphers) + if rsa_key_exchange_and_chacha20_excluded: + try: + context.set_ciphers(f"{ciphers}:!kRSA:!CHACHA20") + except ssl.SSLError: + context.minimum_version = ssl.TLSVersion.TLSv1_3 # rebind-ok: sets up the caller's context in place + verbose_logger.warning( + "fips_mode: cipher list '%s' has no TLS 1.2 suite without RSA key exchange or ChaCha20, " + "so outbound TLS offers only TLS 1.3 and TLS 1.2-only servers will fail.", + ciphers, + ) + + +def exclude_rsa_key_exchange_and_chacha20(excluded: bool) -> None: + """ + Turn RSA key exchange and TLS 1.2 ChaCha20 off or back on for new SSL contexts and every cached one, which clients + already hold. + """ + if excluded == _rsa_key_exchange_and_chacha20_excluded.is_set(): + return + if excluded: + _rsa_key_exchange_and_chacha20_excluded.set() + else: + _rsa_key_exchange_and_chacha20_excluded.clear() + for (_, ssl_security_level, _, _), context in list(_ssl_context_cache.items()): + _set_ciphers(context, ssl_security_level, excluded) def _create_ssl_context( @@ -329,18 +371,7 @@ def _create_ssl_context( """ custom_ssl_context: Final = ssl.create_default_context(cafile=cafile) - # Optimize SSL handshake performance - # Set minimum TLS version to 1.2 for better performance - custom_ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 - - # Configure cipher suites for optimal performance - if ssl_security_level and isinstance(ssl_security_level, str): - # User provided custom cipher configuration (e.g., via SSL_SECURITY_LEVEL env var) - custom_ssl_context.set_ciphers(ssl_security_level) - else: - # Use optimized cipher list that strongly prefers fast ciphers - # but falls back to widely compatible ones - custom_ssl_context.set_ciphers(DEFAULT_SSL_CIPHERS) + _set_ciphers(custom_ssl_context, ssl_security_level, _rsa_key_exchange_and_chacha20_excluded.is_set()) # Configure ECDH curve for key exchange (e.g., to disable PQC and improve performance) # Set SSL_ECDH_CURVE env var or litellm.ssl_ecdh_curve to 'X25519' to disable PQC diff --git a/litellm/proxy/common_utils/fips.py b/litellm/proxy/common_utils/fips.py index 6cd23e5c0ff..efa57817482 100644 --- a/litellm/proxy/common_utils/fips.py +++ b/litellm/proxy/common_utils/fips.py @@ -2,6 +2,7 @@ import threading from collections.abc import Mapping from typing import Final +from litellm.llms.custom_httpx.http_handler import exclude_rsa_key_exchange_and_chacha20 from litellm.secret_managers.main import str_to_bool FIPS_MODE_SETTING: Final = "fips_mode" @@ -13,7 +14,7 @@ class FipsModeError(Exception): pass -def _fips_mode_setting(general_settings: Mapping[str, object]) -> bool | None: +def fips_mode_setting(general_settings: Mapping[str, object]) -> bool | None: """general_settings.fips_mode as a boolean: False when the key is absent, None when it is not a boolean, including a key with no value.""" if FIPS_MODE_SETTING not in general_settings: @@ -60,7 +61,7 @@ def cryptography_enforces_fips() -> bool: def fips_mode_refusal(general_settings: Mapping[str, object]) -> str | None: """Why the proxy must not start: general_settings.fips_mode is not a boolean, or is on but Python's or the cryptography library's OpenSSL does not enforce FIPS. None when it may start.""" - setting: Final = _fips_mode_setting(general_settings) + setting: Final = fips_mode_setting(general_settings) if setting is None: value: Final = general_settings.get(FIPS_MODE_SETTING) if value is None: @@ -89,7 +90,9 @@ def enforce_fips_mode(general_settings: Mapping[str, object]) -> None: refusal: Final = fips_mode_refusal(general_settings) if refusal is not None: raise FipsModeError(refusal) - if _fips_mode_setting(general_settings): + active: Final = fips_mode_setting(general_settings) is True + exclude_rsa_key_exchange_and_chacha20(active) + if active: _FIPS_MODE_ACTIVE.set() else: _FIPS_MODE_ACTIVE.clear() diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index de98a4d571b..9f721e3fdcb 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -419,7 +419,7 @@ from litellm.llms.anthropic.messages.convertible_blocks import ( malformed_tool_result_count_error, ) from litellm.llms.custom_httpx.aiohttp_pool_diagnostics import build_pool_wait_trace_config -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, exclude_rsa_key_exchange_and_chacha20 from litellm.llms.openai.live import parse_live_session_start from litellm.llms.openai_like.model_info import MODEL_INFO_REFRESH_SECONDS from litellm.llms.vertex_ai.vertex_llm_base import VertexBase @@ -547,7 +547,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( encrypt_value_helper, ) from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id -from litellm.proxy.common_utils.fips import enforce_fips_mode +from litellm.proxy.common_utils.fips import enforce_fips_mode, fips_mode_setting from litellm.proxy.common_utils.healthy_model_filter import ( get_hidden_unhealthy_model_names, is_healthy_only_listing_default, @@ -6680,6 +6680,11 @@ class ProxyConfig: printed_yaml: Final = copy.deepcopy(config) printed_yaml.pop("environment_variables", None) + # Secret manager reads and the rest of config load make outbound TLS calls before enforce_fips_mode runs + raw_general_settings: Final = as_str_mapping(config.get("general_settings")) + if raw_general_settings is not None and fips_mode_setting(raw_general_settings) is True: + exclude_rsa_key_exchange_and_chacha20(True) + self._initialize_secret_manager_from_raw_config(config=config, config_file_path=config_file_path) config = self._check_for_os_environ_vars(config=config) diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index 4e031531180..adb6410d360 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -339,6 +339,114 @@ def test_get_ssl_configuration_integration(): assert ssl_context.verify_mode is not None +def _offered_ciphers(context: bool | str | ssl.SSLContext) -> tuple[tuple[str, str, str, str | None], ...]: + assert isinstance(context, ssl.SSLContext) + return tuple( + (cipher["name"], cipher["protocol"], cipher["kea"], cipher["symmetric"]) for cipher in context.get_ciphers() + ) + + +def _kept_in_fips_mode(cipher: tuple[str, str, str, str | None]) -> bool: + _, protocol, kea, symmetric = cipher + return kea != "kx-rsa" and (protocol, symmetric) != ("TLSv1.2", "chacha20-poly1305") + + +def _use_ssl_security_level(monkeypatch: pytest.MonkeyPatch, ssl_security_level: str | None) -> None: + from litellm.llms.custom_httpx.http_handler import _ssl_context_cache + + _ssl_context_cache.clear() + for name in ("SSL_SECURITY_LEVEL", "SSL_ECDH_CURVE", "SSL_CERT_FILE", "SSL_VERIFY"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(litellm, "ssl_security_level", ssl_security_level) + + +def _fake_fips_openssl(monkeypatch: pytest.MonkeyPatch) -> None: + import _hashlib + + from cryptography.hazmat.bindings._rust import openssl as rust_openssl + + monkeypatch.setattr(_hashlib, "get_fips_mode", lambda: 1) + monkeypatch.setattr( + _hashlib, "scrypt", MagicMock(side_effect=ValueError("[digital envelope routines] unsupported")) + ) + monkeypatch.setattr(rust_openssl, "is_fips_enabled", lambda: True) + + +@pytest.mark.parametrize( + "ssl_security_level", + [None, "AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384"], + ids=["default-cipher-list", "ssl-security-level"], +) +def test_fips_mode_drops_rsa_key_exchange_and_tls_1_2_chacha20_from_the_outbound_cipher_list( + monkeypatch: pytest.MonkeyPatch, ssl_security_level: str | None +) -> None: + from litellm.constants import DEFAULT_SSL_CIPHERS + from litellm.llms.custom_httpx.http_handler import _ssl_context_cache + from litellm.proxy.common_utils.fips import enforce_fips_mode + + _use_ssl_security_level(monkeypatch, ssl_security_level) + configured: Final = ssl.create_default_context() + configured.set_ciphers(ssl_security_level or DEFAULT_SSL_CIPHERS) + + # A client built while the config loads holds its context from before FIPS mode is enforced + held: Final = get_ssl_configuration() + standard: Final = _offered_ciphers(held) + _fake_fips_openssl(monkeypatch) + enforce_fips_mode({"fips_mode": True}) + try: + held_in_fips: Final = _offered_ciphers(held) + new_in_fips: Final = _offered_ciphers(get_ssl_configuration(http2=True)) + finally: + enforce_fips_mode({}) + held_after_fips: Final = _offered_ciphers(held) + _ssl_context_cache.clear() + + assert standard == _offered_ciphers(configured) + kept_in_fips: Final = tuple(filter(_kept_in_fips_mode, standard)) + assert held_in_fips == kept_in_fips + assert new_in_fips == kept_in_fips + assert held_after_fips == standard + + +@pytest.mark.parametrize("excluded_suites", ["kRSA", "CHACHA20"]) +def test_fips_mode_offers_only_tls_1_3_when_the_cipher_list_has_only_excluded_tls_1_2_suites( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + excluded_suites: str, +) -> None: + from litellm.llms.custom_httpx.http_handler import _ssl_context_cache + from litellm.proxy.common_utils.fips import enforce_fips_mode, fips_mode_active + + excluded: Final = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + try: + excluded.set_ciphers(excluded_suites) + except ssl.SSLError: + pytest.skip(f"this OpenSSL has no {excluded_suites} suites") + _use_ssl_security_level( + monkeypatch, + ":".join(cipher["name"] for cipher in excluded.get_ciphers() if cipher["protocol"] != "TLSv1.3"), + ) + held: Final = get_ssl_configuration() + assert isinstance(held, ssl.SSLContext) + _fake_fips_openssl(monkeypatch) + caplog.set_level(logging.WARNING, logger="LiteLLM") + enforce_fips_mode({"fips_mode": True}) + try: + started_in_fips: Final = fips_mode_active() + new: Final = get_ssl_configuration(http2=True) + assert isinstance(new, ssl.SSLContext) + in_fips: Final = (held.minimum_version, new.minimum_version) + finally: + enforce_fips_mode({}) + after_fips: Final = held.minimum_version + _ssl_context_cache.clear() + + assert started_in_fips is True + assert in_fips == (ssl.TLSVersion.TLSv1_3, ssl.TLSVersion.TLSv1_3) + assert after_fips == ssl.TLSVersion.TLSv1_2 + assert any("offers only TLS 1.3" in record.getMessage() for record in caplog.records) + + # Session Reuse Tests class MockClientSession: """Mock ClientSession that is not callable""" diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index 712b4134c05..91ff818da4c 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -14,6 +14,7 @@ import json import logging import os import re +import ssl import sys from collections.abc import Awaitable, Callable, Mapping from contextlib import nullcontext @@ -34,9 +35,11 @@ from pydantic import JsonValue, TypeAdapter, ValidationError import litellm from litellm._logging import turn_on_json +from litellm.constants import DEFAULT_SSL_CIPHERS from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.proxy._types import CommonProxyErrors, ConfigGeneralSettings from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +from litellm.proxy.common_utils.fips import enforce_fips_mode from litellm.proxy.config_resolvers.settings_store import SettingsStore from litellm.router_api import ApplyResult, RouterSettings from litellm.types.router import Deployment, LiteLLM_Params @@ -1901,6 +1904,73 @@ async def test_ProxyConfig_get_config_reuses_an_already_initialized_secret_manag } == {"client_reused": True, "master_key": "master-from-vault"} +TLS_RECORDING_SECRET_MANAGER_MODULE = """ +import os + +from litellm.integrations.custom_secret_manager import CustomSecretManager +from litellm.llms.custom_httpx.http_handler import get_ssl_configuration + + +class TlsRecordingSecretManager(CustomSecretManager): + def sync_read_secret(self, secret_name, optional_params=None, timeout=None, **kwargs): + offers_rsa = any(c["kea"] == "kx-rsa" for c in get_ssl_configuration().get_ciphers()) + with open(os.environ["RSA_OFFER_LOG"], "a") as f: + f.write(f"{secret_name} {offers_rsa}\\n") + return "provider-from-vault" + + async def async_read_secret(self, secret_name, optional_params=None, timeout=None, **kwargs): + return self.sync_read_secret(secret_name) +""" + +TLS_RECORDING_CONFIG = """ +model_list: + - model_name: my-model + litellm_params: + model: openai/gpt-4o-mini + api_key: os.environ/MY_PROVIDER_KEY + +general_settings: + fips_mode: {fips_mode} + key_management_system: custom + key_management_settings: + custom_secret_manager: tls_recording_secret_manager.TlsRecordingSecretManager + hosted_keys: + - MY_PROVIDER_KEY +""" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "fips_mode, rsa_offered", + [("true", False), ('"true"', False), ("false", True)], + ids=["fips-mode", "fips-mode-from-a-string", "standard-mode"], +) +async def test_ProxyConfig_get_config_reads_secrets_without_rsa_key_exchange_in_fips_mode( + tmp_path, monkeypatch, fips_mode: str, rsa_offered: bool +): + """Secret reads during get_config run before enforce_fips_mode, so fips_mode already excludes RSA key exchange.""" + default_cipher_list: Final = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + default_cipher_list.set_ciphers(DEFAULT_SSL_CIPHERS) + if not any(cipher["kea"] == "kx-rsa" for cipher in default_cipher_list.get_ciphers()): + pytest.skip("this OpenSSL offers no RSA key exchange suite from DEFAULT_SSL_CIPHERS") + config_file_path = _write_vault_backed_config( + tmp_path, monkeypatch, TLS_RECORDING_CONFIG.format(fips_mode=fips_mode) + ) + (tmp_path / "tls_recording_secret_manager.py").write_text(TLS_RECORDING_SECRET_MANAGER_MODULE) + rsa_offer_log: Final = tmp_path / "rsa_offers.log" + monkeypatch.setenv("RSA_OFFER_LOG", str(rsa_offer_log)) + + try: + cfg = await ProxyConfig().get_config(config_file_path=config_file_path) + finally: + enforce_fips_mode({}) + + assert { + "reads": set(rsa_offer_log.read_text().splitlines()), + "api_key": cfg["model_list"][0]["litellm_params"]["api_key"], + } == {"reads": {f"MY_PROVIDER_KEY {rsa_offered}"}, "api_key": "provider-from-vault"} + + @pytest.mark.asyncio async def test_ProxyConfig_get_config_without_key_management_system_leaves_secret_manager_unset(tmp_path, monkeypatch): """No ``key_management_system`` means no manager, an unresolvable reference stays None, and From 8b8ec4e3a64facae7af657ab06bc214dd5bf9060 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:06:57 +0000 Subject: [PATCH 65/86] fix(vertex): drop floor-only retirement dates, veo 3.1 successor and gemini token limits from the Vertex model pages (#51180) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 232 +++++++----------- model_prices_and_context_window.json | 232 +++++++----------- 2 files changed, 182 insertions(+), 282 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index ead85584c33..f3cf258a2bb 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -29732,12 +29732,12 @@ }, "gemini-2.5-flash": { "cache_read_input_audio_token_cost": 1e-07, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_priority": 1.8e-07, "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_flex": 3e-08, "cache_read_input_token_cost_priority": 5.4e-08, "deprecation_date": "2026-10-20", + "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "google_maps_grounding_cost_per_query": 0.025, "input_cost_per_audio_token": 1e-06, "input_cost_per_audio_token_above_200k_tokens": 3e-07, @@ -29753,7 +29753,7 @@ "input_cost_per_token_priority": 5.4e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, @@ -29845,13 +29845,11 @@ }, "gemini-3-pro-image": { "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, @@ -29873,6 +29871,8 @@ "output_cost_per_token_batches": 6e-06, "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -29894,12 +29894,6 @@ "supports_system_messages": true, "supports_vision": true, "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, "web_search_billing_unit": "per_query" }, "gemini-3-pro-image-preview": { @@ -29961,15 +29955,13 @@ "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "cache_read_input_token_cost_priority": 9e-08, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 65536, + "max_input_tokens": 131072, "max_output_tokens": 32768, "mode": "image_generation", "output_cost_per_image": 0.0672, @@ -29980,6 +29972,8 @@ "output_cost_per_token_batches": 1.5e-06, "output_cost_per_token_flex": 1.5e-06, "output_cost_per_token_priority": 5.4e-06, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -30001,12 +29995,6 @@ "supports_system_messages": true, "supports_vision": true, "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-image-preview": { @@ -30152,7 +30140,6 @@ }, "gemini-3.1-flash-lite": { "cache_read_input_audio_token_cost": 5e-08, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_batches": 2.5e-08, "cache_read_input_audio_token_cost_flex": 2.5e-08, "cache_read_input_audio_token_cost_priority": 9e-08, @@ -30160,7 +30147,6 @@ "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, - "deprecation_date": "2027-05-07", "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 5e-07, "input_cost_per_audio_token_above_200k_tokens": 5e-07, @@ -30219,12 +30205,11 @@ "web_search_billing_unit": "per_query" }, "gemini-3.5-flash-lite": { - "deprecation_date": "2027-07-21", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, @@ -30238,6 +30223,8 @@ "output_cost_per_token_batches": 1.25e-06, "output_cost_per_token_flex": 1.25e-06, "output_cost_per_token_priority": 4.5e-06, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -30257,6 +30244,7 @@ "supports_audio_output": false, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -30269,15 +30257,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "web_search_billing_unit": "per_query" }, "deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, @@ -30316,12 +30296,12 @@ }, "gemini-2.5-flash-lite": { "cache_read_input_audio_token_cost": 3e-08, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_priority": 5.4e-08, "cache_read_input_token_cost": 1e-08, "cache_read_input_token_cost_flex": 1e-08, "cache_read_input_token_cost_priority": 1.8e-08, "deprecation_date": "2026-10-20", + "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "google_maps_grounding_cost_per_query": 0.025, "input_cost_per_audio_token": 3e-07, "input_cost_per_audio_token_above_200k_tokens": 3e-07, @@ -30337,7 +30317,7 @@ "input_cost_per_token_priority": 1.8e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, @@ -30604,23 +30584,39 @@ "gemini_native_audio": true }, "gemini-2.5-pro": { - "deprecation_date": "2026-10-20", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", - "successor_model": "gemini-3.8-flash", "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, "cache_read_input_token_cost_flex": 1.25e-07, "cache_read_input_token_cost_priority": 2.25e-07, + "deprecation_date": "2026-10-20", + "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", + "google_maps_grounding_cost_per_query": 0.025, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, + "input_cost_per_token_above_200k_tokens_batches": 1.25e-06, + "input_cost_per_token_above_200k_tokens_flex": 1.25e-06, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.25e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, + "output_cost_per_token_above_200k_tokens_batches": 7.5e-06, + "output_cost_per_token_above_200k_tokens_flex": 7.5e-06, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 1.8e-05, + "prompt_cache_min_tokens": 2048.0, + "search_context_cost_per_query": {"search_context_size_low":0.035,"search_context_size_medium":0.035,"search_context_size_high":0.035}, + "search_free_requests": {"count":10000,"period":"day","shared_group":"gemini-2.5-pro"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "gemini-3.8-flash", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -30638,33 +30634,13 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "search_free_requests": {"count": 10000, "period": "day", "shared_group": "gemini-2.5-pro"}, - "google_maps_grounding_cost_per_query": 0.025, - "input_cost_per_token_above_200k_tokens_batches": 1.25e-06, - "input_cost_per_token_above_200k_tokens_flex": 1.25e-06, - "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, - "input_cost_per_token_batches": 6.25e-07, - "input_cost_per_token_flex": 6.25e-07, - "input_cost_per_token_priority": 2.25e-06, - "output_cost_per_token_above_200k_tokens_batches": 7.5e-06, - "output_cost_per_token_above_200k_tokens_flex": 7.5e-06, - "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, - "output_cost_per_token_batches": 5e-06, - "output_cost_per_token_flex": 5e-06, - "output_cost_per_token_priority": 1.8e-05 + "supports_web_search": true }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -30852,7 +30828,7 @@ "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_token": 3e-06, "output_cost_per_token_batches": 1.5e-06, @@ -30891,22 +30867,32 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.5-flash": { - "prompt_cache_min_tokens": 4096, - "deprecation_date": "2027-05-19", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", - "successor_model": "vertex_ai/gemini-3.8-flash", "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, - "input_cost_per_token": 1.5e-06, + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_priority": 2.7e-07, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_audio_token_priority": 2.7e-06, + "input_cost_per_token": 1.5e-06, + "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, + "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, + "output_cost_per_token_batches": 4.5e-06, + "output_cost_per_token_flex": 4.5e-06, + "output_cost_per_token_priority": 1.62e-05, + "prompt_cache_min_tokens": 4096.0, "regional_endpoint_uplift_multiplier": 1.1, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-3.8-flash", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -30924,6 +30910,7 @@ "supports_audio_input": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -30935,24 +30922,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 2.7e-06, - "output_cost_per_token_priority": 1.62e-05, - "cache_read_input_token_cost_priority": 2.7e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_token_batches": 7.5e-07, - "output_cost_per_token_batches": 4.5e-06, - "input_cost_per_token_flex": 7.5e-07, - "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -33162,7 +33132,7 @@ "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 3e-06, "output_cost_per_token": 3e-06, @@ -33234,21 +33204,31 @@ "deprecation_date": "2026-11-06" }, "gemini-3.5-flash": { - "prompt_cache_min_tokens": 4096, - "deprecation_date": "2027-05-19", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", - "successor_model": "gemini-3.8-flash", "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_priority": 2.7e-07, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "input_cost_per_token": 1.5e-06, + "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, + "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, + "output_cost_per_token_batches": 4.5e-06, + "output_cost_per_token_flex": 4.5e-06, + "output_cost_per_token_priority": 1.62e-05, + "prompt_cache_min_tokens": 4096.0, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "gemini-3.8-flash", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -33263,10 +33243,11 @@ "supported_output_modalities": [ "text" ], - "supports_audio_output": false, "supports_audio_input": true, + "supports_audio_output": false, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -33278,24 +33259,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 2.7e-06, - "output_cost_per_token_priority": 1.62e-05, - "cache_read_input_token_cost_priority": 2.7e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_token_batches": 7.5e-07, - "output_cost_per_token_batches": 4.5e-06, - "input_cost_per_token_flex": 7.5e-07, - "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "web_search_billing_unit": "per_query" }, "gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -54132,13 +54096,11 @@ }, "vertex_ai/gemini-3-pro-image": { "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, @@ -54160,15 +54122,14 @@ "output_cost_per_token_batches": 6e-06, "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05, - "supports_reasoning": false, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/images/generations", "/v1/images/edits", "/v1/batch" - ] - + ], + "supports_reasoning": false }, "vertex_ai/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -54210,15 +54171,13 @@ "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "cache_read_input_token_cost_priority": 9e-08, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 65536, + "max_input_tokens": 131072, "max_output_tokens": 32768, "mode": "image_generation", "output_cost_per_image": 0.0672, @@ -54230,15 +54189,14 @@ "output_cost_per_token_flex": 1.5e-06, "output_cost_per_token_priority": 5.4e-06, "regional_endpoint_uplift_multiplier": 1.1, - "supports_reasoning": false, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/images/generations", "/v1/images/edits", "/v1/batch" - ] - + ], + "supports_reasoning": false }, "vertex_ai/gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -54364,7 +54322,6 @@ }, "vertex_ai/gemini-3.1-flash-lite": { "cache_read_input_audio_token_cost": 5e-08, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_batches": 2.5e-08, "cache_read_input_audio_token_cost_flex": 2.5e-08, "cache_read_input_audio_token_cost_priority": 9e-08, @@ -54372,7 +54329,6 @@ "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, - "deprecation_date": "2027-05-07", "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 5e-07, "input_cost_per_audio_token_above_200k_tokens": 5e-07, @@ -54432,12 +54388,11 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.5-flash-lite": { - "deprecation_date": "2027-07-21", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, @@ -54452,6 +54407,8 @@ "output_cost_per_token_flex": 1.25e-06, "output_cost_per_token_priority": 4.5e-06, "regional_endpoint_uplift_multiplier": 1.1, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -54471,6 +54428,7 @@ "supports_audio_output": false, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -54483,15 +54441,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "web_search_billing_unit": "per_query" }, "vertex_ai/deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, @@ -55287,14 +55237,13 @@ ] }, "vertex_ai/veo-3.1-generate-001": { - "deprecation_date": "2026-11-17", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.4, "output_cost_per_second_4k": 0.6, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-omni-1.2-flash", "supported_modalities": [ "text" ], @@ -55303,8 +55252,6 @@ ] }, "vertex_ai/veo-3.1-fast-generate-001": { - "deprecation_date": "2026-11-17", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, "mode": "video_generation", @@ -55312,6 +55259,7 @@ "output_cost_per_second_1080p": 0.12, "output_cost_per_second_4k": 0.3, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-omni-1.2-flash", "supported_modalities": [ "text" ], @@ -72086,18 +72034,20 @@ "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-omni-1.2-flash" }, "vertex_ai/gemini-omni-1.1-flash-preview": { "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai", - "max_output_tokens": 57920, "max_input_tokens": 131072, + "max_output_tokens": 57920, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-1-1-flash", + "successor_model": "vertex_ai/gemini-omni-1.2-flash", "supported_endpoints": [ "/v1beta/interactions" ], diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index ead85584c33..f3cf258a2bb 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -29732,12 +29732,12 @@ }, "gemini-2.5-flash": { "cache_read_input_audio_token_cost": 1e-07, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_priority": 1.8e-07, "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_flex": 3e-08, "cache_read_input_token_cost_priority": 5.4e-08, "deprecation_date": "2026-10-20", + "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "google_maps_grounding_cost_per_query": 0.025, "input_cost_per_audio_token": 1e-06, "input_cost_per_audio_token_above_200k_tokens": 3e-07, @@ -29753,7 +29753,7 @@ "input_cost_per_token_priority": 5.4e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, @@ -29845,13 +29845,11 @@ }, "gemini-3-pro-image": { "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, @@ -29873,6 +29871,8 @@ "output_cost_per_token_batches": 6e-06, "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -29894,12 +29894,6 @@ "supports_system_messages": true, "supports_vision": true, "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, "web_search_billing_unit": "per_query" }, "gemini-3-pro-image-preview": { @@ -29961,15 +29955,13 @@ "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "cache_read_input_token_cost_priority": 9e-08, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 65536, + "max_input_tokens": 131072, "max_output_tokens": 32768, "mode": "image_generation", "output_cost_per_image": 0.0672, @@ -29980,6 +29972,8 @@ "output_cost_per_token_batches": 1.5e-06, "output_cost_per_token_flex": 1.5e-06, "output_cost_per_token_priority": 5.4e-06, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -30001,12 +29995,6 @@ "supports_system_messages": true, "supports_vision": true, "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-image-preview": { @@ -30152,7 +30140,6 @@ }, "gemini-3.1-flash-lite": { "cache_read_input_audio_token_cost": 5e-08, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_batches": 2.5e-08, "cache_read_input_audio_token_cost_flex": 2.5e-08, "cache_read_input_audio_token_cost_priority": 9e-08, @@ -30160,7 +30147,6 @@ "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, - "deprecation_date": "2027-05-07", "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 5e-07, "input_cost_per_audio_token_above_200k_tokens": 5e-07, @@ -30219,12 +30205,11 @@ "web_search_billing_unit": "per_query" }, "gemini-3.5-flash-lite": { - "deprecation_date": "2027-07-21", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, @@ -30238,6 +30223,8 @@ "output_cost_per_token_batches": 1.25e-06, "output_cost_per_token_flex": 1.25e-06, "output_cost_per_token_priority": 4.5e-06, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -30257,6 +30244,7 @@ "supports_audio_output": false, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -30269,15 +30257,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "web_search_billing_unit": "per_query" }, "deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, @@ -30316,12 +30296,12 @@ }, "gemini-2.5-flash-lite": { "cache_read_input_audio_token_cost": 3e-08, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_priority": 5.4e-08, "cache_read_input_token_cost": 1e-08, "cache_read_input_token_cost_flex": 1e-08, "cache_read_input_token_cost_priority": 1.8e-08, "deprecation_date": "2026-10-20", + "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "google_maps_grounding_cost_per_query": 0.025, "input_cost_per_audio_token": 3e-07, "input_cost_per_audio_token_above_200k_tokens": 3e-07, @@ -30337,7 +30317,7 @@ "input_cost_per_token_priority": 1.8e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, @@ -30604,23 +30584,39 @@ "gemini_native_audio": true }, "gemini-2.5-pro": { - "deprecation_date": "2026-10-20", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", - "successor_model": "gemini-3.8-flash", "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, "cache_read_input_token_cost_flex": 1.25e-07, "cache_read_input_token_cost_priority": 2.25e-07, + "deprecation_date": "2026-10-20", + "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", + "google_maps_grounding_cost_per_query": 0.025, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, + "input_cost_per_token_above_200k_tokens_batches": 1.25e-06, + "input_cost_per_token_above_200k_tokens_flex": 1.25e-06, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.25e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, + "output_cost_per_token_above_200k_tokens_batches": 7.5e-06, + "output_cost_per_token_above_200k_tokens_flex": 7.5e-06, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 1.8e-05, + "prompt_cache_min_tokens": 2048.0, + "search_context_cost_per_query": {"search_context_size_low":0.035,"search_context_size_medium":0.035,"search_context_size_high":0.035}, + "search_free_requests": {"count":10000,"period":"day","shared_group":"gemini-2.5-pro"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "gemini-3.8-flash", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -30638,33 +30634,13 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "search_free_requests": {"count": 10000, "period": "day", "shared_group": "gemini-2.5-pro"}, - "google_maps_grounding_cost_per_query": 0.025, - "input_cost_per_token_above_200k_tokens_batches": 1.25e-06, - "input_cost_per_token_above_200k_tokens_flex": 1.25e-06, - "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, - "input_cost_per_token_batches": 6.25e-07, - "input_cost_per_token_flex": 6.25e-07, - "input_cost_per_token_priority": 2.25e-06, - "output_cost_per_token_above_200k_tokens_batches": 7.5e-06, - "output_cost_per_token_above_200k_tokens_flex": 7.5e-06, - "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, - "output_cost_per_token_batches": 5e-06, - "output_cost_per_token_flex": 5e-06, - "output_cost_per_token_priority": 1.8e-05 + "supports_web_search": true }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -30852,7 +30828,7 @@ "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_token": 3e-06, "output_cost_per_token_batches": 1.5e-06, @@ -30891,22 +30867,32 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.5-flash": { - "prompt_cache_min_tokens": 4096, - "deprecation_date": "2027-05-19", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", - "successor_model": "vertex_ai/gemini-3.8-flash", "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, - "input_cost_per_token": 1.5e-06, + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_priority": 2.7e-07, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_audio_token_priority": 2.7e-06, + "input_cost_per_token": 1.5e-06, + "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, + "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, + "output_cost_per_token_batches": 4.5e-06, + "output_cost_per_token_flex": 4.5e-06, + "output_cost_per_token_priority": 1.62e-05, + "prompt_cache_min_tokens": 4096.0, "regional_endpoint_uplift_multiplier": 1.1, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-3.8-flash", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -30924,6 +30910,7 @@ "supports_audio_input": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -30935,24 +30922,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 2.7e-06, - "output_cost_per_token_priority": 1.62e-05, - "cache_read_input_token_cost_priority": 2.7e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_token_batches": 7.5e-07, - "output_cost_per_token_batches": 4.5e-06, - "input_cost_per_token_flex": 7.5e-07, - "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -33162,7 +33132,7 @@ "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 3e-06, "output_cost_per_token": 3e-06, @@ -33234,21 +33204,31 @@ "deprecation_date": "2026-11-06" }, "gemini-3.5-flash": { - "prompt_cache_min_tokens": 4096, - "deprecation_date": "2027-05-19", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", - "successor_model": "gemini-3.8-flash", "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_priority": 2.7e-07, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "input_cost_per_token": 1.5e-06, + "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, + "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, + "output_cost_per_token_batches": 4.5e-06, + "output_cost_per_token_flex": 4.5e-06, + "output_cost_per_token_priority": 1.62e-05, + "prompt_cache_min_tokens": 4096.0, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "gemini-3.8-flash", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -33263,10 +33243,11 @@ "supported_output_modalities": [ "text" ], - "supports_audio_output": false, "supports_audio_input": true, + "supports_audio_output": false, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -33278,24 +33259,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 2.7e-06, - "output_cost_per_token_priority": 1.62e-05, - "cache_read_input_token_cost_priority": 2.7e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_token_batches": 7.5e-07, - "output_cost_per_token_batches": 4.5e-06, - "input_cost_per_token_flex": 7.5e-07, - "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "web_search_billing_unit": "per_query" }, "gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -54132,13 +54096,11 @@ }, "vertex_ai/gemini-3-pro-image": { "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, @@ -54160,15 +54122,14 @@ "output_cost_per_token_batches": 6e-06, "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05, - "supports_reasoning": false, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/images/generations", "/v1/images/edits", "/v1/batch" - ] - + ], + "supports_reasoning": false }, "vertex_ai/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -54210,15 +54171,13 @@ "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "cache_read_input_token_cost_priority": 9e-08, - "deprecation_date": "2027-05-28", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "input_cost_per_token_priority": 9e-07, "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 65536, + "max_input_tokens": 131072, "max_output_tokens": 32768, "mode": "image_generation", "output_cost_per_image": 0.0672, @@ -54230,15 +54189,14 @@ "output_cost_per_token_flex": 1.5e-06, "output_cost_per_token_priority": 5.4e-06, "regional_endpoint_uplift_multiplier": 1.1, - "supports_reasoning": false, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/images/generations", "/v1/images/edits", "/v1/batch" - ] - + ], + "supports_reasoning": false }, "vertex_ai/gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -54364,7 +54322,6 @@ }, "vertex_ai/gemini-3.1-flash-lite": { "cache_read_input_audio_token_cost": 5e-08, - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_audio_token_cost_batches": 2.5e-08, "cache_read_input_audio_token_cost_flex": 2.5e-08, "cache_read_input_audio_token_cost_priority": 9e-08, @@ -54372,7 +54329,6 @@ "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, - "deprecation_date": "2027-05-07", "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_audio_token": 5e-07, "input_cost_per_audio_token_above_200k_tokens": 5e-07, @@ -54432,12 +54388,11 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.5-flash-lite": { - "deprecation_date": "2027-07-21", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "cache_read_input_token_cost": 3e-08, "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, @@ -54452,6 +54407,8 @@ "output_cost_per_token_flex": 1.25e-06, "output_cost_per_token_priority": 4.5e-06, "regional_endpoint_uplift_multiplier": 1.1, + "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, + "search_free_requests": {"count":5000,"period":"month","shared_group":"gemini-3"}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", @@ -54471,6 +54428,7 @@ "supports_audio_output": false, "supports_computer_use": true, "supports_function_calling": true, + "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -54483,15 +54441,7 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "search_free_requests": {"count": 5000, "period": "month", "shared_group": "gemini-3"}, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "web_search_billing_unit": "per_query" }, "vertex_ai/deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, @@ -55287,14 +55237,13 @@ ] }, "vertex_ai/veo-3.1-generate-001": { - "deprecation_date": "2026-11-17", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.4, "output_cost_per_second_4k": 0.6, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-omni-1.2-flash", "supported_modalities": [ "text" ], @@ -55303,8 +55252,6 @@ ] }, "vertex_ai/veo-3.1-fast-generate-001": { - "deprecation_date": "2026-11-17", - "deprecation_source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/model-versions", "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, "mode": "video_generation", @@ -55312,6 +55259,7 @@ "output_cost_per_second_1080p": 0.12, "output_cost_per_second_4k": 0.3, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-omni-1.2-flash", "supported_modalities": [ "text" ], @@ -72086,18 +72034,20 @@ "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "successor_model": "vertex_ai/gemini-omni-1.2-flash" }, "vertex_ai/gemini-omni-1.1-flash-preview": { "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai", - "max_output_tokens": 57920, "max_input_tokens": 131072, + "max_output_tokens": 57920, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-1-1-flash", + "successor_model": "vertex_ai/gemini-omni-1.2-flash", "supported_endpoints": [ "/v1beta/interactions" ], From 25b1de636985aaa483ccd679ce0963f5cc30ae46 Mon Sep 17 00:00:00 2001 From: Oliver Jensen Date: Wed, 11 Nov 2026 16:23:45 +0100 Subject: [PATCH 66/86] docs(security): updated mailinglist and vdp in security.md (#51199) * tweak wording on ghsa again --- SECURITY.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index d3603448d46..6d0a9a6414d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,23 +4,19 @@ LiteLLM maintains a security announcements mailing list that is open to anyone. Subscribers receive advance notice, typically one to two days, before we release a fix for a particularly severe vulnerability or for any vulnerability exploitable by an unauthenticated attacker. This notice is provided on a best-effort basis -To subscribe, visit [https://berriai.github.io/security-announce-signup/](https://berriai.github.io/security-announce-signup/) +To subscribe, visit [https://security.litellm.ai/mailinglist/](https://security.litellm.ai/mailinglist/) ## Security Vulnerability Reporting Guidelines -> [!WARNING] -> Reports that do not include a video demonstrating the exploit will be closed without review. See [Reproduction Video Requirement](#reproduction-video-requirement) below. -We value the security community's role in protecting our systems and users. To report a security vulnerability: +We value the security community's role in protecting our systems and users. +To report a security vulnerability, please submit a report through our vulnerability disclosure form: -- File a private vulnerability report on GitHub: [Report a vulnerability](https://github.com/BerriAI/litellm/security/advisories/new) -- Include steps to reproduce the issue -- Include a video or screen recording demonstrating the full exploit against a live LiteLLM instance, from initial access through to impact. A terminal recording (for example asciinema) is fine for CLI-only exploits. -- Provide any relevant additional information +[LiteLLM VDP](https://security.litellm.ai/vdp/) -### Reproduction Video Requirement +These reports are submitted to HackerOne and are triaged there, though no account is needed. -A video demonstrating the exploit is required for every report. AI tools have made it easy to produce plausible-sounding vulnerability reports that do not reproduce in practice, and triaging them takes time away from real issues. Reports submitted without a working reproduction video will be closed without review. If you add a video to a closed report, we will reopen and triage it. +**Please do not submit vulnerabilities as draft GHSAs. As of 2026-11-11 new GHSA submissions will be closed.** ### Vulnerability Categories @@ -55,7 +51,9 @@ To qualify for a bounty, reports must include clear reproduction steps, a reprod ### Known Non-Issues -- Attacks that require a misconfiguration on setup (e.g not setting a `master_key` on the proxy configuration), are **explicitly not in scope** and are not considered vulnerable. +- **Misconfigurations:** attacks that require misconfiguration of the application (e.g. not setting a `master_key` on the proxy configuration). +- **Insecure configurations:** attacks that require deliberate configuration changes to disable security functionality. (e.g. IDOR on response IDs requiring configuration of `disable_responses_id_security` to `true`) +- **Social engineering:** attacks that rely on social engineering as their primary mechanism (e.g. convincing a user's agent to install a malicious package we did not publish). ## Security Measures From 1d97931c84703453368852d786a33bda644524e7 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:44:09 -0800 Subject: [PATCH 67/86] chore(cost-map): rolling registry PR, fal_ai rows, databricks gemini-4-carbon, deepinfra and bedrock tier prices (#51193) --- ...odel_prices_and_context_window_backup.json | 347 +++++++++++++++++- model_prices_and_context_window.json | 347 +++++++++++++++++- tests/unit/test_cost_calculator.py | 31 ++ 3 files changed, 715 insertions(+), 10 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f3cf258a2bb..66e3a094879 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14379,12 +14379,14 @@ "bedrock/moonshotai.kimi-k2.5": { "input_cost_per_token": 6e-07, "input_cost_per_token_flex": 3e-07, + "input_cost_per_token_priority": 1.05e-06, "litellm_provider": "bedrock", "max_input_tokens": 262144, "max_output_tokens": 16000, "mode": "chat", "output_cost_per_token": 3e-06, "output_cost_per_token_flex": 1.5e-06, + "output_cost_per_token_priority": 5.25e-06, "source": "https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/bedrock/USD/current/bedrock.json", "supports_function_calling": true, "supports_reasoning": true, @@ -24828,7 +24830,7 @@ "deepinfra/mistralai/Mistral-Nemo-Instruct-2407": { "max_input_tokens": 131072, "max_output_tokens": 131072, - "input_cost_per_token": 1.9e-08, + "input_cost_per_token": 2.9e-08, "output_cost_per_token": 3e-08, "litellm_provider": "deepinfra", "mode": "chat", @@ -24909,8 +24911,8 @@ }, "deepinfra/nvidia/NVIDIA-Nemotron-3.5-Lightning": { "max_input_tokens": 262144, - "input_cost_per_token": 8e-08, - "output_cost_per_token": 2e-07, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 1.6e-07, "litellm_provider": "deepinfra", "mode": "chat", "source": "https://deepinfra.com/pricing", @@ -56200,6 +56202,7 @@ "zai.glm-5": { "input_cost_per_token": 1e-06, "input_cost_per_token_batches": 5e-07, + "input_cost_per_token_flex": 5e-07, "input_cost_per_token_priority": 1.75e-06, "output_cost_per_token": 3.2e-06, "litellm_provider": "bedrock_converse", @@ -56207,6 +56210,7 @@ "max_output_tokens": 128000, "mode": "chat", "output_cost_per_token_batches": 1.6e-06, + "output_cost_per_token_flex": 1.6e-06, "output_cost_per_token_priority": 5.6e-06, "supports_function_calling": true, "supports_reasoning": true, @@ -67981,12 +67985,12 @@ "source": "https://deepinfra.com/pricing" }, "deepinfra/anthropic/claude-sonnet-5": { - "input_cost_per_token": 2e-06, + "input_cost_per_token": 3e-06, "litellm_provider": "deepinfra", "max_input_tokens": 1000000, "max_output_tokens": 1000000, "mode": "chat", - "output_cost_per_token": 1e-05, + "output_cost_per_token": 1.5e-05, "prompt_cache_min_tokens": 1024, "source": "https://deepinfra.com/pricing", "supports_adaptive_thinking": true, @@ -100106,5 +100110,338 @@ "supports_web_search": false, "supports_xhigh_reasoning_effort": true, "use_openai_responses_path": true + }, + "fal_ai/fal-ai/kling-video/v2.6/pro/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video with the default generate_audio=true ($0.14); videos without audio are $0.07 per second and videos with audio and voice control are $0.168 per second, which have no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.6/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the fixed 4s clip price of $0.20 per video"}, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/vidu/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/start-end-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the fixed 4s clip price of $0.20 per video"}, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/vidu/start-end-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the fixed 4s clip price of $0.40 per video"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the 5s clip prices by resolution ($0.15 at 540p, $0.30 at 720p, $1.20 at 1080p); output_cost_per_second is the default resolution 540p rate, which has no tier field; HDR ($0.60 at 720p, $2.40 at 1080p per 5s) and HDR+EXR ($0.90, $3.60) are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.24, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the 5s and 10s clip prices by resolution ($0.50/$1.00 at 540p, $1.00/$2.00 at 720p, $2.00/$4.00 at 1080p); output_cost_per_second is the default resolution 540p rate, which has no tier field"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.4, + "output_cost_per_second_720p": 0.2, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the 5s and 10s clip prices by resolution ($0.72/$1.44 at 540p, $1.08/$2.16 at 720p, $2.16/$4.32 at 1080p); output_cost_per_second is the default resolution 540p rate, which has no tier field; HDR and HDR+EXR prices are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.144, + "output_cost_per_second_1080p": 0.432, + "output_cost_per_second_720p": 0.216, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixverse/v6/max/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution without audio (the default generate_audio_switch=false); output_cost_per_second is the default resolution 720p rate; 360p $0.054 and 540p $0.081 have no catalog field; with audio the rates are $0.081/$0.099/$0.135/$0.252 per second, which is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.099, + "output_cost_per_second_1080p": 0.198, + "output_cost_per_second_720p": 0.099, + "source": "https://fal.ai/models/pixverse/v6/max/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixverse/v6/max/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution without audio (the default generate_audio_switch=false); output_cost_per_second is the default resolution 720p rate; 360p $0.054 and 540p $0.081 have no catalog field; with audio the rates are $0.081/$0.099/$0.135/$0.252 per second, which is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.099, + "output_cost_per_second_1080p": 0.198, + "output_cost_per_second_720p": 0.099, + "source": "https://fal.ai/models/pixverse/v6/max/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/flux-2-pro": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.03 for the first output megapixel plus $0.015 per additional input or output megapixel, rounded up, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-2-pro", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-pro/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.03 for the first output megapixel plus $0.015 per additional input or output megapixel, rounded up, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-2-pro/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-pro/outpaint": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.03 for the first output megapixel plus $0.015 per additional input or output megapixel, rounded up, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-2-pro/outpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-max": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.07 for the first processed megapixel plus $0.03 per additional megapixel, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://fal.ai/models/fal-ai/flux-2-max", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-max/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.07 for the first processed megapixel plus $0.03 per additional megapixel, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://fal.ai/models/fal-ai/flux-2-max/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1/erase": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the first generated megapixel; fal bills $0.004 for each subsequent generated megapixel and $0.004 per reference megapixel with a 3 MP minimum, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1/erase", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/smart-resize": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per single-image request at standard resolution: $0.15 per output image plus the $0.05 minimum vision analysis fee per request; additional output images in one request ($0.15 each) and 4K outputs (charged double) are not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.2, + "source": "https://fal.ai/models/fal-ai/smart-resize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/pro/layerize": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per generated layer for total pixel area under 1536x1536; layers for a base image over 1536x1536 are $0.0675 each, which has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.03375, + "source": "https://fal.ai/models/bytedance/seedream/v5/pro/layerize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/luma/agent/uni-1/v1/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per edit with one source image ($0.042 per edit plus $0.003 per reference or source image); each additional reference image adds $0.003, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.045, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/luma/agent/uni-1/v1/max/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per edit with one source image ($0.102 per edit plus $0.003 per reference or source image); each additional reference image adds $0.003, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.105, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/max/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "databricks/databricks-gemini-4-carbon": { + "cache_creation_input_token_cost": 2e-06, + "cache_read_input_token_cost": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "metadata": {"notes":"Gemini 4 Carbon on Databricks is available only through Unity Gateway with the model service name system.ai.gemini-4-carbon on a global endpoint. The Databricks pricing pages list no row for it yet, so input and output are the Global rates on the Vertex AI pricing page for Gemini 4 Carbon (listed there through November 30, 2026), no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true + }, + "databricks/system.ai.gemini-4-carbon": { + "cache_creation_input_token_cost": 2e-06, + "cache_read_input_token_cost": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "metadata": {"notes":"Gemini 4 Carbon on Databricks is available only through Unity Gateway with the model service name system.ai.gemini-4-carbon on a global endpoint. The Databricks pricing pages list no row for it yet, so input and output are the Global rates on the Vertex AI pricing page for Gemini 4 Carbon (listed there through November 30, 2026), no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f3cf258a2bb..66e3a094879 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14379,12 +14379,14 @@ "bedrock/moonshotai.kimi-k2.5": { "input_cost_per_token": 6e-07, "input_cost_per_token_flex": 3e-07, + "input_cost_per_token_priority": 1.05e-06, "litellm_provider": "bedrock", "max_input_tokens": 262144, "max_output_tokens": 16000, "mode": "chat", "output_cost_per_token": 3e-06, "output_cost_per_token_flex": 1.5e-06, + "output_cost_per_token_priority": 5.25e-06, "source": "https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/bedrock/USD/current/bedrock.json", "supports_function_calling": true, "supports_reasoning": true, @@ -24828,7 +24830,7 @@ "deepinfra/mistralai/Mistral-Nemo-Instruct-2407": { "max_input_tokens": 131072, "max_output_tokens": 131072, - "input_cost_per_token": 1.9e-08, + "input_cost_per_token": 2.9e-08, "output_cost_per_token": 3e-08, "litellm_provider": "deepinfra", "mode": "chat", @@ -24909,8 +24911,8 @@ }, "deepinfra/nvidia/NVIDIA-Nemotron-3.5-Lightning": { "max_input_tokens": 262144, - "input_cost_per_token": 8e-08, - "output_cost_per_token": 2e-07, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 1.6e-07, "litellm_provider": "deepinfra", "mode": "chat", "source": "https://deepinfra.com/pricing", @@ -56200,6 +56202,7 @@ "zai.glm-5": { "input_cost_per_token": 1e-06, "input_cost_per_token_batches": 5e-07, + "input_cost_per_token_flex": 5e-07, "input_cost_per_token_priority": 1.75e-06, "output_cost_per_token": 3.2e-06, "litellm_provider": "bedrock_converse", @@ -56207,6 +56210,7 @@ "max_output_tokens": 128000, "mode": "chat", "output_cost_per_token_batches": 1.6e-06, + "output_cost_per_token_flex": 1.6e-06, "output_cost_per_token_priority": 5.6e-06, "supports_function_calling": true, "supports_reasoning": true, @@ -67981,12 +67985,12 @@ "source": "https://deepinfra.com/pricing" }, "deepinfra/anthropic/claude-sonnet-5": { - "input_cost_per_token": 2e-06, + "input_cost_per_token": 3e-06, "litellm_provider": "deepinfra", "max_input_tokens": 1000000, "max_output_tokens": 1000000, "mode": "chat", - "output_cost_per_token": 1e-05, + "output_cost_per_token": 1.5e-05, "prompt_cache_min_tokens": 1024, "source": "https://deepinfra.com/pricing", "supports_adaptive_thinking": true, @@ -100106,5 +100110,338 @@ "supports_web_search": false, "supports_xhigh_reasoning_effort": true, "use_openai_responses_path": true + }, + "fal_ai/fal-ai/kling-video/v2.6/pro/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video with the default generate_audio=true ($0.14); videos without audio are $0.07 per second and videos with audio and voice control are $0.168 per second, which have no catalog field"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "source": "https://fal.ai/models/fal-ai/kling-video/v2.6/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the fixed 4s clip price of $0.20 per video"}, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/vidu/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/start-end-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the fixed 4s clip price of $0.20 per video"}, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "source": "https://fal.ai/models/fal-ai/vidu/start-end-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/vidu/reference-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the fixed 4s clip price of $0.40 per video"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/vidu/reference-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the 5s clip prices by resolution ($0.15 at 540p, $0.30 at 720p, $1.20 at 1080p); output_cost_per_second is the default resolution 540p rate, which has no tier field; HDR ($0.60 at 720p, $2.40 at 1080p per 5s) and HDR+EXR ($0.90, $3.60) are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.24, + "output_cost_per_second_720p": 0.06, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the 5s and 10s clip prices by resolution ($0.50/$1.00 at 540p, $1.00/$2.00 at 720p, $2.00/$4.00 at 1080p); output_cost_per_second is the default resolution 540p rate, which has no tier field"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.4, + "output_cost_per_second_720p": 0.2, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/luma/agent/ray/v3.2/video-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second derived from the 5s and 10s clip prices by resolution ($0.72/$1.44 at 540p, $1.08/$2.16 at 720p, $2.16/$4.32 at 1080p); output_cost_per_second is the default resolution 540p rate, which has no tier field; HDR and HDR+EXR prices are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.144, + "output_cost_per_second_1080p": 0.432, + "output_cost_per_second_720p": 0.216, + "source": "https://fal.ai/models/luma/agent/ray/v3.2/video-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixverse/v6/max/text-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution without audio (the default generate_audio_switch=false); output_cost_per_second is the default resolution 720p rate; 360p $0.054 and 540p $0.081 have no catalog field; with audio the rates are $0.081/$0.099/$0.135/$0.252 per second, which is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.099, + "output_cost_per_second_1080p": 0.198, + "output_cost_per_second_720p": 0.099, + "source": "https://fal.ai/models/pixverse/v6/max/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/pixverse/v6/max/image-to-video": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per second of generated video by resolution without audio (the default generate_audio_switch=false); output_cost_per_second is the default resolution 720p rate; 360p $0.054 and 540p $0.081 have no catalog field; with audio the rates are $0.081/$0.099/$0.135/$0.252 per second, which is not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.099, + "output_cost_per_second_1080p": 0.198, + "output_cost_per_second_720p": 0.099, + "source": "https://fal.ai/models/pixverse/v6/max/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/flux-2-pro": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.03 for the first output megapixel plus $0.015 per additional input or output megapixel, rounded up, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-2-pro", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-pro/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.03 for the first output megapixel plus $0.015 per additional input or output megapixel, rounded up, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-2-pro/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-pro/outpaint": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.03 for the first output megapixel plus $0.015 per additional input or output megapixel, rounded up, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-2-pro/outpaint", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-2-max": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.07 for the first processed megapixel plus $0.03 per additional megapixel, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://fal.ai/models/fal-ai/flux-2-max", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/flux-2-max/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the default 1 MP output; fal bills $0.07 for the first processed megapixel plus $0.03 per additional megapixel, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.07, + "source": "https://fal.ai/models/fal-ai/flux-2-max/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/flux-pro/v1/erase": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per image for the first generated megapixel; fal bills $0.004 for each subsequent generated megapixel and $0.004 per reference megapixel with a 3 MP minimum, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.03, + "source": "https://fal.ai/models/fal-ai/flux-pro/v1/erase", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/fal-ai/smart-resize": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per single-image request at standard resolution: $0.15 per output image plus the $0.05 minimum vision analysis fee per request; additional output images in one request ($0.15 each) and 4K outputs (charged double) are not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.2, + "source": "https://fal.ai/models/fal-ai/smart-resize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/bytedance/seedream/v5/pro/layerize": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per generated layer for total pixel area under 1536x1536; layers for a base image over 1536x1536 are $0.0675 each, which has no catalog field"}, + "mode": "image_generation", + "output_cost_per_image": 0.03375, + "source": "https://fal.ai/models/bytedance/seedream/v5/pro/layerize", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/luma/agent/uni-1/v1/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per edit with one source image ($0.042 per edit plus $0.003 per reference or source image); each additional reference image adds $0.003, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.045, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/luma/agent/uni-1/v1/max/edit": { + "litellm_provider": "fal_ai", + "metadata": {"comment":"per edit with one source image ($0.102 per edit plus $0.003 per reference or source image); each additional reference image adds $0.003, which is not modeled"}, + "mode": "image_generation", + "output_cost_per_image": 0.105, + "source": "https://fal.ai/models/luma/agent/uni-1/v1/max/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "databricks/databricks-gemini-4-carbon": { + "cache_creation_input_token_cost": 2e-06, + "cache_read_input_token_cost": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "metadata": {"notes":"Gemini 4 Carbon on Databricks is available only through Unity Gateway with the model service name system.ai.gemini-4-carbon on a global endpoint. The Databricks pricing pages list no row for it yet, so input and output are the Global rates on the Vertex AI pricing page for Gemini 4 Carbon (listed there through November 30, 2026), no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true + }, + "databricks/system.ai.gemini-4-carbon": { + "cache_creation_input_token_cost": 2e-06, + "cache_read_input_token_cost": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "metadata": {"notes":"Gemini 4 Carbon on Databricks is available only through Unity Gateway with the model service name system.ai.gemini-4-carbon on a global endpoint. The Databricks pricing pages list no row for it yet, so input and output are the Global rates on the Vertex AI pricing page for Gemini 4 Carbon (listed there through November 30, 2026), no '*_dbu_cost_per_token' fields are published, and cache tokens bill at the input rate until Databricks publishes cache DBU rates."}, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true } } diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py index b991f492bdd..ad1e15330d2 100644 --- a/tests/unit/test_cost_calculator.py +++ b/tests/unit/test_cost_calculator.py @@ -3259,6 +3259,37 @@ def test_completion_cost_service_tier_for_bedrock(_local_model_cost_map): assert priority_cost > default_cost > flex_cost > 0 +@pytest.mark.parametrize( + ("model", "service_tier", "regional_row"), + [ + pytest.param( + "bedrock/moonshotai.kimi-k2.5", + "priority", + "bedrock/us-west-2/moonshotai.kimi-k2.5", + id="kimi-k2.5-priority", + ), + pytest.param("zai.glm-5", "flex", "bedrock/us-west-2/zai.glm-5", id="glm-5-flex"), + ], +) +def test_cost_per_token_bedrock_service_tier_without_a_region_matches_the_regional_row( + _local_model_cost_map, model: str, service_tier: str, regional_row: str +) -> None: + """Regression for #46880: a Bedrock priority or flex call priced without a region fell back to the + standard rate because the region-less row lacked the tier prices its regional rows carry.""" + regional: Final = litellm.model_cost[regional_row] + tier_input: Final = regional[f"input_cost_per_token_{service_tier}"] + tier_output: Final = regional[f"output_cost_per_token_{service_tier}"] + assert tier_input != regional["input_cost_per_token"] + + usage: Final = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500) + prompt_usd, completion_usd = cost_per_token( + model=model, custom_llm_provider="bedrock", usage_object=usage, service_tier=service_tier + ) + + assert prompt_usd == pytest.approx(1000 * tier_input) + assert completion_usd == pytest.approx(500 * tier_output) + + def test_completion_cost_service_tier_for_anthropic(_local_model_cost_map): """ Anthropic priority-tier requests must be priced at the priority rate. From 0a4ccf95544127d7b1e9b9121ee0c41deec4f0c9 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Wed, 11 Nov 2026 07:44:45 -0800 Subject: [PATCH 68/86] fix(router): preserve held tiers for OpenCode subagent results (#51176) * fix(router): preserve held tiers for OpenCode task results * fix(router): handle OpenCode subagent completion envelopes --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- .../complexity_router/README.md | 4 +- .../complexity_router/complexity_router.py | 21 +++- .../router_strategy/test_complexity_router.py | 117 ++++++++++++++++++ 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index d06248a555f..394052c1dd4 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -690,7 +690,7 @@ generic behavior; use the real client when checking Claude Code routing. Agent harnesses inject their own context into the conversation as ordinary message text. That text is plumbing, not something a human asked for, so the router strips complete reminder blocks before classifying and picking a tier. A turn that is nothing but a reminder block strips to empty and is skipped, and the router falls back to the last real ask instead -By default the router strips complete `` blocks. For requests with a Codex user agent, it also strips complete ``, ``, ``, and `` blocks, plus repository instructions from the fixed heading prefix `# AGENTS.md instructions for ` through ``, regardless of the repository path. Other clients keep those tags and their contents +By default the router strips complete `` blocks. For requests with a Codex user agent, it also strips complete ``, ``, ``, and `` blocks, plus repository instructions from the fixed heading prefix `# AGENTS.md instructions for ` through ``, regardless of the repository path. For requests with an `opencode/` user agent, it also strips complete `` blocks and `` blocks through ``, attributes included, which OpenCode uses to return subagent output as a user turn. Other clients keep those tags and their contents The proxy records the incoming user agent in request metadata. SDK callers can supply `metadata.user_agent` (or `litellm_metadata.user_agent` on Responses requests), or configure `reminder_markers` explicitly when their client identity is unavailable @@ -713,7 +713,7 @@ model_list: close: "[[SUBAGENT_CONTEXT_END]]" ``` -Setting `reminder_markers` replaces all built-in pairs, including the Codex heading pair, so include every default your harness still needs. Matching is case-insensitive. Blocks that nest or overlap across pairs are stripped whole. An unclosed delimiter is not a block and is left in place, which keeps prose that merely mentions a delimiter from being eaten +Setting `reminder_markers` replaces all built-in pairs, including the Codex and OpenCode pairs, so include every default your harness still needs. Matching is case-insensitive. Blocks that nest or overlap across pairs are stripped whole. An unclosed delimiter is not a block and is left in place, which keeps prose that merely mentions a delimiter from being eaten ### Code Detection diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index acf1445ac36..2ce1bda173a 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -520,6 +520,11 @@ _CODEX_REMINDER_MARKERS: Final = _DEFAULT_REMINDER_MARKERS + ( ("", ""), ("# agents.md instructions for ", ""), ) +_OPENCODE_REMINDER_MARKERS: Final = _DEFAULT_REMINDER_MARKERS + ( + ("", ""), + (""), +) +_OPENCODE_USER_AGENT_PREFIX: Final = "opencode/" _TRUNCATION_MARKER: Final = "..." _TRUNCATION_HEAD_FRACTION: Final = 0.3 @@ -530,6 +535,10 @@ _CLASSIFIER_CIRCUIT_OPEN_SIGNAL: Final = "classifier-circuit-open" _CJK_CHARACTER: Final = re.compile("[぀-ヿㇰ-ㇿ㐀-䶿一-鿿豈-﫿ヲ-ン\U00020000-\U0003ffff]") +def _is_opencode_user_agent(user_agent: str) -> bool: + return user_agent.lower().startswith(_OPENCODE_USER_AGENT_PREFIX) + + def _message_text(content: object) -> str: """Flatten message content to plain text, joining multi-part text blocks. @@ -4418,12 +4427,20 @@ class ComplexityRouter(CustomLogger): def _reminder_markers_for_request(self, request_kwargs: Mapping[str, object]) -> tuple[tuple[str, str], ...]: if self.config.reminder_markers is not None: return self._reminder_markers - return _CODEX_REMINDER_MARKERS if self._is_codex_request(request_kwargs) else _DEFAULT_REMINDER_MARKERS + if self._is_codex_request(request_kwargs): + return _CODEX_REMINDER_MARKERS + if self._request_user_agent_matches(request_kwargs, _is_opencode_user_agent): + return _OPENCODE_REMINDER_MARKERS + return _DEFAULT_REMINDER_MARKERS @staticmethod def _is_codex_request(request_kwargs: Mapping[str, object]) -> bool: + return ComplexityRouter._request_user_agent_matches(request_kwargs, is_codex_user_agent) + + @staticmethod + def _request_user_agent_matches(request_kwargs: Mapping[str, object], matches: Callable[[str], bool]) -> bool: return any( - is_codex_user_agent(user_agent) + matches(user_agent) for metadata_key in ("litellm_metadata", "metadata") if isinstance(metadata := request_kwargs.get(metadata_key), Mapping) if isinstance(user_agent := metadata.get("user_agent"), str) diff --git a/tests/unit/router_strategy/test_complexity_router.py b/tests/unit/router_strategy/test_complexity_router.py index 59f637bb294..054c48bbb5e 100644 --- a/tests/unit/router_strategy/test_complexity_router.py +++ b/tests/unit/router_strategy/test_complexity_router.py @@ -10250,6 +10250,12 @@ _CODEX_ENVELOPES: Final = ( "LITELLM ESCALATE environment", "# AGENTS.md instructions for /repo with spaces/中文\nLITELLM ESCALATE instructions", ) +_OPENCODE_USER_AGENT: Final = "opencode/latest/2.0.29/cli" +_OPENCODE_SUBAGENT_RESULTS: Final = ( + "\nLITELLM ESCALATE subagent found the race in cache.py", + '\n' + "LITELLM ESCALATE subagent found the race in cache.py\n", +) class TestContextAwareClassifier: @@ -10490,6 +10496,117 @@ class TestContextAwareClassifier: completion.assert_awaited_once() assert completion.call_args.kwargs["messages"][1]["content"].strip() == f"Classify this message:\n{envelope}" + @pytest.mark.asyncio + @pytest.mark.parametrize("subagent_result", _OPENCODE_SUBAGENT_RESULTS) + @pytest.mark.parametrize("user_agent", (_OPENCODE_USER_AGENT, "OpenCode/latest/2.0.29/cli")) + async def test_opencode_subagent_result_turn_keeps_held_tier_until_a_new_ask( + self, user_agent: str, subagent_result: str + ) -> None: + completion: Final = AsyncMock( + side_effect=[_llm_response('{"tier":"SIMPLE"}'), _llm_response('{"tier":"COMPLEX"}')] + ) + router: Final = ComplexityRouter( + model_name="router", + ctx=RoutingContext(MagicMock(acompletion=completion, **{"health.return_value.store": DualCache()})), + complexity_router_config={ + "tiers": {"SIMPLE": "simple-model", "COMPLEX": "task-model"}, + "classifier_type": "llm", + "classifier_llm_config": {"model": "classifier-model"}, + "classification_mode": "user_turn", + }, + ) + metadata: Final = {"user_agent": user_agent, "session_id": "opencode-task-session"} + asked: Final = [{"role": "user", "content": "Find the race in cache.py"}] + task_returned: Final = [ + *asked, + {"role": "assistant", "content": "Delegating to a subagent"}, + {"role": "user", "content": subagent_result}, + ] + new_ask: Final = [ + *task_returned, + {"role": "assistant", "content": "Found it"}, + {"role": "user", "content": "Now design cache invalidation"}, + {"role": "user", "content": subagent_result}, + ] + original: Final = deepcopy((asked, task_returned, new_ask)) + + results: Final = [ + await router.async_pre_routing_hook( + model="router", request_kwargs={"metadata": metadata}, messages=messages + ) + for messages in (asked, task_returned, new_ask) + ] + + assert [(result.model, result.routing_decision["cause"]) for result in results] == [ + ("simple-model", "llm_classifier"), + ("simple-model", "user_turn_continuation"), + ("task-model", "llm_classifier"), + ] + classified: Final = [call.kwargs["messages"][1]["content"] for call in completion.await_args_list] + assert [payload.rsplit("Classify this message:\n", 1)[1] for payload in classified] == [ + "Find the race in cache.py", + "Now design cache invalidation", + ] + assert all("subagent found the race" not in payload for payload in classified) + assert tuple(result.messages for result in results) == original + + @pytest.mark.asyncio + @pytest.mark.parametrize("subagent_result", _OPENCODE_SUBAGENT_RESULTS) + @pytest.mark.parametrize( + "request_kwargs,content,custom_markers,expected_ask", + ( + ({"metadata": {"user_agent": _OPENCODE_USER_AGENT}}, "{result}\nAdd retries", False, "Add retries"), + ({"litellm_metadata": {"user_agent": _OPENCODE_USER_AGENT}}, "Add retries {result}", False, "Add retries"), + ( + {"metadata": {"user_agent": _OPENCODE_USER_AGENT}}, + "{unclosed}\nAdd retries", + False, + "{unclosed}\nAdd retries", + ), + ({"metadata": {"user_agent": _OPENCODE_USER_AGENT}}, "{result}", True, "{result}"), + ({}, "{result}", False, "{result}"), + ({"metadata": {"user_agent": "curl/8.7.1"}}, "{result}", False, "{result}"), + ({"metadata": {"user_agent": "codex-tui"}}, "{result}", False, "{result}"), + ({"metadata": {"user_agent": "opencode_go/1.0"}}, "{result}", False, "{result}"), + ({"metadata": {"user_agent": "opencodex/1.0"}}, "{result}", False, "{result}"), + ), + ) + async def test_classifier_strips_only_complete_opencode_subagent_results( + self, + request_kwargs: dict[str, object], + content: str, + custom_markers: bool, + expected_ask: str, + subagent_result: str, + ) -> None: + unclosed: Final = subagent_result[: subagent_result.rindex("", "close": ""}]} if custom_markers else {}), + }, + ) + messages: Final = [{"role": "user", "content": message}] + + response: Final = await router.async_pre_routing_hook( + model="router", request_kwargs=request_kwargs, messages=messages + ) + + assert response is not None and response.model == "task-model" + assert response.messages == [{"role": "user", "content": message}] + completion.assert_awaited_once() + classified: Final = completion.call_args.kwargs["messages"][1]["content"] + expected: Final = expected_ask.format(result=subagent_result, unclosed=unclosed) + assert classified.strip() == f"Classify this message:\n{expected}" + @pytest.mark.parametrize("envelope", _CODEX_ENVELOPES) def test_codex_envelopes_preserve_delegated_task_and_prior_context(self, envelope: str) -> None: from litellm.router_strategy.complexity_router.complexity_router import ( From 20d53f2951c79bb9164af9032588c8b0a561727b Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:18:18 +0000 Subject: [PATCH 69/86] refactor: remove redundant comments from fresh changes (#51163) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/rubrik.py | 2 -- litellm/passthrough/utils.py | 1 - ui/litellm-dashboard/src/contexts/PluginModeContext.tsx | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/integrations/rubrik.py b/litellm/integrations/rubrik.py index 7d04b1df95e..131ab765b2c 100644 --- a/litellm/integrations/rubrik.py +++ b/litellm/integrations/rubrik.py @@ -675,8 +675,6 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): else: tools = optional_params.get("tools") - # The response moderation service consumes only messages/model/tools - # (plus metadata.rac_agent_id when set). # Don't forward proxy_server_request -- in litellm >=1.83 its ``body`` # snapshot carries a UserAPIKeyAuth instance that breaks json.dumps, # silently fail-opening the guardrail. diff --git a/litellm/passthrough/utils.py b/litellm/passthrough/utils.py index 1bf5f08b1c1..7ef426898d8 100644 --- a/litellm/passthrough/utils.py +++ b/litellm/passthrough/utils.py @@ -96,7 +96,6 @@ class BasePassthroughUtils: header_name, ) continue - # Strip the 'x-pass-' prefix and normalize to lowercase actual_header_name = header_name[len(PASS_THROUGH_HEADER_PREFIX) :].lower() headers[actual_header_name] = header_value diff --git a/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx b/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx index 247cae44235..617ed455fe3 100644 --- a/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx +++ b/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx @@ -4,7 +4,7 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import { createApiClient } from "@/lib/http/client"; import { getProxyBaseUrl } from "@/components/networking"; -export type PluginMode = "ai-gateway" | "customer-portal" | string; // built-in modes or a registered plugin name +export type PluginMode = "ai-gateway" | "customer-portal" | string; export const BUILT_IN_MODES: ReadonlySet = new Set(["ai-gateway", "customer-portal"]); export interface PluginNavItem { From 3cd48a6ec74da40e946642ab4b8deb862860d889 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:15:42 +0000 Subject: [PATCH 70/86] test(security): expect the async release check on offline mode (#51213) Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/integration/security/test_offline_mode.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/integration/security/test_offline_mode.py b/tests/integration/security/test_offline_mode.py index 11012eb14bc..2488d05bc2f 100644 --- a/tests/integration/security/test_offline_mode.py +++ b/tests/integration/security/test_offline_mode.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Final import pytest -from integration._support.client import Gateway, object_value +from integration._support.client import Gateway, eventually, object_value from integration._support.forward_proxy import refusing_forward_proxy from integration._support.process import owned_proxy from pydantic import JsonValue @@ -120,7 +120,19 @@ def test_offline_mode_stops_every_self_initiated_request( source: Final = object_value(_json(isolated, "GET", "/model/cost_map/source")) reloaded: Final = isolated.request("POST", "/reload/model_cost_map") beta_headers: Final = object_value(_json(isolated, "POST", "/reload/anthropic_beta_headers")) - release: Final = _json(isolated, "GET", "/get/latest_release_info") + release: Final = isolated.request("GET", "/get/latest_release_info") + assert (release.status_code, release.json()) == (202 if offline_mode is None else 200, None), release.text + if offline_mode is None: + settled_release: Final = eventually( + lambda: isolated.request("GET", "/get/latest_release_info"), + lambda response: response.status_code != 202, + ) + assert (settled_release.status_code, settled_release.json()) == ( + 502, + {"detail": "Latest release info unavailable"}, + ), settled_release.text + else: + assert _json(isolated, "GET", "/get/latest_release_info") is None presets: Final = object_value(_json(isolated, "GET", "/public/autorouter_presets")) templates: Final = _json(isolated, "GET", "/policy/templates") blog: Final = object_value(_json(isolated, "GET", "/public/litellm_blog_posts")) @@ -164,7 +176,6 @@ def test_offline_mode_stops_every_self_initiated_request( assert source["is_env_forced"] is (offline_mode is not None), source assert reloaded.status_code == reload_status, reloaded.text assert beta_headers["status"] == "success", beta_headers - assert release is None assert sorted(presets) == sorted(object_value(_bundled("proxy/public_endpoints/autorouter_presets.json"))) assert templates == _bundled("policy_templates_backup.json") assert [object_value(post)["url"] for post in _list(blog["posts"])] == [ From 3a91fdfc077166ef4251bc3f1eb5dc360111d1eb Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:48:06 +0000 Subject: [PATCH 71/86] fix(management): keep classifier deployments serving during reloads (#51111) * fix(management): track self-heal for test_moving_the_workspace_under_a_burst_moves_the_classifier_and_logs_every_request_once Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(management): keep classifier deployments serving during reloads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: mateo <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/crates/router-core/src/lib.rs | 7 +- .../crates/router-core/src/registry.rs | 75 ++++++++++++++++ .../model_management_endpoints.py | 59 +----------- litellm/proxy/proxy_server.py | 5 +- litellm/router_rust/steps/prerouting.py | 81 ++++++++++------- litellm/router_rust/surface/registry.py | 89 ++++++++----------- .../test_model_management_endpoints.py | 66 ++++---------- tests/unit/router_rust/test_atomic_upsert.py | 89 +++++++++++++++++++ .../core/test_gaps_l11100_strategy.py | 6 +- .../test_registry_characterization_writes.py | 24 ++--- 10 files changed, 293 insertions(+), 208 deletions(-) create mode 100644 tests/unit/router_rust/test_atomic_upsert.py diff --git a/litellm-rust/crates/router-core/src/lib.rs b/litellm-rust/crates/router-core/src/lib.rs index f9b71c689c9..e61d570c78f 100644 --- a/litellm-rust/crates/router-core/src/lib.rs +++ b/litellm-rust/crates/router-core/src/lib.rs @@ -688,7 +688,7 @@ impl Router { entries, resolve_secrets, |deployment| { - working.push_built(deployment.spec.clone(), add)?; + working.install_built(deployment.spec.clone(), add, install)?; installed.push(deployment.spec.clone()); core.validate_deployment(working.last().expect("just installed")) .map_err(Error::from) @@ -698,7 +698,7 @@ impl Router { if built.is_ok() && replace && path == BuildPath::Create { working.set_model_names(model_names); } - self.serve_built(&base, working, installed, add)?; + self.serve_built(&base, working, installed, add, install)?; built } @@ -710,6 +710,7 @@ impl Router { built: Registry, installed: Vec, add: bool, + install: build::Install, ) -> Result<(), ConfigError> { let _writer = self.core.writer.lock(); let current = self.core.snapshot.load_full(); @@ -718,7 +719,7 @@ impl Router { } else { let mut next = Registry::clone(¤t.registry); for spec in installed { - next.push_built(spec, add)?; + next.install_built(spec, add, install)?; } next }; diff --git a/litellm-rust/crates/router-core/src/registry.rs b/litellm-rust/crates/router-core/src/registry.rs index 594aff5f6dd..2aff7f243ff 100644 --- a/litellm-rust/crates/router-core/src/registry.rs +++ b/litellm-rust/crates/router-core/src/registry.rs @@ -626,6 +626,27 @@ impl Registry { } } + pub fn install_built( + &mut self, + spec: DeploymentSpec, + add: bool, + install: crate::build::Install, + ) -> Result<(), ConfigError> { + if install == crate::build::Install::Upsert { + let id = self.carried(spec.clone())?.id; + if self.by_id.contains_key(&id) { + *self = self.apply( + &ChangeSet { + remove: Some(id), + ..ChangeSet::default() + }, + None, + )?; + } + } + self.push_built(spec, add) + } + /// Add one deployment a build accepted at the end and index it in place (`add` is /// `add_deployment`, which also names its group). A build installs into a registry of its /// own this way, so a list of n deployments builds in O(n) rather than reindexing per @@ -1531,6 +1552,60 @@ mod tests { assert_eq!((resolution.kind, resolution.group.as_str()), (kind, group)); } + #[rstest] + #[case::empty(false)] + #[case::replacement(true)] + fn installing_an_upsert_replaces_only_the_working_snapshot(#[case] populated: bool) { + let registry = Registry::build( + if populated { + vec![spec( + "a", + json!({"model": "openai/old"}), + json!({"id": "1"}), + )] + } else { + Vec::new() + }, + BTreeMap::new(), + Vec::new(), + ) + .unwrap(); + let mut working = registry.clone(); + working + .install_built( + spec("a", json!({"model": "openai/new"}), json!({"id": "1"})), + true, + crate::build::Install::Upsert, + ) + .unwrap(); + assert_eq!(registry.all().len(), usize::from(populated)); + assert_eq!(working.all().len(), 1); + assert_eq!(working.all()[0].raw_params["model"], json!("openai/new")); + assert_eq!(working.host.position_of("1"), Some(0)); + } + + #[rstest] + fn an_invalid_upsert_keeps_the_previous_deployment() { + let mut registry = Registry::build( + vec![spec( + "a", + json!({"model": "openai/old"}), + json!({"id": "1"}), + )], + BTreeMap::new(), + Vec::new(), + ) + .unwrap(); + let invalid = spec("a", json!({"model": 42}), json!({"id": "1"})); + assert!( + registry + .install_built(invalid, true, crate::build::Install::Upsert) + .is_err() + ); + assert_eq!(registry.all().len(), 1); + assert_eq!(registry.all()[0].raw_params["model"], json!("openai/old")); + } + #[rstest] fn apply_moves_an_edited_deployment_to_the_end() { let registry = Registry::build( diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 38b814225ce..7846922bae2 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -4043,8 +4043,8 @@ async def clear_cache() -> ReconcileOutcome: raise_if_reload_degraded_serving. Runs under DB_RECONCILE_LOCK for its whole extent, not just the reload at the - end, so the auto-router reset and the reload that rebuilds those routers are atomic - to any other reconcile. The inner call is _add_deployment_locked because + end, so concurrent model writes cannot interleave their reconciles. + The inner call is _add_deployment_locked because add_deployment would re-acquire the same non-reentrant lock and deadlock. """ from litellm.proxy.proxy_server import ( @@ -4062,75 +4062,22 @@ async def clear_cache() -> ReconcileOutcome: async with DB_RECONCILE_LOCK: try: - # Only clear DB models, preserve config models - verbose_proxy_logger.debug("Clearing only DB models, preserving config models") + verbose_proxy_logger.debug("Reconciling DB models, preserving config models") # Get current models and filter out DB models current_models: Final = llm_router.registry().all().copy() config_models: Final = [] db_model_ids: Final = [] - db_router_names: Final = set() - for model in as_object_list(current_models) or (): deployment = decoded_json_object(model) model_info = decoded_json_object(deployment.get("model_info", {})) if model_info.get("db_model", False): db_model_ids.append(model_info.get("id")) - # Auto-router deployments (and only those) are wiped here, in the - # same pass, so the reload rebuilds them -- see the comment below. - deployment_name = deployment.get("model_name") - if isinstance(deployment_name, str) and str( - decoded_json_object(deployment.get("litellm_params", {})).get("model", "") - ).startswith("auto_router/"): - db_router_names.add(deployment_name) - router_model_id = model_info.get("id") - if isinstance(router_model_id, str): - llm_router.apply(ChangeSet(delete=[router_model_id])) else: # This is a config model, preserved by the reconcile below config_models.append(model) - # ORDINARY db deployments are deliberately NOT wiped. This used to - # delete_deployment() every db model before the reload put them back, which - # left the router serving ZERO db models for the whole width of the reload - # -- a real data-plane hole that every inference request landing in it fell - # into. It was also redundant for them: the reload's _delete_deployment - # evicts exactly the ids the db no longer lists, and upsert_deployment - # pops-and-re-adds a deployment whose params changed while no-opping one - # that did not, so the reconcile converges on its own. Every mutation is - # visible to that comparison -- `blocked` and (for premium) `updated_at` - # are written into model_info. - # - # AUTO-ROUTER db deployments are the exception and ARE wiped -- in the - # classification pass above, together with the strategy entries popped - # just below. Their strategy registries are keyed - # by model_name, which no deployment-id reconcile touches, so they have to - # be popped and rebuilt here. But the rebuild only happens on the ADD path: - # Router.upsert_deployment returns early when a deployment is unchanged and - # never reaches add_deployment -> _add_deployment -> - # init_auto_router_deployment, which is what repopulates the registries. - # Popping without deleting would therefore strip every db-backed auto, - # complexity, adaptive and quality router on this pod and never put it back, - # so ANY unrelated model write would leave them unroutable until a restart. - # Deleting the deployment forces upsert down the add path, which rebuilds - # both the deployment and its strategy entry. - # - # That pass restricts the wipe to deployments whose model is actually an - # auto_router/* so a config router that merely shares a model_name with a - # regular db model isn't evicted -- config routers are never re-added by the - # reload (it only reloads db models) and would be permanently unroutable. - # The auto_router/ prefix also covers quality_router/ and adaptive_router/, - # so pop the name from every registry (no-op where absent); a missing - # quality/adaptive entry would otherwise make init raise "already exists" - # on reload and abort it. - for model_name in db_router_names: - llm_router.registry().plugins("auto").pop(model_name, None) - llm_router.registry().plugins("complexity").pop(model_name, None) - llm_router.registry().plugins("adaptive").pop(model_name, None) - llm_router.registry().plugins("quality").pop(model_name, None) - llm_router.registry().plugins("switchyard").pop(model_name, None) - # Reload only DB models. _add_deployment_locked, not add_deployment: this # coroutine already holds DB_RECONCILE_LOCK and asyncio.Lock is not # reentrant, so the public wrapper would deadlock against itself. diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9f721e3fdcb..23d45e07dfa 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -9024,9 +9024,8 @@ class ProxyConfig: proxy_logging_obj: ProxyLogging, ) -> ReconcileOutcome: """add_deployment's body, minus the locking. DB_RECONCILE_LOCK MUST already - be held. Split out for the one caller that has to hold the lock across more than - this reconcile -- clear_cache, which un-serves every db model before calling it - and would deadlock on a re-acquire.""" + be held. Split out for clear_cache, which already holds the lock and would + deadlock on a re-acquire.""" global llm_router, llm_model_list, master_key, general_settings still_desired_ids: frozenset[str] | None = None diff --git a/litellm/router_rust/steps/prerouting.py b/litellm/router_rust/steps/prerouting.py index 47bd1e69046..2953a87ed92 100644 --- a/litellm/router_rust/steps/prerouting.py +++ b/litellm/router_rust/steps/prerouting.py @@ -133,9 +133,13 @@ def _routing_definition_fingerprint(deployment: Deployment) -> str | None: return deployment_id(deployment.model_name, {"routing_definition": definition}) -def _unregister(registry: StrategyRegistry, model_name: str, tags: tuple[str, ...]) -> bool: +def _unregister( + registry: StrategyRegistry, model_name: str, tags: tuple[str, ...], previous: Deployment | None = None +) -> bool: existing: Final = registry.get(model_name, []) - remaining: Final = [entry for entry in existing if entry.tags != tags] + remaining: Final = [ + entry for entry in existing if entry.tags != tags or (previous is not None and entry.deployment != previous) + ] if len(remaining) == len(existing): return False if remaining: @@ -217,30 +221,31 @@ class PreRoutingStrategies: self._hosted._core_auto_routers = {} # pyright: ignore[reportPrivateUsage] # see above self._hosted._core_switchyard_routers = {} # pyright: ignore[reportPrivateUsage] # see above - def register(self, deployment: Deployment) -> None: + def register(self, deployment: Deployment, *, replace_existing: bool = False) -> None: """The end of ``_add_deployment``: an auto, complexity or quality marker registers its router (adaptive ones wait for ``finalize``).""" match classify_strategy_router_model(deployment.litellm_params.model): case "semantic": - self._init_auto_router(deployment) + self._init_auto_router(deployment, replace_existing=replace_existing) case "complexity": - self._init_complexity_router(deployment) + self._init_complexity_router(deployment, replace_existing=replace_existing) case "quality": - self._init_quality_router(deployment) + self._init_quality_router(deployment, replace_existing=replace_existing) case "switchyard": - self._init_switchyard_router(deployment) + self._init_switchyard_router(deployment, replace_existing=replace_existing) case _: pass - def release(self, deployment: Deployment) -> None: + def release(self, deployment: Deployment, *, replaced: bool = False) -> None: """``_unregister_pre_routing_strategy_for_deployment``.""" if not deployment.litellm_params.model.startswith("auto_router/"): return model_name: Final = deployment.model_name tags: Final = _deployment_tags(deployment) + previous: Final = deployment if replaced else None for registry in (self.auto, self.complexity, self.quality, self.switchyard): - _ = _unregister(registry, model_name, tags) - if _unregister(self.adaptive, model_name, tags): + _ = _unregister(registry, model_name, tags, previous) + if _unregister(self.adaptive, model_name, tags, previous): self._sync_adaptive_router_hooks() def release_entry(self, entry: Mapping[str, object]) -> None: @@ -261,15 +266,6 @@ class PreRoutingStrategies: ): self.finalize() - def without_capability_limit(self, action: Callable[[], object]) -> None: - """Run ``action`` without judging the capability ceiling: a rollback re-admits a serving router.""" - resolver: Final = self._capability_limit - self._capability_limit = None - try: - _ = action() - finally: - self._capability_limit = resolver - def finalize(self) -> None: """``_finalize_adaptive_router_if_configured``: an adaptive router for every adaptive marker, and the adaptive companion of every complexity router configured with ``adaptive``.""" @@ -328,16 +324,30 @@ class PreRoutingStrategies: # -- building -------------------------------------------------------------------------------- def _register( - self, registry: StrategyRegistry, deployment: Deployment, strategy: object, strategy_label: str + self, + registry: StrategyRegistry, + deployment: Deployment, + strategy: object, + strategy_label: str, + *, + replace_existing: bool = False, ) -> None: tags: Final = _deployment_tags(deployment) - if _has_registered_strategy(registry, deployment.model_name, tags): + existing: Final = registry.get(deployment.model_name, []) + remaining: Final = [ + entry + for entry in existing + if not replace_existing + or entry.deployment is None + or entry.deployment.model_info.id != deployment.model_info.id + ] + if any(entry.tags == tags for entry in remaining): raise ValueError( f"{strategy_label} deployment {deployment.model_name} with tags {list(tags)} already exists. " "Please use a different model name or set different tags." ) registry[deployment.model_name] = [ # rebind-ok: the live registry, edited in place as the Python router does - *registry.get(deployment.model_name, []), + *remaining, TaggedPreRoutingStrategy( tags=tags, strategy=strategy, @@ -346,17 +356,22 @@ class PreRoutingStrategies: ), ] - def _capability_violation(self, capability: GatedAutoRouterCapability) -> str | None: + def _capability_violation(self, capability: GatedAutoRouterCapability, deployment: Deployment) -> str | None: resolver: Final = self._capability_limit # `self.auto_router_capability_limit() if ... is not None`: a value that cannot be called raises. limit: Final = resolver() if resolver is not None else None # pyright: ignore[reportCallIssue] # called as given, as Python calls it others: Final = count_capability_routers( - (entry for entry in self._hosted._core_model_list if isinstance(entry, Mapping)), # pyright: ignore[reportPrivateUsage, reportUnnecessaryIsInstance] # the live list + ( + entry + for entry in self._hosted._core_model_list # pyright: ignore[reportPrivateUsage] # the live list + if isinstance(entry, Mapping) # pyright: ignore[reportUnnecessaryIsInstance] # stored entries are mappings + and as_dict(entry.get("model_info")).get("id") != deployment.model_info.id + ), capability=capability, ) return capability_limit_violation(capability=capability, held=others + 1, limit=limit) # pyright: ignore[reportArgumentType] # the resolver's limit - def _init_auto_router(self, deployment: Deployment) -> None: + def _init_auto_router(self, deployment: Deployment, *, replace_existing: bool = False) -> None: from litellm.router_strategy.auto_router.auto_router import AutoRouter params: Final = deployment.litellm_params @@ -382,15 +397,15 @@ class PreRoutingStrategies: ctx=self._ctx, # pyright: ignore[reportArgumentType] # the core's RoutingContext max_input_chars=max_input_chars if max_input_chars is not None else DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS, ) - self._register(self.auto, deployment, router, "Auto-router") + self._register(self.auto, deployment, router, "Auto-router", replace_existing=replace_existing) - def _init_complexity_router(self, deployment: Deployment) -> None: + def _init_complexity_router(self, deployment: Deployment, *, replace_existing: bool = False) -> None: from litellm.router_strategy.complexity_router.complexity_router import ComplexityRouter from litellm.router_strategy.complexity_router.config import ComplexityRouterConfig config: Final = deployment.litellm_params.complexity_router_config for capability in claimed_capabilities(config): - if (violation := self._capability_violation(capability)) is not None: + if (violation := self._capability_violation(capability, deployment)) is not None: raise ValueError(violation) default_model: Final = ( ComplexityRouterConfig.model_validate(config).resolve_default_model( @@ -410,7 +425,7 @@ class PreRoutingStrategies: ctx=self._ctx, # pyright: ignore[reportArgumentType] # the core's RoutingContext complexity_router_config=config, ) - self._register(self.complexity, deployment, router, "Complexity-router") + self._register(self.complexity, deployment, router, "Complexity-router", replace_existing=replace_existing) if router._uses_deployment_pin: # pyright: ignore[reportPrivateUsage] # the Python router reads the same flag self._ensure_deployment_affinity_callback() @@ -451,7 +466,7 @@ class PreRoutingStrategies: self._affinity = affinity self._host.callbacks.add_litellm_callback(affinity) - def _init_quality_router(self, deployment: Deployment) -> None: + def _init_quality_router(self, deployment: Deployment, *, replace_existing: bool = False) -> None: from litellm.router_strategy.quality_router.quality_router import QualityRouter config: Final = deployment.litellm_params.quality_router_config @@ -468,9 +483,9 @@ class PreRoutingStrategies: ctx=self._ctx, # pyright: ignore[reportArgumentType] # the core's RoutingContext quality_router_config=config, ) - self._register(self.quality, deployment, router, "Quality-router") + self._register(self.quality, deployment, router, "Quality-router", replace_existing=replace_existing) - def _init_switchyard_router(self, deployment: Deployment) -> None: + def _init_switchyard_router(self, deployment: Deployment, *, replace_existing: bool = False) -> None: from litellm.router_strategy.switchyard_router.config import SwitchyardRouterConfig, build_algorithm from litellm.router_strategy.switchyard_router.switchyard_router import SwitchyardRouter from litellm.types.decisions import OpenAIDecisionResponse @@ -494,7 +509,7 @@ class PreRoutingStrategies: return result router: Final = SwitchyardRouter(deployment.model_name, config, build_algorithm(config), complete_judge, decide) - self._register(self.switchyard, deployment, router, "Switchyard") + self._register(self.switchyard, deployment, router, "Switchyard", replace_existing=replace_existing) def _init_adaptive_router(self, deployment: Deployment) -> None: from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter diff --git a/litellm/router_rust/surface/registry.py b/litellm/router_rust/surface/registry.py index 83008bdbb94..59d3d7389be 100644 --- a/litellm/router_rust/surface/registry.py +++ b/litellm/router_rust/surface/registry.py @@ -215,6 +215,7 @@ class _Build: entries: dict[int, _BuildEntry] = dataclasses.field(default_factory=dict) # mutable-ok: filled in step order # `add`: the deployment to add, and the entry it is stored as. deployment: Deployment | None = None + replace_existing: bool = False stored: UntypedEntry | None = None # The deployment the build is on, its provider and its data-source values. pending: Deployment | None = None @@ -1007,12 +1008,13 @@ class HostRegistry(HostModelInfo): raise RuntimeError("the host registry builds deployments through its Rust core") for entry in entries: refuse_wide_numbers(entry) - self._core_model_list.begin_build(replacing=install == "replace") - try: - self.build_runner(entries, build.path, resolve_secrets, install, partial(self._answer_shown, build)) - finally: - self._core_model_list.end_build() - self._drop_removed_live_values() + with self._deployment_index_lock: + self._core_model_list.begin_build(replacing=install == "replace") + try: + self.build_runner(entries, build.path, resolve_secrets, install, partial(self._answer_shown, build)) + finally: + self._core_model_list.end_build() + self._drop_removed_live_values() if build.failure is not None: # router-core served what it built before the entry that raised (`set_model_list` # cleared the list first), so the router is left as the Python core leaves it. @@ -1220,7 +1222,11 @@ class HostRegistry(HostModelInfo): deployment: Final = build.deployment if deployment is None: raise RuntimeError("an add build holds its deployment") - if deployment.model_info.id and self._core_has_model_id(deployment.model_info.id): + if ( + not build.replace_existing + and deployment.model_info.id + and self._core_has_model_id(deployment.model_info.id) + ): return {"skip": True} declared_params: Final = deployment.litellm_params.model_dump(exclude_none=True) warn_on_provider_credential_mismatch(model_name=deployment.model_name, litellm_params=declared_params) @@ -1273,7 +1279,9 @@ class HostRegistry(HostModelInfo): deployment: Final = build.pending if deployment is None: raise RuntimeError("an added step follows a validated deployment") - self._register_deployment(deployment, build.provider, build.data_source_values) + self._register_deployment( + deployment, build.provider, build.data_source_values, replace_existing=build.replace_existing + ) build.pending = None # rebind-ok: the build's state, which each step advances build.data_source_values = () # rebind-ok: the build's state, which each step advances self._invalidate_model_group_info_cache() @@ -1319,6 +1327,8 @@ class HostRegistry(HostModelInfo): deployment: Deployment, custom_llm_provider: str | None, data_source_values: Sequence[tuple[int, str, str]], + *, + replace_existing: bool, ) -> None: """``_add_deployment`` after the provider: its data-source values, pass-through credentials and pre-routing strategies (router-core registers its names and patterns).""" @@ -1366,7 +1376,7 @@ class HostRegistry(HostModelInfo): if custom_llm_provider is not None: self._initialize_deployment_for_pass_through(deployment, custom_llm_provider) if self.strategies is not None: - self.strategies.register(deployment) + self.strategies.register(deployment, replace_existing=replace_existing) def _initialize_deployment_for_pass_through(self, deployment: Deployment, custom_llm_provider: str) -> None: """Register vertex credentials for a pass-through deployment (``_initialize_deployment_for_pass_through``).""" @@ -1395,7 +1405,10 @@ class HostRegistry(HostModelInfo): def add_deployment(self, deployment: Deployment) -> Deployment | None: """Add one deployment unless its id exists (``add_deployment``), built by router-core.""" - build: Final = _Build(path="add", deployment=deployment) + return self._install_deployment(deployment, replace_existing=False) + + def _install_deployment(self, deployment: Deployment, *, replace_existing: bool) -> Deployment | None: + build: Final = _Build(path="add", deployment=deployment, replace_existing=replace_existing) self._run_build(build, [deployment.to_json(exclude_none=True)], resolve_secrets=False, install="upsert") if build.result is None: return None @@ -1410,62 +1423,38 @@ class HostRegistry(HostModelInfo): on_router: Final = self._core_get_deployment(model_id=model_id) served: Final = without_model_capability_flags(deployment) try: + if ( + on_router is not None + and deployment.model_name == on_router.model_name + and served.litellm_params == on_router.litellm_params + and deployment.model_info == on_router.model_info + ): + return None + self._install_deployment(deployment, replace_existing=True) if on_router is not None: - if ( - deployment.model_name == on_router.model_name - and served.litellm_params == on_router.litellm_params - and deployment.model_info == on_router.model_info - ): - return None - with self._deployment_index_lock: - removal_idx: Final = self.model_id_to_deployment_index_map.get(model_id) - if removal_idx is not None: - removed: Final = self._core_model_list[removal_idx] # pyright: ignore[reportArgumentType] # raises IndexError for a position past the list, as the pop does - self._remove(model_id) - withdraw_shared_capabilities( - self._host, removed, deployment.to_json(exclude_none=True), self._served_by_live_routers() - ) + withdraw_shared_capabilities( + self._host, + on_router.to_json(exclude_none=True), + deployment.to_json(exclude_none=True), + self._served_by_live_routers(), + ) if self.strategies is not None: - self.strategies.release(on_router) - self._on_change() - self.add_deployment(deployment=deployment) + self.strategies.release(on_router, replaced=True) if self.strategies is not None: self.strategies.upserted(deployment, on_router) except Exception as e: if not self.ignore_invalid_deployments: raise verbose_router_logger.warning( - "Error upserting deployment %s (id=%s): %s. Dropping it and continuing with other deployments.", + "Error upserting deployment %s (id=%s): %s. Keeping its previous deployment, if any.", deployment.model_name, deployment.model_info.id, e, ) - self._restore_deployment_after_failed_upsert(on_router, model_id) return None else: return deployment - def _restore_deployment_after_failed_upsert(self, previous: Deployment | None, model_id: str) -> None: - if previous is None or self._core_has_model_id(model_id): - return - try: - if self.strategies is None: - self.add_deployment(deployment=previous) - else: - self.strategies.without_capability_limit(lambda: self.add_deployment(deployment=previous)) - verbose_router_logger.info( - "Restored deployment %s (id=%s); it keeps serving its previous configuration.", - previous.model_name, - model_id, - ) - except Exception as restore_error: # noqa: BLE001 # best-effort restore: a second failure must not abort the reload - verbose_router_logger.warning( - "Could not restore previously served deployment %s (id=%s) after the failed upsert: %s", - previous.model_name, - model_id, - restore_error, - ) - def _live_handle(self) -> "RouterCoreHandle | None": return None if self.handle_lookup is None else self.handle_lookup() diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index 2b269469f89..2f8456c6d0d 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -1113,7 +1113,7 @@ class TestClearCache: @pytest.mark.asyncio async def test_clear_cache_preserve_config_models(self): """ - clear_cache resets DB-backed auto-router entries and delegates every deployment + clear_cache preserves DB-backed auto-router entries and delegates every deployment change to the reload, leaving config models untouched. It must not wipe deployments itself -- see the delete_deployment assertion below. """ @@ -1122,8 +1122,7 @@ class TestClearCache: ) # Create mock router with mixed DB and config router deployments. The two DB - # entries are auto_router/* deployments (so their router-map entries should be - # cleared for reload); the config-defined router is preserved. + # entries and the config-defined router must remain available during reload. mock_router = MagicMock() plugins: dict[str, dict] = {"auto": {}, "complexity": {}, "adaptive": {}, "quality": {}, "switchyard": {}} mock_router.registry.return_value.plugins.side_effect = plugins.__getitem__ @@ -1165,20 +1164,9 @@ class TestClearCache: ): await clear_cache() - # clear_cache must wipe ONLY the db auto-router deployments -- the ones whose - # strategy entries are popped below and can only be rebuilt via the add path. - # Ordinary db models are left alone: wiping them un-served every db model for - # the width of the reload, and the reconcile converges without it. - assert mock_router.apply.call_count == 2 - mock_router.apply.assert_any_call(ChangeSet(delete=["db-model-1"])) - mock_router.apply.assert_any_call(ChangeSet(delete=["db-model-2"])) - - # DB-backed router entries are cleared so they can be re-populated by the - # reload below; the config-backed router must survive, since add_deployment() - # only reloads DB models and would otherwise leave it permanently unroutable - # (see TestClearCachePreservesConfigRouters). - assert "db-auto-router" not in plugins["auto"] - assert "db-complexity-router" not in plugins["complexity"] + mock_router.apply.assert_not_called() + assert "db-auto-router" in plugins["auto"] + assert "db-complexity-router" in plugins["complexity"] assert "config-router" in plugins["auto"] assert "config-router" in plugins["complexity"] @@ -1188,22 +1176,8 @@ class TestClearCache: ) @pytest.mark.asyncio - async def test_clear_cache_wipes_auto_routers_but_leaves_ordinary_db_models(self): - """An ordinary db model must survive clear_cache; a db auto-router must not. - - Two separate hazards meet here, and fixing one naively breaks the other: - - - Wiping ordinary db models un-serves EVERY db model for the width of the - reload. The reconcile converges without that, so the wipe is a pure - data-plane hole. - - NOT wiping a db auto-router strands it. Its strategy registries are keyed by - model_name and are popped here, but Router.upsert_deployment returns early - for an unchanged deployment and never reaches the add path that rebuilds - them. Any unrelated model write would then leave every db-backed auto, - complexity, adaptive and quality router unroutable until a restart. - - So the wipe is scoped to exactly the auto-router deployments. - """ + async def test_clear_cache_preserves_auto_routers_and_ordinary_db_models(self): + """Deployments and their strategies remain available while the reload runs.""" from litellm.proxy.management_endpoints.model_management_endpoints import ( clear_cache, ) @@ -1242,10 +1216,8 @@ class TestClearCache: ): await clear_cache() - # The auto-router deployment is wiped so the reload takes the add path and - # rebuilds its strategy entry; the ordinary db model is never touched. - mock_router.apply.assert_called_once_with(ChangeSet(delete=["db-auto-1"])) - assert "db-auto-router" not in plugins["auto"] + mock_router.apply.assert_not_called() + assert "db-auto-router" in plugins["auto"] class TestClearCachePreservesConfigRouters: @@ -1299,9 +1271,7 @@ class TestClearCachePreservesConfigRouters: ): await clear_cache() - # The DB-backed router for the model that was actually updated is cleared - # so the reload below can re-populate it. - assert "team-a-db-router" not in plugins["complexity"] + assert "team-a-db-router" in plugins["complexity"] # Config-defined routers for unrelated tenants must survive untouched. assert "config-defined-complexity-router" in plugins["complexity"] assert "config-semantic-router" in plugins["auto"] @@ -1346,11 +1316,8 @@ class TestClearCachePreservesConfigRouters: assert "shared-name" in plugins["complexity"] @pytest.mark.asyncio - async def test_db_quality_adaptive_and_switchyard_routers_are_evicted(self): - """The auto_router/ prefix also covers quality_router/, adaptive_router/ and switchyard. Their - registry entries must be popped too, or reload's init raises 'already exists' - (quality) or leaves a stale entry (adaptive). - """ + async def test_db_quality_adaptive_and_switchyard_routers_are_preserved(self): + """Every strategy remains available until the deployment reconcile replaces it.""" from litellm.proxy.management_endpoints.model_management_endpoints import clear_cache mock_router = MagicMock() @@ -1393,9 +1360,9 @@ class TestClearCachePreservesConfigRouters: ): await clear_cache() - assert "q1" not in plugins["quality"] - assert "a1" not in plugins["adaptive"] - assert "s1" not in plugins["switchyard"] + assert "q1" in plugins["quality"] + assert "a1" in plugins["adaptive"] + assert "s1" in plugins["switchyard"] class TestDeleteModelClearsRouterRegistry: @@ -6136,8 +6103,7 @@ class TestConcurrentModelWritesDoNotEvictEachOther: @pytest.mark.asyncio async def test_clear_cache_reloads_under_the_lock_without_deadlocking(self, monkeypatch): - """clear_cache un-serves every db model before reloading, so it has to hold the - lock across the pair -- and therefore must call the already-locked reload. + """clear_cache holds the reconcile lock and must call the already-locked reload. asyncio.Lock is not reentrant: routing this back through the public add_deployment would block forever on a lock this coroutine already owns, taking diff --git a/tests/unit/router_rust/test_atomic_upsert.py b/tests/unit/router_rust/test_atomic_upsert.py new file mode 100644 index 00000000000..51050b0002d --- /dev/null +++ b/tests/unit/router_rust/test_atomic_upsert.py @@ -0,0 +1,89 @@ +from collections.abc import Callable +from types import SimpleNamespace +from typing import Final + +import pytest + +from litellm.proxy import proxy_server +from litellm.proxy._types import ReconcileOutcome +from litellm.proxy.management_endpoints.model_management_endpoints import clear_cache +from litellm.router_api import ChangeSet +from litellm.router_core import RouterCore +from litellm.router_python_host import Endpoint +from tests.unit.test_router.core.registry_helpers import typed +from tests.unit.test_router.core.scripted_host import ScriptedHost, deployment, rust_core + + +class ObservingHost(ScriptedHost): + def __init__(self) -> None: + super().__init__() + self.observe: Callable[[], None] | None = None + + def invoke(self, op: str) -> Endpoint: + endpoint: Final = super().invoke(op) + if op != "get_llm_provider": + return endpoint + + def provider(*args: object, **kwargs: object) -> object: # kwargs-ok: provider resolver arguments + if self.observe is not None: + self.observe() + return endpoint(*args, **kwargs) + + return provider + + +@pytest.mark.asyncio +async def test_upsert_keeps_the_previous_deployment_serving_while_resolving_its_replacement() -> None: + host: Final = ObservingHost() + core: Final[RouterCore] = rust_core(host, model_list=[deployment("group", "only")]) + seen: Final[list[tuple[str, ...]]] = [] # mutable-ok: observations made by the injected provider resolver + + def observe() -> None: + seen.append(tuple(str(item["model_info"]["id"]) for item in core.registry().deployments("group"))) + + host.observe = observe + result: Final = core.apply(ChangeSet(upsert=[typed("group", "only", api_base="https://replacement.example")])) + + assert seen and set(seen) == {("only",)}, seen + assert list(result.upserted) == [typed("group", "only", api_base="https://replacement.example")] + await core.acompletion(model="group", messages=[{"role": "user", "content": "after replacement"}]) # pyright: ignore[reportAttributeAccessIssue] # the core's completion endpoint + assert host.deployments_called("acompletion") == ["only"] + + +@pytest.mark.asyncio +async def test_clear_cache_keeps_a_db_classifier_serving_while_the_reload_waits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + host: Final = ScriptedHost() + core: Final = rust_core( + host, + model_list=[ + deployment("tier", "tier"), + deployment( + "smart", + "router", + model="auto_router/complexity_router", + complexity_router_config={ + "classifier_type": "heuristic_v2", + "tiers": {"SIMPLE": "tier", "MEDIUM": "tier", "COMPLEX": "tier", "REASONING": "tier"}, + }, + model_info={"db_model": True}, + ), + ], + ) + seen: Final[list[tuple[str, ...]]] = [] # mutable-ok: snapshots at the reload boundary + + async def reload(**kwargs: object) -> ReconcileOutcome: # kwargs-ok: the config reload's injected I/O boundary + seen.append(tuple(str(item["model_info"]["id"]) for item in core.registry().deployments("smart"))) + await core.acompletion(model="smart", messages=[{"role": "user", "content": "hello"}]) # pyright: ignore[reportAttributeAccessIssue] # the core's completion endpoint + return ReconcileOutcome(still_desired=frozenset({"router"}), live_after=frozenset({"router"})) + + monkeypatch.setattr(proxy_server, "llm_router", core) + monkeypatch.setattr(proxy_server, "prisma_client", object()) + monkeypatch.setattr(proxy_server, "proxy_config", SimpleNamespace(add_deployment_locked=reload)) + + outcome: Final = await clear_cache() + + assert seen == [("router",)] + assert outcome == ReconcileOutcome(still_desired=frozenset({"router"}), live_after=frozenset({"router"})) + assert host.deployments_called("acompletion") == ["tier"] diff --git a/tests/unit/test_router/core/test_gaps_l11100_strategy.py b/tests/unit/test_router/core/test_gaps_l11100_strategy.py index 2459c577b9c..c311459c4e5 100644 --- a/tests/unit/test_router/core/test_gaps_l11100_strategy.py +++ b/tests/unit/test_router/core/test_gaps_l11100_strategy.py @@ -300,7 +300,7 @@ def test_a_failed_edit_rolls_back_to_the_serving_router_even_above_a_tightened_l assert restored.litellm_params.tags is None -def test_a_failed_edit_without_ignore_invalid_deployments_raises_and_leaves_the_router_removed( +def test_a_failed_edit_without_ignore_invalid_deployments_raises_and_keeps_the_router_serving( make_core: MakeCore, ) -> None: limit: Final = {"value": 2} @@ -319,8 +319,8 @@ def test_a_failed_edit_without_ignore_invalid_deployments_raises_and_leaves_the_ ChangeSet(upsert=[Deployment(**complexity_marker("two", "m2", config=HEURISTIC_V2, tags=["edited"]))]) # pyright: ignore[reportArgumentType] ) assert str(raised.value) == HEURISTIC_V2_LIMIT_MESSAGE - assert model_ids(core) == ["s", "b", "m1"] - assert registered_tags(core, "complexity") == {"one": [()]} + assert model_ids(core) == ["s", "b", "m1", "m2"] + assert registered_tags(core, "complexity") == {"one": [()], "two": [()]} # --- the adaptive router ------------------------------------------------------------------------------- diff --git a/tests/unit/test_router/core/test_registry_characterization_writes.py b/tests/unit/test_router/core/test_registry_characterization_writes.py index 23fa2a8e0e9..ecb6aa81832 100644 --- a/tests/unit/test_router/core/test_registry_characterization_writes.py +++ b/tests/unit/test_router/core/test_registry_characterization_writes.py @@ -158,14 +158,13 @@ def test_an_upsert_that_empties_a_group_drops_the_group_name(make_core: MakeCore assert core.registry().deployments("emb") == [] -def test_a_failing_upsert_of_an_existing_deployment_raises_and_leaves_it_removed(make_core: MakeCore) -> None: - """Odd but current: without ignore_invalid_deployments the old deployment is popped and not restored.""" +def test_a_failing_upsert_of_an_existing_deployment_raises_and_keeps_it_serving(make_core: MakeCore) -> None: core = _core(make_core) with pytest.raises(litellm.BadRequestError): core.apply(ChangeSet(upsert=[typed("gpt", "a", model="notaprovider/x")])) - assert core.registry().names(kind="ids") == ["b", "e", "w", "t1"] + assert core.registry().names(kind="ids") == ["a", "b", "e", "w", "t1"] def test_a_failing_upsert_under_ignore_invalid_deployments_restores_the_previous_deployment( @@ -179,7 +178,7 @@ def test_a_failing_upsert_under_ignore_invalid_deployments_restores_the_previous restored = core.registry().deployment("a") assert restored is not None assert restored.litellm_params.model == "openai/a" - assert core.registry().names(kind="ids") == ["b", "e", "w", "t1", "a"] + assert core.registry().names(kind="ids") == ["a", "b", "e", "w", "t1"] def test_the_deprecated_upsert_deployment_warns_and_returns_the_deployment(make_core: MakeCore) -> None: @@ -540,7 +539,7 @@ def test_a_read_on_another_thread_waits_for_an_upsert_to_read_the_list_back(make past the list nor, after a removal, the neighbouring group's entries.""" core: Final = _two_per_group(make_core) hosted: Final = core._hosted # pyright: ignore[reportAttributeAccessIssue] # the registry under test - write: Final[Callable[[Mapping[str, object]], None]] = hosted.changes_runner + write: Final = hosted.build_runner started: Final = threading.Event() pool: Final = ThreadPoolExecutor(max_workers=1) reads: Final[list[object]] = [] # mutable-ok: one per write @@ -549,25 +548,30 @@ def test_a_read_on_another_thread_waits_for_an_upsert_to_read_the_list_back(make started.set() return _group_ids(core) - def write_with_a_read_started(changes: Mapping[str, object]) -> None: - write(changes) + def write_with_a_read_started( + entries: Sequence[object], + path: str, + resolve_secrets: bool, + install: str, + answer: Callable[[Mapping[str, object]], object], + ) -> None: + write(entries, path, resolve_secrets, install, answer) started.clear() reads.append(pool.submit(read)) assert started.wait(timeout=5) # The reader runs while router-core holds the write and the mirror does not yet. _ = threading.Event().wait(0.02) - hosted.changes_runner = write_with_a_read_started + hosted.build_runner = write_with_a_read_started try: _ = core.apply(ChangeSet(upsert=[typed(f"m{upserted % _GROUPS}", f"d{upserted}", timeout=11)])) finally: - hosted.changes_runner = write + hosted.build_runner = write assert reads for future in reads: seen = future.result(timeout=5) # pyright: ignore[reportAttributeAccessIssue] # a Future for group in range(_GROUPS): - # Between the upsert's removal and its add, its group is served by the other deployment. assert seen[group], (group, seen) assert set(seen[group]) <= {f"d{group}", f"d{group + _GROUPS}"}, (group, seen) pool.shutdown() From 8fb9f2f0907596e689808c76b746494d93cc759d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:52:17 +0000 Subject: [PATCH 72/86] chore(cost-map): add 24 runwayml models and correct seedance2 and eleven_multilingual_v2 rates (#51216) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 369 +++++++++++++++++- model_prices_and_context_window.json | 369 +++++++++++++++++- 2 files changed, 706 insertions(+), 32 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 66e3a094879..b6eec1c9574 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -56675,7 +56675,7 @@ "runwayml/seedance2": { "litellm_provider": "runwayml", "mode": "video_generation", - "output_cost_per_second": 0.36, + "output_cost_per_second": 0.3, "output_cost_per_second_1080p": 0.4, "output_cost_per_second_4k": 1.5, "source": "https://docs.dev.runwayml.com/guides/pricing/", @@ -56687,14 +56687,13 @@ "supported_output_modalities": [ "video" ], - "metadata": { - "comment": "36 credits per second at 480p/720p, 40 at 1080p, 150 at 4K @ $0.01 per credit" - } + "metadata": {"comment":"14/30/40/150 credits per second at 480p/720p/1080p/4K @ $0.01 per credit; 56 credit minimum per generation not modeled"}, + "output_cost_per_second_480p": 0.14 }, "runwayml/seedance2_fast": { "litellm_provider": "runwayml", "mode": "video_generation", - "output_cost_per_second": 0.29, + "output_cost_per_second": 0.24, "source": "https://docs.dev.runwayml.com/guides/pricing/", "supported_modalities": [ "text", @@ -56704,14 +56703,13 @@ "supported_output_modalities": [ "video" ], - "metadata": { - "comment": "29 credits per second at 480p/720p @ $0.01 per credit = $0.29 per second" - } + "metadata": {"comment":"11 credits per second at 480p, 24 at 720p @ $0.01 per credit; 44 credit minimum per generation not modeled"}, + "output_cost_per_second_480p": 0.11 }, "runwayml/seedance2_mini": { "litellm_provider": "runwayml", "mode": "video_generation", - "output_cost_per_second": 0.16, + "output_cost_per_second": 0.15, "source": "https://docs.dev.runwayml.com/guides/pricing/", "supported_modalities": [ "text", @@ -56721,9 +56719,8 @@ "supported_output_modalities": [ "video" ], - "metadata": { - "comment": "16 credits per second @ $0.01 per credit = $0.16 per second; 64 credit minimum per task not modeled" - } + "metadata": {"comment":"7 credits per second at 480p, 15 at 720p @ $0.01 per credit; 28 credit minimum per generation not modeled"}, + "output_cost_per_second_480p": 0.07 }, "runwayml/seedance2_5": { "litellm_provider": "runwayml", @@ -56848,11 +56845,9 @@ "runwayml/eleven_multilingual_v2": { "litellm_provider": "runwayml", "mode": "audio_speech", - "input_cost_per_character": 3e-07, + "input_cost_per_character": 0.0002, "source": "https://docs.dev.runwayml.com/guides/pricing/", - "metadata": { - "comment": "Estimated cost based on standard TTS pricing. RunwayML uses ElevenLabs models." - } + "metadata": {"comment":"1 credit per 50 characters @ $0.01 per credit = $0.0002 per character"} }, "fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct": { "max_input_tokens": 262144, @@ -100443,5 +100438,347 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true + }, + "runwayml/kling2.5_turbo_pro": { + "litellm_provider": "runwayml", + "metadata": {"comment":"12 credits per second @ $0.01 per credit = $0.12 per second"}, + "mode": "video_generation", + "output_cost_per_second": 0.12, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling3.0_standard": { + "litellm_provider": "runwayml", + "metadata": {"comment":"13 credits per second with audio, 9 without @ $0.01 per credit; priced at the with-audio rate (audio defaults to true)"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling3.0_pro": { + "litellm_provider": "runwayml", + "metadata": {"comment":"17 credits per second with audio, 12 without @ $0.01 per credit; priced at the with-audio rate (audio defaults to true)"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling3.0_4k": { + "litellm_provider": "runwayml", + "metadata": {"comment":"43 credits per second @ $0.01 per credit = $0.43 per second, with or without audio"}, + "mode": "video_generation", + "output_cost_per_second": 0.43, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/klingO3_standard": { + "litellm_provider": "runwayml", + "metadata": {"comment":"13 credits per second @ $0.01 per credit = $0.13 per second; video-to-video is billed per second of the input video, not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/klingO3_pro": { + "litellm_provider": "runwayml", + "metadata": {"comment":"17 credits per second @ $0.01 per credit = $0.17 per second; video-to-video is billed per second of the input video, not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/klingO3_4k": { + "litellm_provider": "runwayml", + "metadata": {"comment":"43 credits per second @ $0.01 per credit = $0.43 per second; video-to-video is billed per second of the input video, not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.43, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling_4_flash": { + "litellm_provider": "runwayml", + "metadata": {"comment":"9.4 credits per second (720p) without input video @ $0.01 per credit = $0.094 per second; the with-input-video rate (6.3 credits per output second plus 6.3 per second of the greater of input and output duration), rounding up to the next credit and the 29 credit minimum are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/wan3": { + "litellm_provider": "runwayml", + "metadata": {"comment":"5/10/20 credits per second at 480p/720p/1080p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/wan3_prime": { + "litellm_provider": "runwayml", + "metadata": {"comment":"6.8/14/28 credits per second at 480p/720p/1080p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/grok_imagine_1_5": { + "litellm_provider": "runwayml", + "metadata": {"comment":"10/16/29 credits per second at 480p/720p/1080p @ $0.01 per credit; 1 credit per image or audio reference not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.16, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_480p": 0.1, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/grok_imagine_1_5_lite": { + "litellm_provider": "runwayml", + "metadata": {"comment":"2/3/14 credits per second at 480p/720p/1080p @ $0.01 per credit; 1 credit for an image-to-video start frame not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.02, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/h3_max": { + "litellm_provider": "runwayml", + "metadata": {"comment":"8 credits per second at 768p, 5 at 480p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/happyhorse_1_0": { + "litellm_provider": "runwayml", + "metadata": {"comment":"15 credits per second at 720p, 30 at 1080p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.3, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/gemini_omni_flash_1.2": { + "litellm_provider": "runwayml", + "metadata": {"comment":"7/10/21 credits per second at 720p/1080p/4K @ $0.01 per credit; the 360p rate (2 credits per second), 1 credit per reference image or keyframe, 1 credit per second of input video and the 6 credit minimum per request are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_1080p": 0.1, + "output_cost_per_second_4k": 0.21, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/gemini_omni_flash_1.1": { + "litellm_provider": "runwayml", + "metadata": {"comment":"10/15/30 credits per second at 720p/1080p/4K @ $0.01 per credit; the 360p rate (3.4 credits per second), 1 credit per reference image and 1 credit per second of input video are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/muse_image": { + "litellm_provider": "runwayml", + "metadata": {"comment":"1 credit per image (any resolution) @ $0.01 per credit = $0.01 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/seedream5_lite": { + "litellm_provider": "runwayml", + "metadata": {"comment":"4 credits per image (any resolution) @ $0.01 per credit = $0.04 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/seedream5_flash": { + "litellm_provider": "runwayml", + "metadata": {"comment":"2 credits per image (any resolution) @ $0.01 per credit = $0.02 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/gemini_image3.1_flash_lite": { + "litellm_provider": "runwayml", + "metadata": {"comment":"4 credits per 1K image @ $0.01 per credit = $0.04 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/bria_fibo_edit_1_5": { + "litellm_provider": "runwayml", + "metadata": {"comment":"5 credits per image @ $0.01 per credit = $0.05 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/gemini_2.5_flash": { + "litellm_provider": "runwayml", + "metadata": {"comment":"5 credits per image (any resolution) @ $0.01 per credit = $0.05 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/eleven_v3": { + "input_cost_per_character": 0.0002, + "litellm_provider": "runwayml", + "metadata": {"comment":"1 credit per 50 characters @ $0.01 per credit = $0.0002 per character; 1 credit minimum not modeled"}, + "mode": "audio_speech", + "source": "https://docs.dev.runwayml.com/guides/pricing/" + }, + "runwayml/eleven_v4": { + "input_cost_per_character": 5e-05, + "litellm_provider": "runwayml", + "metadata": {"comment":"5 credits per 1,000 characters @ $0.01 per credit = $0.00005 per character; 1 credit minimum not modeled"}, + "mode": "audio_speech", + "source": "https://docs.dev.runwayml.com/guides/pricing/" } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 66e3a094879..b6eec1c9574 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -56675,7 +56675,7 @@ "runwayml/seedance2": { "litellm_provider": "runwayml", "mode": "video_generation", - "output_cost_per_second": 0.36, + "output_cost_per_second": 0.3, "output_cost_per_second_1080p": 0.4, "output_cost_per_second_4k": 1.5, "source": "https://docs.dev.runwayml.com/guides/pricing/", @@ -56687,14 +56687,13 @@ "supported_output_modalities": [ "video" ], - "metadata": { - "comment": "36 credits per second at 480p/720p, 40 at 1080p, 150 at 4K @ $0.01 per credit" - } + "metadata": {"comment":"14/30/40/150 credits per second at 480p/720p/1080p/4K @ $0.01 per credit; 56 credit minimum per generation not modeled"}, + "output_cost_per_second_480p": 0.14 }, "runwayml/seedance2_fast": { "litellm_provider": "runwayml", "mode": "video_generation", - "output_cost_per_second": 0.29, + "output_cost_per_second": 0.24, "source": "https://docs.dev.runwayml.com/guides/pricing/", "supported_modalities": [ "text", @@ -56704,14 +56703,13 @@ "supported_output_modalities": [ "video" ], - "metadata": { - "comment": "29 credits per second at 480p/720p @ $0.01 per credit = $0.29 per second" - } + "metadata": {"comment":"11 credits per second at 480p, 24 at 720p @ $0.01 per credit; 44 credit minimum per generation not modeled"}, + "output_cost_per_second_480p": 0.11 }, "runwayml/seedance2_mini": { "litellm_provider": "runwayml", "mode": "video_generation", - "output_cost_per_second": 0.16, + "output_cost_per_second": 0.15, "source": "https://docs.dev.runwayml.com/guides/pricing/", "supported_modalities": [ "text", @@ -56721,9 +56719,8 @@ "supported_output_modalities": [ "video" ], - "metadata": { - "comment": "16 credits per second @ $0.01 per credit = $0.16 per second; 64 credit minimum per task not modeled" - } + "metadata": {"comment":"7 credits per second at 480p, 15 at 720p @ $0.01 per credit; 28 credit minimum per generation not modeled"}, + "output_cost_per_second_480p": 0.07 }, "runwayml/seedance2_5": { "litellm_provider": "runwayml", @@ -56848,11 +56845,9 @@ "runwayml/eleven_multilingual_v2": { "litellm_provider": "runwayml", "mode": "audio_speech", - "input_cost_per_character": 3e-07, + "input_cost_per_character": 0.0002, "source": "https://docs.dev.runwayml.com/guides/pricing/", - "metadata": { - "comment": "Estimated cost based on standard TTS pricing. RunwayML uses ElevenLabs models." - } + "metadata": {"comment":"1 credit per 50 characters @ $0.01 per credit = $0.0002 per character"} }, "fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct": { "max_input_tokens": 262144, @@ -100443,5 +100438,347 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true + }, + "runwayml/kling2.5_turbo_pro": { + "litellm_provider": "runwayml", + "metadata": {"comment":"12 credits per second @ $0.01 per credit = $0.12 per second"}, + "mode": "video_generation", + "output_cost_per_second": 0.12, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling3.0_standard": { + "litellm_provider": "runwayml", + "metadata": {"comment":"13 credits per second with audio, 9 without @ $0.01 per credit; priced at the with-audio rate (audio defaults to true)"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling3.0_pro": { + "litellm_provider": "runwayml", + "metadata": {"comment":"17 credits per second with audio, 12 without @ $0.01 per credit; priced at the with-audio rate (audio defaults to true)"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling3.0_4k": { + "litellm_provider": "runwayml", + "metadata": {"comment":"43 credits per second @ $0.01 per credit = $0.43 per second, with or without audio"}, + "mode": "video_generation", + "output_cost_per_second": 0.43, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/klingO3_standard": { + "litellm_provider": "runwayml", + "metadata": {"comment":"13 credits per second @ $0.01 per credit = $0.13 per second; video-to-video is billed per second of the input video, not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.13, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/klingO3_pro": { + "litellm_provider": "runwayml", + "metadata": {"comment":"17 credits per second @ $0.01 per credit = $0.17 per second; video-to-video is billed per second of the input video, not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.17, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/klingO3_4k": { + "litellm_provider": "runwayml", + "metadata": {"comment":"43 credits per second @ $0.01 per credit = $0.43 per second; video-to-video is billed per second of the input video, not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.43, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/kling_4_flash": { + "litellm_provider": "runwayml", + "metadata": {"comment":"9.4 credits per second (720p) without input video @ $0.01 per credit = $0.094 per second; the with-input-video rate (6.3 credits per output second plus 6.3 per second of the greater of input and output duration), rounding up to the next credit and the 29 credit minimum are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.094, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/wan3": { + "litellm_provider": "runwayml", + "metadata": {"comment":"5/10/20 credits per second at 480p/720p/1080p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.2, + "output_cost_per_second_480p": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/wan3_prime": { + "litellm_provider": "runwayml", + "metadata": {"comment":"6.8/14/28 credits per second at 480p/720p/1080p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.14, + "output_cost_per_second_1080p": 0.28, + "output_cost_per_second_480p": 0.068, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/grok_imagine_1_5": { + "litellm_provider": "runwayml", + "metadata": {"comment":"10/16/29 credits per second at 480p/720p/1080p @ $0.01 per credit; 1 credit per image or audio reference not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.16, + "output_cost_per_second_1080p": 0.29, + "output_cost_per_second_480p": 0.1, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/grok_imagine_1_5_lite": { + "litellm_provider": "runwayml", + "metadata": {"comment":"2/3/14 credits per second at 480p/720p/1080p @ $0.01 per credit; 1 credit for an image-to-video start frame not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.03, + "output_cost_per_second_1080p": 0.14, + "output_cost_per_second_480p": 0.02, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/h3_max": { + "litellm_provider": "runwayml", + "metadata": {"comment":"8 credits per second at 768p, 5 at 480p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.08, + "output_cost_per_second_480p": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/happyhorse_1_0": { + "litellm_provider": "runwayml", + "metadata": {"comment":"15 credits per second at 720p, 30 at 1080p @ $0.01 per credit"}, + "mode": "video_generation", + "output_cost_per_second": 0.15, + "output_cost_per_second_1080p": 0.3, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/gemini_omni_flash_1.2": { + "litellm_provider": "runwayml", + "metadata": {"comment":"7/10/21 credits per second at 720p/1080p/4K @ $0.01 per credit; the 360p rate (2 credits per second), 1 credit per reference image or keyframe, 1 credit per second of input video and the 6 credit minimum per request are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.07, + "output_cost_per_second_1080p": 0.1, + "output_cost_per_second_4k": 0.21, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/gemini_omni_flash_1.1": { + "litellm_provider": "runwayml", + "metadata": {"comment":"10/15/30 credits per second at 720p/1080p/4K @ $0.01 per credit; the 360p rate (3.4 credits per second), 1 credit per reference image and 1 credit per second of input video are not modeled"}, + "mode": "video_generation", + "output_cost_per_second": 0.1, + "output_cost_per_second_1080p": 0.15, + "output_cost_per_second_4k": 0.3, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "runwayml/muse_image": { + "litellm_provider": "runwayml", + "metadata": {"comment":"1 credit per image (any resolution) @ $0.01 per credit = $0.01 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.01, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/seedream5_lite": { + "litellm_provider": "runwayml", + "metadata": {"comment":"4 credits per image (any resolution) @ $0.01 per credit = $0.04 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/seedream5_flash": { + "litellm_provider": "runwayml", + "metadata": {"comment":"2 credits per image (any resolution) @ $0.01 per credit = $0.02 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/gemini_image3.1_flash_lite": { + "litellm_provider": "runwayml", + "metadata": {"comment":"4 credits per 1K image @ $0.01 per credit = $0.04 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/bria_fibo_edit_1_5": { + "litellm_provider": "runwayml", + "metadata": {"comment":"5 credits per image @ $0.01 per credit = $0.05 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/gemini_2.5_flash": { + "litellm_provider": "runwayml", + "metadata": {"comment":"5 credits per image (any resolution) @ $0.01 per credit = $0.05 per image"}, + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://docs.dev.runwayml.com/guides/pricing/", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "image" + ] + }, + "runwayml/eleven_v3": { + "input_cost_per_character": 0.0002, + "litellm_provider": "runwayml", + "metadata": {"comment":"1 credit per 50 characters @ $0.01 per credit = $0.0002 per character; 1 credit minimum not modeled"}, + "mode": "audio_speech", + "source": "https://docs.dev.runwayml.com/guides/pricing/" + }, + "runwayml/eleven_v4": { + "input_cost_per_character": 5e-05, + "litellm_provider": "runwayml", + "metadata": {"comment":"5 credits per 1,000 characters @ $0.01 per credit = $0.00005 per character; 1 credit minimum not modeled"}, + "mode": "audio_speech", + "source": "https://docs.dev.runwayml.com/guides/pricing/" } } From 13089b191b0fc16af5e298c0d2b689610be928e0 Mon Sep 17 00:00:00 2001 From: kenneth-berri Date: Wed, 11 Nov 2026 10:53:54 -0800 Subject: [PATCH 73/86] fix(azure): use supported API versions for Responses requests (#51116) --- .../llms/azure/responses/transformation.py | 36 ++++++++++---- .../response/test_azure_transformation.py | 48 +++++++++++++++++-- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 6a0740b2f15..637d3cfc219 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -37,6 +37,16 @@ _AZURE_RESPONSES_PROMPT_CACHE_BREAKPOINT: Final = TypeAdapter(AzureResponsesProm _DATED_GA_API_VERSION: Final = re.compile(r"\d{4}-\d{2}-\d{2}") +_DATED_API_VERSION: Final = re.compile(r"\d{4}-\d{2}-\d{2}(?:-preview)?") +_MIN_RESPONSES_API_DATE: Final = "2025-03-01" + + +def _predates_responses_api(api_version: str | None) -> bool: + return ( + api_version is not None + and _DATED_API_VERSION.fullmatch(api_version) is not None + and api_version[:10] < _MIN_RESPONSES_API_DATE + ) def _str_param(litellm_params: Mapping[str, object], key: str) -> str | None: @@ -45,16 +55,14 @@ def _str_param(litellm_params: Mapping[str, object], key: str) -> str | None: def _resolve_responses_api_version(api_base: str | None, litellm_params: Mapping[str, object]) -> str | None: - """ - Dated GA api_versions (e.g. 2024-10-21) predate the Responses API on Azure and 404 there, so a deployment - pinned to one for chat gets the default Responses version unless it sets responses_api_version. - """ - api_version: Final = _str_param(litellm_params, "api_version") - if api_base is not None and "api-version" in httpx.URL(api_base).params: - return api_version responses_api_version: Final = _str_param(litellm_params, "responses_api_version") if responses_api_version is not None: return responses_api_version + api_version: Final = _str_param(litellm_params, "api_version") + if api_base is not None and "api-version" in httpx.URL(api_base).params: + return api_version + if _predates_responses_api(api_version): + return None if api_version is not None and _DATED_GA_API_VERSION.fullmatch(api_version): return AZURE_DEFAULT_RESPONSES_API_VERSION return api_version @@ -188,7 +196,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def get_complete_url( self, api_base: str | None, - litellm_params: dict, + litellm_params: dict[str, object], ) -> str: """ Constructs a complete URL for the API request. @@ -206,7 +214,17 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): - A complete URL string, e.g., "https://litellm8397336933.openai.azure.com/openai/responses?api-version=2024-05-01-preview" """ - resolved_api_base: Final = BaseAzureLLM.resolve_azure_api_base(api_base) + base: Final = BaseAzureLLM.resolve_azure_api_base(api_base) + parsed_url: Final = httpx.URL(base) if base is not None else None + resolved_api_base: Final = ( + str(parsed_url.copy_with(params=parsed_url.params.remove("api-version"))) + if parsed_url is not None + and ( + _predates_responses_api(parsed_url.params.get("api-version")) + or _str_param(litellm_params, "responses_api_version") is not None + ) + else base + ) responses_params: Final = _with_responses_api_version(resolved_api_base, litellm_params) parsed_base: Final = urlsplit(resolved_api_base or "") path: Final = parsed_base.path.rstrip("/") diff --git a/tests/unit/llms/azure/response/test_azure_transformation.py b/tests/unit/llms/azure/response/test_azure_transformation.py index 05c0817e19b..3a8c9d96da4 100644 --- a/tests/unit/llms/azure/response/test_azure_transformation.py +++ b/tests/unit/llms/azure/response/test_azure_transformation.py @@ -93,13 +93,13 @@ def test_get_complete_url(): """ azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() api_base = "https://litellm8397336933.openai.azure.com" - litellm_params = {"api_version": "2024-05-01-preview"} + litellm_params = {"api_version": "2025-04-01-preview"} result = azure_openai_responses_apiconfig.get_complete_url( api_base=api_base, litellm_params=litellm_params ) - expected = "https://litellm8397336933.openai.azure.com/openai/responses?api-version=2024-05-01-preview" + expected = "https://litellm8397336933.openai.azure.com/openai/responses?api-version=2025-04-01-preview" assert result == expected @@ -467,7 +467,12 @@ class TestAzureResponsesAPIConfig: litellm_params={"api_version": "2024-10-21", "responses_api_version": responses_api_version}, ) - assert url == api_base + expected: Final = ( + "https://litellm8397336933.openai.azure.com/openai/v1/responses?api-version=v1" + if responses_api_version is not None + else api_base + ) + assert url == expected def test_api_version_in_env_api_base_query_wins_over_a_dated_ga_api_version(self, monkeypatch: pytest.MonkeyPatch): env_api_base: Final = ( @@ -1015,6 +1020,43 @@ def _unset_api_version_responses_url() -> httpx.URL: return httpx.URL(AzureOpenAIResponsesAPIConfig().get_complete_url(api_base=_AZURE_RESOURCE, litellm_params={})) +@pytest.mark.parametrize("api_version", ["2025-02-01-preview", "2025-02-28", "2024-05-01-preview"]) +@pytest.mark.parametrize("source", ["params", "query", "both"]) +def test_pre_responses_api_version_uses_the_native_responses_default(api_version: str, source: str) -> None: + base: Final = ( + f"{_AZURE_RESOURCE}?api-version={api_version}&tenant=test" + if source in ("query", "both") + else f"{_AZURE_RESOURCE}?tenant=test" + ) + params: Final = {"api_version": api_version} if source in ("params", "both") else {} + url: Final = httpx.URL(AzureOpenAIResponsesAPIConfig().get_complete_url(api_base=base, litellm_params=params)) + expected: Final = _unset_api_version_responses_url() + + assert url.copy_with(query=None) == expected.copy_with(query=None) + assert dict(url.params) == {**dict(expected.params), "tenant": "test"} + + +@pytest.mark.parametrize("api_version", ["2025-03-01-preview", "2025-04-01-preview", "preview", "latest", "v1"]) +@pytest.mark.parametrize("source", ["params", "query"]) +def test_supported_responses_api_versions_are_preserved(api_version: str, source: str) -> None: + base: Final = f"{_AZURE_RESOURCE}?api-version={api_version}" if source == "query" else _AZURE_RESOURCE + params: Final = {"api_version": api_version} if source == "params" else {} + url: Final = httpx.URL(AzureOpenAIResponsesAPIConfig().get_complete_url(api_base=base, litellm_params=params)) + + assert url.params["api-version"] == api_version + + +@pytest.mark.parametrize("api_version", ["2025-02-01-preview", "2025-04-01-preview"]) +def test_responses_override_wins_over_query_and_chat_version(api_version: str) -> None: + config: Final = AzureOpenAIResponsesAPIConfig() + params: Final = {"api_version": api_version, "responses_api_version": "2025-03-01-preview"} + url: Final = config.get_complete_url( + api_base=f"{_AZURE_RESOURCE}?api-version={api_version}", litellm_params=params + ) + + assert url == f"{_AZURE_RESOURCE}/openai/responses?api-version=2025-03-01-preview" + + def _azure_responses_create(api_version: str) -> str | None: response: Final = litellm.responses( model="azure/gpt-5.4-nano", From fdafffb84285892c1e97bf8b31d5eba0cbd7420a Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 11 Nov 2026 11:16:21 -0800 Subject: [PATCH 74/86] feat(proxy): count a rate-limit fallback in x-litellm-attempted-fallbacks and name it in x-litellm-fallback-reason (#51138) * feat(proxy): count a rate-limit fallback in x-litellm-attempted-fallbacks and name it in x-litellm-fallback-reason Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): count every fallback candidate a local rate limit rejected in x-litellm-attempted-fallbacks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): count fallback attempts without a mutable list and keep self.data on the served request Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/common_request_processing.py | 45 +++++++-- .../common_utils/proxy_rate_limit_error.py | 92 ++++++++++++++++++- litellm/proxy/litellm_pre_call_utils.py | 1 + .../test_rate_limit_fallback_headers.py | 79 ++++++++++++++++ .../test_proxy_rate_limit_error.py | 58 ++++++++++++ .../proxy/test_common_request_processing.py | 35 +++++++ .../unit/proxy/test_litellm_pre_call_utils.py | 2 + 7 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 tests/integration/routing/test_rate_limit_fallback_headers.py create mode 100644 tests/unit/proxy/common_utils/test_proxy_rate_limit_error.py diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 46a9f9910d7..c6981015417 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1,10 +1,11 @@ import asyncio import contextlib +import itertools import json import logging import math import time -from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Mapping, Sequence +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone from functools import lru_cache @@ -129,6 +130,11 @@ from litellm.proxy.common_utils.openai_error_payload import ( openai_error_type, relayed_provider_specific_fields, ) +from litellm.proxy.common_utils.proxy_rate_limit_error import ( + ATTEMPTED_FALLBACKS_HEADER, + rate_limit_fallback_from_request_data, + rate_limit_fallback_headers, +) from litellm.proxy.common_utils.responses_stream_errors import ( SSE_LINE_BREAK, ResponsesStreamErrorState, @@ -2384,12 +2390,12 @@ class ProxyBaseLLMRequestProcessing: response_cost: float | str | None = None, hidden_params: Mapping[str, object] | None = None, fastest_response_batch_completion: bool | None = None, - request_data: dict | None = {}, + request_data: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, litellm_logging_obj: LiteLLMLoggingObj | None = None, read_timing_from_logging_obj: bool = True, - **kwargs, - ) -> dict: + **kwargs: object, + ) -> dict[str, str]: exclude_values: Final = {"", None, "None"} hidden_params = hidden_params or {} resolved_call_id: Final = ( @@ -2453,10 +2459,14 @@ class ProxyBaseLLMRequestProcessing: **{k: str(v) for k, v in kwargs.items()}, } if request_data: - remaining_tokens_header: Final = get_remaining_tokens_and_requests_from_request_data(request_data) + remaining_tokens_header: Final = get_remaining_tokens_and_requests_from_request_data(dict(request_data)) headers.update(remaining_tokens_header) - logging_caching_headers: Final = get_logging_caching_headers(request_data) + quota_fallback: Final = rate_limit_fallback_from_request_data(request_data) + if quota_fallback is not None: + headers.update(rate_limit_fallback_headers(quota_fallback, headers.get(ATTEMPTED_FALLBACKS_HEADER))) + + logging_caching_headers: Final = get_logging_caching_headers(dict(request_data)) if logging_caching_headers: headers.update(logging_caching_headers) @@ -2864,7 +2874,9 @@ class ProxyBaseLLMRequestProcessing: from litellm.proxy.common_utils.proxy_rate_limit_error import ( PER_MODEL_RATE_LIMIT_DESCRIPTOR_KEYS, ProxyRateLimitError, + RateLimitFallback, per_model_rate_limits_disable_fallbacks, + with_rate_limit_fallback, ) general_settings_view: Final = cast( # cast-ok: this method keeps its legacy bare-dict settings parameter @@ -2933,6 +2945,7 @@ class ProxyBaseLLMRequestProcessing: if isinstance(raw_router_settings, Mapping) else None ) + attempt_numbers: Final[Iterator[int]] = itertools.count(1) try: for fallback_model in fallback_models: if fallback_model == original_model: @@ -2955,7 +2968,10 @@ class ProxyBaseLLMRequestProcessing: user_api_key_dict.team_id, ): continue - return await self.common_processing_pre_call_logic( + attempt = next(attempt_numbers) + admitted: tuple[ + Mapping[str, object], LiteLLMLoggingObj + ] = await self.common_processing_pre_call_logic( request=request, general_settings=general_settings, proxy_logging_obj=proxy_logging_obj, @@ -2976,6 +2992,21 @@ class ProxyBaseLLMRequestProcessing: if is_hard_per_model_limit(fallback_exc): raise continue + else: + admitted_data, admitted_logging_obj = admitted + stamped: dict = dict( # mutable-ok: caller-defined request-payload shape + with_rate_limit_fallback( + admitted_data, + get_metadata_variable_name(request, admitted_data), + RateLimitFallback( + original_model_group=original_model, + descriptor_key=original_exc.descriptor_key or "unknown", + attempts=attempt, + ), + ) + ) + self.data = stamped + return stamped, admitted_logging_obj except BaseException: self.data = rate_limited_data raise diff --git a/litellm/proxy/common_utils/proxy_rate_limit_error.py b/litellm/proxy/common_utils/proxy_rate_limit_error.py index 28d28a5c9bd..6f3ef4e03d4 100644 --- a/litellm/proxy/common_utils/proxy_rate_limit_error.py +++ b/litellm/proxy/common_utils/proxy_rate_limit_error.py @@ -37,10 +37,12 @@ This module provides a single proxy-side error class that: import json from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType from typing import Final from fastapi import HTTPException -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger from litellm.exceptions import RateLimitError, RateLimitErrorCategory, RateLimitType @@ -73,6 +75,94 @@ def per_model_rate_limits_disable_fallbacks(general_settings: Mapping[str, objec return False +RATE_LIMIT_FALLBACK_METADATA_KEY: Final = "rate_limit_fallback" +RATE_LIMIT_FALLBACK_REASON_HEADER: Final = "x-litellm-fallback-reason" +ATTEMPTED_FALLBACKS_HEADER: Final = "x-litellm-attempted-fallbacks" + + +@dataclass(frozen=True, slots=True) +class RateLimitFallback: + original_model_group: str + descriptor_key: str + attempts: int = 1 + + def stamp(self) -> Mapping[str, object]: + return MappingProxyType( + { + "original_model_group": self.original_model_group, + "descriptor_key": self.descriptor_key, + "attempts": self.attempts, + } + ) + + @property + def reason(self) -> str: + return f"rate_limit:{self.descriptor_key}" + + +class _StampedBucket(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + rate_limit_fallback: RateLimitFallback | None = None + + +class _StampedRequest(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + metadata: _StampedBucket | None = None + litellm_metadata: _StampedBucket | None = None + + +_STAMPED_REQUEST: Final[TypeAdapter[_StampedRequest]] = TypeAdapter(_StampedRequest) +_ROUTER_HOPS: Final[TypeAdapter[int]] = TypeAdapter(int) + + +def rate_limit_fallback_from_request_data(request_data: Mapping[str, object]) -> RateLimitFallback | None: + try: + stamped: Final = _STAMPED_REQUEST.validate_python(request_data) + except ValidationError: + return None + buckets: Final = (stamped.metadata, stamped.litellm_metadata) + return next( + (bucket.rate_limit_fallback for bucket in buckets if bucket is not None and bucket.rate_limit_fallback), None + ) + + +_METADATA_BUCKET: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) + + +def _metadata_bucket(value: object) -> Mapping[str, object]: + try: + return _METADATA_BUCKET.validate_python(value) + except ValidationError: + return MappingProxyType({}) + + +def with_rate_limit_fallback( + request_data: Mapping[str, object], bucket_name: str, fallback: RateLimitFallback +) -> Mapping[str, object]: + bucket: Final = _metadata_bucket(request_data.get(bucket_name)) + return MappingProxyType( + {**request_data, bucket_name: {**bucket, RATE_LIMIT_FALLBACK_METADATA_KEY: dict(fallback.stamp())}} + ) + + +def rate_limit_fallback_headers(fallback: RateLimitFallback, attempted_fallbacks: object) -> Mapping[str, str]: + try: + router_hops: Final = _ROUTER_HOPS.validate_python(attempted_fallbacks) + except ValidationError: + return MappingProxyType( + { + ATTEMPTED_FALLBACKS_HEADER: str(max(fallback.attempts, 1)), + RATE_LIMIT_FALLBACK_REASON_HEADER: fallback.reason, + } + ) + return MappingProxyType( + { + ATTEMPTED_FALLBACKS_HEADER: str(max(router_hops, 0) + max(fallback.attempts, 1)), + RATE_LIMIT_FALLBACK_REASON_HEADER: fallback.reason, + } + ) + + def map_v3_rate_limit_type( v3_value: str | None, ) -> RateLimitType | None: diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 0b2ba93af71..ff0883b6958 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -536,6 +536,7 @@ ROUTER_RESERVED_METADATA_FIELDS: Final = frozenset( "attempted_fallbacks", "original_model_group", "previous_models", + "rate_limit_fallback", "request_retry_count", CLIENT_OUTPUT_CEILING_METADATA_KEY, ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY, diff --git a/tests/integration/routing/test_rate_limit_fallback_headers.py b/tests/integration/routing/test_rate_limit_fallback_headers.py new file mode 100644 index 00000000000..18a42cb4d1d --- /dev/null +++ b/tests/integration/routing/test_rate_limit_fallback_headers.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from contextlib import ExitStack +from dataclasses import dataclass +from typing import Final + +import pytest +from integration._support.client import Gateway, gateway_from_environment +from integration._support.process import OwnedState, graceful_stop_seconds, owned_proxy_process +from integration._support.wire import wire_server +from integration.routing.test_per_model_rate_limit_fallbacks import ( + assert_served, + chat, + healthy_reply, + model_entry, + proxy_config, +) + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + + +@dataclass(frozen=True, slots=True) +class Rig: + gateway: Gateway + primary: str + fallback: str + second_fallback: str + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + suffix: Final = uuid.uuid4().hex[:10] + primary: Final = f"quota-primary-{suffix}" + fallback: Final = f"quota-fallback-{suffix}" + second_fallback: Final = f"quota-second-fallback-{suffix}" + directory: Final = tmp_path_factory.mktemp("rate-limit-fallback-headers") + with ExitStack() as stack: + rig_gateway: Final = stack.enter_context(gateway_from_environment()) + primary_wire: Final = stack.enter_context(wire_server(healthy_reply)) + fallback_wire: Final = stack.enter_context(wire_server(healthy_reply)) + second_fallback_wire: Final = stack.enter_context(wire_server(healthy_reply)) + config: Final = proxy_config( + directory, + model_list=( + model_entry(primary, primary_wire), + model_entry(fallback, fallback_wire), + model_entry(second_fallback, second_fallback_wire), + ), + fallbacks=({primary: [fallback, second_fallback]},), + general_settings={}, + ) + owned: Final = stack.enter_context( + owned_proxy_process( + rig_gateway, directory, {}, config=config, workers=2, state=OwnedState.SHARED_WITH_WORKER + ) + ) + yield Rig(owned.gateway, primary, fallback, second_fallback) + + +def test_quota_fallback_response_names_the_hop_and_its_reason(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + key: Final = scenario.key(model_tpm_limit={rig.primary: 1}) + + response: Final = chat(rig.gateway, key, rig.primary, f"pmrl-{uuid.uuid4().hex}") + assert_served(response, rig.fallback, "served pmrl-") + assert response.headers.get("x-litellm-attempted-fallbacks") == "1", dict(response.headers) + assert response.headers.get("x-litellm-fallback-reason") == "rate_limit:model_per_key", dict(response.headers) + + +def test_quota_fallback_response_counts_every_candidate_a_limit_rejected(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + key: Final = scenario.key(model_tpm_limit={rig.primary: 1, rig.fallback: 1}) + + response: Final = chat(rig.gateway, key, rig.primary, f"pmrl-{uuid.uuid4().hex}") + assert_served(response, rig.second_fallback, "served pmrl-") + assert response.headers.get("x-litellm-attempted-fallbacks") == "2", dict(response.headers) + assert response.headers.get("x-litellm-fallback-reason") == "rate_limit:model_per_key", dict(response.headers) diff --git a/tests/unit/proxy/common_utils/test_proxy_rate_limit_error.py b/tests/unit/proxy/common_utils/test_proxy_rate_limit_error.py new file mode 100644 index 00000000000..a7628b72341 --- /dev/null +++ b/tests/unit/proxy/common_utils/test_proxy_rate_limit_error.py @@ -0,0 +1,58 @@ +from typing import Final + +import pytest + +from litellm.proxy.common_utils.proxy_rate_limit_error import ( + RateLimitFallback, + rate_limit_fallback_from_request_data, + rate_limit_fallback_headers, +) + +STAMP: Final = {"original_model_group": "opus", "descriptor_key": "model_per_end_user"} + + +@pytest.mark.parametrize("bucket", ("metadata", "litellm_metadata")) +def test_rate_limit_fallback_is_read_from_either_metadata_bucket(bucket: str) -> None: + fallback: Final = rate_limit_fallback_from_request_data({"model": "luna", bucket: {"rate_limit_fallback": STAMP}}) + + assert fallback is not None + assert fallback == RateLimitFallback(original_model_group="opus", descriptor_key="model_per_end_user") + assert fallback.reason == "rate_limit:model_per_end_user" + + +@pytest.mark.parametrize( + "request_data", + ( + {"model": "luna"}, + {"model": "luna", "metadata": {}}, + {"model": "luna", "metadata": {"rate_limit_fallback": "opus"}}, + {"model": "luna", "metadata": {"rate_limit_fallback": {"original_model_group": "opus"}}}, + ), +) +def test_a_request_without_a_complete_stamp_has_no_rate_limit_fallback(request_data: dict[str, object]) -> None: + assert rate_limit_fallback_from_request_data(request_data) is None + + +@pytest.mark.parametrize(("router_hops", "expected"), ((None, "1"), (0, "1"), ("0", "1"), (2, "3"), ("junk", "1"))) +def test_rate_limit_fallback_headers_count_the_hop_on_top_of_the_router(router_hops: object, expected: str) -> None: + headers: Final = rate_limit_fallback_headers( + RateLimitFallback(original_model_group="opus", descriptor_key="model_per_key"), router_hops + ) + + assert headers == { + "x-litellm-attempted-fallbacks": expected, + "x-litellm-fallback-reason": "rate_limit:model_per_key", + } + + +@pytest.mark.parametrize( + ("attempts", "router_hops", "expected"), ((2, None, "2"), (2, 0, "2"), (3, 1, "4"), (0, 0, "1")) +) +def test_rate_limit_fallback_headers_count_every_candidate_a_limit_rejected( + attempts: int, router_hops: object, expected: str +) -> None: + headers: Final = rate_limit_fallback_headers( + RateLimitFallback(original_model_group="opus", descriptor_key="model_per_key", attempts=attempts), router_hops + ) + + assert headers["x-litellm-attempted-fallbacks"] == expected diff --git a/tests/unit/proxy/test_common_request_processing.py b/tests/unit/proxy/test_common_request_processing.py index 23023560a41..bfe381cb848 100644 --- a/tests/unit/proxy/test_common_request_processing.py +++ b/tests/unit/proxy/test_common_request_processing.py @@ -13742,3 +13742,38 @@ class TestTotalTimeout: assert not prepared assert not called assert elapsed < 120 + + +@pytest.mark.parametrize("router_attempted_fallbacks", (0, 1)) +def test_get_custom_headers_count_a_quota_fallback_and_name_its_reason(router_attempted_fallbacks: int) -> None: + auth: Final = ProxyUserAPIKeyAuth(api_key="sk-quota-fallback", tpm_limit=None, rpm_limit=None, max_budget=None) + request_data: Final = { + "model": "luna", + "metadata": {"rate_limit_fallback": {"original_model_group": "opus", "descriptor_key": "model_per_key"}}, + } + + custom_headers: Callable[..., dict[str, str]] = ProxyBaseLLMRequestProcessing.get_custom_headers + headers: Final = custom_headers( + user_api_key_dict=auth, + call_id="quota-fallback", + request_data=request_data, + **{"x-litellm-attempted-fallbacks": router_attempted_fallbacks}, + ) + + assert headers["x-litellm-attempted-fallbacks"] == str(router_attempted_fallbacks + 1) + assert headers["x-litellm-fallback-reason"] == "rate_limit:model_per_key" + + +def test_get_custom_headers_leave_a_plain_request_without_a_fallback_reason() -> None: + auth: Final = ProxyUserAPIKeyAuth(api_key="sk-no-fallback", tpm_limit=None, rpm_limit=None, max_budget=None) + + custom_headers: Callable[..., dict[str, str]] = ProxyBaseLLMRequestProcessing.get_custom_headers + headers: Final = custom_headers( + user_api_key_dict=auth, + call_id="no-fallback", + request_data={"model": "opus", "metadata": {}}, + **{"x-litellm-attempted-fallbacks": 0}, + ) + + assert headers["x-litellm-attempted-fallbacks"] == "0" + assert "x-litellm-fallback-reason" not in headers diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py index f5757a0ff18..7891a87b1dc 100644 --- a/tests/unit/proxy/test_litellm_pre_call_utils.py +++ b/tests/unit/proxy/test_litellm_pre_call_utils.py @@ -9369,6 +9369,7 @@ def _reserved_stamp_key(key_metadata: dict | None = None) -> UserAPIKeyAuth: _PLANTED_STAMPS = { "attempted_fallbacks": 99, "original_model_group": "spoofed-group", + "rate_limit_fallback": {"original_model_group": "spoofed-group", "descriptor_key": "model_per_key"}, "request_retry_count": -100, "previous_models": [{"exception_type": "Timeout", "deployment_id": "spoofed", "attempted_retries": 101}], "_client_output_ceiling": {"api_base": "https://attacker.example"}, @@ -9404,6 +9405,7 @@ async def test_add_litellm_data_to_request_strips_router_reserved_stamps_from_bo assert "litellm_metadata" not in updated assert "attempted_fallbacks" not in updated["metadata"] assert "original_model_group" not in updated["metadata"] + assert "rate_limit_fallback" not in updated["metadata"] assert "_client_output_ceiling" not in updated["metadata"] assert "request_retry_count" not in updated["metadata"] assert "previous_models" not in updated["metadata"] From b079bf5a51ddad9c7d5f89d515d3751ea230e5bf Mon Sep 17 00:00:00 2001 From: yujonglee Date: Thu, 12 Nov 2026 04:40:00 +0900 Subject: [PATCH 75/86] refactor(proxy): let the gunicorn server take a private listener (#50871) Extract serving_port() and configure_database() as they were inline, turn the LITELLM_RUST parser into a reusable RustSwitch type, and reformat tests/unit/proxy/test_proxy_cli.py to the 120 column limit. run_gunicorn_server takes private_listener, an inherited socket or an assigned loopback port. With one, gunicorn binds it instead of host:port and trusts no peer's forwarding headers, since every peer on it is the local process in front. Both are gunicorn's own settings, bind and forwarded_allow_ips, so the uvicorn worker is unchanged. The reload options take factory. No caller passes either yet, so every entry point serves as before. --- gateway/launch.py | 6 +- litellm/proxy/dev_reload.py | 7 +- litellm/proxy/proxy_cli.py | 16 +- litellm/proxy/server_listener.py | 38 ++ litellm/rust_bridge/configuration.py | 37 +- tests/unit/proxy/test_dev_reload.py | 41 +- tests/unit/proxy/test_proxy_cli.py | 667 +++++++------------ tests/unit/proxy/test_server_listener.py | 28 + tests/unit/rust_bridge/test_configuration.py | 13 +- 9 files changed, 394 insertions(+), 459 deletions(-) create mode 100644 litellm/proxy/server_listener.py create mode 100644 tests/unit/proxy/test_server_listener.py diff --git a/gateway/launch.py b/gateway/launch.py index 59f7121e513..b9303fd9447 100644 --- a/gateway/launch.py +++ b/gateway/launch.py @@ -75,7 +75,7 @@ def _serve(argv: Sequence[str]) -> None: uvicorn_main(tuple(argv), prog_name="uvicorn") -def main(argv: Sequence[str], serve: Callable[[Sequence[str]], None] = _serve) -> None: +def configure_database() -> None: from litellm.proxy.db.db_url_settings import DatabaseURLSettings from litellm.proxy.db.pgbouncer import PgBouncerError, PgBouncerSettings, export_pooled_database_url @@ -86,6 +86,10 @@ def main(argv: Sequence[str], serve: Callable[[Sequence[str]], None] = _serve) - sys.exit(f"LiteLLM gateway: in-container pgbouncer could not start: {pooled_url.reason}") if pooled_url is not None: export_pooled_database_url(pooled_url) + + +def main(argv: Sequence[str], serve: Callable[[Sequence[str]], None] = _serve) -> None: + configure_database() serve(uvicorn_argv(argv, os.environ)) diff --git a/litellm/proxy/dev_reload.py b/litellm/proxy/dev_reload.py index 500f581b34d..7b057271cb6 100644 --- a/litellm/proxy/dev_reload.py +++ b/litellm/proxy/dev_reload.py @@ -13,7 +13,7 @@ from socket import socket from typing import Final, Literal from pydantic import ConfigDict, ImportString, JsonValue, TypeAdapter, with_config -from typing_extensions import ReadOnly, Required, TypedDict +from typing_extensions import NotRequired, ReadOnly, Required, TypedDict from uvicorn import Config, Server from uvicorn import config as uvicorn_config from uvicorn.supervisors import ChangeReload, Multiprocess @@ -42,6 +42,7 @@ class ServerOptions(TypedDict, total=False): @with_config(ConfigDict(extra="allow")) class ReloadOptions(ServerOptions): app: Required[ReadOnly[str]] + factory: NotRequired[ReadOnly[bool]] reload: ReadOnly[bool] reload_dirs: ReadOnly[tuple[str, ...]] reload_includes: ReadOnly[tuple[str, ...]] @@ -92,7 +93,9 @@ def load_worker_app(import_path: str, worker_app_factory: bool) -> Callable[..., def pool_config(options: ReloadOptions, workers: int, worker_overrides: Mapping[str, object] | None = None) -> Config: overrides: Final = worker_overrides or {} worker_app: Final = TypeAdapter(str).validate_python(overrides.get("app", options["app"])) - worker_app_factory: Final = TypeAdapter(bool).validate_python(overrides.get("factory", False)) + worker_app_factory: Final = TypeAdapter(bool).validate_python( + overrides.get("factory", options.get("factory", False)) + ) return Config( partial(load_worker_app, worker_app, worker_app_factory), factory=True, diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 41aedf4b5cd..b238cefd24f 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -31,6 +31,7 @@ from litellm.proxy.db.pgbouncer import ( start_in_container_pgbouncer, ) from litellm.proxy.db.query_engine_reaper import start_query_engine_reaper +from litellm.proxy.server_listener import PrivateListener, gunicorn_overrides from litellm.types.llms.base import LiteLLMBaseModel if TYPE_CHECKING: @@ -686,6 +687,7 @@ class ProxyInitializationHelpers: max_requests_before_restart: int | None = None, max_requests_before_restart_jitter: int | None = None, max_request_header_size: int | None = None, + private_listener: PrivateListener | None = None, ): """ Run litellm with `gunicorn` @@ -766,6 +768,7 @@ class ProxyInitializationHelpers: "accesslog": "-", # Log to stdout "timeout": 600, # default to very high number, bedrock/anthropic.claude-v2:1 can take 30+ seconds for the 1st chunk to come in "access_log_format": '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s', + **gunicorn_overrides(private_listener), } # Optional: recycle workers after N requests to mitigate memory growth @@ -821,7 +824,7 @@ class ProxyInitializationHelpers: _run_ollama_serve = run_ollama_serve @staticmethod - def is_port_in_use(port): + def is_port_in_use(port: int): import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -829,6 +832,10 @@ class ProxyInitializationHelpers: _is_port_in_use = is_port_in_use + @staticmethod + def serving_port(port: int) -> int: + return random.randint(1024, 49152) if port == 4000 and ProxyInitializationHelpers.is_port_in_use(port) else port + @staticmethod def get_loop_type(): """Helper function to determine the event loop type based on platform""" @@ -1228,8 +1235,8 @@ class ProxyInitializationHelpers: ) def run_server( cli_args, - host, - port, + host: str, + port: int, api_base, api_version, model, @@ -1717,8 +1724,7 @@ def run_server( ) sys.exit(1) export_pooled_database_url(pooled_database_url) - if port == 4000 and ProxyInitializationHelpers.is_port_in_use(port): - port = random.randint(1024, 49152) + port = ProxyInitializationHelpers.serving_port(port) if prometheus_metrics_port == port: raise click.UsageError("--prometheus_metrics_port must differ from --port") diff --git a/litellm/proxy/server_listener.py b/litellm/proxy/server_listener.py new file mode 100644 index 00000000000..bf57fce4ebd --- /dev/null +++ b/litellm/proxy/server_listener.py @@ -0,0 +1,38 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, TypeAlias + +from typing_extensions import assert_never + +LOOPBACK_HOST: Final = "127.0.0.1" +NO_TRUSTED_PROXIES: Final = "" + + +@dataclass(frozen=True, slots=True) +class InheritedFd: + fd: int + + +@dataclass(frozen=True, slots=True) +class AssignedPort: + port: int + + +PrivateListener: TypeAlias = InheritedFd | AssignedPort + + +def _gunicorn_bind(listener: PrivateListener) -> str: + match listener: + case InheritedFd(fd): + return f"fd://{fd}" + case AssignedPort(port): + return f"{LOOPBACK_HOST}:{port}" + case _: + assert_never(listener) + + +def gunicorn_overrides(listener: PrivateListener | None) -> Mapping[str, str]: + if listener is None: + return MappingProxyType({}) + return MappingProxyType({"bind": _gunicorn_bind(listener), "forwarded_allow_ips": NO_TRUSTED_PROXIES}) diff --git a/litellm/rust_bridge/configuration.py b/litellm/rust_bridge/configuration.py index 152ba632996..dedbf857a07 100644 --- a/litellm/rust_bridge/configuration.py +++ b/litellm/rust_bridge/configuration.py @@ -3,13 +3,29 @@ from __future__ import annotations import os from enum import Enum, auto from functools import lru_cache -from typing import Final +from typing import Annotated, Final, TypeAlias -from pydantic import TypeAdapter, ValidationError +from pydantic import BeforeValidator, TypeAdapter, ValidationError from typing_extensions import assert_never -_GLOBAL_ENV_NAME: Final = "LITELLM_RUST" -_ENV_BOOL: Final = TypeAdapter(bool) +ENV_NAME: Final = "LITELLM_RUST" + + +def _blank_as_unset(value: object) -> object: + stripped: Final = value.strip() if isinstance(value, str) else value + return None if stripped == "" else stripped + + +RustSwitch: TypeAlias = Annotated[bool | None, BeforeValidator(_blank_as_unset)] +_RUST_SWITCH: Final = TypeAdapter[bool | None](RustSwitch) + + +@lru_cache(maxsize=16) +def _switch_or_unset(raw: str | None) -> bool | None: + try: + return _RUST_SWITCH.validate_python(raw) + except ValidationError: + return None class Rollout(Enum): @@ -33,17 +49,6 @@ class _RustConfiguration: _CONFIGURATION: Final = _RustConfiguration() -@lru_cache(maxsize=16) -def _parse_env_bool(value: str | None) -> bool | None: - """`LITELLM_RUST` as a bool; cached by raw value because `decision` runs per tokenizer call.""" - if value is None: - return None - try: - return _ENV_BOOL.validate_python(value.strip()) - except ValidationError: - return None - - def decide( rollout: Rollout, *, @@ -72,7 +77,7 @@ def decision(rollout: Rollout) -> Decision: return decide( rollout, process_override=_CONFIGURATION.override, - environment_override=_parse_env_bool(os.getenv(_GLOBAL_ENV_NAME)), + environment_override=_switch_or_unset(os.getenv(ENV_NAME)), ) diff --git a/tests/unit/proxy/test_dev_reload.py b/tests/unit/proxy/test_dev_reload.py index 60a7c53820b..6650c1cd3e3 100644 --- a/tests/unit/proxy/test_dev_reload.py +++ b/tests/unit/proxy/test_dev_reload.py @@ -86,6 +86,39 @@ def test_a_broken_factory_exits_the_worker_as_a_startup_failure( assert exit_info.value.code == uvicorn_config.STARTUP_FAILURE +@pytest.mark.parametrize("worker_overrides", [None, {"factory": True}]) +def test_a_factory_flag_in_the_options_reaches_the_workers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, worker_overrides: dict[str, object] | None +) -> None: + (tmp_path / "reload_factory_flag_app.py").write_text( + "async def served(scope, receive, send):\n" + " await send({'type': 'http.response.start', 'status': 204, 'headers': []})\n" + " await send({'type': 'http.response.body', 'body': b'', 'more_body': False})\n" + "\n" + "def factory():\n" + " return served\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + config: Final = pool_config( + RELOAD_OPTIONS.validate_python({**_options("reload_factory_flag_app:factory"), "factory": True}), + workers=POOL_WORKERS, + worker_overrides=worker_overrides, + ) + config.load() + loaded_app: Final = config.loaded_app + assert loaded_app is not None + sent: Final[list[Message]] = [] + + async def receive() -> Message: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: Message) -> None: + sent.append(message) + + asyncio.run(loaded_app({"type": "http", "method": "GET", "path": "/", "headers": []}, receive, send)) + assert sent[0] == {"type": "http.response.start", "status": 204, "headers": []} + + @REQUIRES_UVICORN_STARTUP_FAILURE @pytest.mark.parametrize( ("module", "source"), @@ -118,7 +151,9 @@ def test_the_reload_pool_defaults_the_worker_healthcheck_and_keeps_an_explicit_o explicit: Final = RELOAD_WORKER_HEALTHCHECK_SECONDS + 7 assert pool_config(_options("app:app"), workers=2).timeout_worker_healthcheck == RELOAD_WORKER_HEALTHCHECK_SECONDS assert ( - pool_config({**_options("app:app"), "timeout_worker_healthcheck": explicit}, workers=2).timeout_worker_healthcheck + pool_config( + {**_options("app:app"), "timeout_worker_healthcheck": explicit}, workers=2 + ).timeout_worker_healthcheck == explicit ) @@ -142,7 +177,9 @@ def test_the_reload_pool_accepts_and_keeps_every_uvicorn_arg_the_proxy_cli_build def test_the_reload_pool_passes_a_uvicorn_arg_it_does_not_list_through_to_the_workers() -> None: - config: Final = pool_config(RELOAD_OPTIONS.validate_python({**_options("app:app"), "backlog": 123}), workers=POOL_WORKERS) + config: Final = pool_config( + RELOAD_OPTIONS.validate_python({**_options("app:app"), "backlog": 123}), workers=POOL_WORKERS + ) assert (config.backlog, config.reload) == (123, False) diff --git a/tests/unit/proxy/test_proxy_cli.py b/tests/unit/proxy/test_proxy_cli.py index 9a091959631..eec682f365f 100644 --- a/tests/unit/proxy/test_proxy_cli.py +++ b/tests/unit/proxy/test_proxy_cli.py @@ -34,6 +34,7 @@ from litellm._logging import turn_on_json from litellm.constants import DEFAULT_MAX_REQUEST_HEADER_SIZE, GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS from litellm.proxy import proxy_cli from litellm.proxy.proxy_cli import ProxyInitializationHelpers, run_server +from litellm.proxy.server_listener import AssignedPort, InheritedFd from litellm.proxy.shutdown.graceful_shutdown_manager import DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT @@ -201,42 +202,30 @@ class TestProxyInitializationHelpers: # Execute with pytest.raises(ValueError, match="Invalid test value"): - ProxyInitializationHelpers._run_test_chat_completion( - "localhost", 8000, "gpt-3.5-turbo", True - ) + ProxyInitializationHelpers._run_test_chat_completion("localhost", 8000, "gpt-3.5-turbo", True) # Test with valid string test value - ProxyInitializationHelpers._run_test_chat_completion( - "localhost", 8000, "gpt-3.5-turbo", "http://test-url" - ) + ProxyInitializationHelpers._run_test_chat_completion("localhost", 8000, "gpt-3.5-turbo", "http://test-url") # Assert - mock_openai.assert_called_once_with( - api_key="My API Key", base_url="http://test-url" - ) + mock_openai.assert_called_once_with(api_key="My API Key", base_url="http://test-url") mock_client.chat.completions.create.assert_called() def test_get_default_unvicorn_init_args(self): # Test without log_config - args = ProxyInitializationHelpers._get_default_unvicorn_init_args( - "localhost", 8000 - ) + args = ProxyInitializationHelpers._get_default_unvicorn_init_args("localhost", 8000) assert args["app"] == "litellm.proxy.proxy_server:app" assert args["host"] == "localhost" assert args["port"] == 8000 assert args["server_header"] is False # Test with log_config - args = ProxyInitializationHelpers._get_default_unvicorn_init_args( - "localhost", 8000, "log_config.json" - ) + args = ProxyInitializationHelpers._get_default_unvicorn_init_args("localhost", 8000, "log_config.json") assert args["log_config"] == "log_config.json" # Test with json_logs=True with patch("litellm.json_logs", True): - args = ProxyInitializationHelpers._get_default_unvicorn_init_args( - "localhost", 8000 - ) + args = ProxyInitializationHelpers._get_default_unvicorn_init_args("localhost", 8000) # When json_logs is True, log_config should be set to the JSON log config dict assert args["log_config"] is not None assert isinstance(args["log_config"], dict) @@ -244,15 +233,11 @@ class TestProxyInitializationHelpers: assert "formatters" in args["log_config"] # Test with keepalive_timeout - args = ProxyInitializationHelpers._get_default_unvicorn_init_args( - "localhost", 8000, None, 60 - ) + args = ProxyInitializationHelpers._get_default_unvicorn_init_args("localhost", 8000, None, 60) assert args["timeout_keep_alive"] == 60 # Test with both log_config and keepalive_timeout - args = ProxyInitializationHelpers._get_default_unvicorn_init_args( - "localhost", 8000, "log_config.json", 120 - ) + args = ProxyInitializationHelpers._get_default_unvicorn_init_args("localhost", 8000, "log_config.json", 120) assert args["log_config"] == "log_config.json" assert args["timeout_keep_alive"] == 120 @@ -383,14 +368,10 @@ class TestProxyInitializationHelpers: py_file = tmp_path / "module.py" py_file.write_text("x = 1\n") - applied = ProxyInitializationHelpers._patch_statreload_extra_paths( - [str(config_file)] - ) + applied = ProxyInitializationHelpers._patch_statreload_extra_paths([str(config_file)]) assert applied is True - fake_self = types.SimpleNamespace( - config=types.SimpleNamespace(reload_dirs=[tmp_path]) - ) + fake_self = types.SimpleNamespace(config=types.SimpleNamespace(reload_dirs=[tmp_path])) yielded_paths = {Path(p).resolve() for p in StatReload.iter_py_files(fake_self)} assert config_file.resolve() in yielded_paths @@ -407,14 +388,10 @@ class TestProxyInitializationHelpers: env_file = tmp_path / ".env" env_file.write_text("FOO=bar\n") - applied = ProxyInitializationHelpers._patch_statreload_extra_paths( - [str(env_file)] - ) + applied = ProxyInitializationHelpers._patch_statreload_extra_paths([str(env_file)]) assert applied is True - fake_self = types.SimpleNamespace( - config=types.SimpleNamespace(reload_dirs=[tmp_path]) - ) + fake_self = types.SimpleNamespace(config=types.SimpleNamespace(reload_dirs=[tmp_path])) yielded_paths = {Path(p).resolve() for p in StatReload.iter_py_files(fake_self)} assert env_file.resolve() in yielded_paths @@ -426,10 +403,7 @@ class TestProxyInitializationHelpers: StatReload._litellm_patched_config_paths.clear() assert ProxyInitializationHelpers._patch_statreload_extra_paths([]) is False - assert ( - ProxyInitializationHelpers._patch_statreload_extra_paths([None, ""]) - is False - ) + assert ProxyInitializationHelpers._patch_statreload_extra_paths([None, ""]) is False def test_patch_statreload_extra_paths_is_idempotent(self, tmp_path): from pathlib import Path @@ -447,18 +421,14 @@ class TestProxyInitializationHelpers: for _ in range(3): ProxyInitializationHelpers._patch_statreload_extra_paths([str(config_file)]) - fake_self = types.SimpleNamespace( - config=types.SimpleNamespace(reload_dirs=[tmp_path]) - ) + fake_self = types.SimpleNamespace(config=types.SimpleNamespace(reload_dirs=[tmp_path])) yielded = list(StatReload.iter_py_files(fake_self)) assert len(yielded) == len(set(map(str, yielded))) yielded_paths = {Path(p).resolve() for p in yielded} assert config_file.resolve() in yielded_paths assert py_file.resolve() in yielded_paths - def test_configure_dev_reload_watches_env_and_sets_override_flag( - self, tmp_path, monkeypatch - ): + def test_configure_dev_reload_watches_env_and_sets_override_flag(self, tmp_path, monkeypatch): from pathlib import Path from uvicorn.supervisors.statreload import StatReload @@ -475,9 +445,7 @@ class TestProxyInitializationHelpers: uvicorn_args: dict = {} with patch("litellm._logging.verbose_proxy_logger.warning") as mock_warning: - ProxyInitializationHelpers._configure_dev_reload( - uvicorn_args, str(config_file) - ) + ProxyInitializationHelpers._configure_dev_reload(uvicorn_args, str(config_file)) assert os.environ["LITELLM_DEV_ENV_HOT_RELOAD"] == "True" assert uvicorn_args["reload"] is True @@ -488,9 +456,7 @@ class TestProxyInitializationHelpers: assert "override" in warning_text assert ".env" in warning_text - fake_self = types.SimpleNamespace( - config=types.SimpleNamespace(reload_dirs=[tmp_path]) - ) + fake_self = types.SimpleNamespace(config=types.SimpleNamespace(reload_dirs=[tmp_path])) yielded_paths = {Path(p).resolve() for p in StatReload.iter_py_files(fake_self)} assert env_file.resolve() in yielded_paths assert config_file.resolve() in yielded_paths @@ -514,17 +480,13 @@ class TestProxyInitializationHelpers: mock_app = MagicMock() # Execute - ProxyInitializationHelpers._init_hypercorn_server( - mock_app, "localhost", 8000, None, None, None - ) + ProxyInitializationHelpers._init_hypercorn_server(mock_app, "localhost", 8000, None, None, None) # Assert mock_asyncio_run.assert_called_once() # Test with SSL - ProxyInitializationHelpers._init_hypercorn_server( - mock_app, "localhost", 8000, "cert.pem", "key.pem", "ECDHE" - ) + ProxyInitializationHelpers._init_hypercorn_server(mock_app, "localhost", 8000, "cert.pem", "key.pem", "ECDHE") @patch("granian.Granian") @patch("builtins.print") @@ -693,10 +655,14 @@ class TestProxyInitializationHelpers: database_name_enc = urllib.parse.quote_plus(database_name) # Construct DATABASE_URL from the provided variables - database_url = f"postgresql://{database_username_enc}:{database_password_enc}@{database_host}/{database_name_enc}" + database_url = ( + f"postgresql://{database_username_enc}:{database_password_enc}@{database_host}/{database_name_enc}" + ) # Assert the correct URL was constructed with properly escaped characters - expected_url = "postgresql://user%40with%2Bspecial:test-password-special-chars@localhost:5432/db_name%2Ftest" + expected_url = ( + "postgresql://user%40with%2Bspecial:test-password-special-chars@localhost:5432/db_name%2Ftest" + ) assert database_url == expected_url # Test appending query parameters @@ -714,9 +680,7 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("atexit.register") # critical @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_skip_server_startup( self, mock_should_update, mock_setup_db, mock_atexit_register, mock_uvicorn_run, tmp_path: Path ): @@ -734,11 +698,7 @@ class TestProxyInitializationHelpers: ) # Remove DATABASE_URL/DIRECT_URL so the CLI doesn't attempt # real prisma operations when these are set in CI. - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env["PROMETHEUS_MULTIPROC_DIR"] = str(tmp_path) live_proxy_samples = tmp_path / "counter_123.db" live_proxy_samples.write_bytes(b"samples of a proxy that is still running") @@ -758,9 +718,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -771,18 +729,12 @@ class TestProxyInitializationHelpers: # --- skip startup --- result = runner.invoke(run_server, ["--local", "--skip_server_startup"]) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" assert "Skipping server startup" in result.output mock_uvicorn_run.assert_not_called() - result = runner.invoke( - run_server, ["--local", "--skip_server_startup", "--telemetry", "False"] - ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + result = runner.invoke(run_server, ["--local", "--skip_server_startup", "--telemetry", "False"]) + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" assert "Skipping server startup" in result.output assert "telemetry" not in runner.invoke(run_server, ["--help"]).output assert live_proxy_samples.exists() @@ -792,9 +744,7 @@ class TestProxyInitializationHelpers: result = runner.invoke(run_server, ["--local"]) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_called_once() assert not live_proxy_samples.exists() @@ -831,9 +781,7 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_limit_concurrency_passed_to_uvicorn( self, mock_should_update, mock_setup_db, mock_atexit_register, mock_uvicorn_run ): @@ -850,11 +798,7 @@ class TestProxyInitializationHelpers: KeyManagementSettings=MagicMock(), save_worker_config=MagicMock(), ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -864,9 +808,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.side_effect = lambda *a, **k: { "app": "litellm.proxy.proxy_server:app", @@ -874,20 +816,14 @@ class TestProxyInitializationHelpers: "port": 8000, } - result = runner.invoke( - run_server, ["--local", "--limit_concurrency", "250"] - ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + result = runner.invoke(run_server, ["--local", "--limit_concurrency", "250"]) + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_called_once() assert mock_uvicorn_run.call_args.kwargs.get("limit_concurrency") == 250 mock_uvicorn_run.reset_mock() result = runner.invoke(run_server, ["--local"]) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_called_once() assert "limit_concurrency" not in mock_uvicorn_run.call_args.kwargs @@ -905,7 +841,9 @@ class TestProxyInitializationHelpers: @patch("httpx.HTTPTransport.handle_request") @patch("atexit.register") @patch("subprocess.Popen") - @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch( + "litellm.proxy.db.prisma_client.PrismaManager.setup_database" + ) # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above @patch( # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False ) @@ -943,9 +881,7 @@ class TestProxyInitializationHelpers: save_worker_config=MagicMock(), ) clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL", "PROMETHEUS_METRICS_PORT") + k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL", "PROMETHEUS_METRICS_PORT") } clean_env["PROMETHEUS_MULTIPROC_DIR"] = str(tmp_path) with ( @@ -971,9 +907,7 @@ class TestProxyInitializationHelpers: run_server, ["--local", "--host", "127.0.0.1", "--port", "4000", "--prometheus_metrics_port", "4001"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_popen.assert_called_once() spawned = list(mock_popen.call_args.args[0]) assert spawned[1:3] == ["-m", "litellm.proxy.prometheus_metrics_server"] @@ -996,9 +930,7 @@ class TestProxyInitializationHelpers: mock_popen.reset_mock() mock_uvicorn_run.reset_mock() result = runner.invoke(run_server, ["--local"]) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_popen.assert_not_called() mock_uvicorn_run.assert_called_once() @@ -1012,9 +944,7 @@ class TestProxyInitializationHelpers: mock_popen.assert_not_called() mock_uvicorn_run.assert_not_called() - result = runner.invoke( - run_server, ["--local", "--prometheus_metrics_port", "0"] - ) + result = runner.invoke(run_server, ["--local", "--prometheus_metrics_port", "0"]) assert result.exit_code == 2 assert "Invalid value for '--prometheus_metrics_port'" in result.output mock_popen.assert_not_called() @@ -1036,9 +966,7 @@ class TestProxyInitializationHelpers: @patch("subprocess.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_db_timeout_settings_are_forwarded_to_pool_timeout( self, mock_should_update, @@ -1071,11 +999,7 @@ class TestProxyInitializationHelpers: } ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), @@ -1086,9 +1010,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, patch( "litellm.proxy.proxy_cli.append_query_params", side_effect=lambda url, params: ( @@ -1107,9 +1029,7 @@ class TestProxyInitializationHelpers: ["--local", "--config", "test-config.yaml", "--skip_server_startup"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_append_query_params.assert_called() appended_params = mock_append_query_params.call_args.args[1] assert appended_params["connection_limit"] == 5 @@ -1164,9 +1084,7 @@ class TestProxyInitializationHelpers: @patch("subprocess.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_db_connection_extra_params_forwarded_to_url( self, mock_should_update, @@ -1201,11 +1119,7 @@ class TestProxyInitializationHelpers: } ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), @@ -1216,9 +1130,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, patch( "litellm.proxy.proxy_cli.append_query_params", side_effect=lambda url, params: str(url), @@ -1235,9 +1147,7 @@ class TestProxyInitializationHelpers: ["--local", "--config", "test-config.yaml", "--skip_server_startup"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_append_query_params.assert_called() appended_params = mock_append_query_params.call_args.args[1] assert appended_params["connect_timeout"] == 15 @@ -1288,9 +1198,7 @@ class TestProxyInitializationHelpers: @patch("subprocess.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_disable_prepared_statements_forwarded_to_url( self, mock_should_update, @@ -1322,11 +1230,7 @@ class TestProxyInitializationHelpers: } ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), @@ -1337,9 +1241,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, patch( "litellm.proxy.proxy_cli.append_query_params", side_effect=lambda url, params: str(url), @@ -1356,9 +1258,7 @@ class TestProxyInitializationHelpers: ["--local", "--config", "test-config.yaml", "--skip_server_startup"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_append_query_params.assert_called() appended_params = mock_append_query_params.call_args.args[1] if expect_pgbouncer: @@ -1380,9 +1280,7 @@ class TestProxyInitializationHelpers: @patch("subprocess.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_disable_prepared_statements_env_var_forwarded_to_url( self, mock_should_update, @@ -1430,9 +1328,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, patch( "litellm.proxy.proxy_cli.append_query_params", side_effect=lambda url, params: str(url), @@ -1449,9 +1345,7 @@ class TestProxyInitializationHelpers: ["--local", "--config", "test-config.yaml", "--skip_server_startup"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" appended_params = mock_append_query_params.call_args.args[1] if expect_pgbouncer: assert appended_params["pgbouncer"] == "true", appended_params @@ -1461,9 +1355,7 @@ class TestProxyInitializationHelpers: @patch("subprocess.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_malformed_disable_prepared_statements_env_var_is_rejected_even_when_config_enables_it( self, mock_should_update, @@ -1509,9 +1401,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, patch( "litellm.proxy.proxy_cli.append_query_params", side_effect=lambda url, params: str(url), @@ -1535,9 +1425,7 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) def test_proxy_default_api_version_uses_azure_default( self, mock_should_update, mock_setup_db, mock_atexit_register, mock_uvicorn_run ): @@ -1554,11 +1442,7 @@ class TestProxyInitializationHelpers: KeyManagementSettings=MagicMock(), save_worker_config=MagicMock(), ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -1568,9 +1452,7 @@ class TestProxyInitializationHelpers: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -1578,9 +1460,7 @@ class TestProxyInitializationHelpers: "port": 8000, } result = runner.invoke(run_server, ["--local", "--skip_server_startup"]) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_proxy_module.save_worker_config.assert_called_once() call_kwargs = mock_proxy_module.save_worker_config.call_args[1] assert call_kwargs["api_version"] == litellm.AZURE_DEFAULT_API_VERSION @@ -1588,12 +1468,8 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("builtins.print") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) - def test_keepalive_timeout_flag( - self, mock_should_update, mock_setup_db, mock_print, mock_uvicorn_run - ): + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) + def test_keepalive_timeout_flag(self, mock_should_update, mock_setup_db, mock_print, mock_uvicorn_run): """Test that the keepalive_timeout flag is properly passed to uvicorn""" from click.testing import CliRunner @@ -1610,11 +1486,7 @@ class TestProxyInitializationHelpers: # DB-setup block (un-timeout'd `subprocess.run(["prisma"])` + # migrate-deploy retry loop) — same isolation every other run_server # test in this file uses. - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), @@ -1629,9 +1501,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, patch( "litellm.proxy.proxy_cli.ProxyInitializationHelpers.is_port_in_use", return_value=False, @@ -1664,12 +1534,8 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("builtins.print") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - @patch( - "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False - ) - def test_timeout_worker_healthcheck_flag( - self, mock_should_update, mock_setup_db, mock_print, mock_uvicorn_run - ): + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) + def test_timeout_worker_healthcheck_flag(self, mock_should_update, mock_setup_db, mock_print, mock_uvicorn_run): """Test that the --timeout_worker_healthcheck flag is threaded through to the uvicorn init helper.""" from click.testing import CliRunner @@ -1686,11 +1552,7 @@ class TestProxyInitializationHelpers: # DB-setup block (un-timeout'd `subprocess.run(["prisma"])` + # migrate-deploy retry loop) — same isolation every other run_server # test in this file uses. - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), @@ -1705,9 +1567,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, patch( "litellm.proxy.proxy_cli.ProxyInitializationHelpers.is_port_in_use", return_value=False, @@ -1719,9 +1579,7 @@ class TestProxyInitializationHelpers: "port": 8000, } - result = runner.invoke( - run_server, ["--local", "--timeout_worker_healthcheck", "15"] - ) + result = runner.invoke(run_server, ["--local", "--timeout_worker_healthcheck", "15"]) assert result.exit_code == 0 mock_get_args.assert_called_once_with( @@ -1736,9 +1594,7 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("builtins.print") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - def test_max_requests_before_restart_flag( - self, mock_setup_db, mock_print, mock_uvicorn_run - ): + def test_max_requests_before_restart_flag(self, mock_setup_db, mock_print, mock_uvicorn_run): """Test that the max_requests_before_restart flag is passed to uvicorn as limit_max_requests""" from click.testing import CliRunner @@ -1751,11 +1607,7 @@ class TestProxyInitializationHelpers: mock_key_mgmt = MagicMock() mock_save_worker_config = MagicMock() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict( os.environ, @@ -1773,9 +1625,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -1783,13 +1633,9 @@ class TestProxyInitializationHelpers: "port": 8000, } - result = runner.invoke( - run_server, ["--local", "--max_requests_before_restart", "123"] - ) + result = runner.invoke(run_server, ["--local", "--max_requests_before_restart", "123"]) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_called_once() # Check that uvicorn.run was called with limit_max_requests parameter @@ -1799,9 +1645,7 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("builtins.print") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - def test_max_requests_before_restart_jitter_flag( - self, mock_setup_db, mock_print, mock_uvicorn_run - ): + def test_max_requests_before_restart_jitter_flag(self, mock_setup_db, mock_print, mock_uvicorn_run): """--max_requests_before_restart_jitter maps to uvicorn limit_max_requests_jitter""" from click.testing import CliRunner @@ -1812,11 +1656,7 @@ class TestProxyInitializationHelpers: pass runner = CliRunner() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch("uvicorn.Config", _NewUvicornConfig), @@ -1831,9 +1671,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -1852,9 +1690,7 @@ class TestProxyInitializationHelpers: ], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_called_once() call_args = mock_uvicorn_run.call_args assert call_args[1]["limit_max_requests"] == 1000 @@ -1873,11 +1709,7 @@ class TestProxyInitializationHelpers: from litellm.proxy.proxy_cli import run_server runner = CliRunner() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -1891,9 +1723,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -1913,9 +1743,7 @@ class TestProxyInitializationHelpers: ], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_not_called() mock_run_gunicorn.assert_called_once() g_kwargs = mock_run_gunicorn.call_args[1] @@ -1961,20 +1789,14 @@ class TestProxyInitializationHelpers: @patch("uvicorn.run") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") - def test_single_worker_max_requests_warning_printed_on_uvicorn( - self, mock_setup_db, mock_uvicorn_run - ): + def test_single_worker_max_requests_warning_printed_on_uvicorn(self, mock_setup_db, mock_uvicorn_run): """uvicorn single-worker startup prints the does-not-recycle warning""" from click.testing import CliRunner from litellm.proxy.proxy_cli import run_server runner = CliRunner() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -1988,9 +1810,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -1998,13 +1818,9 @@ class TestProxyInitializationHelpers: "port": 8000, } - result = runner.invoke( - run_server, ["--local", "--max_requests_before_restart", "123"] - ) + result = runner.invoke(run_server, ["--local", "--max_requests_before_restart", "123"]) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" assert "--max_requests_before_restart 123 with --num_workers 1" in result.output assert "uvicorn exits the process" in result.output @@ -2021,11 +1837,7 @@ class TestProxyInitializationHelpers: from litellm.proxy.proxy_cli import run_server runner = CliRunner() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -2039,9 +1851,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -2054,12 +1864,8 @@ class TestProxyInitializationHelpers: ["--local", "--num_workers", "2", "--max_requests_before_restart", "123"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" - assert not any( - "does not recycle a worker" in str(c) for c in mock_print.call_args_list - ) + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" + assert not any("does not recycle a worker" in str(c) for c in mock_print.call_args_list) @patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.run_gunicorn_server") @patch("uvicorn.run") @@ -2073,11 +1879,7 @@ class TestProxyInitializationHelpers: from litellm.proxy.proxy_cli import run_server runner = CliRunner() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -2091,9 +1893,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -2106,9 +1906,7 @@ class TestProxyInitializationHelpers: ["--local", "--run_gunicorn", "--max_requests_before_restart", "123"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" assert "--max_requests_before_restart 123 with --num_workers 1" in result.output assert "gunicorn accepts no connections" in result.output @@ -2125,11 +1923,7 @@ class TestProxyInitializationHelpers: from litellm.proxy.proxy_cli import run_server runner = CliRunner() - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} with ( patch.dict(os.environ, clean_env, clear=True), patch.dict( @@ -2143,9 +1937,7 @@ class TestProxyInitializationHelpers: ) }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -2158,13 +1950,9 @@ class TestProxyInitializationHelpers: ["--local", "--run_hypercorn", "--max_requests_before_restart", "5"], ) - assert ( - result.exit_code == 0 - ), f"exit_code={result.exit_code}, output={result.output}" + assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_init_hypercorn.assert_called_once() - assert not any( - "does not recycle a worker" in str(c) for c in mock_print.call_args_list - ) + assert not any("does not recycle a worker" in str(c) for c in mock_print.call_args_list) @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") def test_gunicorn_options_include_max_requests_jitter(self): @@ -2244,6 +2032,89 @@ class TestProxyInitializationHelpers: assert worker.config.h11_max_incomplete_event_size == max_request_header_size assert captured["options"]["graceful_timeout"] == 8 + GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS + @staticmethod + def _gunicorn_application(**listener: object): + captured: Final[list] = [] + + def capture_run(application) -> None: + captured.append(application) + + with ( + patch("gunicorn.app.base.BaseApplication.run", capture_run), + patch( # test-quality-ok: option tests must not start a thread or change the pytest worker's child ownership + "litellm.proxy.proxy_cli.start_query_engine_reaper" + ), + ): + ProxyInitializationHelpers._run_gunicorn_server( + host="0.0.0.0", + port=4012, + app=MagicMock(), + num_workers=2, + ssl_certfile_path=None, + ssl_keyfile_path=None, + **listener, + ) + return captured[0] + + @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") + def test_gunicorn_serving_clients_directly_keeps_its_host_port_and_its_own_trusted_proxies(self): + pytest.importorskip("gunicorn") + import gunicorn.config + + config: Final = self._gunicorn_application().cfg + assert (config.address, config.forwarded_allow_ips) == ( + [("0.0.0.0", 4012)], + gunicorn.config.Config().forwarded_allow_ips, + ) + + @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") + def test_gunicorn_listens_on_the_socket_it_is_handed_instead_of_its_host_and_port(self): + pytest.importorskip("gunicorn") + import socket + + import gunicorn.glogging + import gunicorn.sock + + with socket.socket() as handed: + handed.bind(("127.0.0.1", 0)) + handed.listen() + config: Final = self._gunicorn_application(private_listener=InheritedFd(os.dup(handed.fileno()))).cfg + listeners: Final = gunicorn.sock.create_sockets(config, gunicorn.glogging.Logger(config)) + try: + assert [listener.getsockname() for listener in listeners] == [handed.getsockname()] + finally: + gunicorn.sock.close_sockets(listeners, unlink=False) + + @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") + def test_gunicorn_workers_on_a_private_listener_ignore_a_forwarded_client(self, monkeypatch): + pytest.importorskip("gunicorn") + import gunicorn.config + import gunicorn.glogging + + for name in ("uvicorn.error", "uvicorn.access"): + uvicorn_logger = logging.getLogger(name) + for attr in ("handlers", "level", "propagate"): + monkeypatch.setattr(uvicorn_logger, attr, getattr(uvicorn_logger, attr)) + application: Final = self._gunicorn_application(private_listener=AssignedPort(45556)) + worker: Final = application.options["worker_class"]( + 0, os.getpid(), [], MagicMock(), 30, application.cfg, gunicorn.glogging.Logger(gunicorn.config.Config()) + ) + seen: Final[list[str]] = [] + + async def probe(scope, receive, send) -> None: + seen.append(scope["client"][0]) + + worker.config.app = probe + worker.config.load() + request_from_loopback: Final = { + "type": "http", + "client": ("127.0.0.1", 51234), + "headers": [(b"x-forwarded-for", b"203.0.113.9")], + } + asyncio.run(worker.config.loaded_app(request_from_loopback, AsyncMock(), AsyncMock())) + + assert seen == ["127.0.0.1"] + @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") def test_gunicorn_master_is_reserved_for_forking_before_it_runs(self, fork_reservation): """preload forks workers from the master, so native routes are forbidden there first""" @@ -2370,7 +2241,9 @@ class TestProxyInitializationHelpers: with patch.dict(os.environ, test_env_special): result = construct_database_url_from_env_vars() - expected_url = "postgresql://user%40with%2Bspecial:test-password-special-chars@localhost:5432/db_name%2Ftest" + expected_url = ( + "postgresql://user%40with%2Bspecial:test-password-special-chars@localhost:5432/db_name%2Ftest" + ) assert result == expected_url # Test without password (should still work) @@ -2515,12 +2388,8 @@ class TestQueryEngineReaperWiring: }, ), patch("uvicorn.run") as mock_uvicorn_run, - patch( - "litellm.proxy.proxy_cli.start_query_engine_reaper" - ) as mock_start_reaper, - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.start_query_engine_reaper") as mock_start_reaper, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -2531,17 +2400,13 @@ class TestQueryEngineReaperWiring: return result, mock_uvicorn_run, mock_start_reaper def test_multi_worker_uvicorn_starts_reaper(self): - result, mock_uvicorn_run, mock_start_reaper = self._invoke_run_server( - ["--local", "--num_workers", "2"] - ) + result, mock_uvicorn_run, mock_start_reaper = self._invoke_run_server(["--local", "--num_workers", "2"]) assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_called_once() mock_start_reaper.assert_called_once() def test_single_worker_uvicorn_does_not_start_reaper(self): - result, mock_uvicorn_run, mock_start_reaper = self._invoke_run_server( - ["--local", "--num_workers", "1"] - ) + result, mock_uvicorn_run, mock_start_reaper = self._invoke_run_server(["--local", "--num_workers", "1"]) assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" mock_uvicorn_run.assert_called_once() mock_start_reaper.assert_not_called() @@ -2566,9 +2431,7 @@ class TestQueryEngineReaperWiring: with ( patch("gunicorn.app.base.BaseApplication.run"), - patch( - "litellm.proxy.proxy_cli.start_query_engine_reaper" - ) as mock_start_reaper, + patch("litellm.proxy.proxy_cli.start_query_engine_reaper") as mock_start_reaper, ): ProxyInitializationHelpers._run_gunicorn_server( host="127.0.0.1", @@ -2616,11 +2479,7 @@ class TestRunServerDbSetup: save_worker_config=MagicMock(), ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test" with ( @@ -2632,9 +2491,7 @@ class TestRunServerDbSetup: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -2665,10 +2522,18 @@ class TestRunServerDbSetup: mock_setup_database.assert_called_with(use_migrate=False, use_v2_resolver=True) @patch("atexit.register") - @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above - @patch("litellm.proxy.db.prisma_client.PrismaManager.build_request_log_indexes") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above - @patch("litellm.proxy.db.check_migration.check_prisma_schema_diff") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above - @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch( + "litellm.proxy.db.prisma_client.PrismaManager.setup_database" + ) # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch( + "litellm.proxy.db.prisma_client.PrismaManager.build_request_log_indexes" + ) # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch( + "litellm.proxy.db.check_migration.check_prisma_schema_diff" + ) # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch( + "litellm.proxy.db.prisma_client.should_update_prisma_schema" + ) # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above def test_migrations_run_when_the_prisma_cli_is_not_on_path( self, mock_should_update_schema, @@ -2692,11 +2557,7 @@ class TestRunServerDbSetup: save_worker_config=MagicMock(), ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test" clean_env["PATH"] = str(empty_bin) @@ -2856,11 +2717,7 @@ class TestRunServerDbSetup: save_worker_config=MagicMock(), ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test" with ( @@ -2872,9 +2729,7 @@ class TestRunServerDbSetup: "litellm.proxy.proxy_server": mock_proxy_module, }, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -2917,11 +2772,7 @@ class TestRunServerDbSetup: save_worker_config=MagicMock(), ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env["DATABASE_URL"] = "sqlite:///data/litellm.db" with ( @@ -2935,9 +2786,7 @@ class TestRunServerDbSetup: ), ): with pytest.raises(SystemExit) as exc_info: - run_server.main( - ["--local", "--skip_server_startup"], standalone_mode=False - ) + run_server.main(["--local", "--skip_server_startup"], standalone_mode=False) assert exc_info.value.code == 1 mock_setup_database.assert_not_called() err: Final = capsys.readouterr().err @@ -2979,11 +2828,7 @@ class TestRunServerDbSetup: save_worker_config=MagicMock(), ) - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test" clean_env["USE_V2_MIGRATION_RESOLVER"] = "true" @@ -2997,9 +2842,7 @@ class TestRunServerDbSetup: }, ), ): - run_server.main( - ["--local", "--skip_server_startup"], standalone_mode=False - ) + run_server.main(["--local", "--skip_server_startup"], standalone_mode=False) mock_setup_database.assert_called_once_with(use_migrate=True, use_v2_resolver=True) assert "--use_v2_migration_resolver is deprecated" not in capsys.readouterr().out @@ -3042,9 +2885,7 @@ class TestRunServerDbSetup: {"proxy_server": mock_proxy_module, "litellm.proxy.proxy_server": mock_proxy_module}, ), ): - run_server.main( - ["--local", "--skip_server_startup", "--use_v2_migration_resolver"], standalone_mode=False - ) + run_server.main(["--local", "--skip_server_startup", "--use_v2_migration_resolver"], standalone_mode=False) mock_setup_database.assert_called_once_with(use_migrate=True, use_v2_resolver=True) assert "--use_v2_migration_resolver is deprecated and has no effect" in capsys.readouterr().out @@ -3181,12 +3022,7 @@ class TestRunServerDbSetup: def test_resolve_v2_migration_resolver(self, use_legacy_flag, env_value, expected): from litellm.proxy.proxy_cli import resolve_v2_migration_resolver - assert ( - resolve_v2_migration_resolver( - use_legacy_flag=use_legacy_flag, env_value=env_value - ) - is expected - ) + assert resolve_v2_migration_resolver(use_legacy_flag=use_legacy_flag, env_value=env_value) is expected def test_deprecated_v2_flag_not_reported_outside_a_cli_invocation(self): from litellm.proxy.proxy_cli import deprecated_v2_flag_passed_on_cli @@ -3227,9 +3063,7 @@ class TestRunServerDbSetup: ) clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL", "USE_V2_MIGRATION_RESOLVER") + k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL", "USE_V2_MIGRATION_RESOLVER") } clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test" @@ -3310,9 +3144,7 @@ class TestRunServerDbSetup: "sys.modules", {"proxy_server": mock_proxy_module, "litellm.proxy.proxy_server": mock_proxy_module}, ), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, outcome as exc_info, ): mock_get_args.return_value = {"app": "litellm.proxy.proxy_server:app", "host": "localhost", "port": 8000} @@ -3364,11 +3196,7 @@ class TestWorkerStartupHooks: "LITELLM_WORKER_STARTUP_HOOKS": "tests.unit.proxy.test_proxy_cli:_dummy_hook", } # Remove DATABASE_URL to avoid real DB setup - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env.update(env_overrides) with patch.dict(os.environ, clean_env, clear=True): @@ -3391,11 +3219,7 @@ class TestWorkerStartupHooks: env_overrides = { "LITELLM_WORKER_STARTUP_HOOKS": "tests.unit.proxy.test_proxy_cli:_dummy_async_hook", } - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env.update(env_overrides) with patch.dict(os.environ, clean_env, clear=True): @@ -3415,11 +3239,7 @@ class TestWorkerStartupHooks: env_overrides = { "LITELLM_WORKER_STARTUP_HOOKS": "tests.unit.proxy.test_proxy_cli:_failing_hook", } - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env.update(env_overrides) with patch.dict(os.environ, clean_env, clear=True): @@ -3448,18 +3268,11 @@ class TestWorkerStartupHooks: from litellm.proxy.proxy_server import proxy_startup_event - hooks = ( - "tests.unit.proxy.test_proxy_cli:_dummy_hook," - "tests.unit.proxy.test_proxy_cli:_dummy_async_hook" - ) + hooks = "tests.unit.proxy.test_proxy_cli:_dummy_hook,tests.unit.proxy.test_proxy_cli:_dummy_async_hook" env_overrides = { "LITELLM_WORKER_STARTUP_HOOKS": hooks, } - clean_env = { - k: v - for k, v in os.environ.items() - if k not in ("DATABASE_URL", "DIRECT_URL") - } + clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")} clean_env.update(env_overrides) with patch.dict(os.environ, clean_env, clear=True): @@ -3605,13 +3418,9 @@ class TestPostgresStatementTimeoutOptions: import yaml config_path = tmp_path / "config.yaml" - config_path.write_text( - yaml.dump({"model_list": [], "general_settings": {"database_statement_timeout": 60}}) - ) + config_path.write_text(yaml.dump({"model_list": [], "general_settings": {"database_statement_timeout": 60}})) - captured = _run_server_and_capture_urls( - str(config_path), direct_url="postgresql://t:t@localhost:5432/t" - ) + captured = _run_server_and_capture_urls(str(config_path), direct_url="postgresql://t:t@localhost:5432/t") assert "options" in urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query) assert "options" not in urlparse.parse_qs(urlparse.urlparse(captured["DIRECT_URL"]).query) @@ -3783,9 +3592,7 @@ class TestReadReplicaConnectionParams: import yaml config_path: Final = tmp_path / "config.yaml" - config_path.write_text( - yaml.dump({"model_list": [], "general_settings": {"database_connection_pool_limit": 3}}) - ) + config_path.write_text(yaml.dump({"model_list": [], "general_settings": {"database_connection_pool_limit": 3}})) _run_server_and_capture_urls( str(config_path), @@ -3805,9 +3612,7 @@ class TestReadReplicaConnectionParams: import yaml config_path: Final = tmp_path / "config.yaml" - config_path.write_text( - yaml.dump({"model_list": [], "general_settings": {"database_connection_pool_limit": 3}}) - ) + config_path.write_text(yaml.dump({"model_list": [], "general_settings": {"database_connection_pool_limit": 3}})) _run_server_and_capture_urls( str(config_path), @@ -4002,9 +3807,7 @@ class TestTokenAuthCliFlags: patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False), patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database"), patch("uvicorn.run"), - patch( - "litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args" - ) as mock_get_args, + patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers.get_default_unvicorn_init_args") as mock_get_args, ): mock_get_args.return_value = { "app": "litellm.proxy.proxy_server:app", @@ -4018,9 +3821,7 @@ class TestTokenAuthCliFlags: return result, database_url, toggle def test_azure_flag_assembles_a_token_bearing_database_url(self): - result, database_url, toggle = self._invoke_with_azure_host( - ["--local", "--azure_postgresql_auth"] - ) + result, database_url, toggle = self._invoke_with_azure_host(["--local", "--azure_postgresql_auth"]) assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" assert database_url is not None @@ -4248,7 +4049,11 @@ def probe_logger() -> Iterator[logging.Logger]: def _log_config_source(kind: str, tmp_path: Path) -> str | dict[str, object]: - config: Final[dict[str, object]] = {"version": 1, "incremental": True, "loggers": {_PROBE_LOGGER: {"level": "ERROR"}}} + config: Final[dict[str, object]] = { + "version": 1, + "incremental": True, + "loggers": {_PROBE_LOGGER: {"level": "ERROR"}}, + } if kind == "dict": return config written: Final = tmp_path / f"log_config.{kind}" diff --git a/tests/unit/proxy/test_server_listener.py b/tests/unit/proxy/test_server_listener.py new file mode 100644 index 00000000000..ec2f7a44ad4 --- /dev/null +++ b/tests/unit/proxy/test_server_listener.py @@ -0,0 +1,28 @@ +import os +from typing import Final + +import pytest + +from litellm.proxy.server_listener import AssignedPort, InheritedFd, PrivateListener, gunicorn_overrides + + +def test_gunicorn_keeps_its_own_listener_and_trust_without_a_private_listener() -> None: + assert dict(gunicorn_overrides(None)) == {} + + +@pytest.mark.skipif(os.name == "nt", reason="gunicorn does not run on Windows") +@pytest.mark.parametrize( + ("listener", "address"), + [ + (InheritedFd(17), 17), + (AssignedPort(45556), ("127.0.0.1", 45556)), + ], +) +def test_gunicorn_serves_a_private_listener_and_trusts_no_peer_to_name_the_client( + listener: PrivateListener, address: int | tuple[str, int] +) -> None: + gunicorn_config: Final = pytest.importorskip("gunicorn.config").Config() + gunicorn_config.set("forwarded_allow_ips", "*") + for name, value in gunicorn_overrides(listener).items(): + gunicorn_config.set(name, value) + assert (gunicorn_config.address, gunicorn_config.forwarded_allow_ips) == ([address], []) diff --git a/tests/unit/rust_bridge/test_configuration.py b/tests/unit/rust_bridge/test_configuration.py index 8116cf98076..70223421471 100644 --- a/tests/unit/rust_bridge/test_configuration.py +++ b/tests/unit/rust_bridge/test_configuration.py @@ -106,8 +106,10 @@ def test_process_override_applies_when_environment_is_unset() -> None: assert configuration.rust_enabled() is True -@pytest.mark.parametrize("value", ("", " ", "sometimes", "2")) -def test_invalid_environment_value_is_ignored(monkeypatch: pytest.MonkeyPatch, value: str) -> None: +@pytest.mark.parametrize("value", ("", " ", "sometimes", "2", "enabled")) +def test_blank_or_invalid_environment_value_leaves_the_switch_unset( + monkeypatch: pytest.MonkeyPatch, value: str +) -> None: monkeypatch.setenv("LITELLM_RUST", value) assert configuration.rust_enabled() is False @@ -116,6 +118,13 @@ def test_invalid_environment_value_is_ignored(monkeypatch: pytest.MonkeyPatch, v assert configuration.rust_enabled() is True +def test_environment_is_read_on_every_decision(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_RUST", "1") + assert configuration.rust_enabled() is True + monkeypatch.setenv("LITELLM_RUST", "0") + assert configuration.rust_enabled() is False + + def test_process_override_and_reset_apply_to_existing_threads() -> None: with ThreadPoolExecutor(max_workers=1) as executor: assert executor.submit(configuration.rust_enabled).result() is False From 95ecfbbac60091dfb665e73c5f0eb7ef8ed899d7 Mon Sep 17 00:00:00 2001 From: yujonglee Date: Thu, 12 Nov 2026 04:40:00 +0900 Subject: [PATCH 76/86] feat(proxy): supervise the opt-in Rust forwarding server (#49606) * feat(proxy): supervise the opt-in Rust forwarding server With LITELLM_RUST on, `litellm` and `python -m gateway.launch` start a supervisor that runs the Rust gateway on the public port and Python on a private loopback listener. Python still serves every route and sees each request as if the client had connected to it directly. Rust sends the client's TCP peer in `x-litellm-rust-peer`. The supervised server turns server-level proxy headers off, and a middleware restores the peer and then applies the operator's own trust, so client IPs, trusted proxies and every IP-based setting behave the same with Rust on or off. The supervisor is transparent to Python. Signals pass through, Python's exit status is the result, and both children shut down when the supervisor's end of their stdin closes, so nothing outlives it. Rust starts only once Python listens. When forwarding cannot start (no gateway binary, TLS, a Unix socket, Hypercorn or Granian), the entry point warns and serves from Python alone. Every forwarding decision lives in litellm/proxy/rust_forward, paired with the gateway-forward crate. OS differences sit behind a Platform protocol, with Windows supported for local CLI runs. AGENTS.md files in the touched folders state the boundary. The `--rust_gateway` flag is gone in favor of the environment variable. * refactor(proxy): switch Rust forwarding on with LITELLM_RUST_FIRST Forwarding gets its own switch, independent of LITELLM_RUST, which keeps selecting only the Rust paths inside the SDK. Neither turns the other on. --------- Co-authored-by: narmstrong-berri --- .github/workflows/test-unit.yml | 1 + gateway/AGENTS.md | 16 + gateway/launch.py | 81 +++- litellm-rust/crates/gateway-forward/AGENTS.md | 3 + .../crates/gateway-forward/Cargo.toml | 2 +- .../crates/gateway-forward/src/input.rs | 29 ++ .../crates/gateway-forward/src/lib.rs | 2 + .../crates/gateway-forward/tests/lifecycle.rs | 53 ++- .../gateway-forward/tests/support/mod.rs | 29 +- litellm-rust/crates/gateway/src/main.rs | 6 +- litellm/proxy/AGENTS.md | 5 + litellm/proxy/proxy_cli.py | 116 +++++- litellm/proxy/rust_forward/AGENTS.md | 48 +++ litellm/proxy/rust_forward/__init__.py | 0 litellm/proxy/rust_forward/mode.py | 142 +++++++ litellm/proxy/rust_forward/peer.py | 69 ++++ litellm/proxy/rust_forward/platform/AGENTS.md | 19 + .../proxy/rust_forward/platform/__init__.py | 58 +++ litellm/proxy/rust_forward/platform/posix.py | 64 +++ .../proxy/rust_forward/platform/windows.py | 68 +++ litellm/proxy/rust_forward/server.py | 75 ++++ .../proxy/rust_forward/supervisor/AGENTS.md | 21 + .../proxy/rust_forward/supervisor/__init__.py | 55 +++ .../proxy/rust_forward/supervisor/children.py | 43 ++ .../rust_forward/supervisor/lifecycle.py | 138 +++++++ .../rust_forward/supervisor/preflight.py | 28 ++ .../proxy/rust_forward/supervisor/runtime.py | 194 +++++++++ litellm/rust_bridge/AGENTS.md | 5 + litellm_cli/main.py | 8 +- tests/integration/_support/process.py | 5 + tests/integration/_support/rust_forward.py | 117 ++++++ .../configuration/test_rust_forward.py | 110 +++++ .../test_rust_forward_lifecycle.py | 269 ++++++++++++ .../test_rust_forward_traffic.py | 387 ++++++++++++++++++ tests/unit/gateway/test_launch.py | 160 +++++++- tests/unit/proxy/rust_forward/__init__.py | 0 .../proxy/rust_forward/supervisor/__init__.py | 0 .../proxy/rust_forward/supervisor/_support.py | 7 + .../rust_forward/supervisor/test_children.py | 54 +++ .../rust_forward/supervisor/test_lifecycle.py | 158 +++++++ .../rust_forward/supervisor/test_preflight.py | 77 ++++ .../rust_forward/supervisor/test_runtime.py | 189 +++++++++ tests/unit/proxy/rust_forward/test_mode.py | 127 ++++++ tests/unit/proxy/rust_forward/test_peer.py | 135 ++++++ tests/unit/proxy/test_proxy_cli.py | 155 ++++++- tests/windows_tests/AGENTS.md | 7 + .../test_rust_forward_platform.py | 35 ++ 47 files changed, 3344 insertions(+), 26 deletions(-) create mode 100644 gateway/AGENTS.md create mode 100644 litellm-rust/crates/gateway-forward/src/input.rs create mode 100644 litellm/proxy/AGENTS.md create mode 100644 litellm/proxy/rust_forward/AGENTS.md create mode 100644 litellm/proxy/rust_forward/__init__.py create mode 100644 litellm/proxy/rust_forward/mode.py create mode 100644 litellm/proxy/rust_forward/peer.py create mode 100644 litellm/proxy/rust_forward/platform/AGENTS.md create mode 100644 litellm/proxy/rust_forward/platform/__init__.py create mode 100644 litellm/proxy/rust_forward/platform/posix.py create mode 100644 litellm/proxy/rust_forward/platform/windows.py create mode 100644 litellm/proxy/rust_forward/server.py create mode 100644 litellm/proxy/rust_forward/supervisor/AGENTS.md create mode 100644 litellm/proxy/rust_forward/supervisor/__init__.py create mode 100644 litellm/proxy/rust_forward/supervisor/children.py create mode 100644 litellm/proxy/rust_forward/supervisor/lifecycle.py create mode 100644 litellm/proxy/rust_forward/supervisor/preflight.py create mode 100644 litellm/proxy/rust_forward/supervisor/runtime.py create mode 100644 tests/integration/_support/rust_forward.py create mode 100644 tests/integration/configuration/test_rust_forward.py create mode 100644 tests/integration/configuration/test_rust_forward_lifecycle.py create mode 100644 tests/integration/configuration/test_rust_forward_traffic.py create mode 100644 tests/unit/proxy/rust_forward/__init__.py create mode 100644 tests/unit/proxy/rust_forward/supervisor/__init__.py create mode 100644 tests/unit/proxy/rust_forward/supervisor/_support.py create mode 100644 tests/unit/proxy/rust_forward/supervisor/test_children.py create mode 100644 tests/unit/proxy/rust_forward/supervisor/test_lifecycle.py create mode 100644 tests/unit/proxy/rust_forward/supervisor/test_preflight.py create mode 100644 tests/unit/proxy/rust_forward/supervisor/test_runtime.py create mode 100644 tests/unit/proxy/rust_forward/test_mode.py create mode 100644 tests/unit/proxy/rust_forward/test_peer.py create mode 100644 tests/windows_tests/AGENTS.md create mode 100644 tests/windows_tests/test_rust_forward_platform.py diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index f8b93b5cc11..d0726e0daa0 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -591,6 +591,7 @@ jobs: tests/unit/proxy/credential_endpoints tests/unit/proxy/discovery_endpoints tests/unit/proxy/shutdown + tests/unit/proxy/rust_forward tests/unit/proxy/public_endpoints tests/unit/proxy/prompts tests/unit/proxy/rag_endpoints diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md new file mode 100644 index 00000000000..1bb4013179f --- /dev/null +++ b/gateway/AGENTS.md @@ -0,0 +1,16 @@ +# Gateway image entry point + +`python -m gateway.launch` is how the gateway image starts. `launch.py` hands its arguments to uvicorn's own CLI and serves `main.py`, which trims the proxy app to the LLM data plane + +- Importing `launch.py` stays cheap. Every uvicorn worker imports it again before it can answer its first health ping, so LiteLLM imports wait until `main` or `worker_app` runs +- PgBouncer starts exactly once per pod, before the workers start, and the workers inherit the loopback URL through `DATABASE_URL`. `configure_database` is the only place that does this + +## Rust forwarding + +`launch.py` is an entry point of Rust forwarding, and every forwarding decision lives in `litellm/proxy/rust_forward/`, whose `AGENTS.md` states the contract + +- `main` dispatches on `rust_forward_mode` and does nothing else about forwarding. With forwarding off or refused it serves exactly as before +- `python_server` reads the flags and environment through uvicorn's own CLI parser, so Rust gets the values uvicorn would run with. Never reparse a uvicorn flag by hand +- Under forwarding the supervisor process runs `configure_database`, as `before_start`, once forwarding is known to be possible. The supervised child never runs it and inherits the exported URL +- The supervised child appends `mode.uvicorn_flags` after the operator's flags, so the private listener and `--no-proxy-headers` win +- `worker_app` wraps the app with `peer.wrap`, which is the identity when forwarding is off diff --git a/gateway/launch.py b/gateway/launch.py index b9303fd9447..c8b5b53af92 100644 --- a/gateway/launch.py +++ b/gateway/launch.py @@ -22,15 +22,16 @@ import sys from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING, Final +from typing_extensions import assert_never from uvicorn.main import main as uvicorn_main from litellm_cli.main import without_gc_pauses if TYPE_CHECKING: - from fastapi import FastAPI - from litellm.proxy.db.db_url_settings import DatabaseURLSettings from litellm.proxy.db.pgbouncer import PgBouncerError, PgBouncerSettings + from litellm.proxy.rust_forward.peer import ASGIApp + from litellm.proxy.rust_forward.server import PythonServer GATEWAY_APP: Final = "gateway.launch:worker_app" KEEPALIVE_FLAG: Final = "--timeout-keep-alive" @@ -65,16 +66,39 @@ def pool_database_url( return start_in_container_pgbouncer(pgbouncer, upstream_url, token_auth=settings.token_auth()) -def worker_app() -> "FastAPI": +def worker_app() -> "ASGIApp": with without_gc_pauses(): from gateway.main import app - return app + from litellm.proxy.rust_forward.mode import rust_forward_mode + from litellm.proxy.rust_forward.peer import wrap + return wrap(app, rust_forward_mode(os.environ)) def _serve(argv: Sequence[str]) -> None: uvicorn_main(tuple(argv), prog_name="uvicorn") +def python_server(argv: Sequence[str]) -> "PythonServer": + """What uvicorn would serve the public port with, read from the same flags and environment it reads.""" + from litellm.proxy.rust_forward.server import PythonServer, Server, UvicornCliParams + from litellm.proxy.shutdown.graceful_shutdown_manager import GracefulShutdownManager + + params: Final = UvicornCliParams.model_validate( + uvicorn_main.make_context("uvicorn", list(uvicorn_argv(argv, os.environ))).params + ) + return PythonServer( + server=Server.UVICORN, + listener=params.listener, + host=params.host, + port=params.port, + keepalive_seconds=params.timeout_keep_alive, + graceful_timeout_seconds=GracefulShutdownManager.get_timeout() + if params.timeout_graceful_shutdown is None + else params.timeout_graceful_shutdown, + trusted_proxies=params.trusted_proxies, + ) + + def configure_database() -> None: from litellm.proxy.db.db_url_settings import DatabaseURLSettings from litellm.proxy.db.pgbouncer import PgBouncerError, PgBouncerSettings, export_pooled_database_url @@ -88,10 +112,57 @@ def configure_database() -> None: export_pooled_database_url(pooled_url) -def main(argv: Sequence[str], serve: Callable[[Sequence[str]], None] = _serve) -> None: +def _serve_python_alone(argv: Sequence[str], serve: Callable[[Sequence[str]], None]) -> None: configure_database() serve(uvicorn_argv(argv, os.environ)) +def _python_server_or_exit(argv: Sequence[str]) -> "PythonServer": + """Report ``--help``, ``--version`` and a bad flag the way uvicorn's own CLI does, since it never gets to.""" + import click + + try: + return python_server(argv) + except click.exceptions.Exit as stopped: + sys.exit(stopped.exit_code) + except click.ClickException as error: + error.show() + sys.exit(error.exit_code) + + +def main(argv: Sequence[str], serve: Callable[[Sequence[str]], None] = _serve) -> None: + from pydantic import ValidationError + + from litellm.proxy.rust_forward.mode import ( + Mode, + Supervised, + environment_error, + rust_forward_mode, + stop_with_supervisor, + uvicorn_flags, + ) + from litellm.proxy.rust_forward.supervisor import supervise + from litellm.proxy.rust_forward.supervisor.preflight import StartupError + + try: + mode: Final = rust_forward_mode(os.environ) + except ValidationError as error: + sys.exit(environment_error(error)) + match mode: + case Mode.DISABLED: + _serve_python_alone(argv, serve) + case Mode.SUPERVISOR: + supervised: Final = supervise(_python_server_or_exit(argv), before_start=configure_database) + if isinstance(supervised, StartupError): + _serve_python_alone(argv, serve) + else: + sys.exit(supervised) + case Supervised(): + stop_with_supervisor(mode) + serve((*uvicorn_argv(argv, os.environ), *uvicorn_flags(mode))) + case _: + assert_never(mode) + + if __name__ == "__main__": main(sys.argv[1:]) diff --git a/litellm-rust/crates/gateway-forward/AGENTS.md b/litellm-rust/crates/gateway-forward/AGENTS.md index 2135980266f..3d07f9ee342 100644 --- a/litellm-rust/crates/gateway-forward/AGENTS.md +++ b/litellm-rust/crates/gateway-forward/AGENTS.md @@ -13,6 +13,7 @@ - Pooled upstream connections are retired well before Python's keep-alive timeout, so a request never lands on a connection Python is closing - Idle client connections close after Python's keep-alive timeout, as uvicorn closed them, so a client that vanishes cannot hold a socket forever. Only the wait for the next request is timed, never an exchange in progress - WebSocket is the one protocol switch Python serves. Upgrades are relayed as raw bytes after the 101 and drained on shutdown +- Drain on EOF of stdin exactly as on SIGTERM. The supervisor holds the only write end, so EOF means it is gone and nobody is left to stop the gateway. `input_closed` reads on a plain thread, since a read blocked on tokio's blocking pool would hold up the runtime's exit ## Layout @@ -22,6 +23,8 @@ Every forwarding decision has one home. `rewrite.rs` owns what Python and the cl `Settings` reads only these variables, and only when `LITELLM_FORWARD_UPSTREAM` is set. The Python supervisor in `litellm/proxy/rust_forward/` sets every one of them from the values Python actually runs with, so all of them are required and a missing or invalid one fails startup naming it. The crate never reads Python's own settings (`HOST`, `PORT`, `KEEPALIVE_TIMEOUT`, `GRACEFUL_SHUTDOWN_TIMEOUT`) and keeps no copy of Python's defaults, since Python can get those values from CLI flags the environment never shows +Only the Python supervisor starts the gateway in forwarding mode, from `litellm` and from `python -m gateway.launch`, on a laptop and in every image. `supervisor/children.py` is the only writer of these variables, so adding, renaming or removing one changes `settings.rs` and `children.rust_spec` in the same change. Without `LITELLM_FORWARD_UPSTREAM` the binary runs as the standalone Rust gateway and this crate does nothing + | Variable | Meaning | |---|---| | `LITELLM_FORWARD_UPSTREAM` | Python's loopback origin, e.g. `http://127.0.0.1:53211` | diff --git a/litellm-rust/crates/gateway-forward/Cargo.toml b/litellm-rust/crates/gateway-forward/Cargo.toml index 2fef19dca34..821ea91d269 100644 --- a/litellm-rust/crates/gateway-forward/Cargo.toml +++ b/litellm-rust/crates/gateway-forward/Cargo.toml @@ -13,7 +13,7 @@ hyper = { version = "1.12.0", features = ["http1", "server"] } hyper-util = { workspace = true, features = ["client-legacy", "http1", "tokio"] } serde.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["io-util"] } +tokio = { workspace = true, features = ["io-util", "sync"] } tokio-util = { version = "0.7", features = ["rt"] } tower-service = "0.3.3" tracing.workspace = true diff --git a/litellm-rust/crates/gateway-forward/src/input.rs b/litellm-rust/crates/gateway-forward/src/input.rs new file mode 100644 index 00000000000..772d4610683 --- /dev/null +++ b/litellm-rust/crates/gateway-forward/src/input.rs @@ -0,0 +1,29 @@ +use std::io::{self, Read}; + +use tokio::sync::oneshot; + +/// Resolves once `input` reaches end of file or fails to read. The Python supervisor holds the only +/// write end of the gateway's stdin, so EOF means it is gone and the gateway drains as on SIGTERM. +/// +/// The read runs on a plain thread because a blocked read on tokio's blocking pool would hold up +/// runtime shutdown after a signal had already stopped the gateway. +pub fn input_closed(mut input: impl Read + Send + 'static) -> impl Future + Send { + let (closed, on_closed) = oneshot::channel::<()>(); + let reader = std::thread::Builder::new() + .name("stdin-eof".into()) + .spawn(move || { + let _ = io::copy(&mut input, &mut io::sink()); + let _ = closed.send(()); + }); + async move { + match reader { + Ok(_) => { + let _ = on_closed.await; + } + Err(error) => { + tracing::warn!(%error, "could not watch stdin, so only signals stop the gateway"); + std::future::pending::<()>().await; + } + } + } +} diff --git a/litellm-rust/crates/gateway-forward/src/lib.rs b/litellm-rust/crates/gateway-forward/src/lib.rs index 4ac17b50544..35f25d19c3c 100644 --- a/litellm-rust/crates/gateway-forward/src/lib.rs +++ b/litellm-rust/crates/gateway-forward/src/lib.rs @@ -2,6 +2,7 @@ mod connections; mod connector; mod error; mod forward; +mod input; mod rewrite; mod server; mod settings; @@ -10,6 +11,7 @@ mod websocket; pub use error::Error; pub(crate) use error::Rejection; pub use forward::Forward; +pub use input::input_closed; pub use rewrite::PEER_HEADER; pub use settings::{Settings, Upstream}; diff --git a/litellm-rust/crates/gateway-forward/tests/lifecycle.rs b/litellm-rust/crates/gateway-forward/tests/lifecycle.rs index 85d10a37be4..97907738ad6 100644 --- a/litellm-rust/crates/gateway-forward/tests/lifecycle.rs +++ b/litellm-rust/crates/gateway-forward/tests/lifecycle.rs @@ -7,9 +7,11 @@ use std::{ use axum::{Router, body::Body, extract::Request, http::StatusCode, response::Response}; use http_body_util::BodyExt; +use litellm_gateway_forward::input_closed; use rstest::rstest; use support::{ - DRAIN, Gateway, Raw, client, raw_upstream, recording, serve, unreachable_upstream, within, + DRAIN, Gateway, Raw, UVICORN_KEEPALIVE, client, raw_upstream, recording, serve, serve_until, + unreachable_upstream, within, }; use tokio::{ net::{TcpListener, TcpStream}, @@ -287,6 +289,55 @@ async fn shutdown_lets_an_in_flight_stream_finish() { within(server).await.unwrap(); } +#[rstest] +#[tokio::test] +async fn the_supervisor_closing_stdin_drains_like_a_stop_signal() { + let release = Arc::new(Notify::new()); + let released = release.clone(); + let upstream = raw_upstream(move |mut python: Raw| { + let released = released.clone(); + async move { + python.request_head().await.unwrap(); + python + .send("HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n5\r\nfirst\r\n") + .await; + released.notified().await; + python.send("4\r\nlast\r\n0\r\n\r\n").await; + } + }) + .await; + let (stdin, supervisor) = std::io::pipe().unwrap(); + let (address, server) = serve_until(upstream, UVICORN_KEEPALIVE, input_closed(stdin)).await; + let response = within( + client().request( + Request::get(format!("http://{address}/")) + .body(Body::empty()) + .unwrap(), + ), + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(TcpStream::connect(address).await.is_ok()); + + drop(supervisor); + + within(async { + while TcpStream::connect(address).await.is_ok() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + assert!(!server.is_finished()); + release.notify_one(); + let body = within(response.into_body().collect()) + .await + .unwrap() + .to_bytes(); + assert_eq!(body, "firstlast"); + within(server).await.unwrap(); +} + #[rstest] #[tokio::test] async fn shutdown_stops_waiting_for_a_stream_still_open_after_the_drain() { diff --git a/litellm-rust/crates/gateway-forward/tests/support/mod.rs b/litellm-rust/crates/gateway-forward/tests/support/mod.rs index d39532a456e..7d92ca4a14a 100644 --- a/litellm-rust/crates/gateway-forward/tests/support/mod.rs +++ b/litellm-rust/crates/gateway-forward/tests/support/mod.rs @@ -32,6 +32,21 @@ pub async fn within(future: impl Future) -> T { tokio::time::timeout(WAIT, future).await.expect("timed out") } +/// A forwarder in front of `upstream` that drains once `shutdown` resolves, as `run` does. +pub async fn serve_until( + upstream: SocketAddr, + keepalive: Duration, + shutdown: impl Future + Send + 'static, +) -> (SocketAddr, JoinHandle<()>) { + let forward = Forward::new(&format!("http://{upstream}").parse().unwrap(), keepalive); + let public = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = public.local_addr().unwrap(); + ( + address, + tokio::spawn(forward.serve(public, shutdown, DRAIN)), + ) +} + /// A running forwarder in front of `upstream`, listening on a real socket like production. pub struct Gateway { pub address: SocketAddr, @@ -45,17 +60,11 @@ impl Gateway { } pub async fn with_keepalive(upstream: SocketAddr, keepalive: Duration) -> Self { - let forward = Forward::new(&format!("http://{upstream}").parse().unwrap(), keepalive); - let public = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = public.local_addr().unwrap(); let (stop, stopped) = oneshot::channel::<()>(); - let server = tokio::spawn(forward.serve( - public, - async { - stopped.await.ok(); - }, - DRAIN, - )); + let (address, server) = serve_until(upstream, keepalive, async { + stopped.await.ok(); + }) + .await; Self { address, stop, diff --git a/litellm-rust/crates/gateway/src/main.rs b/litellm-rust/crates/gateway/src/main.rs index dc77cfe28eb..176bc68d247 100644 --- a/litellm-rust/crates/gateway/src/main.rs +++ b/litellm-rust/crates/gateway/src/main.rs @@ -48,7 +48,11 @@ async fn main() -> Result<(), Box> { level: log.log_level(), }) .install_global()?; - litellm_gateway_forward::run(forward, shutdown_signal()).await?; + let stdin_closed = litellm_gateway_forward::input_closed(std::io::stdin()); + litellm_gateway_forward::run(forward, async { + tokio::select! { () = shutdown_signal() => (), () = stdin_closed => () } + }) + .await?; return Ok(()); } let settings: Settings = settings::from_iter(std::env::vars_os())?; diff --git a/litellm/proxy/AGENTS.md b/litellm/proxy/AGENTS.md new file mode 100644 index 00000000000..62726c30a10 --- /dev/null +++ b/litellm/proxy/AGENTS.md @@ -0,0 +1,5 @@ +# Proxy + +## Rust forwarding + +`proxy_server.py`, routes, auth and middleware never know whether Rust is in front of the proxy, and none of them may branch on it. `proxy_cli.py` only describes what it would serve with as a `rust_forward.server.PythonServer`, calls `rust_forward.supervisor.supervise`, and merges the options `rust_forward.mode` returns, which are identities when forwarding is off. Every forwarding decision lives in `rust_forward/`, and `rust_forward/AGENTS.md` states the contract diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index b238cefd24f..f252e69d739 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -36,6 +36,9 @@ from litellm.types.llms.base import LiteLLMBaseModel if TYPE_CHECKING: from fastapi import FastAPI + + from litellm.proxy.rust_forward.peer import ASGIApp + from litellm.proxy.rust_forward.server import PythonServer else: FastAPI = Any @@ -680,7 +683,7 @@ class ProxyInitializationHelpers: def run_gunicorn_server( host: str, port: int, - app: FastAPI, + app: "ASGIApp", num_workers: int, ssl_certfile_path: str, ssl_keyfile_path: str, @@ -836,6 +839,65 @@ class ProxyInitializationHelpers: def serving_port(port: int) -> int: return random.randint(1024, 49152) if port == 4000 and ProxyInitializationHelpers.is_port_in_use(port) else port + @staticmethod + def python_server( + host: str, + port: int, + keepalive_timeout: int | None, + run_gunicorn: bool, + run_hypercorn: bool, + run_granian: bool, + tls: bool, + ) -> "PythonServer": + """``run_gunicorn_server`` never hands ``--keepalive_timeout`` or forwarded IPs to gunicorn, so its workers keep + gunicorn's keep-alive and gunicorn's trusted proxies, which include ``::1`` where uvicorn's do not.""" + import uvicorn + + from litellm.proxy.rust_forward.server import Listener, PythonServer, Server, uvicorn_trusted_proxies + from litellm.proxy.shutdown.graceful_shutdown_manager import GracefulShutdownManager + + server: Final = ( + Server.HYPERCORN + if run_hypercorn + else Server.GRANIAN + if run_granian + else Server.GUNICORN + if run_gunicorn + else Server.UVICORN + ) + keepalive, trusted_proxies = ( + ProxyInitializationHelpers.gunicorn_keepalive_and_trusted_proxies() + if server is Server.GUNICORN + else ( + uvicorn.Config(app="", log_config=None).timeout_keep_alive + if keepalive_timeout is None + else keepalive_timeout, + uvicorn_trusted_proxies(None), + ) + ) + return PythonServer( + server=server, + listener=Listener.TLS if tls else Listener.TCP, + host=host, + port=ProxyInitializationHelpers.serving_port(port), + keepalive_seconds=keepalive, + graceful_timeout_seconds=GracefulShutdownManager.get_timeout(), + trusted_proxies=trusted_proxies, + ) + + @staticmethod + def gunicorn_keepalive_and_trusted_proxies() -> tuple[int, str]: + """Loaded only when gunicorn is the server, because gunicorn imports ``fcntl``, which Windows lacks.""" + from gunicorn.config import Config as GunicornConfig + + config: Final = GunicornConfig() + keepalive: Final[object] = config.keepalive + forwarded_allow_ips: Final[object] = config.forwarded_allow_ips + return ( + TypeAdapter(int).validate_python(keepalive), + ",".join(TypeAdapter(list[str]).validate_python(forwarded_allow_ips)), + ) + @staticmethod def get_loop_type(): """Helper function to determine the event loop type based on platform""" @@ -1276,7 +1338,7 @@ def run_server( skip_server_startup, validate_config: bool, check_migrations_against_existing_data: bool, - keepalive_timeout, + keepalive_timeout: int | None, timeout_worker_healthcheck, max_request_header_size: int | None, max_requests_before_restart: int | None, @@ -1306,6 +1368,49 @@ def run_server( run_setup_wizard() return + from pydantic import ValidationError + + from litellm.proxy.rust_forward.mode import ( + Mode, + environment_error, + private_listener, + rust_forward_mode, + stop_with_supervisor, + uvicorn_options, + ) + + try: + forward_mode: Final = rust_forward_mode(os.environ) + except ValidationError as error: + raise click.ClickException(environment_error(error)) from None + stop_with_supervisor(forward_mode) + runs_one_command: Final = ( + version is True + or validate_config + or health is True + or test is True + or skip_server_startup is True + or check_migrations_against_existing_data + ) + if forward_mode is Mode.SUPERVISOR and not runs_one_command: + from litellm.proxy.rust_forward.supervisor import supervise + from litellm.proxy.rust_forward.supervisor.preflight import StartupError + + server: Final = ProxyInitializationHelpers.python_server( + host, + port, + keepalive_timeout, + run_gunicorn=run_gunicorn is True, + run_hypercorn=run_hypercorn is True, + run_granian=run_granian is True, + tls=None not in (ssl_keyfile_path, ssl_certfile_path), + ) + if prometheus_metrics_port == server.port: + raise click.UsageError("--prometheus_metrics_port must differ from --port") + supervised: Final = supervise(server) + if not isinstance(supervised, StartupError): + raise SystemExit(supervised) + args: Final = locals() try: from litellm.proxy.proxy_server import ( @@ -1773,6 +1878,8 @@ def run_server( timeout_worker_healthcheck=(timeout_worker_healthcheck if running_uvicorn else None), max_request_header_size=max_request_header_size, ) + for option, value in uvicorn_options(forward_mode, WORKER_APP).items(): + uvicorn_args[option] = value # Optional: recycle uvicorn workers after N requests if max_requests_before_restart is not None: uvicorn_args["limit_max_requests"] = max_requests_before_restart @@ -1837,16 +1944,19 @@ def run_server( workers=num_workers, ) elif run_gunicorn is True: + from litellm.proxy.rust_forward.peer import wrap + ProxyInitializationHelpers.run_gunicorn_server( host=host, port=port, - app=app, + app=wrap(app, forward_mode), num_workers=num_workers, ssl_certfile_path=ssl_certfile_path, ssl_keyfile_path=ssl_keyfile_path, max_requests_before_restart=max_requests_before_restart, max_requests_before_restart_jitter=max_requests_before_restart_jitter, max_request_header_size=max_request_header_size, + private_listener=private_listener(forward_mode), ) elif run_hypercorn is True: ProxyInitializationHelpers.init_hypercorn_server( diff --git a/litellm/proxy/rust_forward/AGENTS.md b/litellm/proxy/rust_forward/AGENTS.md new file mode 100644 index 00000000000..935ff3f896e --- /dev/null +++ b/litellm/proxy/rust_forward/AGENTS.md @@ -0,0 +1,48 @@ +# Rust forwarding, Python half + +With `LITELLM_RUST_FIRST` on, a supervisor runs the Rust gateway on the public port and Python on a private loopback listener. Rust forwards every request to Python unchanged, so Python still serves every route. This package is the Python half of forwarding and pairs with the crate `litellm-rust/crates/gateway-forward`. Once Rust serves every route, both get deleted together, along with one dispatch branch in each entry point + +The same path runs everywhere. `litellm` (the main images and a local CLI run) and `python -m gateway.launch` (the gateway image) both call `supervisor.supervise`, and nothing else starts the Rust gateway in forwarding mode. In a container the supervisor is PID 1, so its signal and exit rules are deployment behavior and not CLI polish + +## Boundary + +A supervised Python server differs from Python alone in exactly two ways, and nothing else in the proxy may change because Rust is in front + +- It listens on the private loopback listener the supervisor gives it, never on the public address +- It restores the client address Rust saw, because its own TCP peer is always Rust + +`proxy_server.py`, routes, auth and middleware never know Rust is there. A change that needs one of them to branch on forwarding is in the wrong place + +## Modules + +- `server.py` describes what an entry point would serve with on the public port, as a `PythonServer`. The supervisor reads nothing else about the entry point +- `mode.py` is the Python child's side. `Supervised` is the whole contract the supervisor hands the child, and the `uvicorn_*` and `gunicorn_*` functions translate it into each server's own options +- `peer.py` restores the client address +- `supervisor/` starts, watches and stops both children. Its rules are in `supervisor/AGENTS.md` +- `platform/` holds every OS-specific operation. Its rules are in `platform/AGENTS.md` + +## Entry points + +Entry points (`litellm/proxy/proxy_cli.py`, `gateway/launch.py`, `litellm_cli/main.py`) only translate between their own flags and this package + +- In the supervisor they describe what they would serve with as a `server.PythonServer` and hand it to `supervisor.supervise`, which decides whether forwarding can start and what Rust and the Python child receive. A refusal (no gateway binary, or a server or listener Rust cannot front) is reported and returned, and the entry point then serves from Python alone exactly as with `LITELLM_RUST_FIRST` off +- In the supervised child they merge `mode.uvicorn_options` or `mode.uvicorn_flags` into their server settings, or hand `mode.private_listener` to `run_gunicorn_server`, and wrap the app with `peer.wrap` in their worker factory. These are identities when forwarding is off, so entry points never branch on it +- A server is started three ways here, through uvicorn keyword arguments, uvicorn's own CLI and gunicorn, and each one states the same two facts, the private listener and that the server itself trusts no peer's forwarding headers. The uvicorn translations are in `mode.py` and turn proxy headers off. `run_gunicorn_server` applies `server_listener.gunicorn_overrides` for the `PrivateListener` it is given, which sets gunicorn's `bind` and an empty `forwarded_allow_ips`. A new way to start a server gets its own translation, never a branch in the entry point + +## Client address + +Python must see each request as if the client had connected directly. `x-litellm-rust-peer` is the only Rust-specific input, and the proxy app itself never knows Rust is in front of it + +- Rust sends the client's TCP peer in `x-litellm-rust-peer` and leaves every forwarding header untouched. It never appends itself to `X-Forwarded-For`, because the proxy parses that header on its own (`use_x_forwarded_for`, `mcp_xff_num_trusted_hops`) and an extra hop would shift what those settings resolve +- Server-level proxy headers are off in a supervised server, through `proxy_headers: False` for uvicorn, `--no-proxy-headers` for its CLI and an empty `forwarded_allow_ips` for gunicorn. The server's TCP peer is always loopback, which uvicorn and gunicorn trust by default, so leaving them on would let any client set its own address and scheme +- `peer.RustPeerMiddleware` puts the real peer back into the scope and then runs uvicorn's own `ProxyHeadersMiddleware` with the operator's trust. It wraps the app from outside, so it runs before anything that reads the client address +- The operator's trust is one string, `trusted_proxies`. It is resolved by the server library the entry point uses (`server.uvicorn_trusted_proxies` or gunicorn's config), never defaulted in this package. `server.NO_TRUSTED_PROXIES` stands for proxy headers the operator turned off, since a trust list that matches nobody behaves the same +- `peer.py` honors the peer header only from the loopback hop and always removes it. Any process on the host can connect to the private listener and send that header, so loopback is the trust boundary of the private listener. That widens what Python alone allows only when the operator's trust list leaves loopback out + +## Environment contracts + +Each environment contract is written and read in one place + +- The Python child's contract is the `mode.Supervised` type itself. `mode.supervised_environment` serializes it into one variable and `mode.rust_forward_mode` validates it back, so a new field needs no parsing code +- `supervisor.children.rust_spec` writes the `LITELLM_FORWARD_*` variables that `gateway-forward`'s `settings.rs` reads. Python owns every value Rust needs, so a new one gets a new variable there and a matching field in `settings.rs` in the same change +- `mode.rust_forward_mode` reads `LITELLM_RUST_FIRST` strictly, so an invalid value stops the CLI at startup with one line naming it. It is independent of `LITELLM_RUST`, which only selects the Rust paths inside the SDK in `litellm/rust_bridge/configuration.py`, so neither turns the other on diff --git a/litellm/proxy/rust_forward/__init__.py b/litellm/proxy/rust_forward/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/proxy/rust_forward/mode.py b/litellm/proxy/rust_forward/mode.py new file mode 100644 index 00000000000..1c417db345f --- /dev/null +++ b/litellm/proxy/rust_forward/mode.py @@ -0,0 +1,142 @@ +import contextlib +import os +import signal +import threading +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from enum import Enum, auto +from types import MappingProxyType +from typing import Final, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError +from typing_extensions import assert_never + +from litellm.proxy.server_listener import LOOPBACK_HOST, AssignedPort, InheritedFd, PrivateListener +from litellm.rust_bridge.configuration import RustSwitch + +SWITCH_ENV: Final = "LITELLM_RUST_FIRST" +SUPERVISED_ENV: Final = "_LITELLM_GATEWAY_SUPERVISED" +RUST_HOP: Final = LOOPBACK_HOST + + +class _Switch(BaseModel): + model_config = ConfigDict(frozen=True) + + rust: RustSwitch = Field(None, validation_alias=SWITCH_ENV) + + +class Mode(Enum): + DISABLED = auto() + SUPERVISOR = auto() + + +@dataclass(frozen=True, slots=True) +class Supervised: + listener: PrivateListener + trusted_proxies: str + + +RustForwardMode: TypeAlias = Mode | Supervised +_SUPERVISED: Final = TypeAdapter(Supervised) + + +def supervised_environment(supervised: Supervised) -> Mapping[str, str]: + return MappingProxyType({SUPERVISED_ENV: _SUPERVISED.dump_json(supervised).decode()}) + + +def rust_forward_mode(environ: Mapping[str, str]) -> RustForwardMode: + switch: Final = _Switch.model_validate(environ) + supervised: Final = environ.get(SUPERVISED_ENV) + if supervised is not None: + return _SUPERVISED.validate_json(supervised) + return Mode.SUPERVISOR if switch.rust else Mode.DISABLED + + +def _wait_for_eof(fd: int) -> None: + with contextlib.suppress(OSError): + while os.read(fd, 4096): + pass + + +def _stop_when_closed(fd: int, stop: Callable[[], None]) -> None: + _wait_for_eof(fd) + stop() + + +def _terminate_self() -> None: + signal.raise_signal(signal.SIGTERM) + + +def stop_with_supervisor(mode: RustForwardMode, fd: int = 0, stop: Callable[[], None] = _terminate_self) -> None: + """Shut the supervised server down gracefully once the supervisor's end of its stdin closes, so it never outlives + a supervisor that was killed. Call it only in the process the supervisor started, not in its workers.""" + match mode: + case Supervised(): + threading.Thread(target=_stop_when_closed, args=(fd, stop), name="supervisor-stdin", daemon=True).start() + case Mode.DISABLED | Mode.SUPERVISOR: + pass + case _: + assert_never(mode) + + +def environment_error(error: ValidationError) -> str: + return "; ".join( + f"{'.'.join(str(part) for part in detail['loc'])} is invalid: {detail['msg']}" + for detail in error.errors(include_url=False) + ) + + +# A supervised server's TCP peer is always Rust on loopback, so server-level proxy headers would trust any +# client's forwarding headers. They stay off there and ``peer.wrap`` applies the operator's trust instead. + + +def _uvicorn_listener(listener: PrivateListener) -> Mapping[str, object]: + match listener: + case InheritedFd(fd): + return MappingProxyType({"fd": fd}) + case AssignedPort(port): + return MappingProxyType({"host": RUST_HOP, "port": port}) + case _: + assert_never(listener) + + +def uvicorn_options(mode: RustForwardMode, factory: str) -> Mapping[str, object]: + match mode: + case Supervised(listener): + return MappingProxyType( + {"app": factory, "factory": True, **_uvicorn_listener(listener), "proxy_headers": False} + ) + case Mode.DISABLED | Mode.SUPERVISOR: + return MappingProxyType({}) + case _: + assert_never(mode) + + +def _uvicorn_listener_flags(listener: PrivateListener) -> tuple[str, ...]: + match listener: + case InheritedFd(fd): + return ("--fd", str(fd)) + case AssignedPort(port): + return ("--host", RUST_HOP, "--port", str(port)) + case _: + assert_never(listener) + + +def uvicorn_flags(mode: RustForwardMode) -> tuple[str, ...]: + match mode: + case Supervised(listener): + return (*_uvicorn_listener_flags(listener), "--no-proxy-headers") + case Mode.DISABLED | Mode.SUPERVISOR: + return () + case _: + assert_never(mode) + + +def private_listener(mode: RustForwardMode) -> PrivateListener | None: + match mode: + case Supervised(listener): + return listener + case Mode.DISABLED | Mode.SUPERVISOR: + return None + case _: + assert_never(mode) diff --git a/litellm/proxy/rust_forward/peer.py b/litellm/proxy/rust_forward/peer.py new file mode 100644 index 00000000000..1dd52e24564 --- /dev/null +++ b/litellm/proxy/rust_forward/peer.py @@ -0,0 +1,69 @@ +from typing import ( + TYPE_CHECKING, + Final, + TypeAlias, + cast, # noqa: TID251 # the one Starlette-to-uvicorn ASGI conversion in served +) + +from typing_extensions import assert_never + +from litellm.proxy.rust_forward.mode import RUST_HOP, Mode, RustForwardMode, Supervised + +if TYPE_CHECKING: + import starlette.types + from uvicorn._types import ASGI3Application, ASGIReceiveCallable, ASGISendCallable, Scope, WWWScope + +PEER_HEADER: Final = b"x-litellm-rust-peer" + +ASGIApp: TypeAlias = "ASGI3Application" + + +def served(app: "starlette.types.ASGIApp") -> ASGIApp: + return cast( # cast-ok: Starlette and uvicorn type the same ASGI 3 callable with unassignable scope/receive/send + "ASGI3Application", app + ) + + +def _restore_peer(scope: "WWWScope") -> None: + headers: Final = tuple(scope["headers"]) + peer: Final = next((value for name, value in headers if name == PEER_HEADER), None) + if peer is None: + return + remaining: Final = tuple((name, value) for name, value in headers if name != PEER_HEADER) + scope["headers"] = remaining # rebind-ok: the peer header is private to the Rust hop + client: Final = scope["client"] + if client is None or client[0] != RUST_HOP: + return + host, _, port = peer.decode("latin-1").rpartition(":") + scope["client"] = (host.strip("[]"), int(port)) # rebind-ok: uvicorn's access log reads this same scope + + +class RustPeerMiddleware: + """Make Python see each request as if the client had connected to it directly. + + Rust owns the public port, so Python's peer is always Rust on loopback. Rust passes the real + TCP peer in ``x-litellm-rust-peer`` and leaves every forwarding header untouched. This puts the + real peer back into the scope, then runs uvicorn's own ``ProxyHeadersMiddleware`` with the trust + settings Python would have used on the public port. + """ + + def __init__(self, app: ASGIApp, mode: Supervised) -> None: + from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + + self.app: Final = ProxyHeadersMiddleware(app, mode.trusted_proxies) + + async def __call__(self, scope: "Scope", receive: "ASGIReceiveCallable", send: "ASGISendCallable") -> None: + if scope["type"] != "lifespan": + _restore_peer(scope) + await self.app(scope, receive, send) + + +def wrap(app: "starlette.types.ASGIApp", mode: RustForwardMode) -> ASGIApp: + converted: Final = served(app) + match mode: + case Supervised(): + return RustPeerMiddleware(converted, mode) + case Mode.DISABLED | Mode.SUPERVISOR: + return converted + case _: + assert_never(mode) diff --git a/litellm/proxy/rust_forward/platform/AGENTS.md b/litellm/proxy/rust_forward/platform/AGENTS.md new file mode 100644 index 00000000000..32d1e4ac5c9 --- /dev/null +++ b/litellm/proxy/rust_forward/platform/AGENTS.md @@ -0,0 +1,19 @@ +# Platforms + +Every OS-specific operation sits behind `Platform`, which `supervise` picks once with `current_platform()`. The supervisor, `mode.py` and the entry points never check the OS themselves, so a new OS difference becomes a `Platform` member and never an `os.name` check somewhere else + +A `Platform` answers these questions and nothing else. What the gateway binary is called, which servers it can supervise, how the private listener reaches Python, which spawn options the children get, which signals are relayed and which of them stop Rust, and how a child is signalled and stopped + +## POSIX + +`PosixPlatform` binds the private socket itself and hands Python the open descriptor (`server_listener.InheritedFd`). Nobody else can take the port between the supervisor picking it and Python serving it. The socket does not listen until Python does, so the readiness probe only succeeds against Python. Children run in their own session (`start_new_session`) only so that a terminal Ctrl-C reaches Python once, through the supervisor + +## Windows + +Windows is supported for running the proxy locally from the CLI. Nobody deploys the proxy on Windows, but `LITELLM_RUST_FIRST` is meant to become the default, and then this is the path every Windows laptop runs. Revisit the choices below before treating Windows as a deployment target + +- Python gets an assigned loopback port (`server_listener.AssignedPort`) instead of an open socket, which uvicorn on Windows cannot serve. Another process can take that port before Python binds it, and the readiness probe would then accept it as Python. Python's failed bind still ends the run +- Each child runs in its own process group, which can only be sent `CTRL_BREAK_EVENT`. Both relayed signals arrive as that, so a second Ctrl-C does not force Python's exit the way it does on POSIX +- gunicorn is refused, since it does not run on Windows +- The Windows-only names are spelled out as constants in `windows.py` so the module imports and type checks on every OS. `tests/windows_tests/test_rust_forward_platform.py` pins them to the stdlib on the Windows runner +- No CI job runs the supervisor itself on Windows, because the `windows-sdk` job installs no proxy dependencies and no `litellm-gateway.exe`. Run a change to `windows.py` by hand on Windows until one does diff --git a/litellm/proxy/rust_forward/platform/__init__.py b/litellm/proxy/rust_forward/platform/__init__.py new file mode 100644 index 00000000000..4043adacdfb --- /dev/null +++ b/litellm/proxy/rust_forward/platform/__init__.py @@ -0,0 +1,58 @@ +import asyncio +import os +import signal +from collections.abc import Callable +from contextlib import AbstractContextManager +from dataclasses import dataclass +from typing import Protocol + +from litellm.proxy.rust_forward.server import Server +from litellm.proxy.server_listener import PrivateListener + + +@dataclass(frozen=True, slots=True) +class PlatformListener: + listener: PrivateListener + port: int + pass_fds: tuple[int, ...] + + +@dataclass(frozen=True, slots=True) +class SpawnOptions: + pass_fds: tuple[int, ...] + start_new_session: bool + creationflags: int + + +class Platform(Protocol): + @property + def name(self) -> str: ... + + @property + def gateway_name(self) -> str: ... + + @property + def servers(self) -> frozenset[Server]: ... + + def private_listener(self) -> AbstractContextManager[PlatformListener]: ... + + def spawn_options(self, pass_fds: tuple[int, ...]) -> SpawnOptions: ... + + @property + def relayed_signals(self) -> tuple[signal.Signals, ...]: ... + + @property + def stop_signals(self) -> frozenset[signal.Signals]: ... + + def catch(self, loop: asyncio.AbstractEventLoop, on_signal: Callable[[signal.Signals], None]) -> None: ... + + def signal_child(self, process: asyncio.subprocess.Process, signum: signal.Signals) -> None: ... + + def stop_group(self, process: asyncio.subprocess.Process) -> None: ... + + +def current_platform() -> Platform: + from litellm.proxy.rust_forward.platform.posix import PosixPlatform + from litellm.proxy.rust_forward.platform.windows import WindowsPlatform + + return WindowsPlatform() if os.name == "nt" else PosixPlatform() diff --git a/litellm/proxy/rust_forward/platform/posix.py b/litellm/proxy/rust_forward/platform/posix.py new file mode 100644 index 00000000000..0d0c6c96649 --- /dev/null +++ b/litellm/proxy/rust_forward/platform/posix.py @@ -0,0 +1,64 @@ +import asyncio +import contextlib +import os +import signal +import socket +from collections.abc import Callable, Generator +from dataclasses import dataclass +from typing import Final + +from pydantic import TypeAdapter + +from litellm.proxy.rust_forward.mode import RUST_HOP +from litellm.proxy.rust_forward.platform import PlatformListener, SpawnOptions +from litellm.proxy.rust_forward.server import Server +from litellm.proxy.server_listener import InheritedFd + +_ADDRESS: Final = TypeAdapter(tuple[str, int]) + + +@dataclass(frozen=True, slots=True) +class PosixPlatform: + name: str = "POSIX" + gateway_name: str = "litellm-gateway" + servers: frozenset[Server] = frozenset({Server.UVICORN, Server.GUNICORN}) + + @contextlib.contextmanager + def private_listener(self) -> Generator[PlatformListener]: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as private: + private.bind((RUST_HOP, 0)) + _, port = _ADDRESS.validate_python(private.getsockname()) + yield PlatformListener(InheritedFd(private.fileno()), port, (private.fileno(),)) + + def spawn_options(self, pass_fds: tuple[int, ...]) -> SpawnOptions: + return SpawnOptions(pass_fds=pass_fds, start_new_session=True, creationflags=0) + + @property + def relayed_signals(self) -> tuple[signal.Signals, ...]: + return ( + signal.SIGTERM, + signal.SIGINT, + signal.SIGHUP, + signal.SIGQUIT, + signal.SIGUSR1, + signal.SIGUSR2, + signal.SIGTTIN, + signal.SIGTTOU, + signal.SIGWINCH, + ) + + @property + def stop_signals(self) -> frozenset[signal.Signals]: + return frozenset({signal.SIGTERM, signal.SIGINT, signal.SIGQUIT}) + + def catch(self, loop: asyncio.AbstractEventLoop, on_signal: Callable[[signal.Signals], None]) -> None: + for signum in self.relayed_signals: + loop.add_signal_handler(signum, on_signal, signum) + + def signal_child(self, process: asyncio.subprocess.Process, signum: signal.Signals) -> None: + with contextlib.suppress(ProcessLookupError): + process.send_signal(signum) + + def stop_group(self, process: asyncio.subprocess.Process) -> None: + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGTERM) diff --git a/litellm/proxy/rust_forward/platform/windows.py b/litellm/proxy/rust_forward/platform/windows.py new file mode 100644 index 00000000000..163b96b9b20 --- /dev/null +++ b/litellm/proxy/rust_forward/platform/windows.py @@ -0,0 +1,68 @@ +import asyncio +import contextlib +import signal +import socket +from collections.abc import Callable, Generator +from dataclasses import dataclass +from types import FrameType +from typing import Final + +from pydantic import TypeAdapter + +from litellm.proxy.rust_forward.mode import RUST_HOP +from litellm.proxy.rust_forward.platform import PlatformListener, SpawnOptions +from litellm.proxy.rust_forward.server import Server +from litellm.proxy.server_listener import AssignedPort + +_ADDRESS: Final = TypeAdapter(tuple[str, int]) +# signal.CTRL_BREAK_EVENT, signal.SIGBREAK and subprocess.CREATE_NEW_PROCESS_GROUP exist only on Windows, so they are +# spelled out to keep this module importable and type checked everywhere. A Windows-only test pins them to the stdlib. +CTRL_BREAK_EVENT: Final = 1 +SIGBREAK: Final = 21 +CREATE_NEW_PROCESS_GROUP: Final = 0x00000200 + + +# Windows support exists only for running the proxy locally from the CLI, never for deploying it. That is why the +# supervisor picks a free loopback port and hands Python its number instead of an open socket, which Windows uvicorn +# cannot serve. Another process could take that port before Python binds it, and the readiness probe would then +# accept it as Python. Python's failed bind still ends the run, which is acceptable on a developer's machine. +@dataclass(frozen=True, slots=True) +class WindowsPlatform: + name: str = "Windows" + gateway_name: str = "litellm-gateway.exe" + servers: frozenset[Server] = frozenset({Server.UVICORN}) + + @contextlib.contextmanager + def private_listener(self) -> Generator[PlatformListener]: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind((RUST_HOP, 0)) + _, port = _ADDRESS.validate_python(probe.getsockname()) + yield PlatformListener(AssignedPort(port), port, ()) + + def spawn_options(self, pass_fds: tuple[int, ...]) -> SpawnOptions: + return SpawnOptions(pass_fds=pass_fds, start_new_session=False, creationflags=CREATE_NEW_PROCESS_GROUP) + + @property + def relayed_signals(self) -> tuple[signal.Signals, ...]: + return (signal.SIGINT, signal.Signals(SIGBREAK)) + + @property + def stop_signals(self) -> frozenset[signal.Signals]: + return frozenset(self.relayed_signals) + + def catch(self, loop: asyncio.AbstractEventLoop, on_signal: Callable[[signal.Signals], None]) -> None: + def handle(signum: int, _frame: FrameType | None) -> None: + loop.call_soon_threadsafe(on_signal, signal.Signals(signum)) + + for signum in self.relayed_signals: + signal.signal(signum, handle) + + # A child in its own process group can only be sent CTRL_BREAK_EVENT, since Windows never delivers CTRL_C_EVENT + # to a new group. Both children shut down on it as on Ctrl-C, but uvicorn force-exits only on a repeated SIGINT, + # so a second Ctrl-C does not cut a Windows shutdown short the way it does on POSIX. + def signal_child(self, process: asyncio.subprocess.Process, signum: signal.Signals) -> None: + self.stop_group(process) + + def stop_group(self, process: asyncio.subprocess.Process) -> None: + with contextlib.suppress(ProcessLookupError): + process.send_signal(CTRL_BREAK_EVENT) diff --git a/litellm/proxy/rust_forward/server.py b/litellm/proxy/rust_forward/server.py new file mode 100644 index 00000000000..95a693291bf --- /dev/null +++ b/litellm/proxy/rust_forward/server.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Final + +from pydantic import BaseModel, ConfigDict, TypeAdapter + +from litellm.proxy.server_listener import NO_TRUSTED_PROXIES + +_TRUSTED_PROXIES: Final = TypeAdapter(str) + + +class Server(Enum): + UVICORN = "uvicorn" + GUNICORN = "gunicorn" + HYPERCORN = "Hypercorn" + GRANIAN = "Granian" + + +class Listener(Enum): + TCP = "plain TCP" + TLS = "TLS" + UNIX_SOCKET = "a Unix socket" + INHERITED_FD = "an inherited file descriptor" + + +@dataclass(frozen=True, slots=True) +class PythonServer: + """What an entry point would serve with on the public port if Rust were not in front of it.""" + + server: Server + listener: Listener + host: str + port: int + keepalive_seconds: int + graceful_timeout_seconds: float + trusted_proxies: str + + +class UvicornCliParams(BaseModel): + """The options uvicorn's own CLI parsed that decide how it serves the public port.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + host: str + port: int + uds: str | None + fd: int | None + ssl_keyfile: str | None + ssl_certfile: str | None + timeout_keep_alive: int + timeout_graceful_shutdown: int | None + proxy_headers: bool + forwarded_allow_ips: str | None + + @property + def listener(self) -> Listener: + if self.ssl_keyfile is not None or self.ssl_certfile is not None: + return Listener.TLS + if self.uds is not None: + return Listener.UNIX_SOCKET + if self.fd is not None: + return Listener.INHERITED_FD + return Listener.TCP + + @property + def trusted_proxies(self) -> str: + return uvicorn_trusted_proxies(self.forwarded_allow_ips) if self.proxy_headers else NO_TRUSTED_PROXIES + + +def uvicorn_trusted_proxies(configured: str | None) -> str: + import uvicorn + + return _TRUSTED_PROXIES.validate_python( + uvicorn.Config(app="", log_config=None, forwarded_allow_ips=configured).forwarded_allow_ips + ) diff --git a/litellm/proxy/rust_forward/supervisor/AGENTS.md b/litellm/proxy/rust_forward/supervisor/AGENTS.md new file mode 100644 index 00000000000..65af84481e5 --- /dev/null +++ b/litellm/proxy/rust_forward/supervisor/AGENTS.md @@ -0,0 +1,21 @@ +# Forwarding supervisor + +`supervise` serves the public port from Rust and runs the entry point's own command behind it, or says why that cannot start. That command is the supervisor's own interpreter command line (`OWN_COMMAND`, from `sys.orig_argv`), so the child has the same import path and interpreter flags as the process the operator started. An entry point never builds a second way to start itself. It is transparent to Python, so a supervised proxy behaves on signals and exits exactly as Python alone. `tests/integration/configuration/test_rust_forward_lifecycle.py` checks that equivalence against Python alone, and a change that breaks one of the rules below breaks it + +## Split by job + +- `preflight` decides whether forwarding can start. It is the only place that refuses, and a refusal is a returned `StartupError`, never an exit, so the entry point can serve from Python alone +- `children` says what each child runs with, and it is the only writer of both environment contracts. `python_spec` writes the `mode.Supervised` variable and hands the Python child the entry point's own arguments with the private port appended. `rust_spec` writes the `LITELLM_FORWARD_*` variables. Each child gets only those variables on top of the supervisor's own environment +- `lifecycle` decides. `step` is a pure function from a `Lifecycle` of facts and an event to the next facts and the actions to run, and `exit_status` reads the result from those facts. It touches no process, socket or clock +- `runtime` acts. It only executes the actions `step` returns and feeds events back. The `contextlib.AsyncExitStack` it enters each child into owns their cleanup, last spawned first + +A new behavior is a new fact, event or action in `lifecycle` with a property test in `test_lifecycle.py`, never a condition in `runtime` + +## Rules + +- Signals pass through. Every signal in `Platform.relayed_signals` goes to Python unchanged, and the ones in `Platform.stop_signals` go to Rust first. The supervisor never reads meaning into a signal, so a repeated SIGINT reaches Python again and forces its exit as it would alone +- Python's exit status is the result. When Python exits for any reason, Rust gets SIGTERM, and once Rust is gone the supervisor exits with Python's status, dying by the same signal when Python died by one. The one exception is Rust exiting while Python serves with no stop requested, which stops Python and exits 1 +- A container's PID 1 cannot die by a signal it sends itself, since the kernel drops it. uvicorn alone survives its own re-raise there and exits 0, so `runtime.exit_like` exits 0 when a stop signal killed Python and with 128 plus the signal number for any other signal, which is how a kill of Python alone is reported +- Children cannot outlive the supervisor. Each child's stdin is a pipe only the supervisor holds open. The Python process the supervisor started (`mode.stop_with_supervisor`, never its workers) and the Rust gateway (`litellm_gateway_forward::input_closed`) shut down gracefully on EOF, so a supervisor killed by SIGKILL or a closed terminal leaves nothing behind +- Rust starts once Python listens, never after a stop was requested or Python exited. Python alone takes no connections until it has booted, so the public port refuses them until then too +- Only a preflight refusal falls back to Python alone. `before_start` runs once forwarding is known to be possible, so a refused start leaves nothing behind, and a child that fails to spawn after that exits 1 diff --git a/litellm/proxy/rust_forward/supervisor/__init__.py b/litellm/proxy/rust_forward/supervisor/__init__.py new file mode 100644 index 00000000000..b6611e30ffa --- /dev/null +++ b/litellm/proxy/rust_forward/supervisor/__init__.py @@ -0,0 +1,55 @@ +import asyncio +import sys +import sysconfig +from collections.abc import Callable, Sequence +from typing import Final + +from typing_extensions import assert_never + +from litellm.proxy.rust_forward.platform import Platform, current_platform +from litellm.proxy.rust_forward.server import PythonServer +from litellm.proxy.rust_forward.supervisor.lifecycle import FAILURE +from litellm.proxy.rust_forward.supervisor.preflight import StartupError, gateway_binary, startup_check +from litellm.proxy.rust_forward.supervisor.runtime import SpawnFailed, exit_like, run + +OWN_COMMAND: Final = (sys.executable, *sys.orig_argv[1:]) + + +def _nothing_to_prepare() -> None: + pass + + +def supervise( + server: PythonServer, + command: Sequence[str] = OWN_COMMAND, + before_start: Callable[[], None] = _nothing_to_prepare, + scripts_dir: str = sysconfig.get_path("scripts"), + platform: Platform | None = None, +) -> int | StartupError: + """Serve ``server``'s public port from Rust and run ``command`` behind it, or say why that cannot start. + + A refusal is returned for the caller to serve from Python alone, and ``before_start`` runs only once forwarding + is known to be possible, so a refused start leaves nothing behind. + """ + chosen: Final = current_platform() if platform is None else platform + checked: Final = startup_check(chosen, server, gateway_binary(chosen, scripts_dir)) + if isinstance(checked, StartupError): + sys.stderr.write(f"{checked.reason}, so Python serves the public port without Rust forwarding\n") + return checked + before_start() + try: + outcome: Final = asyncio.run(run(chosen, checked, command, server)) + except OSError as error: + return _startup_failed(error) + match outcome: + case SpawnFailed(error): + return _startup_failed(error) + case int(): + return exit_like(outcome, chosen.stop_signals) + case _: + assert_never(outcome) + + +def _startup_failed(error: OSError) -> int: + sys.stderr.write(f"Rust gateway startup failed: {error}\n") + return FAILURE diff --git a/litellm/proxy/rust_forward/supervisor/children.py b/litellm/proxy/rust_forward/supervisor/children.py new file mode 100644 index 00000000000..9d8f074f0f2 --- /dev/null +++ b/litellm/proxy/rust_forward/supervisor/children.py @@ -0,0 +1,43 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType + +from litellm.constants import GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS +from litellm.proxy.rust_forward.mode import RUST_HOP, Supervised, supervised_environment +from litellm.proxy.rust_forward.platform import Platform, PlatformListener, SpawnOptions +from litellm.proxy.rust_forward.server import PythonServer + + +@dataclass(frozen=True, slots=True) +class ChildSpec: + argv: tuple[str, ...] + env_overrides: Mapping[str, str] + spawn: SpawnOptions + + +def python_spec( + platform: Platform, command: Sequence[str], private: PlatformListener, server: PythonServer +) -> ChildSpec: + return ChildSpec( + argv=(*command, "--port", str(private.port)), + env_overrides=supervised_environment(Supervised(private.listener, server.trusted_proxies)), + spawn=platform.spawn_options(private.pass_fds), + ) + + +def rust_spec(platform: Platform, binary: str, upstream_port: int, server: PythonServer) -> ChildSpec: + return ChildSpec( + argv=(binary,), + env_overrides=MappingProxyType( + { + "LITELLM_FORWARD_UPSTREAM": f"http://{RUST_HOP}:{upstream_port}", + "LITELLM_FORWARD_HOST": server.host, + "LITELLM_FORWARD_PORT": str(server.port), + "LITELLM_FORWARD_UPSTREAM_KEEPALIVE": str(server.keepalive_seconds), + "LITELLM_FORWARD_SHUTDOWN_DRAIN": str( + server.graceful_timeout_seconds + GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS + ), + } + ), + spawn=platform.spawn_options(()), + ) diff --git a/litellm/proxy/rust_forward/supervisor/lifecycle.py b/litellm/proxy/rust_forward/supervisor/lifecycle.py new file mode 100644 index 00000000000..e462c7df08a --- /dev/null +++ b/litellm/proxy/rust_forward/supervisor/lifecycle.py @@ -0,0 +1,138 @@ +import signal +from dataclasses import dataclass, replace +from enum import Enum, auto +from typing import Final, Literal, TypeAlias + +from typing_extensions import assert_never + + +class Child(Enum): + RUST = "rust" + PYTHON = "python" + + +FAILURE: Final = 1 + + +class Phase(Enum): + NOT_STARTED = auto() + SPAWNED = auto() + LISTENING = auto() + + +@dataclass(frozen=True, slots=True) +class Exited: + status: int + + +@dataclass(frozen=True, slots=True) +class Lost: + """Rust exited while Python was serving and nobody had asked either to stop.""" + + status: int + + +PythonState: TypeAlias = Literal[Phase.SPAWNED, Phase.LISTENING] | Exited +RustState: TypeAlias = Literal[Phase.NOT_STARTED, Phase.SPAWNED] | Exited | Lost + + +@dataclass(frozen=True, slots=True) +class Lifecycle: + python: PythonState + rust: RustState + stop_requested: bool + + +@dataclass(frozen=True, slots=True) +class Caught: + signum: signal.Signals + + +class Probe(Enum): + PYTHON_LISTENING = auto() + + +@dataclass(frozen=True, slots=True) +class ChildExited: + child: Child + status: int + + +@dataclass(frozen=True, slots=True) +class Spawn: + child: Child + + +@dataclass(frozen=True, slots=True) +class Send: + child: Child + signum: signal.Signals + + +Event: TypeAlias = Caught | Literal[Probe.PYTHON_LISTENING] | ChildExited +Action: TypeAlias = Spawn | Send +Transition: TypeAlias = tuple[Lifecycle, tuple[Action, ...]] + + +def boot() -> Transition: + return Lifecycle(Phase.SPAWNED, Phase.NOT_STARTED, stop_requested=False), (Spawn(Child.PYTHON),) + + +def _relay(state: Lifecycle, signum: signal.Signals, stops: bool) -> Transition: + to_rust: Final = (Send(Child.RUST, signum),) if stops and state.rust is Phase.SPAWNED else () + to_python: Final = (Send(Child.PYTHON, signum),) if not isinstance(state.python, Exited) else () + return replace(state, stop_requested=state.stop_requested or stops), (*to_rust, *to_python) + + +# Python alone takes no connections until it has booted, so Rust only opens the public port once Python listens. +def _python_listening(state: Lifecycle) -> Transition: + if state.python is not Phase.SPAWNED: + return state, () + if state.stop_requested: + return replace(state, python=Phase.LISTENING), () + return replace(state, python=Phase.LISTENING, rust=Phase.SPAWNED), (Spawn(Child.RUST),) + + +def _python_exited(state: Lifecycle, status: int) -> Transition: + if isinstance(state.python, Exited): + return state, () + exited: Final = replace(state, python=Exited(status)) + return exited, ((Send(Child.RUST, signal.SIGTERM),) if state.rust is Phase.SPAWNED else ()) + + +def _rust_exited(state: Lifecycle, status: int) -> Transition: + if state.rust is not Phase.SPAWNED: + return state, () + if state.stop_requested or isinstance(state.python, Exited): + return replace(state, rust=Exited(status)), () + return replace(state, rust=Lost(status)), (Send(Child.PYTHON, signal.SIGTERM),) + + +def step(state: Lifecycle, event: Event, stop_signals: frozenset[signal.Signals]) -> Transition: + """The supervisor never gives a signal or an exit a meaning of its own. Every signal reaches Python as sent, + Python's exit status is the supervisor's, and Rust only follows Python, except that losing Rust stops Python.""" + match event: + case Caught(signum): + return _relay(state, signum, signum in stop_signals) + case Probe.PYTHON_LISTENING: + return _python_listening(state) + case ChildExited(child, status): + match child: + case Child.PYTHON: + return _python_exited(state, status) + case Child.RUST: + return _rust_exited(state, status) + case _: + assert_never(child) + case _: + assert_never(event) + + +def exit_status(state: Lifecycle) -> int | None: + match state.python, state.rust: + case Exited(status), Phase.NOT_STARTED | Exited(): + return status + case Exited(), Lost(): + return FAILURE + case _: + return None diff --git a/litellm/proxy/rust_forward/supervisor/preflight.py b/litellm/proxy/rust_forward/supervisor/preflight.py new file mode 100644 index 00000000000..a561e77ba8d --- /dev/null +++ b/litellm/proxy/rust_forward/supervisor/preflight.py @@ -0,0 +1,28 @@ +import os +import shutil +from dataclasses import dataclass + +from litellm.proxy.rust_forward.mode import SWITCH_ENV +from litellm.proxy.rust_forward.platform import Platform +from litellm.proxy.rust_forward.server import Listener, PythonServer, Server + + +@dataclass(frozen=True, slots=True) +class StartupError: + reason: str + + +def gateway_binary(platform: Platform, scripts_dir: str) -> str | None: + return shutil.which(os.path.join(scripts_dir, platform.gateway_name)) + + +def startup_check(platform: Platform, server: PythonServer, binary: str | None) -> str | StartupError: + if server.server in (Server.HYPERCORN, Server.GRANIAN): + return StartupError(f"{SWITCH_ENV} does not support {server.server.value}") + if server.server not in platform.servers: + return StartupError(f"{SWITCH_ENV} does not support {server.server.value} on {platform.name}") + if server.listener is not Listener.TCP: + return StartupError(f"{SWITCH_ENV} does not support {server.listener.value} on the public port") + if binary is None: + return StartupError(f"{SWITCH_ENV} requires the litellm-gateway binary from the litellm-proxy package") + return binary diff --git a/litellm/proxy/rust_forward/supervisor/runtime.py b/litellm/proxy/rust_forward/supervisor/runtime.py new file mode 100644 index 00000000000..7b727fc0d80 --- /dev/null +++ b/litellm/proxy/rust_forward/supervisor/runtime.py @@ -0,0 +1,194 @@ +import asyncio +import contextlib +import math +import os +import signal +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, Protocol + +import anyio +from anyio.abc import TaskGroup +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from typing_extensions import assert_never + +from litellm.proxy.rust_forward.mode import RUST_HOP +from litellm.proxy.rust_forward.platform import Platform +from litellm.proxy.rust_forward.server import PythonServer +from litellm.proxy.rust_forward.supervisor.children import ChildSpec, python_spec, rust_spec +from litellm.proxy.rust_forward.supervisor.lifecycle import ( + Caught, + Child, + ChildExited, + Event, + Probe, + Send, + Spawn, + boot, + exit_status, + step, +) + +PROBE_INTERVAL_SECONDS: Final = 0.05 + + +@dataclass(frozen=True, slots=True) +class SpawnFailed: + error: OSError + + +class Executor(Protocol): + async def spawn(self, child: Child) -> SpawnFailed | None: ... + + def send(self, child: Child, signum: signal.Signals) -> None: ... + + async def next_event(self) -> Event: ... + + +async def run(platform: Platform, binary: str, command: Sequence[str], server: PythonServer) -> int | SpawnFailed: + with platform.private_listener() as private: + specs: Final = MappingProxyType( + { + Child.PYTHON: python_spec(platform, command, private, server), + Child.RUST: rust_spec(platform, binary, private.port, server), + } + ) + async with open_children(platform, specs, private.port) as children: + platform.catch(asyncio.get_running_loop(), children.caught) + return await drive(children, platform.stop_signals) + + +async def drive(executor: Executor, stop_signals: frozenset[signal.Signals]) -> int | SpawnFailed: + """Carry out what ``lifecycle.step`` decides until it settles the exit status.""" + state, actions = boot() + while True: + for action in actions: + match action: + case Spawn(child): + if (failed := await executor.spawn(child)) is not None: + return failed + case Send(child, signum): + executor.send(child, signum) + case _: + assert_never(action) + if (status := exit_status(state)) is not None: + return status + state, actions = step(state, await executor.next_event(), stop_signals) + + +def _die_by(signum: signal.Signals) -> None: + with contextlib.suppress(OSError): + signal.signal(signum, signal.SIG_DFL) + signal.raise_signal(signum) + + +def exit_like( + status: int, stop_signals: frozenset[signal.Signals], die_by: Callable[[signal.Signals], None] = _die_by +) -> int: + """A child killed by a signal kills the supervisor with the same signal, as uvicorn re-raises the one it caught. + + SIGKILL takes no handler and needs none to kill. The kernel drops the signal when the supervisor is a container's + PID 1. uvicorn alone survives its own re-raise there and exits 0, so a stop signal ends the supervisor with 0 too, + and any other signal with the status a shell reports for it.""" + if status >= 0: + return status + signum: Final = signal.Signals(-status) + die_by(signum) + return 0 if signum in stop_signals else 128 + signum + + +@dataclass(frozen=True, slots=True) +class Children: + platform: Platform + specs: Mapping[Child, ChildSpec] + private_port: int + stack: contextlib.AsyncExitStack + tasks: TaskGroup + events_in: MemoryObjectSendStream[Event] + events_out: MemoryObjectReceiveStream[Event] + processes: dict[Child, asyncio.subprocess.Process] # mutable-ok: the running children the stack owns + + def caught(self, signum: signal.Signals) -> None: + with contextlib.suppress(anyio.ClosedResourceError): + self.events_in.send_nowait(Caught(signum)) + + async def spawn(self, child: Child) -> SpawnFailed | None: + try: + process: Final = await self.stack.enter_async_context(_process(self.platform, self.specs[child])) + except OSError as error: + return SpawnFailed(error) + self.processes[child] = process + self.tasks.start_soon(_report_exit, child, process, self.events_in.clone()) + if child is Child.PYTHON: + probe: Final = self.tasks.start_soon(_report_once_listening, self.private_port, self.events_in.clone()) + self.stack.callback(probe.cancel) + return None + + def send(self, child: Child, signum: signal.Signals) -> None: + self.platform.signal_child(self.processes[child], signum) + + async def next_event(self) -> Event: + return await self.events_out.receive() + + +# The task group is entered before any child, so on the way out every child is stopped, last spawned first, before +# the task group waits for the tasks reporting their exits. Signal handlers cannot wait for room, hence the +# unbounded event buffer. +@contextlib.asynccontextmanager +async def open_children( + platform: Platform, specs: Mapping[Child, ChildSpec], private_port: int +) -> AsyncGenerator[Children]: + events_in, events_out = anyio.create_memory_object_stream[Event](math.inf) + with events_in, events_out: + async with contextlib.AsyncExitStack() as stack: + tasks: Final = await stack.enter_async_context(anyio.create_task_group()) + yield Children(platform, specs, private_port, stack, tasks, events_in, events_out, {}) + + +# A child's stdin is a pipe only the supervisor holds open, so the child sees EOF as soon as the supervisor is gone, +# however it went, and shuts down instead of outliving it. Stopping is shielded so a cancelled supervisor still waits +# for the child it is stopping. +@contextlib.asynccontextmanager +async def _process(platform: Platform, spec: ChildSpec) -> AsyncGenerator[asyncio.subprocess.Process]: + process: Final = await asyncio.create_subprocess_exec( + *spec.argv, + stdin=asyncio.subprocess.PIPE, + env={**os.environ, **spec.env_overrides}, + pass_fds=spec.spawn.pass_fds, + start_new_session=spec.spawn.start_new_session, + creationflags=spec.spawn.creationflags, + ) + try: + yield process + finally: + with anyio.CancelScope(shield=True): + platform.stop_group(process) + await process.wait() + if process.stdin is not None: + process.stdin.close() + + +async def _report_exit( + child: Child, process: asyncio.subprocess.Process, events: MemoryObjectSendStream[Event] +) -> None: + with events: + events.send_nowait(ChildExited(child, await process.wait())) + + +async def _report_once_listening(port: int, events: MemoryObjectSendStream[Event]) -> None: + with events: + while not await _accepts(port): + await anyio.sleep(PROBE_INTERVAL_SECONDS) + events.send_nowait(Probe.PYTHON_LISTENING) + + +async def _accepts(port: int) -> bool: + with anyio.move_on_after(PROBE_INTERVAL_SECONDS): + try: + stream: Final = await anyio.connect_tcp(RUST_HOP, port) + except OSError: + return False + await stream.aclose() + return True + return False diff --git a/litellm/rust_bridge/AGENTS.md b/litellm/rust_bridge/AGENTS.md index 167b91c9b89..a13f57fae4b 100644 --- a/litellm/rust_bridge/AGENTS.md +++ b/litellm/rust_bridge/AGENTS.md @@ -46,6 +46,11 @@ - A failure after execution starts is terminal and never triggers fallback - Creating an unstarted native coroutine acquires no clients or credentials and captures no execution context +# The `LITELLM_RUST` switch + +- `decision` treats a blank or invalid `LITELLM_RUST` as unset and caches the parse by raw value. It runs inside SDK calls, so it never fails one over a switch and never validates per call +- It does not put the Rust gateway in front of Python. That is `LITELLM_RUST_FIRST`, which `litellm/proxy/rust_forward/mode.py` reads strictly at startup with this same `RustSwitch` type, stopping the CLI with one line naming the variable + # Native extension - `_native` ships in every wheel and the sdist builds it, so it is always importable diff --git a/litellm_cli/main.py b/litellm_cli/main.py index a9dfa4be179..ea170603d08 100644 --- a/litellm_cli/main.py +++ b/litellm_cli/main.py @@ -33,7 +33,7 @@ from contextlib import contextmanager from typing import TYPE_CHECKING, Final if TYPE_CHECKING: - from fastapi import FastAPI + from litellm.proxy.rust_forward.peer import ASGIApp SDK_PACKAGE: Final = "litellm" CLI_PACKAGE: Final = "litellm.proxy.client" @@ -214,10 +214,12 @@ def without_gc_pauses() -> Generator[None, None, None]: gc.enable() -def proxy_worker_app() -> "FastAPI": +def proxy_worker_app() -> "ASGIApp": with without_gc_pauses(): from litellm.proxy.proxy_cli import ProxyInitializationHelpers ProxyInitializationHelpers.apply_worker_log_config(os.environ) from litellm.proxy.proxy_server import app - return app + from litellm.proxy.rust_forward.mode import rust_forward_mode + from litellm.proxy.rust_forward.peer import wrap + return wrap(app, rust_forward_mode(os.environ)) diff --git a/tests/integration/_support/process.py b/tests/integration/_support/process.py index 491fc87dce2..7a1bd7208d5 100644 --- a/tests/integration/_support/process.py +++ b/tests/integration/_support/process.py @@ -145,6 +145,11 @@ def signal_group(group: int, action: int) -> None: pass +def send_signal(process: subprocess.Popen[bytes], signum: signal.Signals) -> None: + with suppress(ProcessLookupError): + process.send_signal(signum) + + def graceful_stop_seconds() -> float: return readiness_for(import_seconds(), 1) diff --git a/tests/integration/_support/rust_forward.py b/tests/integration/_support/rust_forward.py new file mode 100644 index 00000000000..14b557897c5 --- /dev/null +++ b/tests/integration/_support/rust_forward.py @@ -0,0 +1,117 @@ +import json +import os +import subprocess +import sys +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import httpx +from pydantic import JsonValue + +from tests.integration._support.client import eventually +from tests.integration._support.ports import allocate_port +from tests.integration._support.process import stop_root_process + +MASTER_KEY: Final = "sk-rust-forward-integration-key-not-a-secret" +WORKERS: Final = "2" +HEALTHCHECK_SECONDS: Final = "120" + + +class EntryPoint(Enum): + LITELLM = "litellm.proxy.proxy_cli" + GATEWAY = "gateway.launch" + + +@dataclass(frozen=True, slots=True) +class ForwardedProxy: + port: int + log: Path + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + +def _serving(url: str) -> bool: + try: + return httpx.get(f"{url}/health/liveliness", timeout=5, trust_env=False).status_code == 200 + except httpx.TransportError: + return False + + +def _launch(entry_point: EntryPoint, config: Path) -> tuple[tuple[str, ...], Mapping[str, str]]: + match entry_point: + case EntryPoint.LITELLM: + return ( + ( + "--config", + str(config), + "--num_workers", + WORKERS, + "--timeout_worker_healthcheck", + HEALTHCHECK_SECONDS, + ), + MappingProxyType({}), + ) + case EntryPoint.GATEWAY: + return ( + ("--workers", WORKERS, "--timeout-worker-healthcheck", HEALTHCHECK_SECONDS), + MappingProxyType({"CONFIG_FILE_PATH": str(config), "LITELLM_PGBOUNCER_ENABLED": "false"}), + ) + + +@contextmanager +def forwarded_proxy( + entry_point: EntryPoint, + config: Mapping[str, JsonValue], + directory: Path, + arguments: tuple[str, ...] = (), + environment: Mapping[str, str] = MappingProxyType({}), +) -> Iterator[ForwardedProxy]: + """``entry_point`` on 0.0.0.0 with two workers, ``LITELLM_RUST_FIRST=1`` and no database. It must exit 0 on SIGTERM.""" + proxy: Final = ForwardedProxy(allocate_port(), directory / f"{entry_point.name.lower()}.log") + config_file: Final = directory / "config.yaml" + config_file.write_text(json.dumps(config)) + launch_arguments, launch_environment = _launch(entry_point, config_file) + inherited: Final = { + name: value for name, value in os.environ.items() if not name.startswith("DATABASE_") and name != "DIRECT_URL" + } + with ( + proxy.log.open("wb") as output, + subprocess.Popen( + ( + sys.executable, + "-I", + "-m", + entry_point.value, + "--host", + "0.0.0.0", + "--port", + str(proxy.port), + *launch_arguments, + *arguments, + ), + env={ + **inherited, + "LITELLM_MASTER_KEY": MASTER_KEY, + "LITELLM_RUST_FIRST": "true", + "STORE_MODEL_IN_DB": "False", + "PYTHON_DOTENV_DISABLED": "1", + **launch_environment, + **environment, + }, + stdout=output, + stderr=output, + ) as process, + ): + try: + assert eventually(lambda: _serving(proxy.url), bool, seconds=180), proxy.log.read_text() + yield proxy + finally: + assert stop_root_process(process), proxy.log.read_text() + assert process.returncode == 0, proxy.log.read_text() diff --git a/tests/integration/configuration/test_rust_forward.py b/tests/integration/configuration/test_rust_forward.py new file mode 100644 index 00000000000..cc4e0dc2770 --- /dev/null +++ b/tests/integration/configuration/test_rust_forward.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import httpx +import psutil +import pytest + +from tests.integration._support.client import eventually +from tests.integration._support.ports import allocate_port +from tests.integration._support.rust_forward import MASTER_KEY, EntryPoint, forwarded_proxy + +TRUSTED_BALANCER: Final = "127.0.0.3" +CLIENT: Final = "127.0.0.2" + + +@dataclass(frozen=True, slots=True) +class Caller: + connects_from: str + forwarded_for: str + resolved_ip: str + + +CALLERS: Final = ( + Caller(CLIENT, "1.2.3.4", resolved_ip=CLIENT), + Caller(TRUSTED_BALANCER, "203.0.113.9", resolved_ip="203.0.113.9"), + Caller(CLIENT, f"203.0.113.9, {TRUSTED_BALANCER}", resolved_ip=CLIENT), +) + + +def _client(url: str, local_address: str) -> httpx.Client: + return httpx.Client( + base_url=url, transport=httpx.HTTPTransport(local_address=local_address), trust_env=False, timeout=5 + ) + + +def _resolved_ip(url: str, caller: Caller) -> str: + with _client(url, caller.connects_from) as client: + response: Final = client.get( + "/v1/mcp/network/client-ip", + headers={"Authorization": f"Bearer {MASTER_KEY}", "X-Forwarded-For": caller.forwarded_for}, + ) + assert response.status_code == 200, response.text + return response.json()["ip"] + + +@pytest.mark.parametrize("server_arguments", [(), ("--run_gunicorn",)]) +def test_only_the_trusted_balancer_can_name_the_client_ip_behind_rust( + tmp_path: Path, server_arguments: tuple[str, ...] +) -> None: + metrics_port: Final = allocate_port() + with forwarded_proxy( + EntryPoint.LITELLM, + { + "model_list": [], + "general_settings": {"master_key": "os.environ/LITELLM_MASTER_KEY", "use_x_forwarded_for": False}, + }, + tmp_path, + ("--prometheus_metrics_port", str(metrics_port), *server_arguments), + {"FORWARDED_ALLOW_IPS": TRUSTED_BALANCER}, + ) as proxy: + with _client(f"http://{CLIENT}:{metrics_port}", CLIENT) as external: + metrics: Final = external.get("/metrics", follow_redirects=True) + assert metrics.status_code == 200, metrics.text + assert tuple(_resolved_ip(proxy.url, caller) for caller in CALLERS) == tuple( + caller.resolved_ip for caller in CALLERS + ) + + +@pytest.mark.parametrize("server_arguments", [("--no-proxy-headers",), ("--forwarded-allow-ips", "127.0.0.1")]) +def test_gateway_launcher_logs_the_real_client_address_behind_rust( + tmp_path: Path, server_arguments: tuple[str, ...] +) -> None: + with forwarded_proxy( + EntryPoint.GATEWAY, + {"model_list": [], "general_settings": {"master_key": "os.environ/LITELLM_MASTER_KEY"}}, + tmp_path, + server_arguments, + ) as proxy: + with _client(proxy.url, CLIENT) as external: + response: Final = external.get( + "/health/liveliness?client-ip-proof", headers={"X-Forwarded-For": "198.51.100.10"} + ) + assert response.status_code == 200, response.text + logged: Final = eventually( + lambda: tuple( + line for line in proxy.log.read_text().splitlines() if "GET /health/liveliness?client-ip-proof " in line + ), + bool, + seconds=10, + ) + assert len(logged) == 1 and f"{CLIENT}:" in logged[0], logged + + +@pytest.mark.parametrize("entry_point", list(EntryPoint), ids=[entry_point.name for entry_point in EntryPoint]) +def test_the_python_child_is_started_with_the_supervisors_own_command(tmp_path: Path, entry_point: EntryPoint) -> None: + with forwarded_proxy( + entry_point, + {"model_list": [], "general_settings": {"master_key": "os.environ/LITELLM_MASTER_KEY"}}, + tmp_path, + ): + supervisor: Final = next( + started for started in psutil.Process().children() if entry_point.value in started.cmdline() + ) + own_command: Final = supervisor.cmdline()[1:] + python_children: Final = [ + child.cmdline()[1:] for child in supervisor.children() if entry_point.value in child.cmdline() + ] + assert "-I" in own_command + assert [child[: len(own_command)] for child in python_children] == [own_command] diff --git a/tests/integration/configuration/test_rust_forward_lifecycle.py b/tests/integration/configuration/test_rust_forward_lifecycle.py new file mode 100644 index 00000000000..b3ffb8b7169 --- /dev/null +++ b/tests/integration/configuration/test_rust_forward_lifecycle.py @@ -0,0 +1,269 @@ +import os +import signal +import socket +import subprocess +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Final, TypeVar + +import httpx +import psutil +import pytest + +from tests.integration._support.client import eventually +from tests.integration._support.ports import allocate_port +from tests.integration._support.process import kill_stray_process, send_signal + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="drives POSIX signals") + +APP: Final = """ +import asyncio + + +async def app(scope, receive, send): + if scope["type"] != "http": + return + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + if scope["path"] != "/stream": + await send({"type": "http.response.body", "body": b"ok"}) + return + while True: + await send({"type": "http.response.body", "body": b"tick\\n", "more_body": True}) + await asyncio.sleep(0.1) +""" + +SERVE: Final = """ +import os +import pathlib +import sys + +import uvicorn + + + +def listener(): + from litellm.proxy.rust_forward.mode import Supervised, rust_forward_mode, stop_with_supervisor + + mode = rust_forward_mode(os.environ) + if not isinstance(mode, Supervised): + return {"host": "127.0.0.1", "port": int(sys.argv[2])} + stop_with_supervisor(mode) + pathlib.Path(os.environ["MARKERS"], "python.pid").write_text(str(os.getpid())) + return {"fd": mode.listener.fd} + + +if __name__ == "__main__": + uvicorn.run("app:app", app_dir=os.path.dirname(__file__), workers=int(sys.argv[1]), log_level="warning", **listener()) +""" + +GATEWAY: Final = """ +import asyncio +import os +import pathlib +import signal +import threading + + +def wait_for_eof(loop, stop): + while os.read(0, 4096): + pass + loop.call_soon_threadsafe(stop.set) + + +async def pipe(reader, writer): + try: + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + except OSError: + pass + finally: + writer.close() + + +async def main(): + pathlib.Path(os.environ["MARKERS"], "rust.pid").write_text(str(os.getpid())) + host, port = os.environ["LITELLM_FORWARD_UPSTREAM"].removeprefix("http://").rsplit(":", 1) + loop = asyncio.get_running_loop() + stop = asyncio.Event() + for signum in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(signum, stop.set) + threading.Thread(target=wait_for_eof, args=(loop, stop), daemon=True).start() + relays = set() + + async def relay(client_reader, client_writer): + relays.add(asyncio.current_task()) + try: + upstream_reader, upstream_writer = await asyncio.open_connection(host, int(port)) + await asyncio.gather(pipe(client_reader, upstream_writer), pipe(upstream_reader, client_writer)) + finally: + relays.discard(asyncio.current_task()) + + server = await asyncio.start_server( + relay, os.environ["LITELLM_FORWARD_HOST"], int(os.environ["LITELLM_FORWARD_PORT"]) + ) + await stop.wait() + server.close() + if relays: + await asyncio.wait(relays, timeout=float(os.environ["LITELLM_FORWARD_SHUTDOWN_DRAIN"])) + + +asyncio.run(main()) +""" + +SUPERVISE: Final = """ +import sys +from pathlib import Path + +from litellm.proxy.rust_forward.server import Listener, PythonServer, Server +from litellm.proxy.rust_forward.supervisor import supervise + +workers, port, scripts = sys.argv[1], int(sys.argv[2]), Path(sys.argv[3]) +server = PythonServer(Server.UVICORN, Listener.TCP, "127.0.0.1", port, 5, 30.0, "127.0.0.1") +result = supervise(server, (sys.executable, str(scripts / "serve.py"), workers, str(port)), scripts_dir=str(scripts)) +raise SystemExit(result if isinstance(result, int) else 3) +""" + +T = TypeVar("T") +EXIT_SECONDS: Final = 15 +TIME_TOLERANCE_SECONDS: Final = 5 + + +class Scenario(Enum): + TERM = "SIGTERM" + INT = "SIGINT" + INT_TWICE_WHILE_STREAMING = "SIGINT twice during an open stream" + HUP = "SIGHUP" + + +@dataclass(frozen=True, slots=True) +class Outcome: + status: int | None + survived_hup: bool + + +@dataclass(frozen=True, slots=True) +class Run: + outcome: Outcome + seconds: float + + +def _scripts(directory: Path) -> Path: + (directory / "app.py").write_text(APP) + (directory / "serve.py").write_text(SERVE) + (directory / "supervise.py").write_text(SUPERVISE) + gateway: Final = directory / "litellm-gateway" + gateway.write_text(f"#!{sys.executable}\n{GATEWAY}") + gateway.chmod(0o755) + return directory + + +def _serving(port: int) -> bool: + try: + return httpx.get(f"http://127.0.0.1:{port}/", timeout=1, trust_env=False).text == "ok" + except httpx.TransportError: + return False + + +def _exit_status(process: subprocess.Popen[bytes], seconds: float) -> int | None: + try: + return process.wait(timeout=seconds) + except subprocess.TimeoutExpired: + return None + + +def _streaming(port: int) -> socket.socket: + stream: Final = socket.create_connection(("127.0.0.1", port), timeout=10) + stream.sendall(b"GET /stream HTTP/1.1\r\nHost: test\r\n\r\n") + assert eventually(lambda: stream.recv(4096), lambda received: b"tick" in received, seconds=10) + return stream + + +def _signal_after_ready(process: subprocess.Popen[bytes], port: int, scenario: Scenario) -> Run: + assert eventually(lambda: _serving(port), bool, seconds=120) + started: Final = time.monotonic() + match scenario: + case Scenario.TERM | Scenario.INT: + send_signal(process, signal.SIGTERM if scenario is Scenario.TERM else signal.SIGINT) + return Run(Outcome(_exit_status(process, EXIT_SECONDS), False), time.monotonic() - started) + case Scenario.INT_TWICE_WHILE_STREAMING: + with _streaming(port): + send_signal(process, signal.SIGINT) + time.sleep(0.5) + send_signal(process, signal.SIGINT) + status: Final = _exit_status(process, EXIT_SECONDS) + return Run(Outcome(status, False), time.monotonic() - started) + case Scenario.HUP: + send_signal(process, signal.SIGHUP) + after_hup: Final = _exit_status(process, 3) + if after_hup is not None: + return Run(Outcome(after_hup, False), time.monotonic() - started) + assert eventually(lambda: _serving(port), bool, seconds=30) + send_signal(process, signal.SIGTERM) + return Run(Outcome(_exit_status(process, EXIT_SECONDS), True), time.monotonic() - started) + + +def _run(argv: tuple[str, ...], environment: dict[str, str], act: Callable[[subprocess.Popen[bytes]], T]) -> T: + with subprocess.Popen(argv, env=environment, start_new_session=True) as process: + try: + return act(process) + finally: + if process.poll() is None: + for leftover in (*psutil.Process(process.pid).children(recursive=True), psutil.Process(process.pid)): + kill_stray_process(leftover.pid) + + +def _environment(markers: Path) -> dict[str, str]: + return {**os.environ, "MARKERS": str(markers), "PYTHONUNBUFFERED": "1"} + + +@pytest.mark.parametrize("workers", [1, 2]) +@pytest.mark.parametrize("scenario", list(Scenario), ids=[scenario.name for scenario in Scenario]) +def test_the_supervisor_ends_as_python_alone_would(tmp_path: Path, workers: int, scenario: Scenario) -> None: + scripts: Final = _scripts(tmp_path) + alone_port: Final = allocate_port() + alone: Final = _run( + (sys.executable, str(scripts / "serve.py"), str(workers), str(alone_port)), + _environment(tmp_path), + lambda process: _signal_after_ready(process, alone_port, scenario), + ) + supervised_port: Final = allocate_port() + supervised: Final = _run( + (sys.executable, str(scripts / "supervise.py"), str(workers), str(supervised_port), str(scripts)), + _environment(tmp_path), + lambda process: _signal_after_ready(process, supervised_port, scenario), + ) + assert supervised.outcome == alone.outcome + assert abs(supervised.seconds - alone.seconds) < TIME_TOLERANCE_SECONDS, (alone, supervised) + + +def _alive(pid: int) -> bool: + try: + return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE + except psutil.NoSuchProcess: + return False + + +@pytest.mark.parametrize("workers", [1, 2]) +def test_children_exit_when_the_supervisor_is_killed(tmp_path: Path, workers: int) -> None: + scripts: Final = _scripts(tmp_path) + port: Final = allocate_port() + + def survivors_of_killing_the_supervisor(process: subprocess.Popen[bytes]) -> tuple[int, ...]: + assert eventually(lambda: _serving(port), bool, seconds=120) + python: Final = int((tmp_path / "python.pid").read_text()) + rust: Final = int((tmp_path / "rust.pid").read_text()) + descendants: Final = (python, rust, *(child.pid for child in psutil.Process(python).children(recursive=True))) + kill_stray_process(process.pid) + return eventually(lambda: tuple(pid for pid in descendants if _alive(pid)), lambda alive: not alive) + + survivors: Final = _run( + (sys.executable, str(scripts / "supervise.py"), str(workers), str(port), str(scripts)), + _environment(tmp_path), + survivors_of_killing_the_supervisor, + ) + assert survivors == () diff --git a/tests/integration/configuration/test_rust_forward_traffic.py b/tests/integration/configuration/test_rust_forward_traffic.py new file mode 100644 index 00000000000..4cd2f09e6d7 --- /dev/null +++ b/tests/integration/configuration/test_rust_forward_traffic.py @@ -0,0 +1,387 @@ +import asyncio +import json +import os +import socket +import threading +import time +from collections.abc import AsyncIterator, Callable, Iterator, Mapping +from contextlib import AbstractContextManager, ExitStack +from dataclasses import dataclass +from queue import SimpleQueue +from types import MappingProxyType +from typing import Final + +import anyio +import httpx +import openai +import pytest +from pydantic import BaseModel, JsonValue +from starlette.applications import Starlette +from starlette.requests import Request as StarletteRequest +from starlette.responses import StreamingResponse +from starlette.routing import Route, WebSocketRoute +from starlette.websockets import WebSocket, WebSocketDisconnect +from websockets.sync.client import connect + +from tests.integration._support.asgi import asgi_server +from tests.integration._support.mcp import mcp_peer +from tests.integration._support.rust_forward import MASTER_KEY, EntryPoint, forwarded_proxy +from tests.integration._support.wire import Reply, Request, wire_server + +KEEPALIVE_SECONDS: Final = 1 +FILES_MODEL: Final = "rust-forward-files" +FILE_ID: Final = "file-rust-forward" +CHAT_MODEL: Final = "rust-forward-chat" +REALTIME_MODEL: Final = "rust-forward-realtime" +MCP_HEADERS: Final = MappingProxyType( + { + "Authorization": f"Bearer {MASTER_KEY}", + "Accept": "application/json, text/event-stream", + "mcp-protocol-version": "2025-06-18", + } +) + + +@dataclass(frozen=True, slots=True) +class ScriptedProvider: + url: str + released: threading.Event + hung_up: SimpleQueue[str] + + +@dataclass(frozen=True, slots=True) +class RustProxy: + url: str + port: int + provider: ScriptedProvider + + +class _Message(BaseModel): + content: str + + +class _ChatRequest(BaseModel): + messages: tuple[_Message, ...] + + +class _InputText(BaseModel): + text: str + + +class _Item(BaseModel): + content: tuple[_InputText, ...] = () + + +class _RealtimeFrame(BaseModel): + type: str + item: _Item | None = None + + +def _sse(content: str | None, finish_reason: str | None = None) -> bytes: + chunk: Final = { + "id": "chatcmpl-rust-forward", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "delta": {} if content is None else {"content": content}, "finish_reason": finish_reason} + ], + } + return b"data: " + json.dumps(chunk).encode() + b"\n\n" + + +async def _gated_stream(released: threading.Event) -> AsyncIterator[bytes]: + yield _sse("before-gate") + await anyio.to_thread.run_sync(released.wait, 30) + yield _sse("after-gate") + yield _sse(None, "stop") + yield b"data: [DONE]\n\n" + + +async def _endless_stream(marker: str, hung_up: SimpleQueue[str]) -> AsyncIterator[bytes]: + try: + for index in range(600): + yield _sse(f"tick-{index} ") + await asyncio.sleep(0.1) + except (asyncio.CancelledError, GeneratorExit): + hung_up.put(marker) + raise + + +def _provider_app(released: threading.Event, hung_up: SimpleQueue[str]) -> Starlette: + async def chat(request: StarletteRequest) -> StreamingResponse: + prompt: Final = _ChatRequest.model_validate_json(await request.body()).messages[-1].content + return StreamingResponse( + _gated_stream(released) if prompt == "gated" else _endless_stream(prompt, hung_up), + media_type="text/event-stream", + ) + + async def realtime(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.send_json({"type": "session.created", "session": {"id": "sess_rust_forward"}}) + try: + while True: + frame = _RealtimeFrame.model_validate_json(await websocket.receive_text()) # rebind-ok: per frame + if frame.item is not None: + text = "".join(part.text for part in frame.item.content) # rebind-ok: per frame + await websocket.send_json({"type": "response.output_text.delta", "delta": f"echo:{text}"}) + except WebSocketDisconnect: + return + + return Starlette( + routes=[ + Route("/v1/chat/completions", chat, methods=["POST"]), + WebSocketRoute("/v1/realtime", realtime), + ] + ) + + +def _routable_address() -> str: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.connect(("192.0.2.1", 9)) + address: Final[str] = probe.getsockname()[0] + assert not address.startswith("127."), address + return address + + +def _files_peer(content: bytes) -> Callable[[Request], Reply]: + file_object: Final = json.dumps( + { + "id": FILE_ID, + "object": "file", + "purpose": "batch", + "bytes": len(content), + "created_at": 1, + "filename": "upload.jsonl", + "status": "processed", + } + ).encode() + + def respond(request: Request) -> Reply: + if request.method == "POST" and request.target.startswith("/v1/files"): + return Reply(body=file_object) + if request.method == "GET" and request.target.startswith(f"/v1/files/{FILE_ID}/content"): + return Reply( + content_type="application/octet-stream", body=content, chunks=(content[:65536], content[65536:]) + ) + return Reply(status=404, body=b'{"error": {"message": "not found"}}') + + return respond + + +UPLOAD: Final = b"".join( + json.dumps( + { + "custom_id": f"row-{index}", + "method": "POST", + "url": "/v1/chat/completions", + "body": {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": os.urandom(48).hex()}]}, + } + ).encode() + + b"\n" + for index in range(30_000) +) + + +@pytest.fixture(scope="module") +def files_upstream() -> Iterator[str]: + with wire_server(_files_peer(UPLOAD), keep_alive=True) as wire: + yield wire.url + + +@pytest.fixture(scope="module") +def rust_proxy(tmp_path_factory: pytest.TempPathFactory, files_upstream: str) -> Iterator[RustProxy]: + released: Final = threading.Event() + hung_up: Final[SimpleQueue[str]] = SimpleQueue() + with ExitStack() as stack: + mcp: Final = stack.enter_context(mcp_peer()) + provider: Final = ScriptedProvider( + stack.enter_context(asgi_server(_provider_app(released, hung_up))), released, hung_up + ) + proxy: Final = stack.enter_context( + forwarded_proxy( + EntryPoint.LITELLM, + { + "model_list": [ + _deployment(CHAT_MODEL, "openai/gpt-4o-mini", f"{provider.url}/v1"), + { + **_deployment(REALTIME_MODEL, "openai/gpt-realtime", provider.url), + "model_info": {"mode": "realtime"}, + }, + _deployment(FILES_MODEL, "openai/gpt-4o-mini", f"{files_upstream}/v1"), + ], + "mcp_servers": {"math": {**mcp.registration(), "available_on_public_internet": False}}, + "general_settings": { + "master_key": "os.environ/LITELLM_MASTER_KEY", + "mcp_internal_ip_ranges": ["127.0.0.0/8"], + }, + }, + tmp_path_factory.mktemp("rust-forward-traffic"), + ("--keepalive_timeout", str(KEEPALIVE_SECONDS)), + ) + ) + stack.callback(released.set) + yield RustProxy(proxy.url, proxy.port, provider) + + +def _deployment(name: str, model: str, api_base: str) -> dict[str, JsonValue]: + return { + "model_name": name, + "litellm_params": {"model": model, "api_base": api_base, "api_key": "integration-provider-key"}, + } + + +def _still_open(stream: httpx.Response) -> bool: + try: + next(stream.iter_raw()) + except httpx.ReadTimeout: + return True + except (StopIteration, httpx.TransportError): + return False + return True + + +def _initialize(client: httpx.Client, headers: Mapping[str, str]) -> httpx.Response: + return client.post( + "/mcp", + headers={**MCP_HEADERS, **headers}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "rust-forward", "version": "1"}, + }, + }, + ) + + +def _mcp_post(client: httpx.Client, session: str, body: Mapping[str, JsonValue]) -> httpx.Response: + return client.post("/mcp", headers={**MCP_HEADERS, "mcp-session-id": session}, json=body) + + +def test_mcp_session_keeps_working_while_its_get_stream_sits_idle(rust_proxy: RustProxy) -> None: + idle_seconds: Final = 8 * KEEPALIVE_SECONDS + with httpx.Client(base_url=rust_proxy.url, trust_env=False, timeout=30) as client: + initialized: Final = _initialize(client, {}) + assert initialized.status_code == 200, initialized.text + session: Final = initialized.headers["mcp-session-id"] + assert _mcp_post(client, session, {"jsonrpc": "2.0", "method": "notifications/initialized"}).status_code == 202 + with httpx.Client(base_url=rust_proxy.url, trust_env=False, timeout=httpx.Timeout(5, read=2)) as listener: + with listener.stream( + "GET", "/mcp", headers={**MCP_HEADERS, "Accept": "text/event-stream", "mcp-session-id": session} + ) as notifications: + assert notifications.status_code == 200 + assert notifications.headers["content-type"].startswith("text/event-stream") + time.sleep(idle_seconds) + called: Final = _mcp_post( + client, + session, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "math-add", "arguments": {"a": 2, "b": 3}}, + }, + ) + assert called.status_code == 200, called.text + assert '"text":"5"' in called.text.replace(" ", ""), called.text + assert _still_open(notifications) + + +def test_uploaded_file_and_its_download_cross_byte_for_byte(rust_proxy: RustProxy) -> None: + with openai.OpenAI(api_key=MASTER_KEY, base_url=rust_proxy.url, max_retries=0, timeout=60) as client: + created: Final = client.files.create( + file=("upload.jsonl", UPLOAD), purpose="batch", extra_body={"model": FILES_MODEL} + ) + downloaded: Final = client.files.content(created.id, extra_query={"model": FILES_MODEL}).content + assert len(UPLOAD) > 4 * 1024 * 1024 + assert downloaded == UPLOAD, (len(downloaded), len(UPLOAD)) + + +def test_internal_only_mcp_server_judges_the_connecting_peer_not_its_forwarded_for_claim( + rust_proxy: RustProxy, +) -> None: + outsider: Final = _routable_address() + with httpx.Client( + base_url=f"http://{outsider}:{rust_proxy.port}", + transport=httpx.HTTPTransport(local_address=outsider), + trust_env=False, + timeout=10, + ) as spoofing: + spoofed: Final = _initialize(spoofing, {"X-Forwarded-For": "127.0.0.1"}) + with httpx.Client(base_url=rust_proxy.url, trust_env=False, timeout=10) as internal: + admitted: Final = _initialize(internal, {}) + assert admitted.status_code == 200, admitted.text + assert spoofed.status_code == 403, spoofed.text + assert "client IP" in spoofed.text, spoofed.text + + +def test_oauth_metadata_names_the_origin_the_client_used(rust_proxy: RustProxy) -> None: + with httpx.Client(base_url=rust_proxy.url, trust_env=False, timeout=10) as client: + direct: Final = client.get("/.well-known/oauth-protected-resource") + balanced: Final = client.get( + "/.well-known/oauth-protected-resource", + headers={"Host": "gateway.example.test", "X-Forwarded-Proto": "https"}, + ) + assert direct.status_code == 200, direct.text + assert direct.json()["resource"] == rust_proxy.url + assert direct.json()["authorization_servers"] == [f"{rust_proxy.url}/mcp"] + assert balanced.status_code == 200, balanced.text + assert balanced.json()["resource"] == "https://gateway.example.test" + + +def _chat_stream(client: httpx.Client, prompt: str) -> AbstractContextManager[httpx.Response]: + return client.stream( + "POST", + "/v1/chat/completions", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"model": CHAT_MODEL, "stream": True, "messages": [{"role": "user", "content": prompt}]}, + ) + + +def _content(lines: Iterator[str]) -> str: + return next(line for line in lines if '"content"' in line) + + +def test_stream_chunk_reaches_the_client_before_the_provider_sends_the_next(rust_proxy: RustProxy) -> None: + with httpx.Client(base_url=rust_proxy.url, trust_env=False, timeout=httpx.Timeout(10, read=5)) as client: + with _chat_stream(client, "gated") as response: + assert response.status_code == 200, response.read() + lines: Final = response.iter_lines() + first: Final = _content(lines) + rust_proxy.provider.released.set() + rest: Final = tuple(line for line in lines if line) + assert "before-gate" in first, first + assert any("after-gate" in line for line in rest), rest + assert rest[-1] == "data: [DONE]", rest + + +def test_client_hang_up_mid_stream_closes_the_provider_stream(rust_proxy: RustProxy) -> None: + marker: Final = f"endless-{time.monotonic_ns()}" + with httpx.Client(base_url=rust_proxy.url, trust_env=False, timeout=httpx.Timeout(10, read=5)) as client: + with _chat_stream(client, marker) as response: + assert response.status_code == 200, response.read() + assert "tick-0" in _content(response.iter_lines()) + assert rust_proxy.provider.hung_up.get(timeout=15) == marker + + +def test_realtime_websocket_frames_cross_both_ways(rust_proxy: RustProxy) -> None: + marker: Final = f"realtime-{time.monotonic_ns()}" + with connect( + f"ws://127.0.0.1:{rust_proxy.port}/v1/realtime?model={REALTIME_MODEL}", + additional_headers={"Authorization": f"Bearer {MASTER_KEY}"}, + open_timeout=10, + ) as session: + session.send( + json.dumps( + { + "type": "conversation.item.create", + "item": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": marker}]}, + } + ) + ) + replies: Final = tuple(session.recv(timeout=10) for _ in range(2)) + assert any(f"echo:{marker}" in reply for reply in replies), replies diff --git a/tests/unit/gateway/test_launch.py b/tests/unit/gateway/test_launch.py index d682fcbad2d..95b3a94c3bc 100644 --- a/tests/unit/gateway/test_launch.py +++ b/tests/unit/gateway/test_launch.py @@ -15,10 +15,19 @@ import pytest from uvicorn.importer import import_from_string from uvicorn.main import main as uvicorn_main -from gateway.launch import GATEWAY_APP, main, pool_database_url, uvicorn_argv +from gateway.launch import GATEWAY_APP, main, pool_database_url, python_server, uvicorn_argv from litellm.proxy.db.db_url_settings import DatabaseURLSettings from litellm.proxy.db.pgbouncer import PGBOUNCER_POOLED_ENV_VAR, PgBouncerError, PgBouncerSettings from litellm.proxy.proxy_server import app as proxy_app +from litellm.proxy.rust_forward.mode import ( + Mode, + RustForwardMode, + Supervised, + uvicorn_flags, +) +from litellm.proxy.rust_forward.server import NO_TRUSTED_PROXIES, Listener +from litellm.proxy.server_listener import AssignedPort, InheritedFd +from litellm.proxy.shutdown.graceful_shutdown_manager import GracefulShutdownManager DB_ENV: Final = { "DATABASE_HOST": "db.internal", @@ -114,6 +123,91 @@ def _uvicorn_params(argv: tuple[str, ...]) -> dict[str, object]: class TestUvicornArgv: + @pytest.mark.parametrize( + ("arguments", "listener"), + [ + ((), Listener.TCP), + (("--uds", "/tmp/gateway.sock"), Listener.UNIX_SOCKET), + (("--fd", "0"), Listener.INHERITED_FD), + (("--fd=17",), Listener.INHERITED_FD), + (("--ssl-keyfile", "unused"), Listener.TLS), + (("--ssl-certfile", "unused"), Listener.TLS), + (("--ssl-ciphers", "TLSv1.2"), Listener.TCP), + ], + ) + def test_forwarding_sees_the_public_listener_uvicorn_would_open( + self, arguments: tuple[str, ...], listener: Listener + ) -> None: + assert python_server(arguments).listener is listener + + @pytest.mark.parametrize( + ("argv", "environ", "trust"), + [ + ((), {}, "127.0.0.1"), + ((), {"FORWARDED_ALLOW_IPS": "10.9.9.9"}, "10.9.9.9"), + (("--forwarded-allow-ips", "10.0.0.5"), {"FORWARDED_ALLOW_IPS": "10.9.9.9"}, "10.0.0.5"), + (("--forwarded-allow-ips=*",), {}, "*"), + (("--no-proxy-headers", "--forwarded-allow-ips=*"), {}, NO_TRUSTED_PROXIES), + ], + ) + def test_forwarding_carries_the_trust_uvicorn_resolves_to_the_child( + self, + argv: tuple[str, ...], + environ: dict[str, str], + trust: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("FORWARDED_ALLOW_IPS", raising=False) + for name, value in environ.items(): + monkeypatch.setenv(name, value) + server: Final = python_server(("--host", "0.0.0.0", "--port", "4000", *argv)) + assert (server.host, server.port) == ("0.0.0.0", 4000) + assert server.trusted_proxies == trust + + @pytest.mark.parametrize( + ("argv", "environ"), + [(("--timeout-keep-alive", "2"), {}), ((), {"KEEPALIVE_TIMEOUT": "7"}), ((), {})], + ) + def test_rust_retires_connections_by_the_keepalive_uvicorn_runs_with( + self, argv: tuple[str, ...], environ: dict[str, str], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("KEEPALIVE_TIMEOUT", raising=False) + for name, value in environ.items(): + monkeypatch.setenv(name, value) + served: Final = _uvicorn_params(uvicorn_argv(argv, os.environ))["timeout_keep_alive"] + assert python_server(argv).keepalive_seconds == served + + @pytest.mark.parametrize("argv", [(), ("--timeout-graceful-shutdown", "12")]) + def test_rust_drains_for_pythons_graceful_timeout_plus_teardown( + self, argv: tuple[str, ...], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("GRACEFUL_SHUTDOWN_TIMEOUT", "7.5") + flag: Final = _uvicorn_params(uvicorn_argv(argv, {}))["timeout_graceful_shutdown"] + graceful: Final = GracefulShutdownManager.get_timeout() if flag is None else float(str(flag)) + assert python_server(argv).graceful_timeout_seconds == graceful + + @pytest.mark.parametrize( + ("mode", "served"), + [ + (Mode.DISABLED, {"host": "0.0.0.0", "port": 4000, "fd": None, "proxy_headers": True}), + ( + Supervised(InheritedFd(17), "*"), + {"host": "0.0.0.0", "port": 4000, "fd": 17, "proxy_headers": False}, + ), + ( + Supervised(AssignedPort(45556), "*"), + {"host": "127.0.0.1", "port": 45556, "fd": None, "proxy_headers": False}, + ), + ], + ) + def test_supervised_uvicorn_serves_the_private_hop_and_never_trusts_it( + self, mode: RustForwardMode, served: dict[str, object] + ) -> None: + argv: Final = ("--host", "0.0.0.0", "--port", "4000", "--workers", "2", "--proxy-headers") + params: Final = _uvicorn_params((*uvicorn_argv((*argv, "--forwarded-allow-ips=*"), {}), *uvicorn_flags(mode))) + assert {name: params[name] for name in served} == served + assert (params["workers"], params["forwarded_allow_ips"]) == (2, "*") + def test_keepalive_env_reaches_uvicorn(self): params: Final = _uvicorn_params(uvicorn_argv(("--workers", "4"), {"KEEPALIVE_TIMEOUT": "75"})) assert params["app"] == GATEWAY_APP @@ -229,6 +323,70 @@ class TestMain: DatabaseURLSettings.from_env().apply_to_env() assert os.environ["DATABASE_URL"] == pooled + @pytest.mark.parametrize( + ("rust", "warning"), + [ + ("false", ""), + ( + "true", + "LITELLM_RUST_FIRST does not support a Unix socket on the public port, " + "so Python serves the public port without Rust forwarding\n", + ), + ], + ) + def test_python_alone_keeps_uvicorn_socket_options( + self, + rust: str, + warning: str, + password_env: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.setenv("LITELLM_RUST_FIRST", rust) + monkeypatch.delenv("_LITELLM_GATEWAY_SUPERVISED", raising=False) + monkeypatch.delenv("LITELLM_PGBOUNCER_ENABLED", raising=False) + served: Final[list[tuple[str, ...]]] = [] + main(("--uds", "/tmp/gateway.sock"), serve=lambda argv: served.append(tuple(argv))) + assert _uvicorn_params(served[0])["uds"] == "/tmp/gateway.sock" + assert capsys.readouterr().err == warning + + def test_an_invalid_rust_switch_stops_the_gateway_naming_the_variable( + self, password_env: dict[str, str], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LITELLM_RUST_FIRST", "maybe") + monkeypatch.delenv("_LITELLM_GATEWAY_SUPERVISED", raising=False) + served: Final[list[tuple[str, ...]]] = [] + with pytest.raises(SystemExit) as stopped: + main(("--workers", "4"), serve=lambda argv: served.append(tuple(argv))) + assert str(stopped.value).startswith("LITELLM_RUST_FIRST is invalid: ") + assert served == [] + + @pytest.mark.parametrize( + ("argv", "status", "said"), + [ + pytest.param(("--nope",), 2, "--nope", id="a bad flag is a usage error"), + pytest.param(("--help",), 0, "--timeout-keep-alive", id="help is printed once and exits cleanly"), + ], + ) + def test_the_supervisor_reports_uvicorn_arguments_the_way_uvicorn_does( + self, + argv: tuple[str, ...], + status: int, + said: str, + password_env: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.setenv("LITELLM_RUST_FIRST", "true") + monkeypatch.delenv("_LITELLM_GATEWAY_SUPERVISED", raising=False) + served: Final[list[tuple[str, ...]]] = [] + with pytest.raises(SystemExit) as stopped: + main(argv, serve=lambda served_argv: served.append(tuple(served_argv))) + printed: Final = capsys.readouterr() + assert stopped.value.code == status + assert (printed.out + printed.err).count(said) == 1 + assert served == [] + def test_a_pooler_that_cannot_start_stops_the_gateway_before_uvicorn( self, password_env: dict[str, str], monkeypatch: pytest.MonkeyPatch, tmp_path: Path ): diff --git a/tests/unit/proxy/rust_forward/__init__.py b/tests/unit/proxy/rust_forward/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/rust_forward/supervisor/__init__.py b/tests/unit/proxy/rust_forward/supervisor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/rust_forward/supervisor/_support.py b/tests/unit/proxy/rust_forward/supervisor/_support.py new file mode 100644 index 00000000000..bac5a68b7fc --- /dev/null +++ b/tests/unit/proxy/rust_forward/supervisor/_support.py @@ -0,0 +1,7 @@ +from litellm.proxy.rust_forward.server import Listener, PythonServer, Server + + +def python_server( + server: Server = Server.UVICORN, listener: Listener = Listener.TCP, trusted_proxies: str = "127.0.0.1" +) -> PythonServer: + return PythonServer(server, listener, "0.0.0.0", 4000, 2, 2.5, trusted_proxies) diff --git a/tests/unit/proxy/rust_forward/supervisor/test_children.py b/tests/unit/proxy/rust_forward/supervisor/test_children.py new file mode 100644 index 00000000000..f6ab9732b80 --- /dev/null +++ b/tests/unit/proxy/rust_forward/supervisor/test_children.py @@ -0,0 +1,54 @@ +import socket +from typing import Final + +from litellm.constants import GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS +from litellm.proxy.proxy_cli import run_server +from litellm.proxy.rust_forward.mode import Supervised, rust_forward_mode +from litellm.proxy.rust_forward.platform import PlatformListener +from litellm.proxy.rust_forward.platform.posix import PosixPlatform +from litellm.proxy.rust_forward.platform.windows import CREATE_NEW_PROCESS_GROUP, WindowsPlatform +from litellm.proxy.rust_forward.supervisor.children import python_spec, rust_spec +from litellm.proxy.server_listener import AssignedPort, InheritedFd +from tests.unit.proxy.rust_forward.supervisor._support import python_server + +POSIX: Final = PosixPlatform() +WINDOWS: Final = WindowsPlatform() + + +def test_python_child_serves_the_private_socket_with_the_operators_trust() -> None: + private: Final = PlatformListener(InheritedFd(17), 45555, (17,)) + spec: Final = python_spec(POSIX, ("python",), private, python_server(trusted_proxies="10.0.0.5")) + assert rust_forward_mode(spec.env_overrides) == Supervised(InheritedFd(17), "10.0.0.5") + assert (spec.spawn.pass_fds, spec.spawn.start_new_session) == ((17,), True) + + +def test_windows_python_child_binds_the_assigned_port_in_its_own_process_group() -> None: + with WINDOWS.private_listener() as private: + pass + spec: Final = python_spec(WINDOWS, ("python",), private, python_server()) + assert rust_forward_mode(spec.env_overrides) == Supervised(AssignedPort(private.port), "127.0.0.1") + assert spec.argv[-2:] == ("--port", str(private.port)) + assert (spec.spawn.pass_fds, spec.spawn.creationflags) == ((), CREATE_NEW_PROCESS_GROUP) + with socket.create_server(("127.0.0.1", private.port)): + pass + + +def test_python_child_keeps_the_public_host_and_its_options() -> None: + arguments: Final = ("--host", "0.0.0.0", "--port", "4000", "--num_workers", "2", "--reload") + spec: Final = python_spec(POSIX, arguments, PlatformListener(InheritedFd(17), 45555, (17,)), python_server()) + params: Final = run_server.make_context("litellm", list(spec.argv)).params + assert (params["host"], params["port"]) == ("0.0.0.0", 45555) + assert (params["num_workers"], params["reload"]) == (2, True) + + +def test_rust_child_serves_public_port_and_forwards_to_private_listener() -> None: + spec: Final = rust_spec(POSIX, "gateway", 45555, python_server()) + assert spec.argv == ("gateway",) + assert spec.spawn.pass_fds == () + assert dict(spec.env_overrides) == { + "LITELLM_FORWARD_UPSTREAM": "http://127.0.0.1:45555", + "LITELLM_FORWARD_HOST": "0.0.0.0", + "LITELLM_FORWARD_PORT": "4000", + "LITELLM_FORWARD_UPSTREAM_KEEPALIVE": "2", + "LITELLM_FORWARD_SHUTDOWN_DRAIN": str(2.5 + GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS), + } diff --git a/tests/unit/proxy/rust_forward/supervisor/test_lifecycle.py b/tests/unit/proxy/rust_forward/supervisor/test_lifecycle.py new file mode 100644 index 00000000000..4b479f3d22d --- /dev/null +++ b/tests/unit/proxy/rust_forward/supervisor/test_lifecycle.py @@ -0,0 +1,158 @@ +import functools +import itertools +import signal +from dataclasses import dataclass, replace +from typing import Final + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from litellm.proxy.rust_forward.platform.posix import PosixPlatform +from litellm.proxy.rust_forward.supervisor.lifecycle import ( + FAILURE, + Action, + Caught, + Child, + ChildExited, + Event, + Lifecycle, + Probe, + Send, + Spawn, + boot, + exit_status, + step, +) + +PY: Final = Child.PYTHON +RUST: Final = Child.RUST +LISTENING: Final = Probe.PYTHON_LISTENING +TERM: Final = signal.SIGTERM +INT: Final = signal.SIGINT +HUP: Final = signal.SIGHUP +STOPS: Final = PosixPlatform().stop_signals + + +def _replay(events: tuple[Event, ...]) -> tuple[tuple[tuple[Action, ...], ...], int | None]: + transitions: Final = tuple( + itertools.accumulate(events, lambda transition, event: step(transition[0], event, STOPS), initial=boot()) + ) + return tuple(actions for _, actions in transitions[1:]), exit_status(transitions[-1][0]) + + +@pytest.mark.parametrize( + ("trace", "status"), + [ + pytest.param( + ( + (LISTENING, (Spawn(RUST),)), + (Caught(TERM), (Send(RUST, TERM), Send(PY, TERM))), + (ChildExited(RUST, 0), ()), + (ChildExited(PY, -15), ()), + ), + -15, + id="TERM reaches Rust first, then Python, and the supervisor dies as Python did", + ), + pytest.param( + ( + (LISTENING, (Spawn(RUST),)), + (Caught(INT), (Send(RUST, INT), Send(PY, INT))), + (Caught(INT), (Send(RUST, INT), Send(PY, INT))), + (ChildExited(PY, 1), (Send(RUST, TERM),)), + (ChildExited(RUST, 0), ()), + ), + 1, + id="a second Ctrl-C reaches Python again to force the exit", + ), + pytest.param( + ( + (LISTENING, (Spawn(RUST),)), + (Caught(HUP), (Send(PY, HUP),)), + (Caught(signal.SIGTTIN), (Send(PY, signal.SIGTTIN),)), + (ChildExited(PY, 0), (Send(RUST, TERM),)), + (ChildExited(RUST, 0), ()), + ), + 0, + id="gunicorn's HUP and TTIN reach only Python and Rust keeps serving", + ), + pytest.param( + ( + (LISTENING, (Spawn(RUST),)), + (ChildExited(RUST, 0), (Send(PY, TERM),)), + (ChildExited(PY, -15), ()), + ), + FAILURE, + id="losing Rust while Python serves stops Python and fails", + ), + pytest.param( + ( + (Caught(TERM), (Send(PY, TERM),)), + (LISTENING, ()), + (ChildExited(PY, -15), ()), + ), + -15, + id="a stop before Python listens never starts Rust", + ), + pytest.param(((ChildExited(PY, 3), ()),), 3, id="Python failing to boot exits with its status"), + ], +) +def test_supervisor_relays_signals_and_exits_as_python_did( + trace: tuple[tuple[Event, tuple[Action, ...]], ...], status: int +) -> None: + events: Final = tuple(event for event, _ in trace) + assert _replay(events) == (tuple(actions for _, actions in trace), status) + + +@dataclass(frozen=True, slots=True) +class Observed: + """What an outside observer of the two processes knows, without looking inside ``Lifecycle``.""" + + state: Lifecycle + alive: frozenset[Child] = frozenset({PY}) + rust_spawns: int = 0 + stop_requested: bool = False + rust_lost: bool = False + python_status: int | None = None + + +def _observe(seen: Observed, event: Event) -> Observed: + state, actions = step(seen.state, event, STOPS) + death: Final = event if isinstance(event, ChildExited) and event.child in seen.alive else None + exited: Final = None if death is None else death.child + running: Final = seen.alive - {exited} + assert all(action.child in running for action in actions if isinstance(action, Send)), "signalled a dead child" + if isinstance(event, Caught): + to_rust: Final = (Send(RUST, event.signum),) if event.signum in STOPS and RUST in running else () + assert actions == (*to_rust, *((Send(PY, event.signum),) if PY in running else ())) + spawned: Final = Spawn(RUST) in actions + assert not spawned or (event is LISTENING and not seen.stop_requested), "started Rust out of turn" + stop_requested: Final = seen.stop_requested or (isinstance(event, Caught) and event.signum in STOPS) + after: Final = replace( + seen, + state=state, + alive=running | ({RUST} if spawned else frozenset()), + rust_spawns=seen.rust_spawns + int(spawned), + stop_requested=stop_requested, + rust_lost=seen.rust_lost or (exited is RUST and PY in running and not stop_requested), + python_status=death.status if death is not None and exited is PY else seen.python_status, + ) + settled: Final = None if after.alive or after.python_status is None else after.python_status + assert exit_status(state) == (FAILURE if after.rust_lost and settled is not None else settled) + return after + + +EVENTS: Final = st.lists( + st.one_of( + st.sampled_from(PosixPlatform().relayed_signals).map(Caught), + st.just(LISTENING), + st.builds(ChildExited, st.sampled_from(Child), st.integers(-15, 3)), + ), + max_size=40, +) + + +@given(EVENTS) +def test_any_event_order_keeps_the_supervisor_transparent_to_python(events: list[Event]) -> None: + final: Final = functools.reduce(_observe, events, Observed(boot()[0])) + assert final.rust_spawns <= 1 diff --git a/tests/unit/proxy/rust_forward/supervisor/test_preflight.py b/tests/unit/proxy/rust_forward/supervisor/test_preflight.py new file mode 100644 index 00000000000..82daf5b7ea0 --- /dev/null +++ b/tests/unit/proxy/rust_forward/supervisor/test_preflight.py @@ -0,0 +1,77 @@ +import sys +from pathlib import Path +from typing import Final + +import pytest + +from litellm.proxy.rust_forward.platform import Platform +from litellm.proxy.rust_forward.platform.posix import PosixPlatform +from litellm.proxy.rust_forward.platform.windows import WindowsPlatform +from litellm.proxy.rust_forward.server import Listener, PythonServer, Server +from litellm.proxy.rust_forward.supervisor import supervise +from litellm.proxy.rust_forward.supervisor.preflight import StartupError, gateway_binary, startup_check +from tests.unit.proxy.rust_forward.supervisor._support import python_server + +POSIX: Final = PosixPlatform() +WINDOWS: Final = WindowsPlatform() +POSIX_ONLY: Final = pytest.mark.skipif(sys.platform == "win32", reason="runs a POSIX shebang gateway") + + +@pytest.mark.parametrize( + ("platform", "server", "refusal"), + [ + (POSIX, python_server(), None), + (POSIX, python_server(Server.GUNICORN), None), + (WINDOWS, python_server(), None), + (WINDOWS, python_server(Server.GUNICORN), "does not support gunicorn on Windows"), + (POSIX, python_server(Server.HYPERCORN), "does not support Hypercorn"), + (POSIX, python_server(Server.GRANIAN), "does not support Granian"), + (POSIX, python_server(listener=Listener.TLS), "does not support TLS on the public port"), + (POSIX, python_server(listener=Listener.UNIX_SOCKET), "does not support a Unix socket on the public port"), + ( + POSIX, + python_server(Server.GUNICORN, Listener.INHERITED_FD), + "does not support an inherited file descriptor on the public port", + ), + ], +) +def test_only_plain_tcp_on_a_supported_server_can_be_forwarded( + platform: Platform, server: PythonServer, refusal: str | None +) -> None: + expected: Final = "gateway" if refusal is None else StartupError(f"LITELLM_RUST_FIRST {refusal}") + assert startup_check(platform, server, "gateway") == expected + + +@pytest.mark.parametrize( + ("server", "refusal"), + [ + (python_server(Server.HYPERCORN), "does not support Hypercorn"), + (python_server(), "requires the litellm-gateway binary from the litellm-proxy package"), + ], +) +def test_a_refused_start_says_why_in_one_line_and_starts_nothing( + server: PythonServer, refusal: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + prepared: Final[list[bool]] = [] + refused: Final = supervise( + server, ("python",), before_start=lambda: prepared.append(True), scripts_dir=str(tmp_path), platform=POSIX + ) + assert (refused, prepared) == (StartupError(f"LITELLM_RUST_FIRST {refusal}"), []) + assert ( + capsys.readouterr().err + == f"LITELLM_RUST_FIRST {refusal}, so Python serves the public port without Rust forwarding\n" + ) + + +@POSIX_ONLY +def test_binary_is_found_only_in_the_environment_scripts_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + scripts: Final = tmp_path / "scripts" + elsewhere: Final = tmp_path / "elsewhere" + scripts.mkdir() + elsewhere.mkdir() + binary: Final = elsewhere / "litellm-gateway" + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o755) + monkeypatch.setenv("PATH", str(elsewhere)) + assert gateway_binary(POSIX, str(scripts)) is None + assert gateway_binary(POSIX, str(elsewhere)) == str(binary) diff --git a/tests/unit/proxy/rust_forward/supervisor/test_runtime.py b/tests/unit/proxy/rust_forward/supervisor/test_runtime.py new file mode 100644 index 00000000000..b84b1302e3c --- /dev/null +++ b/tests/unit/proxy/rust_forward/supervisor/test_runtime.py @@ -0,0 +1,189 @@ +import signal +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import anyio +import pytest + +from litellm.proxy.rust_forward.platform import Platform, current_platform +from litellm.proxy.rust_forward.platform.windows import WindowsPlatform +from litellm.proxy.rust_forward.supervisor import supervise +from litellm.proxy.rust_forward.supervisor.children import ChildSpec +from litellm.proxy.rust_forward.supervisor.lifecycle import ( + Action, + Caught, + Child, + ChildExited, + Event, + Probe, + Send, + Spawn, +) +from litellm.proxy.rust_forward.supervisor.runtime import SpawnFailed, drive, exit_like, open_children +from tests.unit.proxy.rust_forward.supervisor._support import python_server + + +PYTHON_CHILD: Final = """ +import json, os, pathlib, socket, time +time.sleep(0.5) +pathlib.Path(os.environ["MARKERS"], "listening").touch() +private = json.loads(os.environ["_LITELLM_GATEWAY_SUPERVISED"])["listener"] +listener = socket.socket(fileno=private["fd"]) if "fd" in private else socket.create_server(("127.0.0.1", private["port"])) +listener.listen() +time.sleep(10) +""" +RUST_CHILD: Final = """ +import os, pathlib +markers = pathlib.Path(os.environ["MARKERS"]) +(markers / "rust_saw_python_listening").write_text(str((markers / "listening").exists())) +""" + + +def _fake_gateway(scripts: Path) -> Platform: + if sys.platform != "win32": + gateway: Final = scripts / "litellm-gateway" + gateway.write_text(f"#!{sys.executable} -I\n{RUST_CHILD}") + gateway.chmod(0o755) + return current_platform() + script: Final = scripts / "rust_child.py" + script.write_text(RUST_CHILD) + (scripts / "litellm-gateway.cmd").write_text(f'@"{sys.executable}" -I "{script}"\r\n') + return WindowsPlatform(gateway_name="litellm-gateway.cmd") + + +def test_rust_opens_the_public_port_only_once_python_listens(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MARKERS", str(tmp_path)) + command: Final = (sys.executable, "-I", "-c", PYTHON_CHILD) + supervise(python_server(), command, scripts_dir=str(tmp_path), platform=_fake_gateway(tmp_path)) + assert (tmp_path / "rust_saw_python_listening").read_text() == "True" + + +TERM: Final = signal.SIGTERM +POSIX_ONLY: Final = pytest.mark.skipif(sys.platform == "win32", reason="the child traps SIGTERM") +STOPS: Final = frozenset({signal.SIGTERM, signal.SIGINT, signal.SIGQUIT}) +RUST_MISSING: Final = SpawnFailed(FileNotFoundError("litellm-gateway")) + + +@dataclass +class ScriptedExecutor: + """Replays a fixed event script and records every action the driver carries out.""" + + events: list[Event] + failing: frozenset[Child] + done: list[Action] = field(default_factory=list) + + async def spawn(self, child: Child) -> SpawnFailed | None: + self.done.append(Spawn(child)) + return RUST_MISSING if child in self.failing else None + + def send(self, child: Child, signum: signal.Signals) -> None: + self.done.append(Send(child, signum)) + + async def next_event(self) -> Event: + return self.events.pop(0) + + +@pytest.mark.parametrize( + ("events", "failing", "done", "outcome", "unread"), + [ + pytest.param( + [Probe.PYTHON_LISTENING, Caught(TERM), ChildExited(Child.RUST, 0), ChildExited(Child.PYTHON, -15)], + frozenset(), + [Spawn(Child.PYTHON), Spawn(Child.RUST), Send(Child.RUST, TERM), Send(Child.PYTHON, TERM)], + -15, + [], + id="carries out each action in order and ends with Python's status", + ), + pytest.param( + [ChildExited(Child.PYTHON, 3), Caught(TERM)], + frozenset(), + [Spawn(Child.PYTHON)], + 3, + [Caught(TERM)], + id="reads no event once the status is settled", + ), + pytest.param( + [Probe.PYTHON_LISTENING, Caught(TERM)], + frozenset({Child.RUST}), + [Spawn(Child.PYTHON), Spawn(Child.RUST)], + RUST_MISSING, + [Caught(TERM)], + id="returns a failed spawn without waiting for more events", + ), + ], +) +async def test_driver_executes_lifecycle_decisions( + events: list[Event], failing: frozenset[Child], done: list[Action], outcome: int | SpawnFailed, unread: list[Event] +) -> None: + executor: Final = ScriptedExecutor(events, failing) + assert await drive(executor, STOPS) == outcome + assert (executor.done, executor.events) == (done, unread) + + +def _children(argv: tuple[str, ...]) -> MappingProxyType[Child, ChildSpec]: + spec: Final = ChildSpec(argv, MappingProxyType({}), current_platform().spawn_options(())) + return MappingProxyType({Child.PYTHON: spec, Child.RUST: spec}) + + +SLOW_TO_STOP: Final = ( + sys.executable, + "-c", + "import signal, time; signal.signal(signal.SIGTERM, lambda *_: (time.sleep(0.5), exit(0))); time.sleep(30)", +) + + +@POSIX_ONLY +async def test_a_cancelled_supervisor_still_waits_for_the_child_it_stops() -> None: + with anyio.CancelScope() as scope: + async with open_children(current_platform(), _children(SLOW_TO_STOP), 1) as children: + assert await children.spawn(Child.RUST) is None + process: Final = children.processes[Child.RUST] + await anyio.sleep(0.3) + scope.cancel() + await anyio.sleep(1) + assert process.returncode == 0 + + +async def test_exiting_children_are_reported_as_events() -> None: + async with open_children(current_platform(), _children((sys.executable, "-c", "exit(7)")), 1) as children: + assert await children.spawn(Child.RUST) is None + with anyio.fail_after(5): + assert await children.next_event() == ChildExited(Child.RUST, 7) + + +async def test_a_child_that_cannot_start_is_returned_as_a_failure(tmp_path: Path) -> None: + async with open_children(current_platform(), _children((str(tmp_path / "missing"),)), 1) as children: + outcome: Final = await children.spawn(Child.RUST) + assert isinstance(outcome, SpawnFailed) + assert isinstance(outcome.error, FileNotFoundError) + + +def _dropped(_signum: signal.Signals) -> None: + pass + + +@POSIX_ONLY +@pytest.mark.parametrize( + ("status", "exits"), + [ + pytest.param(3, 3, id="an exit status is passed on"), + pytest.param(-signal.SIGTERM, 0, id="a stop signal ends it the way uvicorn alone ends as PID 1"), + pytest.param(-signal.SIGKILL, 137, id="a kill is reported the way a shell reports it"), + ], +) +def test_a_supervisor_the_kernel_will_not_signal_still_exits_as_python_alone_would(status: int, exits: int) -> None: + assert exit_like(status, STOPS, die_by=_dropped) == exits + + +@POSIX_ONLY +@pytest.mark.parametrize("signum", [signal.SIGTERM, signal.SIGKILL]) +def test_the_supervisor_dies_by_the_signal_that_killed_python(signum: signal.Signals) -> None: + script: Final = ( + "from litellm.proxy.rust_forward.supervisor.runtime import exit_like\n" + f"raise SystemExit(exit_like({-signum}, frozenset()))" + ) + assert subprocess.run((sys.executable, "-I", "-c", script), check=False, timeout=60).returncode == -signum diff --git a/tests/unit/proxy/rust_forward/test_mode.py b/tests/unit/proxy/rust_forward/test_mode.py new file mode 100644 index 00000000000..59f9feb1d3b --- /dev/null +++ b/tests/unit/proxy/rust_forward/test_mode.py @@ -0,0 +1,127 @@ +import os +import threading +from typing import Final + +import pytest +import uvicorn +from pydantic import ValidationError + +from litellm.proxy.proxy_cli import WORKER_APP, ProxyInitializationHelpers +from litellm.proxy.rust_forward.mode import ( + SUPERVISED_ENV, + Mode, + RustForwardMode, + Supervised, + rust_forward_mode, + private_listener, + stop_with_supervisor, + supervised_environment, + uvicorn_options, +) +from litellm.proxy.rust_forward.server import NO_TRUSTED_PROXIES +from litellm.proxy.server_listener import AssignedPort, InheritedFd, PrivateListener + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + *((value, Mode.DISABLED) for value in (None, "", " ", "0", "false", "off")), + *((value, Mode.SUPERVISOR) for value in ("1", "true", "yes", "on", " 1 ")), + ], +) +def test_mode_is_resolved_from_environment(value: str | None, expected: RustForwardMode) -> None: + assert rust_forward_mode({} if value is None else {"LITELLM_RUST_FIRST": value}) == expected + + +@pytest.mark.parametrize( + ("environ", "expected"), + [ + ({"LITELLM_RUST": "true"}, Mode.DISABLED), + ({"LITELLM_RUST": "enabled"}, Mode.DISABLED), + ({"LITELLM_RUST": "false", "LITELLM_RUST_FIRST": "true"}, Mode.SUPERVISOR), + ], +) +def test_forwarding_is_independent_of_the_sdk_rust_switch(environ: dict[str, str], expected: RustForwardMode) -> None: + assert rust_forward_mode(environ) == expected + + +@pytest.mark.parametrize( + ("environ", "invalid"), + [ + ({"LITELLM_RUST_FIRST": "enabled"}, "LITELLM_RUST_FIRST"), + ({"LITELLM_RUST_FIRST": "2"}, "LITELLM_RUST_FIRST"), + ({SUPERVISED_ENV: '{"listener": {"fd": "seventeen"}, "trusted_proxies": "*"}'}, "fd"), + ({SUPERVISED_ENV: '{"listener": {"fd": 17}, "trusted_proxies": 7}'}, "trusted_proxies"), + ({SUPERVISED_ENV: '{"listener": {"fd": 17}}'}, "trusted_proxies"), + ], +) +def test_invalid_forwarding_value_stops_startup_naming_it(environ: dict[str, str], invalid: str) -> None: + with pytest.raises(ValidationError, match=invalid): + rust_forward_mode(environ) + + +@pytest.mark.parametrize( + "written", + [ + Supervised(InheritedFd(17), "127.0.0.1"), + Supervised(InheritedFd(17), "127.0.0.1,10.0.0.5"), + Supervised(InheritedFd(17), NO_TRUSTED_PROXIES), + Supervised(AssignedPort(45555), "*"), + ], +) +def test_child_reads_back_exactly_what_the_supervisor_wrote(written: Supervised) -> None: + inherited: Final = {"LITELLM_RUST_FIRST": "true", "FORWARDED_ALLOW_IPS": "10.9.9.9"} + assert rust_forward_mode({**inherited, **supervised_environment(written)}) == written + + +def _served_uvicorn_config(mode: RustForwardMode) -> uvicorn.Config: + options: Final = { + **ProxyInitializationHelpers.get_default_unvicorn_init_args("0.0.0.0", 45555), + **uvicorn_options(mode, WORKER_APP), + } + return uvicorn.Config(**options) + + +PUBLIC: Final = ("0.0.0.0", 45555, None) + + +@pytest.mark.parametrize( + ("mode", "app", "uvicorn_listener", "gunicorn_listener", "proxy_headers"), + [ + (Mode.DISABLED, "litellm.proxy.proxy_server:app", PUBLIC, None, True), + (Mode.SUPERVISOR, "litellm.proxy.proxy_server:app", PUBLIC, None, True), + (Supervised(InheritedFd(17), "*"), WORKER_APP, ("0.0.0.0", 45555, 17), InheritedFd(17), False), + (Supervised(AssignedPort(45556), "*"), WORKER_APP, ("127.0.0.1", 45556, None), AssignedPort(45556), False), + ], +) +def test_supervised_server_serves_the_private_hop_and_never_trusts_it( + mode: RustForwardMode, + app: str, + uvicorn_listener: tuple[str, int, int | None], + gunicorn_listener: PrivateListener | None, + proxy_headers: bool, +) -> None: + config: Final = _served_uvicorn_config(mode) + assert (config.app, (config.host, config.port, config.fd), config.proxy_headers) == ( + app, + uvicorn_listener, + proxy_headers, + ) + assert config.factory is (app == WORKER_APP) + assert private_listener(mode) == gunicorn_listener + + +@pytest.mark.parametrize( + ("mode", "stops"), + [(Supervised(InheritedFd(17), "127.0.0.1"), True), (Mode.DISABLED, False), (Mode.SUPERVISOR, False)], +) +def test_only_a_supervised_server_stops_once_its_supervisor_is_gone(mode: RustForwardMode, stops: bool) -> None: + read_end, write_end = os.pipe() + stopped: Final = threading.Event() + try: + stop_with_supervisor(mode, read_end, stopped.set) + assert not stopped.wait(0.2) + os.close(write_end) + assert stopped.wait(5 if stops else 0.5) is stops + finally: + os.close(read_end) diff --git a/tests/unit/proxy/rust_forward/test_peer.py b/tests/unit/proxy/rust_forward/test_peer.py new file mode 100644 index 00000000000..11a4737c6ce --- /dev/null +++ b/tests/unit/proxy/rust_forward/test_peer.py @@ -0,0 +1,135 @@ +import asyncio +import gc +from typing import TYPE_CHECKING, Final + +import httpx +import pytest +from fastapi import Request +from fastapi.routing import APIRoute +from uvicorn.importer import import_from_string +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + +if TYPE_CHECKING: + from uvicorn._types import ( + ASGIReceiveCallable, + ASGISendCallable, + ASGISendEvent, + HTTPDisconnectEvent, + HTTPScope, + Scope, + ) + +from litellm.proxy.proxy_server import app as proxy_app +from litellm.proxy.rust_forward.mode import SUPERVISED_ENV, Supervised, supervised_environment +from litellm.proxy.rust_forward.peer import RustPeerMiddleware +from litellm.proxy.rust_forward.server import NO_TRUSTED_PROXIES +from litellm.proxy.server_listener import InheritedFd + +Headers = tuple[tuple[bytes, bytes], ...] + + +async def _receive() -> "HTTPDisconnectEvent": + return {"type": "http.disconnect"} + + +async def _send(message: "ASGISendEvent") -> None: + return None + + +async def _seen_by_app( + behind_rust: bool, client: tuple[str, int], headers: Headers, mode: Supervised +) -> tuple[tuple[str, int] | None, str, Headers]: + seen: Final[asyncio.Future[tuple[tuple[str, int] | None, str, Headers]]] = ( + asyncio.get_running_loop().create_future() + ) + + async def probe(scope: "Scope", receive: "ASGIReceiveCallable", send: "ASGISendCallable") -> None: + assert scope["type"] == "http" + seen.set_result((scope["client"], scope["scheme"], tuple(scope["headers"]))) + + proxy_headers_off: Final = mode.trusted_proxies == NO_TRUSTED_PROXIES + direct: Final = probe if proxy_headers_off else ProxyHeadersMiddleware(probe, mode.trusted_proxies) + app: Final = RustPeerMiddleware(probe, mode) if behind_rust else direct + scope: Final[HTTPScope] = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "root_path": "", + "headers": headers, + "client": client, + "server": ("127.0.0.1", 4000), + } + await app(scope, _receive, _send) + return seen.result() + + +PEER: Final = ("203.0.113.7", 51234) +TRUSTED_PEER: Final = ("10.0.12.34", 40000) +FORWARDED: Final[Headers] = ( + (b"x-forwarded-for", b"198.51.100.1, 203.0.113.7"), + (b"x-forwarded-proto", b"https"), +) + + +@pytest.mark.parametrize("peer", [PEER, TRUSTED_PEER, ("2001:db8::1", 443)]) +@pytest.mark.parametrize("headers", [(), FORWARDED, ((b"x-forwarded-for", b"10.1.2.3"), (b"host", b"public.example"))]) +@pytest.mark.parametrize( + "mode", + [ + Supervised(InheritedFd(17), "127.0.0.1"), + Supervised(InheritedFd(17), "10.0.0.0/8"), + Supervised(InheritedFd(17), "*"), + Supervised(InheritedFd(17), NO_TRUSTED_PROXIES), + ], +) +async def test_python_behind_rust_sees_what_a_direct_connection_would( + peer: tuple[str, int], headers: Headers, mode: Supervised +) -> None: + rendered: Final = f"[{peer[0]}]:{peer[1]}" if ":" in peer[0] else f"{peer[0]}:{peer[1]}" + behind_rust: Final = await _seen_by_app( + True, ("127.0.0.1", 60000), (*headers, (b"x-litellm-rust-peer", rendered.encode())), mode + ) + direct: Final = await _seen_by_app(False, peer, headers, mode) + assert behind_rust == direct + + +async def test_peer_header_from_anyone_but_the_rust_hop_is_dropped_and_ignored() -> None: + client, _, headers = await _seen_by_app( + True, + ("192.0.2.9", 1000), + ((b"x-litellm-rust-peer", b"10.0.12.34:1"),), + Supervised(InheritedFd(17), "127.0.0.1"), + ) + assert (client, headers) == (("192.0.2.9", 1000), ()) + + +async def _peer_probe(request: Request) -> dict[str, object]: + assert request.client is not None + return {"client": [request.client.host, request.client.port], "peer": request.headers.get("x-litellm-rust-peer")} + + +@pytest.mark.parametrize("entry_point", ["litellm_cli.main:proxy_worker_app", "gateway.launch:worker_app"]) +async def test_every_entry_point_hands_routes_the_peer_rust_saw( + entry_point: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(SUPERVISED_ENV, supervised_environment(Supervised(InheritedFd(17), "127.0.0.1"))[SUPERVISED_ENV]) + monkeypatch.setattr(proxy_app.router, "lifespan_context", proxy_app.router.lifespan_context) + monkeypatch.setattr( + proxy_app.router, "routes", [APIRoute("/__rust_forward_peer_probe", _peer_probe), *proxy_app.router.routes] + ) + try: + served: Final = import_from_string(entry_point)() + finally: + gc.unfreeze() + transport: Final = httpx.ASGITransport(app=served, client=("127.0.0.1", 60000)) + async with httpx.AsyncClient(transport=transport, base_url="http://proxy") as client: + response: Final = await client.get( + "/__rust_forward_peer_probe", headers={"x-litellm-rust-peer": "203.0.113.7:51234"} + ) + assert response.status_code == 200, response.text + assert response.json() == {"client": ["203.0.113.7", 51234], "peer": None} diff --git a/tests/unit/proxy/test_proxy_cli.py b/tests/unit/proxy/test_proxy_cli.py index eec682f365f..d85d685c352 100644 --- a/tests/unit/proxy/test_proxy_cli.py +++ b/tests/unit/proxy/test_proxy_cli.py @@ -26,6 +26,7 @@ import urllib.parse as urlparse import uvicorn import yaml +from gunicorn.config import Config as GunicornConfig from uvicorn.config import LOOP_FACTORIES from uvicorn.importer import import_from_string @@ -34,8 +35,9 @@ from litellm._logging import turn_on_json from litellm.constants import DEFAULT_MAX_REQUEST_HEADER_SIZE, GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS from litellm.proxy import proxy_cli from litellm.proxy.proxy_cli import ProxyInitializationHelpers, run_server +from litellm.proxy.rust_forward.server import Listener, Server from litellm.proxy.server_listener import AssignedPort, InheritedFd -from litellm.proxy.shutdown.graceful_shutdown_manager import DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT +from litellm.proxy.shutdown.graceful_shutdown_manager import DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT, GracefulShutdownManager @pytest.fixture(autouse=True) @@ -4382,3 +4384,154 @@ def test_migration_data_check_requires_database_url(monkeypatch): with pytest.raises(click.UsageError, match="requires DATABASE_URL"): ProxyInitializationHelpers.run_migration_data_check(use_migrate=True) + + +class TestRustForwardServer: + """The public listener ``run_server`` reports to the Rust supervisor matches what it would serve itself.""" + + @staticmethod + def _server(argv: list[str]): + params: Final = run_server.make_context("litellm", ["--port", "45999", *argv]).params + return params, ProxyInitializationHelpers.python_server( + str(params["host"]), + int(str(params["port"])), + params["keepalive_timeout"], + run_gunicorn=params["run_gunicorn"] is True, + run_hypercorn=params["run_hypercorn"] is True, + run_granian=params["run_granian"] is True, + tls=None not in (params["ssl_keyfile_path"], params["ssl_certfile_path"]), + ) + + @pytest.mark.parametrize( + ("argv", "server", "listener"), + [ + ([], Server.UVICORN, Listener.TCP), + (["--run_gunicorn"], Server.GUNICORN, Listener.TCP), + (["--run_hypercorn"], Server.HYPERCORN, Listener.TCP), + (["--run_granian"], Server.GRANIAN, Listener.TCP), + (["--ssl_keyfile_path", "key.pem", "--ssl_certfile_path", "cert.pem"], Server.UVICORN, Listener.TLS), + ], + ) + def test_the_supervisor_sees_the_server_and_listener_python_would_use( + self, argv: list[str], server: Server, listener: Listener + ) -> None: + _, described = self._server(argv) + assert (described.server, described.listener) == (server, listener) + + def test_uvicorn_is_described_where_gunicorn_cannot_be_imported(self, monkeypatch: pytest.MonkeyPatch) -> None: + _, with_gunicorn = self._server([]) + monkeypatch.setitem(sys.modules, "gunicorn.config", None) + _, without_gunicorn = self._server([]) + assert without_gunicorn == with_gunicorn + + @patch("uvicorn.run") + @patch("atexit.register") + @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False) + @pytest.mark.parametrize( + ("argv", "reason", "served_keyfile"), + [ + ( + ["--ssl_keyfile_path", "key.pem", "--ssl_certfile_path", "cert.pem"], + "does not support TLS on the public port", + "key.pem", + ), + (["--ssl_keyfile_path", "key.pem"], "requires the litellm-gateway binary", None), + (["--ssl_certfile_path", "cert.pem"], "requires the litellm-gateway binary", None), + (["--ciphers", "ECDHE"], "requires the litellm-gateway binary", None), + ], + ) + def test_a_refused_forwarding_start_serves_from_python_alone( + self, + mock_should_update, + mock_setup_db, + mock_atexit_register, + mock_uvicorn_run, + argv: list[str], + reason: str, + served_keyfile: str | None, + ) -> None: + from click.testing import CliRunner + + proxy_module: Final = MagicMock( + app=MagicMock(), + ProxyConfig=MagicMock(), + KeyManagementSettings=MagicMock(), + save_worker_config=MagicMock(), + ) + environ: Final = { + **{ + k: v + for k, v in os.environ.items() + if k not in ("DATABASE_URL", "DIRECT_URL", "_LITELLM_GATEWAY_SUPERVISED") + }, + "LITELLM_RUST_FIRST": "true", + } + with ( + patch.dict(os.environ, environ, clear=True), + patch.dict("sys.modules", {"proxy_server": proxy_module, "litellm.proxy.proxy_server": proxy_module}), + patch("litellm.proxy.rust_forward.supervisor.gateway_binary", return_value=None), + ): + result: Final = CliRunner().invoke(run_server, ["--port", "45999", *argv]) + assert result.exit_code == 0, result.output + assert f"LITELLM_RUST_FIRST {reason}" in result.output + assert "so Python serves the public port without Rust forwarding" in result.output + mock_uvicorn_run.assert_called_once() + assert mock_uvicorn_run.call_args.kwargs.get("ssl_keyfile") == served_keyfile + assert mock_uvicorn_run.call_args.kwargs["port"] == 45999 + + def test_an_invalid_rust_switch_stops_startup_with_one_line_naming_it(self) -> None: + from click.testing import CliRunner + + with patch.dict(os.environ, {"LITELLM_RUST_FIRST": "sometimes"}): + result: Final = CliRunner().invoke(run_server, ["--port", "45999"]) + lines: Final = result.output.splitlines() + assert (result.exit_code, len(lines)) == (1, 1) + assert lines[0].startswith("Error: LITELLM_RUST_FIRST is invalid: ") + + def test_a_metrics_port_on_the_public_port_is_refused_before_forwarding_starts(self) -> None: + from click.testing import CliRunner + + with ( + patch.dict(os.environ, {"LITELLM_RUST_FIRST": "true"}), + patch("litellm.proxy.rust_forward.supervisor.supervise") as supervise, + ): + result: Final = CliRunner().invoke(run_server, ["--port", "45999", "--prometheus_metrics_port", "45999"]) + assert result.exit_code == 2 + assert "--prometheus_metrics_port must differ from --port" in result.output + supervise.assert_not_called() + + @staticmethod + def _served_by_python(params: dict[str, object]) -> tuple[object, ...]: + if params["run_gunicorn"]: + gunicorn: Final = GunicornConfig() + return gunicorn.keepalive, GracefulShutdownManager.get_timeout(), ",".join(gunicorn.forwarded_allow_ips) + served: Final = uvicorn.Config( + **ProxyInitializationHelpers.get_default_unvicorn_init_args( + "127.0.0.1", 45555, keepalive_timeout=params["keepalive_timeout"] + ) + ) + return served.timeout_keep_alive, served.timeout_graceful_shutdown, served.forwarded_allow_ips + + @pytest.mark.parametrize( + ("argv", "environ"), + [ + ([], {}), + (["--keepalive_timeout", "2"], {"GRACEFUL_SHUTDOWN_TIMEOUT": "12.5"}), + ([], {"KEEPALIVE_TIMEOUT": "7", "FORWARDED_ALLOW_IPS": "10.0.0.5"}), + (["--run_gunicorn", "--keepalive_timeout", "9"], {}), + ], + ) + def test_rust_follows_the_keepalive_drain_and_trust_python_serves_with( + self, argv: list[str], environ: dict[str, str], monkeypatch: pytest.MonkeyPatch + ) -> None: + for name in ("KEEPALIVE_TIMEOUT", "GRACEFUL_SHUTDOWN_TIMEOUT", "FORWARDED_ALLOW_IPS"): + monkeypatch.delenv(name, raising=False) + for name, value in environ.items(): + monkeypatch.setenv(name, value) + params, server = self._server(argv) + assert ( + server.keepalive_seconds, + server.graceful_timeout_seconds, + server.trusted_proxies, + ) == self._served_by_python(params) diff --git a/tests/windows_tests/AGENTS.md b/tests/windows_tests/AGENTS.md new file mode 100644 index 00000000000..565dba91ce6 --- /dev/null +++ b/tests/windows_tests/AGENTS.md @@ -0,0 +1,7 @@ +# Windows tests + +The `windows-sdk` job in `.github/workflows/test-merge-smoke.yml` is the only place these run, on a Windows runner with no proxy dependencies installed. They prove the SDK works on Windows without them, so a test here must not import uvicorn, fastapi, psutil or anything else outside the `dev` group, and must not need the `litellm-gateway.exe` binary + +A test that cannot run off Windows carries a `skipif` on `sys.platform`, so collecting this folder on another OS never fails + +Windows-only names such as `CTRL_BREAK_EVENT` come from `litellm/proxy/rust_forward/platform/windows.py`, which spells them out so the type checker accepts them on every OS. `test_rust_forward_platform.py` pins those constants to the stdlib diff --git a/tests/windows_tests/test_rust_forward_platform.py b/tests/windows_tests/test_rust_forward_platform.py new file mode 100644 index 00000000000..1415a11f57c --- /dev/null +++ b/tests/windows_tests/test_rust_forward_platform.py @@ -0,0 +1,35 @@ +import signal +import subprocess +import sys +from pathlib import Path +from typing import Final + +import pytest + +from litellm.proxy.rust_forward.platform.windows import ( + CREATE_NEW_PROCESS_GROUP, + CTRL_BREAK_EVENT, + SIGBREAK, + WindowsPlatform, +) +from litellm.proxy.rust_forward.supervisor.preflight import gateway_binary + + +@pytest.mark.skipif(sys.platform != "win32", reason="these names exist only on Windows") +def test_windows_constants_match_the_stdlib() -> None: + assert (CTRL_BREAK_EVENT, SIGBREAK) == (signal.CTRL_BREAK_EVENT, signal.SIGBREAK) + assert CREATE_NEW_PROCESS_GROUP == subprocess.CREATE_NEW_PROCESS_GROUP + + +@pytest.mark.skipif(sys.platform != "win32", reason="only Windows searches the working directory for an executable") +def test_gateway_binary_in_the_working_directory_is_never_chosen( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + scripts: Final = tmp_path / "scripts" + scripts.mkdir() + (tmp_path / "litellm-gateway.exe").write_bytes(b"") + monkeypatch.chdir(tmp_path) + assert gateway_binary(WindowsPlatform(), str(scripts)) is None + installed: Final = scripts / "litellm-gateway.exe" + installed.write_bytes(b"") + assert gateway_binary(WindowsPlatform(), str(scripts)) == str(installed) From c9d3c8b52f0c5ee471715da5e8cd84175725d367 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:00:11 -0800 Subject: [PATCH 77/86] fix(streaming): report zero cost for explicitly free calls (#51093) * fix(streaming): report zero cost for explicitly free calls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(streaming): require a stored entry for free model pricing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(streaming): reuse declared pricing lookup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(streaming): preserve selected free pricing entries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(streaming): recognize explicitly free pricing tiers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(streaming): cover invalid pricing tier fallbacks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: kenneth Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 33 +++++--- litellm/litellm_core_utils/litellm_logging.py | 28 +++++++ .../llm_cost_calc/zero_cost_diagnostic.py | 30 +++++++ .../streaming_chunk_builder_utils.py | 6 +- .../litellm_core_utils/streaming_handler.py | 7 +- litellm/responses/streaming_iterator.py | 6 +- .../test_zero_cost_diagnostic.py | 80 +++++++++++++++++++ .../test_litellm_logging.py | 58 ++++++++++++++ .../test_streaming_chunk_builder_utils.py | 79 ++++++++++++++++++ .../test_streaming_handler.py | 79 ++++++++++++++++++ .../unit/responses/test_streaming_iterator.py | 55 +++++++++++++ 11 files changed, 448 insertions(+), 13 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 5b4a1d30ff0..233ff48e8a3 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -964,6 +964,19 @@ def _bedrock_registration_has_prices(entry: Mapping[str, object]) -> bool: ) +def _bedrock_cost_registration(model: str | None) -> tuple[str, Mapping[str, object]] | None: + if model is None: + return None + return next( + ( + (key, entry) + for key, entry in runtime_model_cost_registrations(model, "bedrock") + if _bedrock_registration_has_prices(entry) + ), + None, + ) + + def _bedrock_pricing_info( model: str | None, invoked_model_id: str | None, @@ -972,14 +985,7 @@ def _bedrock_pricing_info( ) -> ModelInfo | None: if explicit_pricing or custom_llm_provider != "bedrock" or not model: return None - registration: Final = next( - ( - (key, entry) - for key, entry in runtime_model_cost_registrations(model, "bedrock") - if _bedrock_registration_has_prices(entry) - ), - None, - ) + registration: Final = _bedrock_cost_registration(model) profile: Final = _cost_map_model_info(invoked_model_id, "bedrock") if invoked_model_id else None if profile is None or not _declares_rates(profile): return _cost_map_model_info(registration[0], None) if registration is not None else None @@ -2770,7 +2776,14 @@ def pricing_entry_for_cost_calc( custom_pricing is True or base_model is not None, ) if bedrock_model_info is not None: - return bedrock_model_info["key"], bedrock_model_info + key: Final = bedrock_model_info["key"] + declared_pricing: Final = declared_cost_map_pricing(key, model or key, custom_llm_provider) + registration: Final = _bedrock_cost_registration(model) + declared_keys: Final = frozenset(declared_pricing) | ( + frozenset(registration[1]) if registration is not None else frozenset[str]() + ) + selected_entry: Final[Mapping[str, object]] = bedrock_model_info + return key, MappingProxyType({name: value for name, value in selected_entry.items() if name in declared_keys}) candidates: Final = (selected_model, _get_response_model(completion_response), model) resolved: Final = next( (info for info in (_cost_map_model_info(name, custom_llm_provider) for name in candidates if name) if info), @@ -2778,7 +2791,7 @@ def pricing_entry_for_cost_calc( ) if resolved is None: return None - return resolved["key"], _raw_cost_map_entry(resolved["key"]) or resolved + return resolved["key"], declared_cost_map_pricing(resolved["key"], model or resolved["key"], custom_llm_provider) def ocr_cost( diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index d88c8876ae0..8f3101915be 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -135,6 +135,7 @@ from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import ) from litellm.litellm_core_utils.llm_cost_calc.zero_cost_diagnostic import ( diagnose_zero_cost, + is_explicitly_free_usage, zero_cost_warning, ) from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import LiteLLMResponseObjectHandler @@ -2417,6 +2418,33 @@ class Logging(LiteLLMLoggingBaseClass): usage=usage, ) + def is_usage_explicitly_free(self, result: object) -> bool: + usage: Final = get_usage_object(completion_response=result) + if usage is None or self.model_call_details.get("response_cost_failure_debug_information") is not None: + return False + custom_pricing: Final = self.custom_pricing_for(result) + if custom_pricing and self.custom_model_info is not None: + return is_explicitly_free_usage(usage, self.custom_model_info) + if custom_pricing and self.custom_cost_per_token is not None: + return is_explicitly_free_usage(usage, self.custom_cost_per_token) + custom_llm_provider: Final = self.model_call_details.get("custom_llm_provider") + pricing: Final = pricing_entry_for_cost_calc( + model=self.model, + completion_response=result, + custom_llm_provider=self.get_router_pricing_provider() or custom_llm_provider, + custom_pricing=custom_pricing, + base_model=get_base_model_from_metadata(model_call_details=self.model_call_details), + router_model_id=self.get_router_model_id(), + region_name=_resolve_mantle_region_for_cost( + custom_llm_provider=custom_llm_provider, + litellm_params=self.model_call_details.get("litellm_params"), + ), + litellm_logging_obj=self, + ) + if pricing is None: + return False + return is_explicitly_free_usage(usage, pricing[1]) + def spilled_over_ptu_for(self, result: object) -> bool: result_hidden_params: Final = getattr(result, HIDDEN_PARAMS_ATTR, None) or MappingProxyType({}) additional_headers: Final = ( diff --git a/litellm/litellm_core_utils/llm_cost_calc/zero_cost_diagnostic.py b/litellm/litellm_core_utils/llm_cost_calc/zero_cost_diagnostic.py index 3ed598b5e97..78912d0a938 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/zero_cost_diagnostic.py +++ b/litellm/litellm_core_utils/llm_cost_calc/zero_cost_diagnostic.py @@ -1,10 +1,12 @@ from collections.abc import Mapping from functools import reduce +from types import MappingProxyType from typing import Final from pydantic import TypeAdapter, ValidationError from typing_extensions import assert_never +from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input from litellm.litellm_core_utils.llm_cost_calc.utils import parse_prompt_tokens_details from litellm.types.utils import ( CompletionTokensDetailsWrapper, @@ -23,6 +25,7 @@ _TEXT_OUTPUT_RATE: Final = "output_cost_per_token" _AUDIO_OUTPUT_RATE: Final = "output_cost_per_audio_token" _RATE_KEY_MARKERS: Final = ("cost", "pricing") _NESTED_PRICING: Final = TypeAdapter(Mapping[str, object] | tuple[object, ...]) +_PRICING_TIERS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) _MAX_PRICING_DEPTH: Final = 4 @@ -105,6 +108,33 @@ def _is_explicit_zero(value: object) -> bool: return not isinstance(value, bool) and isinstance(value, (int, float)) and value == 0 +def _pricing_for_usage(usage: Usage, pricing_entry: Mapping[str, object]) -> Mapping[str, object]: + tiered_pricing: Final = pricing_entry.get("tiered_pricing") + if not isinstance(tiered_pricing, list): + return pricing_entry + try: + tiers: Final = _PRICING_TIERS.validate_python(tiered_pricing) + except ValidationError: + return pricing_entry + tier: Final = select_tier_for_input(tiers, usage.prompt_tokens) + if tier is None or _TEXT_INPUT_RATE not in tier: + return pricing_entry + cache_rate: Final = tier.get(_CACHE_READ_INPUT_RATE) + return MappingProxyType( + { + **pricing_entry, + **tier, + _CACHE_READ_INPUT_RATE: cache_rate if cache_rate is not None else tier[_TEXT_INPUT_RATE], + } + ) + + +def is_explicitly_free_usage(usage: Usage, pricing_entry: Mapping[str, object]) -> bool: + selected_pricing: Final = _pricing_for_usage(usage, pricing_entry) + used_keys: Final = tuple(_effective_pricing_key(key, selected_pricing) for key in used_pricing_keys(usage)) + return bool(used_keys) and all(_is_explicit_zero(selected_pricing.get(key)) for key in used_keys) + + def diagnose_zero_cost( usage: Usage, pricing_model: str, diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index c87357122ce..e2b651f8885 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -1500,7 +1500,11 @@ def _stamp_streaming_usage_cost(usage: Usage, response: ModelResponse, logging_o computed_cost: Final = logging_obj.response_cost_calculator(result=response) if isinstance(computed_cost, bool) or not isinstance(computed_cost, (int, float)) or computed_cost < 0: return - if computed_cost > 0 or isinstance(getattr(usage, "cost", None), (int, float)): + if ( + computed_cost > 0 + or isinstance(getattr(usage, "cost", None), (int, float)) + or logging_obj.is_usage_explicitly_free(response) + ): stamp_litellm_bill_on_usage(response, usage, float(computed_cost)) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 361e576f3e1..956e202a65a 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -114,7 +114,12 @@ def _stamp_usage_cost( usage: Final[object] = getattr(usage_chunk, "usage", None) if usage is None or isinstance(bill, bool) or not isinstance(bill, (int, float)): return - if bill <= 0 and not isinstance(getattr(usage, "cost", None), (int, float)): + complete_usage: Final = getattr(complete_response, "usage", None) + if ( + bill <= 0 + and not isinstance(getattr(usage, "cost", None), (int, float)) + and not (bill == 0 and getattr(complete_usage, "cost", None) == 0) + ): return chunk_hidden: Final = get_hidden_params(usage_chunk) if chunk_hidden is not None and UPSTREAM_USAGE_COST in complete_hidden: diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 28a82b500c9..c2191375cb4 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -1931,7 +1931,11 @@ def stamp_responses_usage_cost( return if isinstance(bill, bool) or not isinstance(bill, (int, float)): return - if bill > 0 or isinstance(usage_obj.cost, (int, float)): + if ( + bill > 0 + or isinstance(usage_obj.cost, (int, float)) + or (bill == 0 and logging_obj.is_usage_explicitly_free(priced)) + ): stamp_litellm_bill_on_usage(response_obj, usage_obj, float(bill)) diff --git a/tests/unit/litellm_core_utils/llm_cost_calc/test_zero_cost_diagnostic.py b/tests/unit/litellm_core_utils/llm_cost_calc/test_zero_cost_diagnostic.py index bdc14c4934b..d325a4677f7 100644 --- a/tests/unit/litellm_core_utils/llm_cost_calc/test_zero_cost_diagnostic.py +++ b/tests/unit/litellm_core_utils/llm_cost_calc/test_zero_cost_diagnostic.py @@ -6,6 +6,7 @@ import pytest from litellm.litellm_core_utils.llm_cost_calc.zero_cost_diagnostic import ( ZERO_COST_COUNTER_NAME, diagnose_zero_cost, + is_explicitly_free_usage, used_pricing_keys, zero_cost_warning, ) @@ -18,6 +19,85 @@ PRICED_ENTRY: Final = {"input_cost_per_token": 1e-06, "output_cost_per_token": 2 TEXT_USAGE: Final = Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30) +@pytest.mark.parametrize( + ("usage", "rates", "expected"), + [ + (TEXT_USAGE, FREE_ENTRY, True), + (TEXT_USAGE, PRICED_ENTRY, False), + (TEXT_USAGE, {}, False), + (TEXT_USAGE, {"input_cost_per_token": 0.0}, False), + (TEXT_USAGE, {"input_cost_per_token": False, "output_cost_per_token": 0.0}, False), + (Usage(prompt_tokens=10, completion_tokens=0), {"input_cost_per_token": 0.0}, True), + (Usage(prompt_tokens=0, completion_tokens=0), FREE_ENTRY, False), + ( + Usage(prompt_tokens=10, completion_tokens=0, prompt_tokens_details={"cached_tokens": 10}), + FREE_ENTRY, + False, + ), + ( + Usage(prompt_tokens=10, completion_tokens=0, prompt_tokens_details={"cached_tokens": 10}), + {"input_cost_per_token": 0.0}, + True, + ), + ], +) +def test_free_usage_requires_explicit_zero_rates_for_every_used_token_kind( + usage: Usage, rates: Mapping[str, object], expected: bool +) -> None: + assert is_explicitly_free_usage(usage, rates) is expected + + +@pytest.mark.parametrize( + ("input_tokens", "tier_output_rate", "expected"), + [(10, 0.0, True), (11, 0.0, False), (10, 0.001, False), (10, None, False)], +) +def test_free_usage_uses_the_selected_tier_and_preserves_missing_output_prices( + input_tokens: int, tier_output_rate: float | None, expected: bool +) -> None: + rates: Final = { + "input_cost_per_token": 0.01, + "tiered_pricing": [ + {"range": [0, 10], "input_cost_per_token": 0.0, "output_cost_per_token": tier_output_rate}, + {"range": [10, 20], "input_cost_per_token": 0.01, "output_cost_per_token": 0.02}, + ], + } + + assert is_explicitly_free_usage(Usage(prompt_tokens=input_tokens, completion_tokens=5), rates) is expected + + +@pytest.mark.parametrize("cache_rate", (None, 0.01)) +def test_free_cached_usage_uses_the_selected_tier_cache_rate_or_its_input_rate(cache_rate: float | None) -> None: + rates: Final = { + "input_cost_per_token": 0.01, + "cache_read_input_token_cost": 0.02, + "tiered_pricing": [ + {"range": [0, 20], "input_cost_per_token": 0.0, "cache_read_input_token_cost": cache_rate} + ], + } + usage: Final = Usage(prompt_tokens=10, completion_tokens=0, prompt_tokens_details={"cached_tokens": 10}) + + assert is_explicitly_free_usage(usage, rates) is (cache_rate is None) + + +@pytest.mark.parametrize( + "tiers", + [ + ["invalid tier"], + [], + [{"range": [0, 20], "output_cost_per_token": 0.0}], + [{"range": "invalid range", "input_cost_per_token": 0.0, "output_cost_per_token": 0.0}], + ], +) +def test_invalid_or_inactive_tiers_do_not_make_paid_usage_free(tiers: object) -> None: + rates: Final = { + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.02, + "tiered_pricing": tiers, + } + + assert is_explicitly_free_usage(Usage(prompt_tokens=10, completion_tokens=5), rates) is False + + def test_missing_pricing_key_names_every_rate_the_usage_needs() -> None: diagnostic = diagnose_zero_cost( usage=TEXT_USAGE, pricing_model="dep-1", pricing_entry=PER_SECOND_ENTRY, calculation_failed=False diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index e8f90dc0146..c1d878d5b09 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -79,6 +79,64 @@ from typing import List, Optional from litellm.types.utils import LiteLLMCommonStrings +@pytest.mark.parametrize("metadata_key", ("metadata", "litellm_metadata")) +def test_free_usage_uses_deployment_metadata_prices(metadata_key: str) -> None: + obj: Final = Logging( + model="metadata-free-deployment", + messages=[], + stream=True, + call_type="completion", + start_time=1.0, + litellm_call_id="metadata-free", + function_id="test", + ) + obj.litellm_params = {metadata_key: {"model_info": {"input_cost_per_token": 0.0, "output_cost_per_token": 0.0}}} + response: Final = ModelResponse( + model="metadata-free-deployment", usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15) + ) + + assert obj.is_usage_explicitly_free(response) is True + + +@pytest.mark.parametrize("pricing_source", ("model_info", "token_rates")) +@pytest.mark.parametrize("rate", (0.0, 0.001)) +@pytest.mark.parametrize("calculation_failed", (False, True)) +def test_per_call_free_prices_override_catalog_unless_pricing_failed( + monkeypatch: pytest.MonkeyPatch, pricing_source: str, rate: float, calculation_failed: bool +) -> None: + model: Final = "per-call-free-price-test" + monkeypatch.setitem( + litellm.model_cost, + model, + {"litellm_provider": "openai", "mode": "chat", "input_cost_per_token": 0.01, "output_cost_per_token": 0.02}, + ) + obj: Final = Logging( + model=model, + messages=[], + stream=True, + call_type="completion", + start_time=1.0, + litellm_call_id="per-call-free", + function_id="test", + ) + obj.litellm_params = {"input_cost_per_token": rate, "output_cost_per_token": rate} + if pricing_source == "model_info": + obj.set_per_call_pricing( + { + **litellm.get_model_info(model=model, custom_llm_provider="openai"), + "input_cost_per_token": rate, + "output_cost_per_token": rate, + } + ) + else: + obj.custom_cost_per_token = {"input_cost_per_token": rate, "output_cost_per_token": rate} + if calculation_failed: + obj.model_call_details["response_cost_failure_debug_information"] = {"error": "pricing failed"} + response: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15)) + + assert obj.is_usage_explicitly_free(response) is (rate == 0.0 and not calculation_failed) + + @pytest.fixture def logging_obj(): return LitellmLogging( diff --git a/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py index 527c83d5760..858c0f90930 100644 --- a/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -10,6 +10,7 @@ import respx import litellm from litellm import ChatCompletionUsageBlock, stream_chunk_builder +from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.streaming_chunk_builder_utils import ( ChunkProcessor, stream_chunk_builder_text_completion, @@ -31,6 +32,84 @@ from litellm.types.utils import ( ) +@pytest.mark.parametrize("routed_provider", (False, True)) +@pytest.mark.parametrize( + ("profile_rates", "registered_rates", "expected_cost"), + [ + ( + {"input_cost_per_token": 0.01, "output_cost_per_token": 0.02}, + {"input_cost_per_token": 0.0, "output_cost_per_token": 0.0}, + 0.0, + ), + ( + {"input_cost_per_token": 0.01, "output_cost_per_token": 0.02}, + {"input_cost_per_token": 0.001, "output_cost_per_token": 0.002}, + 0.02, + ), + ({"input_cost_per_token": 0.01, "output_cost_per_token": 0.02}, {"input_cost_per_token": 0.0}, 0.1), + ({"input_cost_per_token": 0.0}, {"input_cost_per_token": 0.0}, None), + ], +) +def test_bedrock_stream_cost_uses_registered_rates_over_profile_prices( + monkeypatch: pytest.MonkeyPatch, + profile_rates: dict[str, float], + registered_rates: dict[str, float], + expected_cost: float | None, + routed_provider: bool, +) -> None: + model: Final = "openai.stream-pricing-test" + profile: Final = f"us.{model}" + monkeypatch.setattr( + litellm, + "model_cost", + { + **litellm.model_cost, + profile: {"litellm_provider": "bedrock", "mode": "chat", **profile_rates}, + }, + ) + litellm.register_model({model: registered_rates}, persist_across_reloads=False) + logging_obj: Final = Logging( + model=model, + messages=[], + stream=True, + call_type="completion", + start_time=1.0, + litellm_call_id="bedrock-free-stream", + function_id="test", + ) + logging_obj.optional_params = {} + logging_obj.litellm_params = ( + {"metadata": {"model_info": {"pricing_provider": "bedrock"}}} if routed_provider else {} + ) + logging_obj.model_call_details.update( + custom_llm_provider="openai" if routed_provider else "bedrock", model_id=profile + ) + chunks: Final = [ + ModelResponseStream( + id="bedrock-free-stream", + created=1, + model=model, + choices=[StreamingChoices(index=0, delta=Delta(content="hi"), finish_reason="stop")], + ), + ModelResponseStream( + id="bedrock-free-stream", + created=1, + model=model, + choices=[], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ), + ] + + response: Final = stream_chunk_builder(chunks, logging_obj=logging_obj) + + assert response is not None + assert response.usage.prompt_tokens == 10 + assert response.usage.completion_tokens == 5 + assert getattr(response.usage, "cost", None) == ( + pytest.approx(expected_cost) if expected_cost is not None else None + ) + + def test_get_combined_tool_content(): chunks = [ ModelResponseStream( diff --git a/tests/unit/litellm_core_utils/test_streaming_handler.py b/tests/unit/litellm_core_utils/test_streaming_handler.py index 3b798b35096..cb0ef9a9a33 100644 --- a/tests/unit/litellm_core_utils/test_streaming_handler.py +++ b/tests/unit/litellm_core_utils/test_streaming_handler.py @@ -7649,6 +7649,85 @@ def test_chunk_creator_preserves_hidden_provider_specific_fields_from_parsed_chu assert assembled._hidden_params["provider_specific_fields"] == {"traffic_type": "ON_DEMAND_FLEX"} +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.parametrize( + ("rates", "expected_cost"), + [ + ({"input_cost_per_token": 0.0, "output_cost_per_token": 0.0}, 0.0), + ({"input_cost_per_token": 1e-6, "output_cost_per_token": 2e-6}, 20e-6), + ({"input_cost_per_token": 0.0}, None), + ({}, None), + ( + { + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.02, + "tiered_pricing": [ + {"range": [0, 128000], "input_cost_per_token": 0.0, "output_cost_per_token": 0.0} + ], + }, + 0.0, + ), + ( + { + "tiered_pricing": [ + {"range": [0, 128000], "input_cost_per_token": 0.0, "output_cost_per_token": 0.0} + ] + }, + 0.0, + ), + ], +) +async def test_stream_usage_distinguishes_free_from_unpriced_calls( + monkeypatch: pytest.MonkeyPatch, sync_mode: bool, rates: dict[str, object], expected_cost: float | None +) -> None: + model: Final = "stream-zero-cost-regression" + monkeypatch.setitem(litellm.model_cost, model, {"litellm_provider": "openai", "mode": "chat", **rates}) + logging_obj: Final = Logging( + model=model, + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="completion" if sync_mode else "acompletion", + start_time=1.0, + litellm_call_id="free-stream", + function_id="test", + ) + logging_obj.optional_params = {} + logging_obj.litellm_params = {} + logging_obj.stream_options = {"include_usage": True} + logging_obj.model_call_details["custom_llm_provider"] = "openai" + chunks: Final = [ + ModelResponseStream( + id="free-stream", + created=1, + model=model, + choices=[StreamingChoices(index=0, delta=Delta(content="hi"), finish_reason="stop")], + ), + ModelResponseStream( + id="free-stream", + created=1, + model=model, + choices=[], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ), + ] + stream: Final = CustomStreamWrapper( + completion_stream=ModelResponseListIterator(model_responses=chunks), + model=model, + custom_llm_provider="openai", + logging_obj=logging_obj, + stream_options={"include_usage": True}, + ) + emitted: Final = tuple(stream) if sync_mode else tuple([chunk async for chunk in stream]) + usage_chunks: Final = tuple(chunk for chunk in emitted if getattr(chunk, "usage", None) is not None) + + assert len(usage_chunks) == 1 + assert usage_chunks[0].usage.prompt_tokens == 10 + assert usage_chunks[0].usage.completion_tokens == 5 + assert getattr(usage_chunks[0].usage, "cost", None) == ( + pytest.approx(expected_cost) if expected_cost is not None else None + ) + + def test_the_usage_chunk_carries_the_bill_the_builder_priced_for_the_spend_row() -> None: from litellm.litellm_core_utils.streaming_handler import ( _stamp_usage_cost, # pyright: ignore[reportPrivateUsage] # the helper under test diff --git a/tests/unit/responses/test_streaming_iterator.py b/tests/unit/responses/test_streaming_iterator.py index 11f4e78619e..17a5112ce6f 100644 --- a/tests/unit/responses/test_streaming_iterator.py +++ b/tests/unit/responses/test_streaming_iterator.py @@ -861,6 +861,61 @@ def _responses_api_response_with_usage() -> ResponsesAPIResponse: ) +@pytest.mark.parametrize( + ("rates", "expected_cost"), + [ + ({"input_cost_per_token": 0.0, "output_cost_per_token": 0.0}, 0.0), + ({"input_cost_per_token": 1e-6, "output_cost_per_token": 2e-6}, 140e-6), + ({"input_cost_per_token": 0.0}, None), + ({}, None), + ( + { + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.02, + "tiered_pricing": [ + {"range": [0, 128000], "input_cost_per_token": 0.0, "output_cost_per_token": 0.0} + ], + }, + 0.0, + ), + ( + { + "tiered_pricing": [ + {"range": [0, 128000], "input_cost_per_token": 0.0, "output_cost_per_token": 0.0} + ] + }, + 0.0, + ), + ], +) +def test_responses_stream_usage_distinguishes_free_from_unpriced_calls( + monkeypatch: pytest.MonkeyPatch, rates: dict[str, object], expected_cost: float | None +) -> None: + from litellm.responses.streaming_iterator import stamp_responses_usage_cost + + model: Final = "responses-zero-cost-regression" + monkeypatch.setitem(litellm.model_cost, model, {"litellm_provider": "openai", "mode": "chat", **rates}) + response: Final = _responses_api_response_with_usage().model_copy(update={"model": model}) + logging_obj: Final = LiteLLMLoggingObj( + model=model, + messages=[], + stream=True, + call_type="responses", + start_time=1.0, + litellm_call_id="free-response", + function_id="test", + ) + logging_obj.optional_params = {} + logging_obj.litellm_params = {} + logging_obj.model_call_details["custom_llm_provider"] = "openai" + + stamp_responses_usage_cost(response, logging_obj) + + assert getattr(response.usage, "cost", None) == ( + pytest.approx(expected_cost) if expected_cost is not None else None + ) + + def test_stamp_responses_usage_cost_stamps_computed_cost(): from litellm.responses.streaming_iterator import stamp_responses_usage_cost From a0f2562088f44239e5f578c3bcb452c531ac9548 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 11 Nov 2026 12:00:32 -0800 Subject: [PATCH 78/86] feat(gemini): file-backed batch jobs on /v1/batches with output download and spend tracking (#51032) * feat(gemini): file-backed batch jobs on /v1/batches with output download Adds GeminiBatchesConfig (create + retrieve) behind custom_llm_provider=gemini, registers it in ProviderConfigManager, and implements file content retrieval on GoogleAIStudioFilesHandler so GET /v1/files/{output_file_id}/content returns the batch output. Gemini runs the OpenAI-shaped JSONL the proxy uploads today unchanged. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gemini): type the file content request params so no pyright rule grows Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(gemini): price completed batch output rows so spend lands on the key Gemini writes the usage of an OpenAI-shaped output row as camelCase (promptTokens, completionTokens, totalTokens) with thinking tokens only in totalTokens. Map it onto Usage with the thinking tokens folded into completion_tokens, so the shared batch cost path bills them at the gemini _batches rates. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gemini): parse nanosecond file timestamps Gemini stamps files with nine fractional digits, which strptime's %f rejects, so every retrieve of a batch output file logged a ValueError. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gemini): parse file timestamps on Python 3.10 too fromisoformat on 3.10 takes neither a Z suffix nor more than six fractional digits. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gemini): price native batch output rows, stream output downloads, reject unsupported batch metadata Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gemini): detect native batch rows by key, anchor the streaming file download handler Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(httpx): make anchor_handler_to public so the file content stream can use it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/batches/batch_utils.py | 10 + litellm/files/main.py | 12 +- litellm/llms/custom_httpx/http_handler.py | 20 +- litellm/llms/custom_httpx/llm_http_handler.py | 2 + litellm/llms/gemini/batches/__init__.py | 0 litellm/llms/gemini/batches/transformation.py | 301 +++++++++++++++++ litellm/llms/gemini/files/transformation.py | 96 +++--- litellm/types/utils.py | 2 +- litellm/utils.py | 4 + tests/unit/batches/test_batch_utils.py | 123 ++++++- tests/unit/batches/test_main.py | 25 ++ tests/unit/llms/gemini/batches/__init__.py | 0 .../test_gemini_batches_transformation.py | 310 ++++++++++++++++++ .../files/test_gemini_files_transformation.py | 61 +++- 14 files changed, 878 insertions(+), 88 deletions(-) create mode 100644 litellm/llms/gemini/batches/__init__.py create mode 100644 litellm/llms/gemini/batches/transformation.py create mode 100644 tests/unit/llms/gemini/batches/__init__.py create mode 100644 tests/unit/llms/gemini/batches/test_gemini_batches_transformation.py diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index cdae3de47c8..62ccb07f695 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -49,6 +49,10 @@ def _uses_native_vertex_output( model_name: str | None, first_row: Mapping[str, object] | None, ) -> bool: + if custom_llm_provider == "gemini": + from litellm.llms.gemini.batches.transformation import is_native_gemini_batch_output_row + + return first_row is not None and is_native_gemini_batch_output_row(first_row) if custom_llm_provider != "vertex_ai": return False if model_name and litellm.disable_vertex_batch_output_transformation: @@ -831,6 +835,12 @@ def _get_batch_job_usage_from_response_body( _usage_dict: Final = response_body.get("usage", None) or {} if ResponseAPILoggingUtils.is_response_api_usage(_usage_dict): return ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(_usage_dict) + if custom_llm_provider == "gemini": + from litellm.llms.gemini.batches.transformation import is_gemini_batch_usage_shape, usage_from_batch_output + + gemini_usage: Final = cast(Mapping[str, object], _usage_dict) # cast-ok: usage block comes from provider JSON + if is_gemini_batch_usage_shape(gemini_usage): + return usage_from_batch_output(gemini_usage) usage: Final[Usage] = Usage(**_usage_dict) if custom_llm_provider == "xai": from litellm.llms.xai.chat.transformation import XAIChatConfig diff --git a/litellm/files/main.py b/litellm/files/main.py index d1ddf6c4c4c..3c73f6d5389 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -1091,10 +1091,10 @@ def file_content_streaming( client=client if isinstance(client, (OpenAI, AsyncOpenAI)) else None, litellm_params=optional_params.model_dump(exclude_none=True), ) - elif custom_llm_provider == LlmProviders.VERTEX_AI.value: + elif custom_llm_provider in (LlmProviders.VERTEX_AI.value, LlmProviders.GEMINI.value): if not _is_async: raise litellm.exceptions.BadRequestError( - message="Streaming 'file_content' for vertex_ai is only supported through 'afile_content'.", + message=f"Streaming 'file_content' for {custom_llm_provider} is only supported through 'afile_content'.", model="n/a", llm_provider=custom_llm_provider, response=httpx.Response( @@ -1103,14 +1103,14 @@ def file_content_streaming( request=httpx.Request(method="file_content", url="https://github.com/BerriAI/litellm"), ), ) - vertex_files_config: Final = ProviderConfigManager.get_provider_files_config( + google_files_config: Final = ProviderConfigManager.get_provider_files_config( model="", - provider=LlmProviders.VERTEX_AI, + provider=LlmProviders(custom_llm_provider), ) - assert vertex_files_config is not None + assert google_files_config is not None response = base_llm_http_handler.async_retrieve_file_content_streaming( file_content_request=file_content_request, - provider_config=vertex_files_config, + provider_config=google_files_config, litellm_params=litellm_params, headers=extra_headers or {}, logging_obj=logging_obj, diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 011551faf0c..a5b15a24a7f 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -232,13 +232,13 @@ def _handler_may_close_client(client_refcount: int, owns_client: bool) -> bool: def _drop_streaming_anchor(_handler: object) -> None: - """Release a handler anchored to a streaming response. See ``_anchor_handler_to``. + """Release a handler anchored to a streaming response. See ``anchor_handler_to``. The work is the reference held until this point, so there is nothing to do here. """ -def _anchor_handler_to(response: httpx.Response, handler: object) -> None: +def anchor_handler_to(response: httpx.Response, handler: object) -> None: """Keep the handler alive for as long as a streaming response can still read. A body still arriving reads through the handler's connection pool, and closing @@ -927,7 +927,7 @@ class AsyncHTTPHandler: stream=True, follow_redirects=USE_CLIENT_DEFAULT if follow_redirects is None else follow_redirects, ) - _anchor_handler_to(response, self) + anchor_handler_to(response, self) response.raise_for_status() except httpx.TimeoutException as e: _raise_timeout(e, start_time) @@ -1024,7 +1024,7 @@ class AsyncHTTPHandler: ) response: Final = await self.client.send(req, stream=stream) if stream: - _anchor_handler_to(response, self) + anchor_handler_to(response, self) response.raise_for_status() return response except httpx.ConnectError: @@ -1201,7 +1201,7 @@ class AsyncHTTPHandler: ) response: Final = await self.client.send(req, stream=stream) if stream: - _anchor_handler_to(response, self) + anchor_handler_to(response, self) response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): @@ -1706,7 +1706,7 @@ class HTTPHandler: stream=True, follow_redirects=USE_CLIENT_DEFAULT if follow_redirects is None else follow_redirects, ) - _anchor_handler_to(response, self) + anchor_handler_to(response, self) response.raise_for_status() except httpx.TimeoutException as e: _raise_timeout(e, start_time) @@ -1771,7 +1771,7 @@ class HTTPHandler: ) response: Final = self.client.send(req, stream=stream) if stream: - _anchor_handler_to(response, self) + anchor_handler_to(response, self) response.raise_for_status() return response except httpx.TimeoutException as e: @@ -1820,7 +1820,7 @@ class HTTPHandler: ) response: Final = self.client.send(req, stream=stream) if stream: - _anchor_handler_to(response, self) + anchor_handler_to(response, self) response.raise_for_status() return response except httpx.TimeoutException as e: @@ -1869,7 +1869,7 @@ class HTTPHandler: ) response: Final = self.client.send(req, stream=stream) if stream: - _anchor_handler_to(response, self) + anchor_handler_to(response, self) return response except httpx.TimeoutException as e: _raise_timeout(e, start_time) @@ -1917,7 +1917,7 @@ class HTTPHandler: ) response: Final = self.client.send(req, stream=stream) if stream: - _anchor_handler_to(response, self) + anchor_handler_to(response, self) response.raise_for_status() return response except httpx.TimeoutException as e: diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 5b6a64d2377..efb8b302664 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -133,6 +133,7 @@ from litellm.llms.custom_httpx.container_handler import raise_for_error_status from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, + anchor_handler_to, get_async_httpx_client, get_httpx_client, header_value, @@ -5890,6 +5891,7 @@ class BaseLLMHTTPHandler: except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the buffered fetch raise self._handle_error(e=e, provider_config=provider_config) + anchor_handler_to(response, async_httpx_client) if response.status_code >= 400: error_body: Final = await response.aread() await response.aclose() diff --git a/litellm/llms/gemini/batches/__init__.py b/litellm/llms/gemini/batches/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/gemini/batches/transformation.py b/litellm/llms/gemini/batches/transformation.py new file mode 100644 index 00000000000..10baec7d37d --- /dev/null +++ b/litellm/llms/gemini/batches/transformation.py @@ -0,0 +1,301 @@ +""" +Gemini Developer API batch jobs. Reference: https://ai.google.dev/gemini-api/docs/batch-api + +A file-backed job runs the uploaded JSONL unchanged. Gemini accepts OpenAI-shaped rows +(``{custom_id, method, url, body}``) and then writes the output file OpenAI-shaped too +(``{custom_id, response: {status_code, body}}``), so ``/v1/files purpose=batch`` uploads and the +shared batch cost accounting need no provider branch. The model is set on the job URL. +Ids cross the LiteLLM boundary bare: Gemini's ``batches/`` is the batch id ```` and its +``files/`` output file is the file id ````. +""" + +from collections.abc import Mapping, Sequence +from datetime import datetime +from types import MappingProxyType +from typing import Final, Literal, TypeAlias + +import httpx +from openai.types.batch import BatchRequestCounts +from openai.types.batch import Errors as BatchErrors +from openai.types.batch_error import BatchError +from pydantic import ConfigDict, Field +from typing_extensions import ReadOnly, TypedDict + +from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base +from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo +from litellm.llms.gemini.files.transformation import gemini_file_name +from litellm.types.llms.base import LiteLLMBaseModel +from litellm.types.llms.openai import AllMessageValues, CreateBatchRequest +from litellm.types.utils import LiteLLMBatch, LlmProviders, Usage + +GeminiBatchState: TypeAlias = Literal[ + "BATCH_STATE_UNSPECIFIED", + "BATCH_STATE_PENDING", + "BATCH_STATE_RUNNING", + "BATCH_STATE_SUCCEEDED", + "BATCH_STATE_FAILED", + "BATCH_STATE_CANCELLED", + "BATCH_STATE_EXPIRED", +] +OpenAIBatchStatus: TypeAlias = Literal[ + "validating", "failed", "in_progress", "finalizing", "completed", "expired", "cancelling", "cancelled" +] + +GEMINI_BATCH_RESOURCE_PREFIX: Final = "batches/" +_GEMINI_FILE_RESOURCE_PREFIX: Final = "files/" +_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) +_STATUS_MAP: Final[MappingProxyType[GeminiBatchState, OpenAIBatchStatus]] = MappingProxyType( + { + "BATCH_STATE_UNSPECIFIED": "validating", + "BATCH_STATE_PENDING": "validating", + "BATCH_STATE_RUNNING": "in_progress", + "BATCH_STATE_SUCCEEDED": "completed", + "BATCH_STATE_FAILED": "failed", + "BATCH_STATE_CANCELLED": "cancelled", + "BATCH_STATE_EXPIRED": "expired", + } +) + + +class GeminiBatchInputConfig(TypedDict): + file_name: ReadOnly[str] + + +class GeminiBatchSpec(TypedDict): + display_name: ReadOnly[str] + input_config: ReadOnly[GeminiBatchInputConfig] + + +class GeminiCreateBatchRequest(TypedDict): + batch: ReadOnly[GeminiBatchSpec] + + +class GeminiPresignedRequest(TypedDict): + method: ReadOnly[Literal["GET"]] + url: ReadOnly[str] + headers: ReadOnly[Mapping[str, str]] + + +class _GeminiBatchStats(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + request_count: int = Field(default=0, alias="requestCount") + successful_request_count: int = Field(default=0, alias="successfulRequestCount") + failed_request_count: int = Field(default=0, alias="failedRequestCount") + + +class _GeminiBatchInputConfig(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + file_name: str | None = Field(default=None, alias="fileName") + + +class _GeminiBatchOutput(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + responses_file: str | None = Field(default=None, alias="responsesFile") + + +class _GeminiBatchMetadata(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + display_name: str | None = Field(default=None, alias="displayName") + input_config: _GeminiBatchInputConfig = Field(default_factory=_GeminiBatchInputConfig, alias="inputConfig") + output: _GeminiBatchOutput = Field(default_factory=_GeminiBatchOutput) + create_time: datetime = Field(alias="createTime") + end_time: datetime | None = Field(default=None, alias="endTime") + batch_stats: _GeminiBatchStats = Field(default_factory=_GeminiBatchStats, alias="batchStats") + state: GeminiBatchState = "BATCH_STATE_UNSPECIFIED" + + +class _GeminiOperationError(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + code: int | None = None + message: str = "" + + +class GeminiBatchOperation(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + name: str + metadata: _GeminiBatchMetadata + error: _GeminiOperationError | None = None + + +class _GeminiBatchOutputUsage(LiteLLMBaseModel): + """The ``usage`` block Gemini writes on an OpenAI-shaped batch output row. ``totalTokens`` + includes thinking tokens that ``completionTokens`` leaves out, and Gemini bills them as output.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + prompt_tokens: int = Field(default=0, alias="promptTokens") + completion_tokens: int = Field(default=0, alias="completionTokens") + total_tokens: int = Field(default=0, alias="totalTokens") + + +def is_native_gemini_batch_output_row(row: Mapping[str, object]) -> bool: + """Native rows carry Gemini's ``key`` (and a ``response`` or an error ``status``) where OpenAI-shaped rows + carry ``custom_id``, so the shape is known even when the first row failed.""" + return "key" in row and "custom_id" not in row + + +def is_gemini_batch_usage_shape(usage_object: Mapping[str, object]) -> bool: + return any(key in usage_object for key in ("promptTokens", "completionTokens", "totalTokens")) + + +def usage_from_batch_output(usage_object: Mapping[str, object]) -> Usage: + reported: Final = _GeminiBatchOutputUsage.model_validate(usage_object) + reasoning_tokens: Final = max(reported.total_tokens - reported.prompt_tokens - reported.completion_tokens, 0) + return Usage( + prompt_tokens=reported.prompt_tokens, + completion_tokens=reported.completion_tokens + reasoning_tokens, + total_tokens=reported.total_tokens, + reasoning_tokens=reasoning_tokens or None, + ) + + +def _bare_id(resource_name: str | None, prefix: str) -> str | None: + return None if resource_name is None else resource_name.removeprefix(prefix) + + +def _to_batch_errors(error: _GeminiOperationError | None) -> BatchErrors | None: + if error is None: + return None + return BatchErrors( + object="list", data=[BatchError(code=str(error.code) if error.code else None, message=error.message)] + ) + + +def _to_litellm_batch(operation: GeminiBatchOperation) -> LiteLLMBatch: + meta: Final = operation.metadata + status: Final = _STATUS_MAP[meta.state] + terminal_at: Final = int(meta.end_time.timestamp()) if meta.end_time is not None else None + return LiteLLMBatch( + id=_bare_id(operation.name, GEMINI_BATCH_RESOURCE_PREFIX) or operation.name, + object="batch", + endpoint="/v1/chat/completions", + input_file_id=_bare_id(meta.input_config.file_name, _GEMINI_FILE_RESOURCE_PREFIX) or "", + completion_window="24h", + status=status, + created_at=int(meta.create_time.timestamp()), + completed_at=terminal_at if status == "completed" else None, + failed_at=terminal_at if status == "failed" else None, + expired_at=terminal_at if status == "expired" else None, + cancelled_at=terminal_at if status == "cancelled" else None, + output_file_id=_bare_id(meta.output.responses_file, _GEMINI_FILE_RESOURCE_PREFIX), + errors=_to_batch_errors(operation.error), + request_counts=BatchRequestCounts( + total=meta.batch_stats.request_count, + completed=meta.batch_stats.successful_request_count, + failed=meta.batch_stats.failed_request_count, + ), + metadata={"display_name": meta.display_name} if meta.display_name else None, + ) + + +def _auth_headers(headers: Mapping[str, str], api_key: str | None) -> dict[str, str]: # mutable-ok: header contract + resolved_api_key: Final = GeminiModelInfo.get_api_key(api_key) + if not resolved_api_key: + raise ValueError("GEMINI_API_KEY is required for Gemini batch operations") + return {**headers, "x-goog-api-key": resolved_api_key} + + +class GeminiBatchesConfig(BaseBatchesConfig): + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.GEMINI + + def validate_environment( + self, + headers: Mapping[str, str], + model: str, + messages: Sequence[AllMessageValues], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + api_key: str | None = None, + api_base: str | None = None, + ) -> dict[str, str]: # mutable-ok: BaseBatchesConfig signature + return _auth_headers(headers, api_key) + + def get_complete_batch_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + data: CreateBatchRequest, + ) -> str: + model_segment: Final = encode_url_path_segment(model.removeprefix("models/"), field_name="model") + return join_api_base( + GeminiModelInfo.get_api_base(api_base), f"v1beta/models/{model_segment}:batchGenerateContent" + ) + + def transform_create_batch_request( + self, + model: str, + create_batch_data: CreateBatchRequest, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: BaseBatchesConfig signature + input_file_id: Final = create_batch_data.get("input_file_id") + if input_file_id is None: + raise ValueError("input_file_id is required to create a Gemini batch job") + file_name: Final = gemini_file_name(input_file_id) + metadata: Final = create_batch_data.get("metadata") or {} + unsupported_metadata: Final = sorted(set(metadata) - {"display_name"}) + if unsupported_metadata: + raise ValueError(f"Gemini batch jobs carry no metadata besides display_name, got {unsupported_metadata}") + display_name: Final = ( + metadata.get("display_name") or f"litellm-{file_name.removeprefix(_GEMINI_FILE_RESOURCE_PREFIX)}" + ) + body: Final = GeminiCreateBatchRequest( + batch=GeminiBatchSpec(display_name=display_name, input_config=GeminiBatchInputConfig(file_name=file_name)) + ) + return dict(body) + + def transform_create_batch_response( + self, + model: str | None, + raw_response: httpx.Response, + logging_obj: object, + litellm_params: Mapping[str, object], + ) -> LiteLLMBatch: + return _to_litellm_batch(GeminiBatchOperation.model_validate(raw_response.json())) + + def transform_retrieve_batch_request( + self, + batch_id: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: BaseBatchesConfig signature + bare_batch_id: Final = batch_id.removeprefix(GEMINI_BATCH_RESOURCE_PREFIX) + encoded_batch_id: Final = encode_url_path_segment(bare_batch_id, field_name="batch_id") + api_base: Final = litellm_params.get("api_base") + api_key: Final = litellm_params.get("api_key") + request: Final = GeminiPresignedRequest( + method="GET", + url=join_api_base( + GeminiModelInfo.get_api_base(api_base if isinstance(api_base, str) else None), + f"v1beta/{GEMINI_BATCH_RESOURCE_PREFIX}{encoded_batch_id}", + ), + headers=_auth_headers(_NO_HEADERS, api_key if isinstance(api_key, str) else None), + ) + return dict(request) + + def transform_retrieve_batch_response( + self, + model: str | None, + raw_response: httpx.Response, + logging_obj: object, + litellm_params: Mapping[str, object], + ) -> LiteLLMBatch: + return _to_litellm_batch(GeminiBatchOperation.model_validate(raw_response.json())) + + def get_error_class( + self, error_message: str, status_code: int, headers: Mapping[str, str] | httpx.Headers + ) -> BaseLLMException: + return GeminiError(status_code=status_code, message=error_message, headers=dict(headers)) diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 17460c0295a..4fb5a77653d 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -4,8 +4,10 @@ Supports writing files to Google AI Studio Files API. For vertex ai, check out the vertex_ai/files/handler.py file. """ +import re import time from collections.abc import Mapping +from datetime import datetime from typing import Final, Literal, TypedDict from urllib.parse import urlparse @@ -25,6 +27,7 @@ from litellm.llms.base_llm.files.transformation import ( from litellm.types.llms.openai import ( AllMessageValues, CreateFileRequest, + FileContentRequest, HttpxBinaryResponseContent, OpenAICreateFileRequestOptionalParams, OpenAIFileObject, @@ -53,6 +56,34 @@ class _GeminiCreateFileResponse(TypedDict): file: ReadOnly[_GeminiFileMetadata] +_RFC3339_PARTS: Final = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?([+-]\d{2}:\d{2})") + + +def _gemini_timestamp(rfc3339: str) -> int: + """Gemini stamps files with up to nine fractional digits and a Z suffix; Python 3.10's + fromisoformat takes neither, so trim the fraction to microseconds and spell out UTC.""" + parts: Final = _RFC3339_PARTS.fullmatch(rfc3339.replace("Z", "+00:00")) + if parts is None: + raise ValueError(f"Unrecognized Gemini timestamp: {rfc3339}") + seconds, fraction, offset = parts.groups() + return int(datetime.fromisoformat(f"{seconds}{(fraction or '')[:7]}{offset}").timestamp()) + + +def gemini_file_name(file_id: str) -> str: + """ + Normalize a file identifier into Gemini's `files/{id}` resource name. + + Supports: + - `abc123` + - `files/abc123` + - `https://generativelanguage.googleapis.com/v1beta/files/abc123` + """ + path: Final = urlparse(file_id).path.lstrip("/") if file_id.startswith(("http://", "https://")) else file_id + files_index: Final = path.find("files/") + bare_id: Final = (path[files_index:] if files_index != -1 else path).strip("/").removeprefix("files/") + return f"files/{encode_url_path_segment(bare_id, field_name='file_id')}" + + class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): def __init__(self): pass @@ -194,14 +225,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): return OpenAIFileObject( id=response_object["uri"], # Gemini uses URI as identifier bytes=int(response_object["sizeBytes"]), # Gemini doesn't return file size - created_at=int( - time.mktime( - time.strptime( - response_object["createTime"].replace("Z", "+00:00"), - "%Y-%m-%dT%H:%M:%S.%f%z", - ) - ) - ), + created_at=_gemini_timestamp(response_object["createTime"]), filename=response_object["displayName"], object="file", purpose="user_data", # Default to assistants as that's the main use case @@ -246,32 +270,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): return url, {} def _normalize_gemini_file_id(self, file_id: str) -> str: - """ - Normalize file identifier into `files/{id}` form. - - Supports: - - `abc123` - - `files/abc123` - - `https://generativelanguage.googleapis.com/v1beta/files/abc123` - """ - if file_id.startswith(("http://", "https://")): - parsed: Final = urlparse(file_id) - path: Final = parsed.path.lstrip("/") - files_index: Final = path.find("files/") - if files_index != -1: - normalized_file_id = path[files_index:] - else: - normalized_file_id = path - else: - normalized_file_id = file_id - - normalized_file_id = normalized_file_id.strip("/") - if normalized_file_id.startswith("files/"): - normalized_file_id = normalized_file_id.removeprefix("files/") - - encoded_file_id: Final = encode_url_path_segment(normalized_file_id, field_name="file_id") - - return f"files/{encoded_file_id}" + return gemini_file_name(file_id) def transform_retrieve_file_response( self, @@ -299,14 +298,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): return OpenAIFileObject( id=response_json.get("uri", ""), bytes=int(response_json.get("sizeBytes", 0)), - created_at=int( - time.mktime( - time.strptime( - response_json["createTime"].replace("Z", "+00:00"), - "%Y-%m-%dT%H:%M:%S.%f%z", - ) - ) - ), + created_at=_gemini_timestamp(response_json["createTime"]), filename=response_json.get("displayName", ""), object="file", purpose="user_data", @@ -403,11 +395,19 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): def transform_file_content_request( self, - file_content_request, - optional_params: dict, - litellm_params: dict, - ) -> tuple[str, dict]: - raise NotImplementedError("GoogleAIStudioFilesHandler does not support file content retrieval") + file_content_request: FileContentRequest, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, str]]: # mutable-ok: BaseFilesConfig signature + file_id: Final = file_content_request.get("file_id") + if file_id is None: + raise ValueError("file_id is required to download file content") + api_base: Final = litellm_params.get("api_base") + url: Final = join_api_base( + self.get_api_base(api_base if isinstance(api_base, str) else None), + f"download/v1beta/{gemini_file_name(file_id)}:download", + ) + return url, {"alt": "media"} def transform_file_content_response( self, @@ -415,4 +415,4 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): logging_obj: LiteLLMLoggingObj, litellm_params: dict, ) -> HttpxBinaryResponseContent: - raise NotImplementedError("GoogleAIStudioFilesHandler does not support file content retrieval") + return HttpxBinaryResponseContent(response=raw_response) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index addd642eca7..81d39f848fb 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4833,7 +4833,7 @@ OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS: set[str] = { } FILE_CONTENT_STREAMING_PROVIDERS: Final[frozenset[str]] = frozenset( - {*OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS, LlmProviders.VERTEX_AI.value} + {*OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS, LlmProviders.VERTEX_AI.value, LlmProviders.GEMINI.value} ) LITELLM_EXECUTED_BATCH_PROVIDERS: Final[frozenset[str]] = frozenset({LlmProviders.HOSTED_VLLM.value}) diff --git a/litellm/utils.py b/litellm/utils.py index 156247897c3..7c73d1cc4d5 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -10866,6 +10866,10 @@ class ProviderConfigManager: from litellm.llms.mistral.batches.transformation import MistralBatchesConfig return MistralBatchesConfig() + elif LlmProviders.GEMINI == provider: + from litellm.llms.gemini.batches.transformation import GeminiBatchesConfig + + return GeminiBatchesConfig() return None @staticmethod diff --git a/tests/unit/batches/test_batch_utils.py b/tests/unit/batches/test_batch_utils.py index bef78554a36..2c8114f8736 100644 --- a/tests/unit/batches/test_batch_utils.py +++ b/tests/unit/batches/test_batch_utils.py @@ -14,27 +14,28 @@ maps (litellm.completion_cost, batch_cost_calculator), the tokenizer deterministic stand-ins so the arithmetic under test is the only variable. """ -import asyncio, json, time +import asyncio +import json import logging +import time from typing import Final, Literal +from unittest.mock import AsyncMock, patch import httpx import pytest import respx - import litellm import litellm.batches.batch_utils as bu -from litellm.types.utils import LiteLLMBatch, ModelInfo, Usage -from litellm.batches.batch_utils import( +from litellm.batches.batch_utils import ( _aggregate_batch_cost_usage_models, - get_file_content_as_dictionary, _get_response_from_batch_job_output_file, calculate_batch_cost_and_usage, + get_file_content_as_dictionary, ) from litellm.cost_calculator import batch_cost_calculator +from litellm.types.utils import LiteLLMBatch, ModelInfo, Usage from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome -from unittest.mock import AsyncMock, patch # --------------------------------------------------------------------------- # # Builders for batch OUTPUT file rows. @@ -1188,7 +1189,9 @@ async def test_handle_completed_vertex_batch_computes_cost_usage_and_models(monk @pytest.mark.asyncio -async def test_handle_completed_vertex_batch_regional_location_costs_uplift_over_global(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_handle_completed_vertex_batch_regional_location_costs_uplift_over_global( + monkeypatch: pytest.MonkeyPatch, +) -> None: import litellm.files.main as files_main rows = [_vertex_openai_row("request-1", "gemini-3.6-flash", 10, 5)] @@ -2188,8 +2191,7 @@ GROUNDED_USAGE_METADATA = { PASSTHROUGH_OUTPUT_URI = ( - "gs://litellm-bucket/litellm-vertex-files/passthrough/publishers/google/models/gemini-2.5-flash/u/" - "predictions.jsonl" + "gs://litellm-bucket/litellm-vertex-files/passthrough/publishers/google/models/gemini-2.5-flash/u/predictions.jsonl" ) @@ -2555,6 +2557,7 @@ def _vcr_outcome_gate(request, vcr): yield record_vcr_outcome(request, vcr) + @pytest.fixture(scope="session") def event_loop(): try: @@ -2564,6 +2567,7 @@ def event_loop(): yield loop loop.close() + @pytest.fixture(scope="function") def setup_and_teardown(event_loop): original_state = _copy_litellm_state() @@ -2580,6 +2584,7 @@ def setup_and_teardown(event_loop): if pending: event_loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + def _copy_litellm_state(): state = {} for attr in _CALLBACK_ATTRS: @@ -2591,6 +2596,7 @@ def _copy_litellm_state(): state[attr] = getattr(litellm, attr) return state + _CALLBACK_ATTRS = ( "callbacks", "success_callback", @@ -2613,6 +2619,7 @@ _SCALAR_ATTRS = ( "cohere_key", ) + def _clear_logging_queue(loop=None) -> None: from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER @@ -2621,6 +2628,7 @@ def _clear_logging_queue(loop=None) -> None: return asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue()) + def _reset_litellm_callbacks() -> None: for attr in _CALLBACK_ATTRS: if hasattr(litellm, attr): @@ -2630,11 +2638,13 @@ def _reset_litellm_callbacks() -> None: if callable(reset): reset() + def _restore_litellm_state(state) -> None: for attr, value in state.items(): if hasattr(litellm, attr): setattr(litellm, attr, value) + def _make_batch_output_line(prompt_tokens: int = 10, completion_tokens: int = 5): """Return a single successful batch output line (OpenAI JSONL format).""" return { @@ -2663,11 +2673,13 @@ def _make_batch_output_line(prompt_tokens: int = 10, completion_tokens: int = 5) "error": None, } + CUSTOM_MODEL_INFO = { "input_cost_per_token_batches": 0.00125, "output_cost_per_token_batches": 0.005, } + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_batch_cost_calculator_explicit_zero_pricing_not_overridden_by_global( monkeypatch, @@ -2699,6 +2711,7 @@ def test_batch_cost_calculator_explicit_zero_pricing_not_overridden_by_global( assert prompt_cost == 0.0 assert completion_cost == 0.0 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_batch_cost_calculator_uses_custom_model_info(): """batch_cost_calculator should use model_info override when provided.""" @@ -2718,6 +2731,7 @@ def test_batch_cost_calculator_uses_custom_model_info(): f"Expected completion cost {expected_completion}, got {completion_cost}" ) + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_aggregate_batch_cost_uses_custom_model_info(): """_aggregate_batch_cost_usage_models should thread model_info to batch_cost_calculator.""" @@ -2732,6 +2746,7 @@ def test_aggregate_batch_cost_uses_custom_model_info(): expected = (10 * 0.00125) + (5 * 0.005) assert result.cost == pytest.approx(expected), f"Expected total cost {expected}, got {result.cost}" + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.parametrize("data_residency", ["eu", "us", "fedramp"]) def test_batch_cost_calculator_applies_data_residency_uplift(data_residency, monkeypatch): @@ -2779,6 +2794,7 @@ async def test_calculate_batch_cost_and_usage_uses_custom_model_info(): assert result.usage.prompt_tokens == 10 assert result.usage.completion_tokens == 5 + @pytest.fixture def sample_file_content(): return b""" @@ -2786,6 +2802,7 @@ def sample_file_content(): {"id": "batch_req_6769ca597e588190920666612634e2b4", "custom_id": "request-2", "response": {"status_code": 200, "request_id": "82e04f4c001fe2c127cbad199f5fd31b", "body": {"id": "chatcmpl-AhjSNgVB4Oa4Hq0NruTRsBaEbRWUP", "object": "chat.completion", "created": 1734986203, "model": "gpt-4o-mini-2024-07-18", "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello! What can I do for you today?", "refusal": null}, "logprobs": null, "finish_reason": "length"}], "usage": {"prompt_tokens": 22, "completion_tokens": 10, "total_tokens": 32, "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}}, "system_fingerprint": "fp_0aa8d3e20b"}}, "error": null} """ + @pytest.fixture def sample_file_content_dict(): return [ @@ -2877,6 +2894,7 @@ def sample_file_content_dict(): }, ] + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_get_file_content_as_dictionary(sample_file_content): result = get_file_content_as_dictionary(sample_file_content) @@ -2886,6 +2904,7 @@ def test_get_file_content_as_dictionary(sample_file_content): assert result[0]["response"]["status_code"] == 200 assert result[0]["response"]["body"]["usage"]["total_tokens"] == 30 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_get_batch_job_total_usage_from_file_content(sample_file_content_dict): with patch("litellm.completion_cost", return_value=0.0): @@ -2894,6 +2913,7 @@ def test_get_batch_job_total_usage_from_file_content(sample_file_content_dict): assert result.usage.prompt_tokens == 42 # 20 + 22 assert result.usage.completion_tokens == 20 # 10 + 10 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_batch_cost_calculator(sample_file_content_dict): @@ -2913,6 +2933,7 @@ async def test_batch_cost_calculator(sample_file_content_dict): assert result.prompt_cost == pytest.approx(0.6) assert result.completion_cost == pytest.approx(0.4) + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_get_response_from_batch_job_output_file(sample_file_content_dict): result = _get_response_from_batch_job_output_file(sample_file_content_dict[0]) @@ -2920,6 +2941,7 @@ def test_get_response_from_batch_job_output_file(sample_file_content_dict): assert result["object"] == "chat.completion" assert result["usage"]["total_tokens"] == 30 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_batch_retrieve_cost_tracking_with_completed_batch_no_explicit_cost(): @@ -3016,6 +3038,7 @@ async def test_batch_retrieve_cost_tracking_with_completed_batch_no_explicit_cos assert mock_batch._hidden_params["batch_failed_requests"] == 0 assert mock_batch.usage == expected_usage + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_handle_completed_batch_computes_real_cost_from_output_file( @@ -3059,6 +3082,7 @@ async def test_handle_completed_batch_computes_real_cost_from_output_file( assert result.successful_requests == 2 assert result.failed_requests == 0 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_batch_retrieve_cost_tracking_with_explicit_cost_data(): @@ -3146,6 +3170,7 @@ async def test_batch_retrieve_cost_tracking_with_explicit_cost_data(): assert mock_batch._hidden_params["batch_models"] == explicit_models assert mock_batch.usage == explicit_usage + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_batch_retrieve_explicit_cost_split_sets_cost_breakdown(): @@ -3196,6 +3221,7 @@ async def test_batch_retrieve_explicit_cost_split_sets_cost_breakdown(): assert logging_obj.cost_breakdown["output_cost"] == 0.04 assert logging_obj.cost_breakdown["total_cost"] == 0.10 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_batch_retrieve_cost_tracking_with_unified_file_id_incomplete_batch(): @@ -3276,6 +3302,7 @@ async def test_batch_retrieve_cost_tracking_with_unified_file_id_incomplete_batc assert "batch_models" not in mock_batch._hidden_params assert not hasattr(mock_batch, "usage") or mock_batch.usage is None + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_batch_retrieve_cost_tracking_with_partial_explicit_data(): @@ -3445,7 +3472,10 @@ def test_batch_ocr_preserves_additive_page_quantities_without_changing_cost(): assert captured["pages_processed"] == 5 assert captured["doc_size_bytes"] == 300 assert result.cost == pytest.approx(5 * 0.01) - assert sorted(row["usage"]["pages_processed"] for row in result.usage.model_dump()["batch_usage_by_request"]) == [2, 3] + assert sorted(row["usage"]["pages_processed"] for row in result.usage.model_dump()["batch_usage_by_request"]) == [ + 2, + 3, + ] def test_native_vertex_capture_retains_unknown_models_without_changing_legacy_counts(): @@ -3456,3 +3486,76 @@ def test_native_vertex_capture_retains_unknown_models_without_changing_legacy_co assert captured["batch_usage_by_request"] == ({"model": "missing-usage-model", "usage": None},) assert captured["batch_unknown_usage_requests"] == 1 assert (result.cost, result.successful_requests, result.failed_requests) == (0, 0, 1) + + +def test_gemini_output_lines_bill_camelcase_usage_with_thinking_tokens_at_batch_rates(): + row = _success_row( + model="gemini-3.8-flash", + usage={"promptTokens": 9, "completionTokens": 1, "totalTokens": 75}, + ) + + result = bu._aggregate_batch_cost_usage_models( + entries=[row], + custom_llm_provider="gemini", + model_info=ModelInfo( + key="gemini/gemini-3.8-flash", + max_tokens=None, + max_input_tokens=None, + max_output_tokens=None, + input_cost_per_token=7.5e-7, + output_cost_per_token=3.75e-6, + litellm_provider="gemini", + mode="chat", + supported_openai_params=None, + input_cost_per_token_batches=3.75e-7, + output_cost_per_token_batches=1.875e-6, + ), + ) + + assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == (9, 66, 75) + assert result.cost == pytest.approx((9 * 3.75e-7) + (66 * 1.875e-6)) + assert (result.successful_requests, result.failed_requests, result.models) == (1, 0, ["gemini-3.8-flash"]) + + +@pytest.mark.asyncio +async def test_gemini_native_output_rows_are_priced_from_usage_metadata(monkeypatch): + native_row = { + "key": "r0", + "response": { + "candidates": [{"content": {"parts": [{"text": "0"}], "role": "model"}, "finishReason": "STOP"}], + "usageMetadata": { + "promptTokenCount": 3, + "candidatesTokenCount": 1, + "totalTokenCount": 65, + "thoughtsTokenCount": 61, + }, + "modelVersion": "gemini-3.8-flash", + }, + } + failed_first_row = {"key": "bad", "status": {"code": 3, "message": "Invalid request"}} + jsonl = "\n".join(json.dumps(row) for row in (failed_first_row, native_row)).encode() + monkeypatch.setattr(bu, "_fetch_batch_managed_file_content", AsyncMock(return_value=jsonl)) + monkeypatch.setattr(bu, "count_error_file_failed_requests", AsyncMock(return_value=0)) + + result = await bu.handle_completed_batch( + batch=_batch("batch-la6"), + custom_llm_provider="gemini", + model_name="gemini-3.8-flash", + model_info=ModelInfo( + key="gemini/gemini-3.8-flash", + max_tokens=None, + max_input_tokens=None, + max_output_tokens=None, + input_cost_per_token=7.5e-7, + output_cost_per_token=3.75e-6, + litellm_provider="gemini", + mode="chat", + supported_openai_params=None, + input_cost_per_token_batches=3.75e-7, + output_cost_per_token_batches=1.875e-6, + ), + ) + + assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == (3, 62, 65) + assert result.cost == pytest.approx((3 * 3.75e-7) + (62 * 1.875e-6)) + assert (result.successful_requests, result.failed_requests) == (1, 1) diff --git a/tests/unit/batches/test_main.py b/tests/unit/batches/test_main.py index 8cd74c7d83a..b12ea53bce3 100644 --- a/tests/unit/batches/test_main.py +++ b/tests/unit/batches/test_main.py @@ -1382,3 +1382,28 @@ async def test_openai_batch_list_items_come_back_as_openai_sent_them(respx_mock: assert first.usage is not None assert (first.usage.input_tokens_details.cached_tokens, first.usage.output_tokens_details.reasoning_tokens) == (4, 2) assert (second.id, second.object, second.status) == ("batch_def456", "batch", "queued") + + +# =========================================================================== # +# gemini - a provider-config provider, like mistral, so it requires `model` +# =========================================================================== # + + +def test_create__gemini_routes_to_base_http_handler_with_gemini_config(seams): + result = bm.create_batch(**CREATE_KW, custom_llm_provider="gemini", model="gemini/gemini-3.8-flash") + + assert result is seams.base_http.create_batch.return_value + _assert_only(seams.base_http.create_batch, seams, "create_batch") + forwarded = seams.base_http.create_batch.call_args.kwargs + assert type(forwarded["provider_config"]).__name__ == "GeminiBatchesConfig" + assert forwarded["model"] == "gemini-3.8-flash" + + +def test_retrieve__gemini_routes_to_base_http_handler_with_gemini_config(seams): + result = bm.retrieve_batch(batch_id="la6", custom_llm_provider="gemini", model="gemini/gemini-3.8-flash") + + assert result is seams.base_http.retrieve_batch.return_value + _assert_only(seams.base_http.retrieve_batch, seams, "retrieve_batch") + forwarded = seams.base_http.retrieve_batch.call_args.kwargs + assert type(forwarded["provider_config"]).__name__ == "GeminiBatchesConfig" + assert forwarded["batch_id"] == "la6" diff --git a/tests/unit/llms/gemini/batches/__init__.py b/tests/unit/llms/gemini/batches/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/gemini/batches/test_gemini_batches_transformation.py b/tests/unit/llms/gemini/batches/test_gemini_batches_transformation.py new file mode 100644 index 00000000000..c8f15c9c590 --- /dev/null +++ b/tests/unit/llms/gemini/batches/test_gemini_batches_transformation.py @@ -0,0 +1,310 @@ +""" +Regression tests for ``GeminiBatchesConfig``, the BaseBatchesConfig implementation behind +``custom_llm_provider="gemini"`` on /v1/batches. + +Locks the ``models/{model}:batchGenerateContent`` create URL, the ``input_config.file_name`` +request shape, Gemini -> OpenAI state mapping, ``batchStats`` -> ``request_counts``, bare ids at +the LiteLLM boundary (``batches/`` and ``files/`` stripped) and ``x-goog-api-key`` auth. +Everything runs for real against canned httpx responses; only the API key env var is set. +""" + +import json + +import httpx +import pytest + +from litellm.llms.gemini.batches.transformation import ( + GeminiBatchesConfig, + is_gemini_batch_usage_shape, + is_native_gemini_batch_output_row, + usage_from_batch_output, +) +from litellm.llms.gemini.common_utils import GeminiError +from litellm.types.utils import LiteLLMBatch, LlmProviders + +STATUS_MAP = { + "BATCH_STATE_UNSPECIFIED": "validating", + "BATCH_STATE_PENDING": "validating", + "BATCH_STATE_RUNNING": "in_progress", + "BATCH_STATE_SUCCEEDED": "completed", + "BATCH_STATE_FAILED": "failed", + "BATCH_STATE_CANCELLED": "cancelled", + "BATCH_STATE_EXPIRED": "expired", +} + + +def _operation(**metadata_overrides): + metadata = { + "@type": "type.googleapis.com/google.ai.generativelanguage.v1main.GenerateContentBatch", + "model": "models/gemini-3.8-flash", + "displayName": "nightly-eval", + "inputConfig": {"fileName": "files/haf0y9b1kydd"}, + "output": {"responsesFile": "files/batch-la6uw1zzqrect7ugge7te7fueeslt1ms9xiw"}, + "createTime": "2026-11-10T21:18:34.714998843Z", + "endTime": "2026-11-10T21:21:01.499150334Z", + "updateTime": "2026-11-10T21:21:01.499150354Z", + "batchStats": {"requestCount": "3", "successfulRequestCount": "2", "failedRequestCount": "1"}, + "state": "BATCH_STATE_SUCCEEDED", + "name": "batches/la6uw1zzqrect7ugge7te7fueeslt1ms9xiw", + } + return { + "name": "batches/la6uw1zzqrect7ugge7te7fueeslt1ms9xiw", + "metadata": {**metadata, **metadata_overrides}, + "done": True, + } + + +def _response(payload: dict, status_code: int = 200) -> httpx.Response: + return httpx.Response( + status_code=status_code, + content=json.dumps(payload).encode(), + request=httpx.Request("GET", "https://generativelanguage.googleapis.com/v1beta/batches/x"), + ) + + +@pytest.fixture +def config() -> GeminiBatchesConfig: + return GeminiBatchesConfig() + + +@pytest.fixture +def api_key(monkeypatch) -> str: + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.setenv("GEMINI_API_KEY", "AIza-test") + return "AIza-test" + + +def test_custom_llm_provider(config): + assert config.custom_llm_provider == LlmProviders.GEMINI + + +@pytest.mark.parametrize( + "input_file_id", + ["haf0y9b1kydd", "files/haf0y9b1kydd", "https://generativelanguage.googleapis.com/v1beta/files/haf0y9b1kydd"], +) +def test_create_request_names_the_uploaded_file_in_input_config(config, input_file_id): + body = config.transform_create_batch_request( + model="gemini-3.8-flash", + create_batch_data={ + "input_file_id": input_file_id, + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + optional_params={}, + litellm_params={}, + ) + assert body == { + "batch": {"display_name": "litellm-haf0y9b1kydd", "input_config": {"file_name": "files/haf0y9b1kydd"}} + } + + +def test_create_request_uses_display_name_from_metadata(config): + body = config.transform_create_batch_request( + model="gemini-3.8-flash", + create_batch_data={ + "input_file_id": "files/abc", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "metadata": {"display_name": "nightly-eval"}, + }, + optional_params={}, + litellm_params={}, + ) + assert body["batch"]["display_name"] == "nightly-eval" + + +def test_create_request_requires_input_file(config): + with pytest.raises(ValueError, match="input_file_id"): + config.transform_create_batch_request( + model="gemini-3.8-flash", + create_batch_data={"endpoint": "/v1/chat/completions", "completion_window": "24h"}, + optional_params={}, + litellm_params={}, + ) + + +@pytest.mark.parametrize( + "api_base,model,expected", + [ + ( + None, + "gemini-3.8-flash", + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:batchGenerateContent", + ), + ( + "https://proxy.example/", + "models/gemini-3.8-flash", + "https://proxy.example/v1beta/models/gemini-3.8-flash:batchGenerateContent", + ), + ], +) +def test_create_url_targets_the_model_batch_route(config, api_base, model, expected): + data = {"input_file_id": "files/abc", "endpoint": "/v1/chat/completions", "completion_window": "24h"} + assert config.get_complete_batch_url(api_base, None, model, {}, {}, data) == expected + + +def test_validate_environment_uses_goog_api_key_header(config, api_key): + headers = config.validate_environment({"x-trace": "1"}, "gemini-3.8-flash", [], {}, {}) + assert headers == {"x-trace": "1", "x-goog-api-key": api_key} + + +def test_validate_environment_explicit_key_wins(config, api_key): + headers = config.validate_environment({}, "gemini-3.8-flash", [], {}, {}, api_key="AIza-deployment") + assert headers["x-goog-api-key"] == "AIza-deployment" + + +def test_validate_environment_without_key_raises(config, monkeypatch): + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + with pytest.raises(ValueError, match="GEMINI_API_KEY"): + config.validate_environment({}, "gemini-3.8-flash", [], {}, {}) + + +def test_create_response_maps_operation_onto_openai_batch(config): + batch = config.transform_create_batch_response( + model="gemini-3.8-flash", raw_response=_response(_operation()), logging_obj=None, litellm_params={} + ) + assert isinstance(batch, LiteLLMBatch) + assert batch.model_dump(exclude_none=True) == { + "id": "la6uw1zzqrect7ugge7te7fueeslt1ms9xiw", + "object": "batch", + "endpoint": "/v1/chat/completions", + "input_file_id": "haf0y9b1kydd", + "output_file_id": "batch-la6uw1zzqrect7ugge7te7fueeslt1ms9xiw", + "completion_window": "24h", + "status": "completed", + "created_at": 1794345514, + "completed_at": 1794345661, + "request_counts": {"total": 3, "completed": 2, "failed": 1}, + "metadata": {"display_name": "nightly-eval"}, + } + + +def test_retrieve_request_is_presigned_get_with_bare_or_prefixed_id(config, api_key): + for batch_id in ("la6", "batches/la6"): + req = config.transform_retrieve_batch_request( + batch_id=batch_id, optional_params={}, litellm_params={"api_base": "https://proxy.example"} + ) + assert req == { + "method": "GET", + "url": "https://proxy.example/v1beta/batches/la6", + "headers": {"x-goog-api-key": api_key}, + }, batch_id + + +def test_retrieve_request_prefers_litellm_params_api_key(config, api_key): + req = config.transform_retrieve_batch_request( + batch_id="la6", optional_params={}, litellm_params={"api_key": "AIza-deployment"} + ) + assert req["headers"]["x-goog-api-key"] == "AIza-deployment" + + +@pytest.mark.parametrize("gemini_state,openai_status", sorted(STATUS_MAP.items())) +def test_retrieve_response_status_mapping(config, gemini_state, openai_status): + batch = config.transform_retrieve_batch_response( + model=None, raw_response=_response(_operation(state=gemini_state)), logging_obj=None, litellm_params={} + ) + assert batch.status == openai_status + + +@pytest.mark.parametrize( + "gemini_state,populated_field", + [ + ("BATCH_STATE_SUCCEEDED", "completed_at"), + ("BATCH_STATE_FAILED", "failed_at"), + ("BATCH_STATE_EXPIRED", "expired_at"), + ("BATCH_STATE_CANCELLED", "cancelled_at"), + ], +) +def test_end_time_lands_on_the_terminal_timestamp_for_its_state(config, gemini_state, populated_field): + batch = config.transform_retrieve_batch_response( + model=None, raw_response=_response(_operation(state=gemini_state)), logging_obj=None, litellm_params={} + ) + timestamps = {f: getattr(batch, f) for f in ("completed_at", "failed_at", "expired_at", "cancelled_at")} + assert timestamps == {f: (1794345661 if f == populated_field else None) for f in timestamps} + + +def test_running_batch_has_no_output_file_or_terminal_timestamp(config): + running = _operation(state="BATCH_STATE_RUNNING", batchStats={"requestCount": "3", "pendingRequestCount": "3"}) + del running["metadata"]["output"] + del running["metadata"]["endTime"] + batch = config.transform_retrieve_batch_response( + model=None, raw_response=_response(running), logging_obj=None, litellm_params={} + ) + assert batch.status == "in_progress" + assert batch.output_file_id is None + assert batch.completed_at is None + assert batch.request_counts.model_dump() == {"total": 3, "completed": 0, "failed": 0} + + +def test_failed_operation_error_is_reported_in_errors(config): + failed = {**_operation(state="BATCH_STATE_FAILED"), "error": {"code": 3, "message": "Input file is empty."}} + batch = config.transform_retrieve_batch_response( + model=None, raw_response=_response(failed), logging_obj=None, litellm_params={} + ) + assert batch.status == "failed" + assert [(e.code, e.message) for e in batch.errors.data] == [("3", "Input file is empty.")] + + +def test_error_class_is_gemini_error(config): + err = config.get_error_class("quota", 429, {"retry-after": "7"}) + assert isinstance(err, GeminiError) + assert (err.status_code, err.message) == (429, "quota") + + +# Gemini writes the usage of an OpenAI-shaped output row as camelCase, with thinking tokens +# counted in totalTokens but not in completionTokens (observed 2026-11-10 on gemini-3.8-flash). +@pytest.mark.parametrize( + "reported,expected", + [ + ( + {"promptTokens": 9, "completionTokens": 1, "totalTokens": 75}, + {"prompt_tokens": 9, "completion_tokens": 66, "total_tokens": 75, "reasoning_tokens": 65}, + ), + ( + {"promptTokens": 9, "completionTokens": 4, "totalTokens": 13}, + {"prompt_tokens": 9, "completion_tokens": 4, "total_tokens": 13, "reasoning_tokens": None}, + ), + ], +) +def test_batch_output_usage_folds_thinking_tokens_into_completion(reported, expected): + usage = usage_from_batch_output(reported) + details = usage.completion_tokens_details + assert { + "prompt_tokens": usage.prompt_tokens, + "completion_tokens": usage.completion_tokens, + "total_tokens": usage.total_tokens, + "reasoning_tokens": details.reasoning_tokens if details else None, + } == expected + + +@pytest.mark.parametrize( + "usage_object,expected", + [({"promptTokens": 9}, True), ({"prompt_tokens": 9, "completion_tokens": 1}, False), ({}, False)], +) +def test_batch_usage_shape_detection(usage_object, expected): + assert is_gemini_batch_usage_shape(usage_object) is expected + + +def test_create_request_rejects_metadata_gemini_cannot_carry(config): + with pytest.raises(ValueError, match=r"\['run_id'\]"): + config.transform_create_batch_request( + model="gemini-3.8-flash", + create_batch_data={ + "input_file_id": "files/abc", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "metadata": {"display_name": "nightly", "run_id": "42"}, + }, + optional_params={}, + litellm_params={}, + ) + + +def test_native_output_row_detection(): + native = {"key": "r0", "response": {"candidates": [], "usageMetadata": {"promptTokenCount": 3}}} + openai_shaped = {"custom_id": "r0", "response": {"status_code": 200, "body": {"usage": {"promptTokens": 3}}}} + assert (is_native_gemini_batch_output_row(native), is_native_gemini_batch_output_row(openai_shaped)) == ( + True, + False, + ) diff --git a/tests/unit/llms/gemini/files/test_gemini_files_transformation.py b/tests/unit/llms/gemini/files/test_gemini_files_transformation.py index a2f95724688..0baf4e7b0fb 100644 --- a/tests/unit/llms/gemini/files/test_gemini_files_transformation.py +++ b/tests/unit/llms/gemini/files/test_gemini_files_transformation.py @@ -43,9 +43,7 @@ class TestGoogleAIStudioFilesTransformation: # CRITICAL: params should be empty dict, not contain Content-Type or any other params # These would be incorrectly interpreted as query parameters assert params == {}, f"Expected empty params dict, got: {params}" - assert ( - "Content-Type" not in params - ), "Content-Type should not be in query params" + assert "Content-Type" not in params, "Content-Type should not be in query params" def test_transform_retrieve_file_request_with_file_name_only(self): """ @@ -66,9 +64,7 @@ class TestGoogleAIStudioFilesTransformation: # CRITICAL: params should be empty dict assert params == {}, f"Expected empty params dict, got: {params}" - assert ( - "Content-Type" not in params - ), "Content-Type should not be in query params" + assert "Content-Type" not in params, "Content-Type should not be in query params" def test_transform_retrieve_file_request_with_raw_id_only(self): """ @@ -86,9 +82,7 @@ class TestGoogleAIStudioFilesTransformation: litellm_params=litellm_params, ) - assert ( - url == "https://generativelanguage.googleapis.com/v1beta/files/cctqueckiggb" - ) + assert url == "https://generativelanguage.googleapis.com/v1beta/files/cctqueckiggb" assert "key=" not in url assert params == {} @@ -103,8 +97,7 @@ class TestGoogleAIStudioFilesTransformation: ) assert ( - url - == "https://generativelanguage.googleapis.com/v1beta/files/..%2F..%2Fmodels%2Fgemini-pro%3Fx%3D1%23frag" + url == "https://generativelanguage.googleapis.com/v1beta/files/..%2F..%2Fmodels%2Fgemini-pro%3Fx%3D1%23frag" ) assert params == {} @@ -358,7 +351,49 @@ class TestGoogleAIStudioFilesTransformation: ) assert ( - url - == "https://generativelanguage.googleapis.com/v1beta/files/..%2F..%2Fmodels%2Fgemini-pro%3Fx%3D1%23frag" + url == "https://generativelanguage.googleapis.com/v1beta/files/..%2F..%2Fmodels%2Fgemini-pro%3Fx%3D1%23frag" ) assert params == {} + + +@pytest.mark.parametrize( + "file_id", + ["batch-la6", "files/batch-la6", "https://generativelanguage.googleapis.com/v1beta/files/batch-la6"], +) +def test_file_content_request_downloads_the_file_as_media(file_id): + handler = GoogleAIStudioFilesHandler() + url, params = handler.transform_file_content_request( + file_content_request={"file_id": file_id}, + optional_params={}, + litellm_params={"api_base": "https://proxy.example"}, + ) + assert (url, params) == ("https://proxy.example/download/v1beta/files/batch-la6:download", {"alt": "media"}) + + +def test_file_content_response_passes_the_jsonl_bytes_through(): + handler = GoogleAIStudioFilesHandler() + body = b'{"custom_id":"r0","response":{"status_code":200,"body":{"object":"chat.completion"}}}\n' + raw = httpx.Response(200, content=body, request=httpx.Request("GET", "https://generativelanguage.googleapis.com/x")) + assert ( + handler.transform_file_content_response(raw_response=raw, logging_obj=Mock(), litellm_params={}).content == body + ) + + +def test_retrieve_file_response_accepts_nanosecond_create_time(): + handler = GoogleAIStudioFilesHandler() + raw = httpx.Response( + 200, + json={ + "name": "files/batch-la6", + "displayName": "Results of batch la6", + "mimeType": "application/jsonl", + "sizeBytes": "1024", + "createTime": "2026-11-10T21:35:00.902340270Z", + "updateTime": "2026-11-10T21:35:00.902340270Z", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/batch-la6", + "state": "ACTIVE", + }, + request=httpx.Request("GET", "https://generativelanguage.googleapis.com/v1beta/files/batch-la6"), + ) + file_object = handler.transform_retrieve_file_response(raw_response=raw, logging_obj=Mock(), litellm_params={}) + assert (file_object.created_at, file_object.bytes, file_object.status) == (1794346500, 1024, "processed") From 8f42db985d0c9aee79960cde1198b97f18f7dfef Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Wed, 11 Nov 2026 12:18:44 -0800 Subject: [PATCH 79/86] fix(router): map Gemini stream-opening errors (#51218) * fix(router): map errors when opening Gemini streams * fix(streaming): preserve stream-open exception causes * fix(streaming): limit open-error mapping to provider and transport errors --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- .../litellm_core_utils/streaming_handler.py | 17 ++- .../test_streaming_handler.py | 130 +++++++++++++++--- .../test_streaming_characterization_chat.py | 51 ++++++- 3 files changed, 176 insertions(+), 22 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 956e202a65a..079b29d5509 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -46,6 +46,7 @@ from litellm.litellm_core_utils.model_response_utils import ( ) from litellm.litellm_core_utils.redact_messages import LiteLLMLoggingObject, drop_token_ids from litellm.litellm_core_utils.thread_pool_executor import executor +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.ollama.common_utils import OllamaResponseIdNormalizer from litellm.types.llms.base import LiteLLMBaseModel from litellm.types.router import GenericLiteLLMParams @@ -2534,7 +2535,10 @@ class CustomStreamWrapper: def fetch_sync_stream(self): if self.completion_stream is None and self.make_call is not None: # Call make_call to get the completion stream - self.completion_stream = self.make_call(client=litellm.module_level_client) + try: + self.completion_stream = self.make_call(client=litellm.module_level_client) + except (BaseLLMException, httpx.HTTPError) as e: + self._raise_open_error(e) self._stream_iter = self.completion_stream.__iter__() return self.completion_stream @@ -2542,7 +2546,10 @@ class CustomStreamWrapper: async def fetch_stream(self): if self.completion_stream is None and self.make_call is not None: # Call make_call to get the completion stream - self.completion_stream = await self.make_call(client=litellm.module_level_aclient) + try: + self.completion_stream = await self.make_call(client=litellm.module_level_aclient) + except (BaseLLMException, httpx.HTTPError) as e: + self._raise_open_error(e) self._stream_iter = self.completion_stream.__aiter__() return self.completion_stream @@ -2864,6 +2871,12 @@ class CustomStreamWrapper: if not self._emitted_disqualifying_content and self._delta_disqualifies_continuation(delta): self._emitted_disqualifying_content = True + def _raise_open_error(self, error: BaseLLMException | httpx.HTTPError) -> NoReturn: + try: + raise self._map_stream_exception(error) + except Exception as mapped: + raise mapped from error + def _map_stream_exception(self, original_exception: Exception) -> Exception: return exception_type( model=self.model, diff --git a/tests/unit/litellm_core_utils/test_streaming_handler.py b/tests/unit/litellm_core_utils/test_streaming_handler.py index cb0ef9a9a33..beb1b58ec27 100644 --- a/tests/unit/litellm_core_utils/test_streaming_handler.py +++ b/tests/unit/litellm_core_utils/test_streaming_handler.py @@ -38,9 +38,10 @@ from litellm.litellm_core_utils.streaming_handler import ( _ProviderChunkParsed, ) from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicModelResponseIterator -from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.llms.custom_httpx.http_handler import HTTPHandler, MaskedHTTPStatusError from litellm.llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig from litellm.llms.openai.chat.gpt_transformation import OpenAIChatCompletionStreamingHandler +from litellm.llms.vertex_ai.common_utils import VertexAIError from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( ModelResponseIterator as GeminiModelResponseIterator, ) @@ -1433,6 +1434,102 @@ def test_sync_streaming_bad_request_not_midstream(logging_obj: Logging): assert "invalid maxOutputTokens" in str(excinfo.value) +def _gemini_stream_error(status_code: int, sync_mode: bool) -> Exception: + if not sync_mode: + return VertexAIError(status_code=status_code, message="upstream said no", headers=None) + request: Final = httpx.Request("POST", "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash") + body: Final = b'{"error": {"message": "upstream said no"}}' + response: Final = httpx.Response(status_code, request=request, content=body) + return MaskedHTTPStatusError( + httpx.HTTPStatusError("upstream said no", request=request, response=response), + message=body, # pyright: ignore[reportArgumentType] # the sync stream handler passes the raw body bytes + text=body, # pyright: ignore[reportArgumentType] # the sync stream handler passes the raw body bytes + ) + + +def _gemini_stream_opened_by(error: BaseException, sync_mode: bool, logging_obj: Logging) -> CustomStreamWrapper: + def make_sync_call(client: object) -> NoReturn: + raise error + + async def make_call(client: object) -> NoReturn: + raise error + + return CustomStreamWrapper( + completion_stream=None, + model="gemini-2.5-flash", + logging_obj=logging_obj, + custom_llm_provider="gemini", + make_call=make_sync_call if sync_mode else make_call, + ) + + +async def _fetch(stream: CustomStreamWrapper, sync_mode: bool) -> object: + return stream.fetch_sync_stream() if sync_mode else await stream.fetch_stream() + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.parametrize( + ("status_code", "expected_error"), + [ + (400, litellm.BadRequestError), + (429, litellm.RateLimitError), + (503, litellm.ServiceUnavailableError), + ], +) +async def test_a_gemini_stream_that_fails_to_open_raises_the_mapped_error_from_fetch( + logging_obj: Logging, + sync_mode: bool, + status_code: int, + expected_error: type[litellm.BadRequestError | litellm.RateLimitError | litellm.ServiceUnavailableError], +) -> None: + provider_error: Final = _gemini_stream_error(status_code, sync_mode) + stream: Final = _gemini_stream_opened_by(provider_error, sync_mode, logging_obj) + with pytest.raises(expected_error, match="upstream said no") as excinfo: + await _fetch(stream, sync_mode) + assert (excinfo.value.status_code, excinfo.value.llm_provider) == (status_code, "gemini") + assert excinfo.value.__cause__ is provider_error + + +@pytest.mark.parametrize("sync_mode", [True, False]) +async def test_a_stream_open_error_that_is_already_mapped_is_raised_as_is( + logging_obj: Logging, sync_mode: bool +) -> None: + already_mapped: Final = litellm.BadRequestError( + message="already mapped", model="gemini-2.5-flash", llm_provider="gemini" + ) + stream: Final = _gemini_stream_opened_by(already_mapped, sync_mode, logging_obj) + with pytest.raises(litellm.BadRequestError) as excinfo: + await _fetch(stream, sync_mode) + assert excinfo.value is already_mapped + assert excinfo.value.__cause__ is None + + +async def test_a_cancelled_stream_open_stays_cancelled(logging_obj: Logging) -> None: + stream: Final = _gemini_stream_opened_by(asyncio.CancelledError(), False, logging_obj) + with pytest.raises(asyncio.CancelledError): + await stream.fetch_stream() + + +@pytest.mark.parametrize("sync_mode", [True, False]) +async def test_a_stream_that_opens_is_opened_once(logging_obj: Logging, sync_mode: bool) -> None: + def make_sync_call(client: object) -> ModelResponseListIterator: + return ModelResponseListIterator(model_responses=[]) + + async def make_call(client: object) -> ModelResponseListIterator: + return ModelResponseListIterator(model_responses=[]) + + stream: Final = CustomStreamWrapper( + completion_stream=None, + model="gemini-2.5-flash", + logging_obj=logging_obj, + custom_llm_provider="gemini", + make_call=make_sync_call if sync_mode else make_call, + ) + opened: Final = await _fetch(stream, sync_mode) + assert isinstance(opened, ModelResponseListIterator) + assert await _fetch(stream, sync_mode) is opened + + def _bedrock_error_event(exception_type: str): """A mocked botocore event-stream error event: status_code is botocore's hard-coded 400, with the real type in the :exception-type header.""" @@ -2113,37 +2210,32 @@ async def test_in_body_stream_error_500_wraps_for_midstream_fallback( assert "internal engine crash" in str(excinfo.value) -@pytest.mark.asyncio -async def test_async_streaming_read_timeout_triggers_midstream_fallback( - logging_obj: Logging, -): - """A mid-stream httpx.ReadTimeout must wrap into MidStreamFallbackError so - the Router's FallbackStreamWrapper can switch to a fallback model. +@pytest.mark.parametrize("sync_mode", [True, False]) +async def test_a_read_timeout_while_opening_the_stream_triggers_midstream_fallback( + logging_obj: Logging, sync_mode: bool +) -> None: + read_timeout: Final = httpx.ReadTimeout("Timeout on reading data from socket") - Previously __anext__ caught httpx.TimeoutException and re-raised it raw, - which bypassed _handle_stream_fallback_error and prevented stream_timeout - from triggering fallbacks the way connection-phase timeout does. - """ - import httpx + def make_sync_call(client: object) -> NoReturn: + raise read_timeout - from litellm.exceptions import MidStreamFallbackError + async def make_call(client: object) -> NoReturn: + raise read_timeout - async def _raise_read_timeout(**kwargs): - raise httpx.ReadTimeout("Timeout on reading data from socket") - - response = CustomStreamWrapper( + response: Final = CustomStreamWrapper( completion_stream=None, model="gpt-4", logging_obj=logging_obj, custom_llm_provider="openai", - make_call=_raise_read_timeout, + make_call=make_sync_call if sync_mode else make_call, ) with pytest.raises(MidStreamFallbackError) as excinfo: - await response.__anext__() + next(response) if sync_mode else await response.__anext__() assert excinfo.value.is_pre_first_chunk is True assert isinstance(excinfo.value.original_exception, litellm.Timeout) + assert excinfo.value.original_exception.__cause__ is read_timeout def _raw_read_timeout() -> NoReturn: diff --git a/tests/unit/test_router/core/test_streaming_characterization_chat.py b/tests/unit/test_router/core/test_streaming_characterization_chat.py index 590fafede8e..8f727c5f0ee 100644 --- a/tests/unit/test_router/core/test_streaming_characterization_chat.py +++ b/tests/unit/test_router/core/test_streaming_characterization_chat.py @@ -10,14 +10,17 @@ while one that raises later fails while the caller iterates. import asyncio from collections.abc import Callable, Mapping -from typing import Final +from typing import Final, NoReturn +import httpx import pytest import litellm from litellm.exceptions import MidStreamFallbackError from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper +from litellm.llms.custom_httpx.http_handler import MaskedHTTPStatusError +from litellm.llms.vertex_ai.common_utils import VertexAIError from litellm.router_core import RouterCore from litellm.types.utils import Usage from tests.unit.test_router.core.scripted_host import ( @@ -35,6 +38,7 @@ from tests.unit.test_router.core.streaming_helpers import ( collect_texts, collect_texts_sync, drain_into, + request_logging, ) MakeCore = Callable[..., RouterCore] @@ -107,6 +111,51 @@ async def test_a_first_chunk_bad_request_is_raised_from_the_call_without_retry( assert host.deployments_called() == ["a"] +def _gemini_stream_that_fails_to_open(sync: bool) -> Callable[..., CustomStreamWrapper]: + request: Final = httpx.Request("POST", "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash") + body: Final = b'{"error": {"code": 400, "message": "gemini said no", "status": "INVALID_ARGUMENT"}}' + sync_error: Final = MaskedHTTPStatusError( + httpx.HTTPStatusError("gemini said no", request=request, response=httpx.Response(400, request=request)), + message=body, # pyright: ignore[reportArgumentType] # the sync stream handler passes the raw body bytes + text=body, # pyright: ignore[reportArgumentType] # the sync stream handler passes the raw body bytes + ) + + def make_sync_call(client: object) -> NoReturn: + raise sync_error + + async def make_call(client: object) -> NoReturn: + raise VertexAIError(status_code=400, message=body.decode(), headers=None) + + def answer(**_request: object) -> CustomStreamWrapper: # kwargs-ok: the host passes the request kwargs + return CustomStreamWrapper( + completion_stream=None, + model="gemini-2.5-flash", + custom_llm_provider="gemini", + logging_obj=request_logging(), + make_call=make_sync_call if sync else make_call, + ) + + return answer + + +async def test_a_gemini_stream_that_fails_to_open_raises_the_mapped_bad_request( + make_core: MakeCore, host: ScriptedHost +) -> None: + host.answer("openai/a", _gemini_stream_that_fails_to_open(sync=False)) + core: Final = make_core(model_list=[deployment("a", "a")], num_retries=0) + with pytest.raises(litellm.BadRequestError, match="gemini said no"): + await open_stream(core) + + +def test_a_sync_gemini_stream_that_fails_to_open_raises_the_mapped_bad_request( + make_core: MakeCore, host: ScriptedHost +) -> None: + host.answer("openai/a", _gemini_stream_that_fails_to_open(sync=True)) + core: Final = make_core(model_list=[deployment("a", "a")], num_retries=0) + with pytest.raises(litellm.BadRequestError, match="gemini said no"): + core.completion(model="a", messages=MESSAGES, stream=True) # pyright: ignore[reportAttributeAccessIssue] # endpoint method + + async def test_the_first_chunk_already_read_is_still_delivered_to_the_caller( make_core: MakeCore, host: ScriptedHost ) -> None: From deb7697a8e515671774b3ccda6dd6d67123e41cc Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Wed, 11 Nov 2026 12:22:12 -0800 Subject: [PATCH 80/86] fix(auth): enforce model access on Google and image URL routes (#51226) * fix(auth): authorize URL-selected Google and image models * fix(auth): preserve server model selection and budget fallbacks * test(auth): isolate URL model authorization settings * fix(auth): type handler model selection boundaries * fix(auth): validate request model selection inputs * fix(auth): tolerate absent request path metadata * fix(auth): support requests without path metadata --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- litellm/proxy/auth/auth_utils.py | 46 +++++++- litellm/proxy/auth/user_api_key_auth.py | 3 + .../proxy/common_utils/http_parsing_utils.py | 6 ++ litellm/proxy/proxy_server.py | 4 + .../spend_tracking/budget_reservation.py | 24 ++++- tests/unit/proxy/auth/test_auth_utils.py | 101 ++++++++++++++++++ .../unit/proxy/auth/test_user_api_key_auth.py | 48 +++++++++ .../test_user_api_key_auth_request_flow.py | 40 +++++++ .../common_utils/test_http_parsing_utils.py | 20 ++++ .../spend_tracking/test_budget_reservation.py | 34 ++++++ 10 files changed, 320 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 7413490e44f..900e993cadb 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -37,7 +37,11 @@ from litellm.llms.azure.passthrough.transformation import azure_router_model_in_ from litellm.llms.nvidia_nim.passthrough.transformation import nvidia_nim_model_group_in_path from litellm.proxy._types import * from litellm.proxy.auth.route_checks import RouteChecks -from litellm.proxy.common_utils.http_parsing_utils import extract_nested_form_metadata +from litellm.proxy.common_utils.http_parsing_utils import ( + extract_nested_form_metadata, + read_request_path_params, + resolve_inference_model, +) from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_ENDPOINT_MARKER, ) @@ -2672,6 +2676,7 @@ def get_model_from_request( llm_router: Router | None = None, request: Request | None = None, team_id: str | None = None, + path_params: Mapping[str, object] | None = None, ) -> str | list[str] | None: """Resolve the model(s) a request targets, for model-access and budget checks. @@ -2690,6 +2695,12 @@ def get_model_from_request( if request_dispatched_to_pass_through_endpoint(request): return None + handler_request_data: Final = _MODEL_REQUEST_DATA.validate_python(request_data) + handler_path_params: Final = read_request_path_params(request) if request is not None else path_params + handler_model: Final = _handler_selected_model(route, handler_request_data, handler_path_params) + if handler_model is not None: + return handler_model + candidates: Final = _extract_model_candidates_from_request( request_data=request_data, route=route, @@ -2719,6 +2730,39 @@ def get_model_from_request( return model +_HandlerModelKind: TypeAlias = Literal["completion", "image_generation", "image_edit"] +_MODEL_REQUEST_DATA: Final = TypeAdapter(Mapping[str, object]) +_HANDLER_MODEL_ROUTES: Final[tuple[tuple[re.Pattern[str], str, _HandlerModelKind], ...]] = ( + (re.compile(r"^(?:/v1beta)?/models/(.+):(?:generateContent|streamGenerateContent)$"), "model_name", "completion"), + (re.compile(r"^/openai/deployments/(.+)/images/generations$"), "model", "image_generation"), + (re.compile(r"^/openai/deployments/(.+)/images/edits$"), "model", "image_edit"), +) + + +def _handler_selected_model( + route: str, request_data: Mapping[str, object], path_params: Mapping[str, object] | None +) -> str | None: + selection: Final[tuple[re.Match[str], str, _HandlerModelKind] | None] = next( + ((match, name, kind) for pattern, name, kind in _HANDLER_MODEL_ROUTES if (match := pattern.match(route))), + None, + ) + if selection is None: + return None + from litellm.proxy.proxy_server import general_settings_view, user_model_view + + route_match, path_param_name, kind = selection + route_model: Final = route_match.group(1) + path_model: Final = path_params.get(path_param_name) if path_params is not None else None + model: Final = resolve_inference_model( + request_data.get("model"), + general_settings_view(), + user_model_view(), + path_model if isinstance(path_model, str) and path_model else route_model, + kind=kind, + ) + return model if isinstance(model, str) and model else route_model + + def _model_from_route_path(route: str) -> str | None: """ Google generateContent-style routes allow "/" inside the model id, e.g. diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index d7a8d4a0700..1b2e4e791e3 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -153,6 +153,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # leg populate_request_with_path_params, read_raw_json_body, read_request_body, + read_request_path_params, resolve_realtime_route_model, rewrite_request_model, safe_get_request_headers, @@ -4252,6 +4253,7 @@ async def _reserve_budget_after_common_checks( reserve_budget_for_request, ) + reservation_path_params: Final = read_request_path_params(request) user_api_key_auth_obj.budget_reservation = await reserve_budget_for_request( request_body=request_data, route=route, @@ -4270,6 +4272,7 @@ async def _reserve_budget_after_common_checks( or user_api_key_auth_obj.billing_agent_policy is not None ), raw_body=await read_raw_json_body(request=request), + path_params=reservation_path_params, ) if request is not None: reservation: Final = user_api_key_auth_obj.budget_reservation diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 80ba2f49745..2786b4271e4 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -37,6 +37,12 @@ _BINARY_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-protobu _ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required}) _DECODED_JSON: Final = TypeAdapter(object) _JSON_REQUEST_BODY: Final = TypeAdapter(Mapping[str, object]) +_REQUEST_PATH_PARAMS: Final = TypeAdapter(Mapping[str, object]) + + +def read_request_path_params(request: Request | None) -> Mapping[str, object] | None: + path_params: Final = _DECODED_JSON.validate_python(getattr(request, "path_params", None)) + return _REQUEST_PATH_PARAMS.validate_python(path_params) if isinstance(path_params, Mapping) else None class _ReadableUpload(Protocol): diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 23d45e07dfa..6fbe562b5d1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3183,6 +3183,10 @@ def general_settings_view() -> Mapping[str, object]: return _GENERAL_SETTINGS_VIEW.validate_python(general_settings) +def user_model_view() -> str | None: + return user_model if isinstance(user_model, str) else None + + config_passthrough_endpoints: list[dict[str, object]] | None = None log_file: Final = "api_log.json" worker_config: Final = None diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index 8f0584bd0d0..096578264e3 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -262,12 +262,13 @@ async def reserve_budget_for_request( apply_user_budget_to_team_keys: bool = False, fail_closed_budget_enforcement: bool = False, raw_body: bytes | None = None, + path_params: Mapping[str, object] | None = None, ) -> dict | None: if valid_token is None or not RouteChecks.is_llm_api_route(route=route): return None if RouteChecks.is_unbilled_route(route): return None - if get_model_from_request(request_body, route, llm_router=llm_router) is None: + if get_model_from_request(request_body, route, llm_router=llm_router, path_params=path_params) is None: return None counters: Final = await _get_budget_counters( @@ -291,6 +292,7 @@ async def reserve_budget_for_request( llm_router=llm_router, team_id=valid_token.team_id, raw_body=raw_body, + path_params=path_params, ) reservation_cost = estimate_request_max_cost( @@ -298,6 +300,7 @@ async def reserve_budget_for_request( route=route, llm_router=llm_router, input_token_counts=input_token_counts, + path_params=path_params, ) # estimate_request_max_cost still returns None when the model is unknown # to the cost map (no token-priced cost fields, e.g. image/audio routes). @@ -334,6 +337,7 @@ async def reserve_budget_for_request( route=route, llm_router=llm_router, input_token_counts=input_token_counts, + path_params=path_params, ) budget_reservation: Final = { "reserved_cost": reservation_cost, @@ -1319,6 +1323,7 @@ def estimate_request_max_cost( route: str, llm_router: Router | None, input_token_counts: Mapping[str, int] | None = None, + path_params: Mapping[str, object] | None = None, ) -> float | None: estimates = [ _estimate_request_max_cost_for_model( @@ -1328,7 +1333,9 @@ def estimate_request_max_cost( llm_router=llm_router, input_tokens=(input_token_counts or {}).get(model_name), ) - for model_name in _get_request_models(request_body=request_body, route=route, llm_router=llm_router) + for model_name in _get_request_models( + request_body=request_body, route=route, llm_router=llm_router, path_params=path_params + ) ] estimates = [estimate for estimate in estimates if estimate is not None] if not estimates: @@ -1341,6 +1348,7 @@ def estimate_request_input_cost( route: str, llm_router: Router | None, input_token_counts: Mapping[str, int] | None = None, + path_params: Mapping[str, object] | None = None, ) -> float | None: """Cost of the request's input tokens alone. @@ -1357,7 +1365,9 @@ def estimate_request_input_cost( llm_router=llm_router, input_tokens=(input_token_counts or {}).get(model_name), ) - for model_name in _get_request_models(request_body=request_body, route=route, llm_router=llm_router) + for model_name in _get_request_models( + request_body=request_body, route=route, llm_router=llm_router, path_params=path_params + ) ] estimates = [estimate for estimate in estimates if estimate is not None] if not estimates: @@ -1609,8 +1619,9 @@ def _get_request_models( request_body: dict, route: str, llm_router: Router | None, + path_params: Mapping[str, object] | None = None, ) -> Sequence[str]: - model: Final = get_model_from_request(request_body, route, llm_router=llm_router) + model: Final = get_model_from_request(request_body, route, llm_router=llm_router, path_params=path_params) if model is None: return () return (model,) if isinstance(model, str) else tuple(model) @@ -1622,11 +1633,14 @@ async def count_request_input_tokens( llm_router: Router | None, team_id: str | None, raw_body: bytes | None = None, + path_params: Mapping[str, object] | None = None, ) -> Mapping[str, int]: """Input-token count per candidate model, counted once per request. The counts are reused by both the max-cost and the input-cost estimate.""" - models: Final = _get_request_models(request_body=request_body, route=route, llm_router=llm_router) + models: Final = _get_request_models( + request_body=request_body, route=route, llm_router=llm_router, path_params=path_params + ) if not models: return MappingProxyType({}) counted_models: Final = MappingProxyType( diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 73a2c534628..58a78aede3c 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -1379,6 +1379,107 @@ def test_get_model_from_request_supports_google_model_names_with_slashes(): ) +_URL_SELECTED_MODEL_ROUTES: Final = ( + ("/v1beta/models/premium-model:generateContent", "premium-model"), + ("/models/premium-model:generateContent", "premium-model"), + ("/v1beta/models/premium-model:streamGenerateContent", "premium-model"), + ("/models/provider/premium-model:7b:streamGenerateContent", "provider/premium-model:7b"), + ("/openai/deployments/premium-model/images/generations", "premium-model"), + ("/openai/deployments/provider/premium-model/images/edits", "provider/premium-model"), +) + + +@pytest.fixture +def no_inference_model_override(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "user_model", None) + + +@pytest.mark.usefixtures("no_inference_model_override") +@pytest.mark.parametrize("route, url_model", _URL_SELECTED_MODEL_ROUTES) +@pytest.mark.parametrize( + "request_data", + [ + {"model": "cheap-model"}, + {"target_model_names": ["cheap-model"]}, + {"model": "cheap-model", "target_model_names": "cheap-model"}, + ], +) +def test_get_model_from_request_checks_the_url_model_on_routes_served_from_the_url( + route: str, url_model: str, request_data: dict[str, object] +) -> None: + assert get_model_from_request(request_data=request_data, route=route) == url_model + + +@pytest.mark.usefixtures("no_inference_model_override") +@pytest.mark.parametrize( + "route, path_params", + [ + ("/v1beta/models/premium-model:generateContent", {"model_name": "cheap-model"}), + ("/models/premium-model:streamGenerateContent", {"model_name": "cheap-model"}), + ("/openai/deployments/premium-model/images/generations", {"model": "cheap-model"}), + ("/openai/deployments/premium-model/images/edits", {"model": "cheap-model"}), + ], +) +def test_get_model_from_request_follows_the_handlers_path_param_over_route_and_body( + route: str, path_params: dict[str, str] +) -> None: + request: Final = Request(scope={"type": "http", "path": route, "path_params": path_params, "headers": []}) + body: Final = {"model": "body-model"} + + assert get_model_from_request(request_data=body, route=route, request=request) == "cheap-model" + assert get_model_from_request(request_data=body, route=route, path_params=path_params) == "cheap-model" + assert get_model_from_request(request_data=body, route=route, path_params={"other": "x"}) == "premium-model" + + +@pytest.mark.parametrize( + "route, settings, cli_model, expected", + [ + ("/v1beta/models/url-model:generateContent", {"completion_model": "configured"}, None, "configured"), + ("/models/url-model:streamGenerateContent", {}, "cli", "cli"), + ("/v1beta/models/url-model:generateContent", {"image_generation_model": "configured"}, None, "url-model"), + ("/openai/deployments/url-model/images/generations", {}, "cli", "cli"), + ("/openai/deployments/url-model/images/generations", {"completion_model": "configured"}, None, "url-model"), + ( + "/openai/deployments/url-model/images/generations", + {"image_generation_model": "configured"}, + None, + "url-model", + ), + ("/openai/deployments/url-model/images/edits", {"completion_model": "configured"}, "cli", "configured"), + ("/openai/deployments/url-model/images/edits", {}, "cli", "cli"), + ("/openai/deployments/url-model/images/edits", {"image_generation_model": "configured"}, None, "url-model"), + ], +) +def test_get_model_from_request_applies_the_handlers_server_model_precedence( + monkeypatch: pytest.MonkeyPatch, + route: str, + settings: dict[str, str], + cli_model: str | None, + expected: str, +) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", settings) + monkeypatch.setattr(proxy_server, "user_model", cli_model) + + assert get_model_from_request(request_data={"model": "body-model"}, route=route) == expected + + +@pytest.mark.usefixtures("no_inference_model_override") +@pytest.mark.parametrize("route", ["/v1/images/generations", "/images/edits", "/v1/chat/completions"]) +def test_get_model_from_request_keeps_the_body_model_where_the_url_names_none(route: str) -> None: + assert get_model_from_request(request_data={"model": "cheap-model"}, route=route) == "cheap-model" + + +@pytest.mark.parametrize("route, url_model", _URL_SELECTED_MODEL_ROUTES) +def test_get_model_from_request_skips_url_model_on_configured_pass_through(route: str, url_model: str) -> None: + request: Final = _request_dispatched_to(_pass_through_endpoint()) + assert get_model_from_request(request_data={"model": url_model}, route=route, request=request) is None + + def test_get_model_from_request_vertex_passthrough_still_works(): route = "/vertex_ai/v1/projects/p/locations/l/publishers/google/models/gemini-1.5-pro:generateContent" assert get_model_from_request(request_data={}, route=route) == "gemini-1.5-pro" diff --git a/tests/unit/proxy/auth/test_user_api_key_auth.py b/tests/unit/proxy/auth/test_user_api_key_auth.py index ce4a844550b..7746fc96673 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -1293,6 +1293,54 @@ def test_get_model_from_request(route, request_data, expected_model): assert get_model_from_request(request_data, route) == expected_model +@pytest.mark.asyncio +@pytest.mark.parametrize( + "route_template", + [ + "/v1beta/models/{model}:generateContent", + "/models/{model}:streamGenerateContent", + "/openai/deployments/{model}/images/generations", + "/openai/deployments/{model}/images/edits", + ], +) +@pytest.mark.parametrize("body", [{"model": "cheap-model"}, {"target_model_names": ["cheap-model"]}]) +async def test_key_model_allowlist_applies_to_the_model_selected_by_the_url( + monkeypatch: pytest.MonkeyPatch, route_template: str, body: dict[str, object] +) -> None: + from litellm.proxy._types import ProxyErrorTypes + from litellm.proxy.auth.user_api_key_auth import enforce_key_and_fallback_model_access + + monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", None) + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {}) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_model", None) + router: Final = litellm.Router( + model_list=[ + {"model_name": name, "litellm_params": {"model": f"openai/{name}", "api_key": "sk-fake"}} + for name in ("cheap-model", "premium-model") + ] + ) + token: Final = UserAPIKeyAuth(models=["cheap-model"]) + + async def authorize(url_model: str) -> None: + route: Final = route_template.format(model=url_model) + request: Final = StarletteRequest( + {"type": "http", "method": "POST", "path": route, "headers": [], "query_string": b""} + ) + await enforce_key_and_fallback_model_access( + valid_token=token, + request_data=dict(body), + route=route, + request=request, + llm_model_list=list(router.model_list), + llm_router=router, + ) + + await authorize("cheap-model") + with pytest.raises(ProxyException) as exc_info: + await authorize("premium-model") + assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied + + @pytest.mark.asyncio async def test_jwt_non_admin_team_route_access(monkeypatch): """ diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 0d2433d9707..5824b5fccab 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -10952,6 +10952,42 @@ class TestCheckTeamMemberModelBudgetWithFallback: assert request_data["model"] == "gpt-4o-mini" + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["generations", "edits"]) + async def test_image_deployment_key_fallback_is_the_model_later_checks_and_reservation_see( + self, monkeypatch: pytest.MonkeyPatch, operation: str + ) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.auth.auth_utils import get_model_from_request + + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "user_model", None) + route: Final = f"/openai/deployments/gpt-4o/images/{operation}" + valid_token: Final = UserAPIKeyAuth( + token="test-key", + models=["gpt-4o", "gpt-4o-mini"], + budget_fallbacks={"gpt-4o": ["gpt-4o-mini"]}, + model_max_budget={ + "gpt-4o": {"max_budget": 5.0, "budget_duration": "1d"}, + "gpt-4o-mini": {"max_budget": 100.0, "budget_duration": "1d"}, + }, + ) + limiter, _ = await _real_model_budget_limiter(key_spend={"gpt-4o": 10.0}) + request_data: Final[dict[str, object]] = {"model": "gpt-4o"} + request: Final = self._request("gpt-4o") + assert get_model_from_request(request_data, route, request=request) == "gpt-4o" + + await _check_key_model_budget_with_fallback( + valid_token=valid_token, + model_max_budget_limiter=limiter, + model_name="gpt-4o", + request_data=request_data, + request=request, + ) + + assert get_model_from_request(request_data, route, request=request) == "gpt-4o-mini" + assert get_model_from_request(request_data, route, path_params=request.path_params) == "gpt-4o-mini" + @pytest.mark.asyncio async def test_fallback_outside_member_allowed_models_is_rejected(self): valid_token: Final = UserAPIKeyAuth( @@ -13440,6 +13476,10 @@ async def test_managed_actor_cannot_access_provider_resource_routes(monkeypatch, ("/v1/chat/completions", {}, "forbidden-model"), ("/v1/images/generations", {"image_generation_model": "forbidden-model"}, None), ("/v1/images/edits", {"image_generation_model": "forbidden-model"}, None), + ("/v1beta/models/test-model:generateContent", {"completion_model": "forbidden-model"}, None), + ("/models/test-model:streamGenerateContent", {}, "forbidden-model"), + ("/openai/deployments/test-model/images/generations", {}, "forbidden-model"), + ("/openai/deployments/test-model/images/edits", {"completion_model": "forbidden-model"}, None), ], ) async def test_managed_agent_cannot_bypass_grants_with_server_default( diff --git a/tests/unit/proxy/common_utils/test_http_parsing_utils.py b/tests/unit/proxy/common_utils/test_http_parsing_utils.py index 8423dee9886..b8a686d3f67 100644 --- a/tests/unit/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/unit/proxy/common_utils/test_http_parsing_utils.py @@ -31,6 +31,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( numeric_form_fields, populate_request_with_path_params, read_raw_json_body, + read_request_path_params, ) from fastapi import Request as Request_http_parsing from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER @@ -1786,3 +1787,22 @@ async def test_convert_upload_files_to_file_data_reads_every_upload_into_file_tu "tags": ["red", "blue"], "display_title": "My skill", } + + +@pytest.mark.parametrize( + "scope_path_params, expected", + [ + ({"model": "deployment/name"}, {"model": "deployment/name"}), + ({}, {}), + (None, None), + ], +) +def test_read_request_path_params_returns_mappings_and_drops_non_mapping_metadata( + scope_path_params: Mapping[str, object] | None, expected: Mapping[str, object] | None +) -> None: + request: Final = Request({"type": "http", "path_params": scope_path_params}) + assert read_request_path_params(request) == expected + + +def test_read_request_path_params_without_request_is_none() -> None: + assert read_request_path_params(None) is None diff --git a/tests/unit/proxy/spend_tracking/test_budget_reservation.py b/tests/unit/proxy/spend_tracking/test_budget_reservation.py index 72927fa0197..087434c981f 100644 --- a/tests/unit/proxy/spend_tracking/test_budget_reservation.py +++ b/tests/unit/proxy/spend_tracking/test_budget_reservation.py @@ -223,6 +223,40 @@ def test_deployment_pricing_update_invalidates_cached_estimate() -> None: assert math.isclose(after, before * 1000) +def _priced_group(model_name: str, cost_per_token: float) -> dict[str, object]: + return Deployment( + model_name=model_name, + litellm_params=LiteLLM_Params(model="dashscope/qwen3-max", api_key="sk-fake"), + model_info=ModelInfo( + id=model_name, + max_output_tokens=1000, + tiered_pricing=[ + {"input_cost_per_token": cost_per_token, "output_cost_per_token": cost_per_token, "range": [0, 128000]} + ], + ), + ).model_dump() + + +def test_google_route_reserves_on_the_model_its_path_param_selects(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "user_model", None) + router: Final = Router(model_list=[_priced_group("cheap-group", 1e-06), _priced_group("premium-group", 1e-03)]) + route: Final = "/v1beta/models/premium-group:generateContent" + body: Final = {"model": "cheap-group", "messages": [{"role": "user", "content": "hello"}], "max_tokens": 10} + + url_model_cost: Final = estimate_request_max_cost(request_body=body, route=route, llm_router=router) + path_param_cost: Final = estimate_request_max_cost( + request_body=body, route=route, llm_router=router, path_params={"model_name": "cheap-group"} + ) + cheap_cost: Final = estimate_request_max_cost( + request_body=body, route="/v1beta/models/cheap-group:generateContent", llm_router=router + ) + + assert url_model_cost is not None and cheap_cost is not None + assert path_param_cost == cheap_cost + assert math.isclose(url_model_cost, cheap_cost * 1000) + + @pytest.mark.asyncio @pytest.mark.parametrize( "expiry_offset, expected_max_budget", From f889b2b587d522944e2c5bd80e10112a890539eb Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 11 Nov 2026 12:36:02 -0800 Subject: [PATCH 81/86] test(integration): fix the baseline accounting and series cap flakes (#51096) * test(integration): run baseline accounting store tests in a private schema The accounting workers share their database with two gateway proxies, and every spend-log tick on those proxies projects any dirty baseline comparison it finds. When one of them published the test's scope between its last append and its own project() call, the test saw "unchanged" instead of "published". The module fixture now copies the twelve tables the store touches into a fresh schema and points DATABASE_URL at it for the module, so the gateway proxies, which only read public, can no longer publish these rows. * test(integration): wait for H9's own prometheus increments before it returns H9 stopped as soon as both workers' sample files already counted `other`, so its own chat's increment could still be in flight and land inside the next cell's window, which then saw two requests instead of one. H9 now takes a before scrape, counts the chats it sends, and finishes with the same _overflowed wait every other TestCapped cell uses. --- .../test_prometheus_series_cap.py | 23 ++++++----- .../spend/test_baseline_accounting.py | 38 ++++++++++++++++++- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/tests/integration/observability/test_prometheus_series_cap.py b/tests/integration/observability/test_prometheus_series_cap.py index ecdb6dabfc5..68c96a257b9 100644 --- a/tests/integration/observability/test_prometheus_series_cap.py +++ b/tests/integration/observability/test_prometheus_series_cap.py @@ -7,6 +7,7 @@ from __future__ import annotations import time from collections.abc import Iterator, Sequence +from itertools import count from typing import Final import anthropic @@ -221,19 +222,21 @@ class TestCapped: """H9: fresh connections reach both workers, and each worker's own sample file names only the warm aliases while counting the fourth key on `other`, since the admitted sets live in the shared directory.""" extra: Final = capped.key("h9") + before: Final = scrape(capped.gateway) + sent: Final = count(1) - def send_on_a_fresh_connection() -> tuple[WorkerSamples, ...]: + def send_on_a_fresh_connection() -> tuple[int, tuple[WorkerSamples, ...]]: assert capped.chat(extra, Call.new()).status_code == 200 - return worker_samples(capped.prom_dir, REQUESTS) + return next(sent), worker_samples(capped.prom_dir, REQUESTS) - workers: Final = eventually( - send_on_a_fresh_connection, - lambda found: ( - sum(1 for worker in found if worker.overflow > 0) >= 2 - or any(extra.alias in worker.aliases for worker in found) - ), - seconds=90, - ) + def reached_both_workers(attempt: tuple[int, tuple[WorkerSamples, ...]]) -> bool: + _, found = attempt + return sum(1 for worker in found if worker.overflow > 0) >= 2 or any( + extra.alias in worker.aliases for worker in found + ) + + chats, workers = eventually(send_on_a_fresh_connection, reached_both_workers, seconds=90) + _overflowed(capped, extra, before, chats) assert all(extra.alias not in worker.aliases for worker in workers), workers assert sum(1 for worker in workers if worker.overflow > 0) >= 2, workers assert frozenset().union(*(worker.aliases for worker in workers)) == capped.warm_aliases, workers diff --git a/tests/integration/spend/test_baseline_accounting.py b/tests/integration/spend/test_baseline_accounting.py index 60c5c32511a..d384d509fd7 100644 --- a/tests/integration/spend/test_baseline_accounting.py +++ b/tests/integration/spend/test_baseline_accounting.py @@ -1,11 +1,13 @@ import asyncio import json +import os import uuid from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from dataclasses import replace from datetime import datetime, timedelta, timezone from typing import Final +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import pytest import pytest_asyncio @@ -29,10 +31,42 @@ from tests.integration._support.prisma_clients import connected_prisma pytestmark = pytest.mark.asyncio(loop_scope="session") +_ISOLATED_TABLES: Final = ( + "LiteLLM_AutoRouterBaselineComparison", + "LiteLLM_AutoRouterBaselineObservation", + "LiteLLM_AutoRouterDailySpend", + "LiteLLM_AutoRouterSession", + "LiteLLM_AutoRouterUserSession", + "LiteLLM_DailyAgentSpend", + "LiteLLM_DailyEndUserSpend", + "LiteLLM_DailyOrganizationSpend", + "LiteLLM_DailyTagSpend", + "LiteLLM_DailyTeamSpend", + "LiteLLM_DailyUserSpend", + "LiteLLM_SpendLogs", +) + + +def _with_schema(database_url: str, schema: str) -> str: + parts: Final = urlsplit(database_url) + kept: Final = tuple((name, value) for name, value in parse_qsl(parts.query) if name != "schema") + return urlunsplit(parts._replace(query=urlencode((*kept, ("schema", schema))))) + + @pytest_asyncio.fixture(scope="module", loop_scope="session") async def db() -> AsyncIterator[Prisma]: - async with connected_prisma() as client: - yield client + schema: Final = f"baseline_accounting_{uuid.uuid4().hex}" + async with connected_prisma() as admin: + await admin.execute_raw(f'CREATE SCHEMA "{schema}"') + try: + for table in _ISOLATED_TABLES: + await admin.execute_raw(f'CREATE TABLE "{schema}"."{table}" (LIKE "{table}" INCLUDING ALL)') + with pytest.MonkeyPatch.context() as environment: + environment.setenv("DATABASE_URL", _with_schema(os.environ["DATABASE_URL"], schema)) + async with connected_prisma() as client: + yield client + finally: + await admin.execute_raw(f'DROP SCHEMA "{schema}" CASCADE') @asynccontextmanager From f52f26d9c636038c6e3609cc7d17cf625d434c3e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 11 Nov 2026 12:37:28 -0800 Subject: [PATCH 82/86] fix(proxy): let /team/delete transactions wait for a pooled connection (#51099) The team-model and team-key transactions in /team/delete used Prisma's 2s default max_wait, so a busy writer pool failed the delete with P2028 while every plain query in the same request waited up to the 60s pool timeout. Both now get the 30s team-delete budget for max_wait, and the key transaction gets it for timeout too. --- .../model_management_endpoints.py | 7 +- litellm/proxy/utils.py | 4 +- .../test_team_delete_large_membership.py | 92 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 7846922bae2..0b4f266f838 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -295,7 +295,9 @@ class _TxModelTables(Protocol): @runtime_checkable class _TransactionFactory(Protocol): - def __call__(self, *, timeout: datetime.timedelta = ...) -> AbstractAsyncContextManager[_TxModelTables]: ... + def __call__( + self, *, timeout: datetime.timedelta = ..., max_wait: datetime.timedelta = ... + ) -> AbstractAsyncContextManager[_TxModelTables]: ... class _ModelTransactionClient(LiteLLMBaseModel): @@ -2449,7 +2451,8 @@ async def delete_team_models( """ deleted_model_ids: Final[list[str]] = [] transaction_client: Final = _ModelTransactionClient.model_validate(prisma_client.db) - async with transaction_client.tx(timeout=datetime.timedelta(seconds=TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS)) as tx: + tx_budget: Final = datetime.timedelta(seconds=TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS) + async with transaction_client.tx(max_wait=tx_budget, timeout=tx_budget) as tx: for team_id in team_ids: rows = await _get_team_deployments(team_id, prisma_client, table=tx.litellm_proxymodeltable) model_ids = [row.model_id for row in rows] diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index bf00bdfb5fb..51f1c8a614d 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -73,6 +73,7 @@ from litellm.constants import ( SPEND_LOG_READ_STATEMENT_TIMEOUT_SECONDS, SPEND_LOG_WRITE_BATCH_MAX_BYTES, SPEND_LOG_WRITE_BATCH_MAX_ROWS, + TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS, VLLM_CHOICE_FIELDS, ) from litellm.cost_calculator import BaseTokenUsageProcessor @@ -5361,6 +5362,7 @@ _deprecated_key_cache: Final[LimitedSizeOrderedDict] = LimitedSizeOrderedDict(ma _DEPRECATED_KEY_CACHE_TTL_SECONDS: Final = 60 _PRISMA_DEFAULT_TX_TIMEOUT: Final = timedelta(seconds=5) _PRISMA_DEFAULT_TX_MAX_WAIT: Final = timedelta(seconds=2) +_TEAM_DELETE_TX_BUDGET: Final = timedelta(seconds=TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS) _SPEND_LOG_READ_STATEMENT_TIMEOUT: Final = timedelta(seconds=SPEND_LOG_READ_STATEMENT_TIMEOUT_SECONDS) _STATEMENT_TIMEOUT_TRANSACTION_GRACE: Final = timedelta(seconds=5) @@ -6983,7 +6985,7 @@ class PrismaClient: return {"deleted_teams": team_id_list} elif table_name == "key" and team_id_list is not None and isinstance(team_id_list, list): # admin only endpoint -> `/team/delete` - async with self.tx() as transaction: + async with self.tx(max_wait=_TEAM_DELETE_TX_BUDGET, timeout=_TEAM_DELETE_TX_BUDGET) as transaction: _ = await transaction.query_raw(_LOCK_KEYS_BY_TEAM_SQL, team_id_list) _ = await transaction.litellm_verificationtoken.delete_many(where={"team_id": {"in": team_id_list}}) except Exception as e: diff --git a/tests/integration/management/test_team_delete_large_membership.py b/tests/integration/management/test_team_delete_large_membership.py index 2a8508ff3c9..361cbf037a3 100644 --- a/tests/integration/management/test_team_delete_large_membership.py +++ b/tests/integration/management/test_team_delete_large_membership.py @@ -21,6 +21,7 @@ import httpx import psycopg import pytest import yaml +from psycopg.rows import TupleRow from pydantic import JsonValue from redis import Redis @@ -55,7 +56,11 @@ FROM generate_series(1, %s::int) AS n """ TAKE_TEAM_LOCK_SQL: Final = "SELECT pg_advisory_xact_lock(hashtext(%s))" LOCK_MODEL_ROW_SQL: Final = 'SELECT model_id FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s FOR UPDATE' +LOCK_TEAM_KEY_ROWS_SQL: Final = 'SELECT token FROM "LiteLLM_VerificationToken" WHERE team_id = %s FOR UPDATE' +LOCK_BUDGET_ROW_SQL: Final = 'SELECT budget_id FROM "LiteLLM_BudgetTable" WHERE budget_id = %s FOR UPDATE' LOCK_HELD_PAST_DEFAULT_TRANSACTION_SECONDS: Final = 7 +POOL_HELD_PAST_DELETE_RETRIES_SECONDS: Final = 12 +SATURATED_POOL_LIMIT: Final = 2 OLDEST_TRANSACTION_BLOCKED_BY_SQL: Final = """ SELECT coalesce(max(extract(epoch FROM clock_timestamp() - xact_start)), 0)::float AS age FROM pg_stat_activity @@ -146,6 +151,16 @@ def _waiters_on_lock_held_by(backend_pid: int) -> int: return waiting +def _sessions_blocked_by(backend_pid: int) -> int: + rows: Final = read_rows( + "SELECT count(*)::int AS blocked FROM pg_stat_activity WHERE %s::int = ANY(pg_blocking_pids(pid))", + (str(backend_pid),), + ) + blocked: Final = rows[0]["blocked"] + assert isinstance(blocked, int) + return blocked + + def _oldest_transaction_blocked_by(backend_pid: int) -> float: rows: Final = read_rows(OLDEST_TRANSACTION_BLOCKED_BY_SQL, (str(backend_pid),)) age: Final = rows[0]["age"] @@ -468,6 +483,83 @@ def test_delete_completes_after_waiting_on_the_team_lock_past_the_default_transa assert len(_tombstones(team)) == 1, _tombstones(team) +def _park_on_team_key_rows(holder: psycopg.Connection[TupleRow], team_id: str) -> None: + _ = holder.execute(LOCK_TEAM_KEY_ROWS_SQL, (team_id,)) + + +def _park_on_deleted_key_records(holder: psycopg.Connection[TupleRow], _team_id: str) -> None: + _ = holder.execute('LOCK TABLE "LiteLLM_DeletedVerificationToken" IN SHARE MODE') + + +def _requests_in_flight_including_this_one(proxy: Gateway) -> JsonValue: + return proxy.get("/health/backlog")["in_flight_requests"] + + +@pytest.mark.timeout(240) +@pytest.mark.parametrize( + "park", + [ + pytest.param(_park_on_team_key_rows, id="next-transaction-is-the-team-model-delete"), + pytest.param(_park_on_deleted_key_records, id="next-transaction-is-the-team-key-delete"), + ], +) +def test_delete_waits_for_a_pooled_connection_when_every_one_is_busy( + gateway: Gateway, + tmp_path: Path, + park: Callable[[psycopg.Connection[TupleRow], str], None], +) -> None: + database_url: Final = os.environ["DATABASE_URL"] + with ( + gateway.scenario() as scenario, + owned_proxy_process( + gateway, + tmp_path, + {"DATABASE_URL": database_url}, + config=_config_with_pool_limit(tmp_path, SATURATED_POOL_LIMIT), + remove_environment=("DATABASE_URL_READ_REPLICA",), + ) as owned, + ThreadPoolExecutor(max_workers=3) as pool, + ): + team: Final = string_value( + owned.gateway.post("/team/new", {"team_alias": f"integration-{uuid.uuid4().hex}"})["team_id"] + ) + scenario.cleanups.callback(_remove_team_by_sql, team) + scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team,)) + keys: Final = _seeded_team_keys(team, 3) + budget: Final = f"integration-{uuid.uuid4().hex}" + owned.gateway.post("/budget/new", {"budget_id": budget, "max_budget": 100}) + scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_BudgetTable" WHERE budget_id = %s', (budget,)) + update_budget: Final = {"budget_id": budget, "max_budget": 1} + + with psycopg.connect(database_url) as budget_holder: + budget_holder.execute(LOCK_BUDGET_ROW_SQL, (budget,)) + first_connection_taker: Final = _post_in_background(pool, owned.gateway, "/budget/update", update_budget) + eventually(lambda: _sessions_blocked_by(budget_holder.info.backend_pid), lambda count: count == 1) + with psycopg.connect(database_url) as parking: + park(parking, team) + pending_delete: Final = _post_in_background(pool, owned.gateway, "/team/delete", {"team_ids": [team]}) + eventually(lambda: _sessions_blocked_by(parking.info.backend_pid), lambda count: count == 1) + second_connection_taker: Final = _post_in_background( + pool, owned.gateway, "/budget/update", update_budget + ) + eventually(lambda: _requests_in_flight_including_this_one(owned.gateway), lambda count: count == 4) + eventually( + pending_delete.done, + lambda done: done, + seconds=POOL_HELD_PAST_DELETE_RETRIES_SECONDS, + return_last_on_timeout=True, + ) + + response: Final = pending_delete.result(timeout=60) + assert response.status_code == 200, response.text + assert response.json() == {"deleted_teams": [team]} + takers: Final = (first_connection_taker, second_connection_taker) + assert [taker.result(timeout=60).status_code for taker in takers] == [200, 200] + assert _team_rows(team) == [] + assert [_live_token(_hashed(key)) for key in keys] == [[], [], []] + assert len(_tombstones(team)) == 1, _tombstones(team) + + def test_deleting_two_teams_sharing_a_member_in_one_call_clears_both_from_the_member(gateway: Gateway) -> None: with gateway.scenario() as scenario: user: Final = scenario.user() From a1f3dcd01bc2ca586780312ae9a06826f2a82c46 Mon Sep 17 00:00:00 2001 From: zoveki <349028329+zoveki@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:59:19 -0800 Subject: [PATCH 83/86] fix(completion)!: answer 400 for a chat message without a role (#51124) * fix(completion): answer 400 for a chat message without a role A message with no role was filled with role assistant and sent upstream, so the caller's text reached the model as its own earlier reply and the request returned 200. Now a missing, null, or empty role answers 400 with OpenAI's wording, naming the message index. A role-less turn that carries tool_calls is still sent as assistant, since only an assistant writes tool calls. * fix(completion): treat a role-less message as the caller's error The 400 now raises MalformedRequestFieldError with OpenAI's param and code and the provider name, so it no longer counts toward the deployment's failure rate, cools it down, or fires an llm_exceptions alert * fix(completion): keep the assistant fill for a role-less function_call turn * refactor(completion): type the role-less call turn check and the request kwargs Read role, tool_calls and function_call off the cleaned message copy, and validate completion's kwargs into a typed mapping before naming the provider on the 400 * fix(completion): keep the alert and failure-count exclusions to the role error Raise MissingMessageRoleError, a MalformedRequestFieldError subclass, so a malformed tools 400 alerts and counts as on main, and fill assistant whenever a role-less turn has a tool_calls or function_call key * fix(completion): keep assistant-only role-less turns and skip SDK and PagerDuty alerts A role-less turn carrying any assistant-only key, null values included, is still filled as assistant, so a dumped assistant message or a reasoning turn keeps working. The Router's alerting_config alert and the PagerDuty failure count skip the role error, like the proxy alert already does. * fix(utils): drop the string code from the missing-role 400 so the Errors tab counts it as 400 The spend log stored code missing_required_parameter as the error code, which the Usage page Errors tab cannot parse as a status, so every role-less request landed under no status. The malformed tools 400 leaves code out the same way. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../pagerduty/pagerduty.py | 3 + litellm/__init__.py | 1 + litellm/exceptions.py | 4 ++ litellm/main.py | 4 +- litellm/proxy/utils.py | 3 + litellm/router_utils/cooldown_handlers.py | 8 ++- litellm/router_utils/handle_error.py | 4 ++ litellm/utils.py | 42 +++++++++---- .../test_pagerduty_alerting.py | 45 ++++++++++++++ ..._utils_model_creation_and_error_logging.py | 13 ++++ .../router_utils/test_cooldown_handlers.py | 44 ++++++++++++++ .../test_fallback_event_handlers.py | 19 ++++++ tests/unit/router_utils/test_handle_error.py | 33 +++++++++- tests/unit/test_utils.py | 60 ++++++++++++++++++- 14 files changed, 264 insertions(+), 19 deletions(-) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py b/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py index f920aa7ac13..a0c896b7e05 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py @@ -17,6 +17,7 @@ from typing import List, Optional, Union from litellm._logging import verbose_logger from litellm.caching import DualCache +from litellm.exceptions import MissingMessageRoleError from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, @@ -86,6 +87,8 @@ class PagerDutyAlerting(SlackAlerting): Record a failure event. Only send an alert to PagerDuty if the configured *failure* threshold is exceeded in the specified window. """ + if isinstance(kwargs.get("exception"), MissingMessageRoleError): + return now = datetime.now(timezone.utc) standard_logging_payload: Optional[StandardLoggingPayload] = kwargs.get( "standard_logging_object" diff --git a/litellm/__init__.py b/litellm/__init__.py index 8a73f3447b8..bfe0e2b5f3e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1457,6 +1457,7 @@ from .exceptions import ( BadRequestError, ImageFetchError, MalformedRequestFieldError as MalformedRequestFieldError, + MissingMessageRoleError as MissingMessageRoleError, VectorStoreSearchError, NotFoundError, PermissionDeniedError, diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 56a5f136b62..f8c6a50a589 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -369,6 +369,10 @@ class MalformedRequestFieldError(BadRequestError): pass +class MissingMessageRoleError(MalformedRequestFieldError): + pass + + class UnsupportedEndpointError(BadRequestError): pass diff --git a/litellm/main.py b/litellm/main.py index 2d6cd18162b..df8e8f29483 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5660,7 +5660,9 @@ def completion( if model is None: raise ValueError("model param not passed in.") # validate messages - messages = validate_and_fix_openai_messages(messages=messages) + messages = validate_and_fix_openai_messages( + messages=messages, model=model, request_kwargs=_REQUEST_KWARGS_ADAPTER.validate_python(kwargs) + ) tools = validate_and_fix_openai_tools(tools=tools) # validate tool_choice requested_tool_choice: Final = cast( # cast-ok: widening an untyped dict to object is always sound diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 51f1c8a614d..eac2700551f 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -151,6 +151,7 @@ from litellm.caching.dual_cache import LimitedSizeOrderedDict from litellm.caching.in_memory_cache import InMemoryCache from litellm.exceptions import ( GuardrailRaisedException, + MissingMessageRoleError, RejectedRequestError, SensitiveDataRouteException, ) @@ -671,6 +672,8 @@ class _BudgetAlertEmailer(Protocol): def _is_client_error_exception(exc: Exception) -> bool: + if isinstance(exc, MissingMessageRoleError): + return True if isinstance(exc, HTTPException): return exc.status_code < 500 if isinstance(exc, ProxyException): diff --git a/litellm/router_utils/cooldown_handlers.py b/litellm/router_utils/cooldown_handlers.py index 190ef43a3e3..22bec9e1ead 100644 --- a/litellm/router_utils/cooldown_handlers.py +++ b/litellm/router_utils/cooldown_handlers.py @@ -24,7 +24,7 @@ from litellm.constants import ( INTERNAL_CALL_ORIGIN_METADATA_KEY, SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD, ) -from litellm.exceptions import MidStreamFallbackError +from litellm.exceptions import MidStreamFallbackError, MissingMessageRoleError from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import mask_api_base_credentials from litellm.router_deprecated import renamed_router_param @@ -83,8 +83,10 @@ def mark_deployment_selection_failure(exception: BaseException) -> None: def is_failure_outside_selected_deployment(exception: BaseException | None) -> bool: - return is_advisor_orchestration_failure(exception) or bool( - getattr(exception, _DEPLOYMENT_SELECTION_FAILURE_ATTR, False) + return ( + is_advisor_orchestration_failure(exception) + or isinstance(exception, MissingMessageRoleError) + or bool(getattr(exception, _DEPLOYMENT_SELECTION_FAILURE_ATTR, False)) ) diff --git a/litellm/router_utils/handle_error.py b/litellm/router_utils/handle_error.py index a1e6d2b2cad..319959b5cfd 100644 --- a/litellm/router_utils/handle_error.py +++ b/litellm/router_utils/handle_error.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Final from litellm._logging import redact_secrets, verbose_router_logger from litellm.constants import MAX_EXCEPTION_MESSAGE_LENGTH +from litellm.exceptions import MissingMessageRoleError from litellm.router_deprecated import renamed_router_param from litellm.router_utils.cooldown_handlers import ( async_get_cooldown_deployments_with_debug_info, @@ -46,6 +47,9 @@ async def send_llm_exception_alert( if slack_alerting_logger is None: return + if isinstance(original_exception, MissingMessageRoleError): + return + if "proxy_server_request" in request_kwargs: # Do not send any alert if it's a request from litellm proxy server request # the proxy is already instrumented to send LLM API call failures diff --git a/litellm/utils.py b/litellm/utils.py index 7c73d1cc4d5..7fa9d32ae64 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -534,6 +534,7 @@ from .exceptions import ( ContentPolicyViolationError, ContextWindowExceededError, MalformedRequestFieldError, + MissingMessageRoleError, ModelNotMappedError, NotFoundError, OpenAIError, @@ -9496,16 +9497,22 @@ def convert_list_message_to_dict( return [dict(cleanup_none_field_in_message(message=_as_message_value(message))) for message in messages] -def validate_and_fix_openai_messages(messages: list): +_ASSISTANT_ONLY_MESSAGE_KEYS: Final = frozenset( + {"tool_calls", "function_call", "reasoning_content", "reasoning_items", "thinking_blocks", "refusal", "audio"} +) + + +def validate_and_fix_openai_messages( + messages: list, model: str = "", request_kwargs: Mapping[str, object] | None = None +): """ Ensures all messages are valid OpenAI chat completion messages. - Handles missing role for assistant messages. + Only an assistant turn carries the keys in _ASSISTANT_ONLY_MESSAGE_KEYS, so one sent without a role but with + any of them, even set to null, gets "assistant" (issue #8661). """ new_messages: Final = [] for message in messages: - if not message.get("role"): - message["role"] = "assistant" if message.get("tool_calls"): message["tool_calls"] = jsonify_tools(tools=message["tool_calls"]) @@ -9516,8 +9523,10 @@ def validate_and_fix_openai_messages(messages: list): ), ) cleaned_message = cleanup_none_field_in_message(message=convert_msg_to_dict) + if not cleaned_message.get("role") and not _ASSISTANT_ONLY_MESSAGE_KEYS.isdisjoint(convert_msg_to_dict): + cleaned_message["role"] = "assistant" new_messages.append(cleaned_message) - return validate_chat_completion_user_messages(messages=new_messages) + return validate_chat_completion_user_messages(messages=new_messages, model=model, request_kwargs=request_kwargs) def validate_and_fix_openai_tools(tools: list | None) -> list[dict] | None: @@ -9570,21 +9579,36 @@ def cleanup_none_field_in_message(message: AllMessageValues): return {k: v for k, v in new_message.items() if v is not None} -def validate_chat_completion_user_messages(messages: list[AllMessageValues]): +def validate_chat_completion_user_messages( + messages: list[AllMessageValues], model: str = "", request_kwargs: Mapping[str, object] | None = None +): """ - Ensures all user messages are valid OpenAI chat completion messages. + Ensures every message has a role and all user messages are valid OpenAI chat completion messages. Args: messages: List of message dictionaries + model: Model named on the error raised for a message without a role + request_kwargs: The request's provider settings, read to name the provider on that error message_content_type: Type to validate content against Returns: List[dict]: The validated messages Raises: + MissingMessageRoleError: If a message has no role ValueError: If any message is invalid """ for idx, m in enumerate(messages): + if "role" not in m or not m["role"]: + raise MissingMessageRoleError( + message=f"Missing required parameter: 'messages[{idx}].role'.", + model=model, + llm_provider=_deployment_provider({**(request_kwargs or {}), "model": model}), + body={ + "type": "invalid_request_error", + "param": f"messages[{idx}].role", + }, + ) try: if m["role"] == "user": user_content = m.get("content") @@ -9597,10 +9621,6 @@ def validate_chat_completion_user_messages(messages: list[AllMessageValues]): if item.get("type") not in ValidUserMessageContentTypes: raise Exception(f"invalid content type={item.get('type')}") except Exception as e: - if isinstance(e, KeyError): - raise Exception( - f"Invalid message at index {idx}. Please ensure all messages are valid OpenAI chat completion messages." - ) if "invalid content type" in str(e): raise Exception( f"Invalid user message at index {idx}. Please ensure all user messages are valid OpenAI chat completion messages." diff --git a/tests/unit/enterprise/enterprise_callbacks/test_pagerduty_alerting.py b/tests/unit/enterprise/enterprise_callbacks/test_pagerduty_alerting.py index c26966eeb38..84b4dc0a93e 100644 --- a/tests/unit/enterprise/enterprise_callbacks/test_pagerduty_alerting.py +++ b/tests/unit/enterprise/enterprise_callbacks/test_pagerduty_alerting.py @@ -74,6 +74,51 @@ async def test_failure_alerts_follow_the_configured_threshold( assert recent_errors[0]["error_llm_provider"] == "openai" +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("exception", "request_count"), + [ + ( + litellm.MissingMessageRoleError( + message="Missing required parameter: 'messages[0].role'.", model="gpt-5.6", llm_provider="openai" + ), + 0, + ), + ( + litellm.MalformedRequestFieldError( + message="tools: Input should be a valid list", model="gpt-5.6", llm_provider="openai" + ), + 1, + ), + ], +) +async def test_only_a_role_less_request_stays_out_of_the_failure_count( + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, + exception: Exception, + request_count: int, +) -> None: + route: Final = respx_mock.post(PAGERDUTY_URL).mock(return_value=httpx.Response(202)) + logger: Final = _pagerduty_logger(monkeypatch) + payload: Final = cast( + StandardLoggingPayload, + { + "error_information": { + "error_class": type(exception).__name__, + "error_code": "400", + "llm_provider": "openai", + }, + "metadata": {}, + }, + ) + + await logger.async_log_failure_event( + {"standard_logging_object": payload, "exception": exception}, None, None, None + ) + + assert route.call_count == request_count + + @pytest.mark.asyncio async def test_hanging_requests_alert_at_the_default_threshold( monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter diff --git a/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py b/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py index 867259a9b3b..5c399f96e83 100644 --- a/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py +++ b/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py @@ -2007,6 +2007,19 @@ class TestPostCallFailureHookLLMExceptionAlerting: async def test_http_exception_does_not_alert(self): assert (await self._alerted(HTTPException(status_code=400, detail="blocked"))).called is False + @pytest.mark.asyncio + async def test_role_less_message_does_not_alert(self): + with pytest.raises(litellm.MalformedRequestFieldError) as exc_info: + litellm.completion(model="openai/gpt-5.6", messages=[{"content": "hi"}], mock_response="ok") + assert (await self._alerted(exc_info.value)).called is False + + @pytest.mark.asyncio + async def test_malformed_tools_field_still_alerts(self): + exc = litellm.MalformedRequestFieldError( + message="tools: Input should be a valid list", model="openai/gpt-5.6", llm_provider="openai" + ) + assert (await self._alerted(exc)).called is True + @pytest.mark.asyncio async def test_genuine_llm_api_error_still_alerts(self): assert (await self._alerted(Exception("upstream 503"))).called is True diff --git a/tests/unit/router_utils/test_cooldown_handlers.py b/tests/unit/router_utils/test_cooldown_handlers.py index 3a61ca48f26..cc3165a036c 100644 --- a/tests/unit/router_utils/test_cooldown_handlers.py +++ b/tests/unit/router_utils/test_cooldown_handlers.py @@ -3,6 +3,7 @@ import copy import datetime import importlib import itertools +import json import random import time import uuid @@ -37,6 +38,7 @@ from litellm.router_utils.cooldown_handlers import ( async_get_cooldown_deployments, cast_exception_status_to_int, get_cooldown_deployments, + is_failure_outside_selected_deployment, is_response_lookup_not_found, mark_advisor_orchestration_failure, set_cooldown_deployments, @@ -1939,6 +1941,48 @@ async def test_should_cooldown_deployment(testing_litellm_router): ) +@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") +@pytest.mark.asyncio +async def test_role_less_requests_do_not_count_toward_the_deployments_failure_rate(): + """A caller's role-less message is not the deployment failing, so it must not push a healthy one into cooldown.""" + litellm_params: Final = {"model": "house-model", "custom_llm_provider": "openai", "mock_response": "ok"} + router: Final = Router( + model_list=[ + {"model_name": "house", "litellm_params": litellm_params, "model_info": {"id": "d1"}}, + {"model_name": "house", "litellm_params": litellm_params, "model_info": {"id": "d2"}}, + ] + ) + for _ in range(6): + with pytest.raises(litellm.MalformedRequestFieldError): + await router.acompletion(model="d1", messages=json.loads('[{"content": "hi"}]')) + await asyncio.sleep(1) + + assert _should_cooldown_deployment(router, "d1", 500, Exception("Test")) is False + + +@pytest.mark.parametrize( + ("exception", "outside_deployment"), + [ + ( + litellm.MissingMessageRoleError( + message="Missing required parameter: 'messages[0].role'.", model="m", llm_provider="openai" + ), + True, + ), + ( + litellm.MalformedRequestFieldError( + message="tools: Input should be a valid list", model="m", llm_provider="openai" + ), + False, + ), + ], +) +def test_only_a_role_less_request_is_a_failure_outside_the_deployment( + exception: Exception, outside_deployment: bool +) -> None: + assert is_failure_outside_selected_deployment(exception) is outside_deployment + + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_should_cooldown_deployment_allowed_fails_set_on_router(): diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index 9c034b77fe7..47d58a3b869 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -2503,6 +2503,25 @@ def test_is_bad_request_without_fallback_only_stops_on_a_real_400_when_the_flag_ assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=True) is False +@pytest.mark.asyncio +async def test_a_role_less_request_is_not_retried_on_the_fallback_group() -> None: + router: Final = Router( + model_list=[ + { + "model_name": "house", + "litellm_params": {"model": "house-model", "custom_llm_provider": "openai", "mock_response": "ok"}, + }, + {"model_name": "backup", "litellm_params": {"model": "openai/gpt-5.6", "mock_response": "ok"}}, + ], + fallbacks=[{"house": ["backup"]}], + fallback_on_unsupported_params=False, + ) + + with pytest.raises(litellm.MalformedRequestFieldError, match="Missing required parameter") as exc_info: + await router.acompletion(model="house", messages=json.loads('[{"content": "hi"}]')) + assert "Fallback to backup" not in str(exc_info.value) + + @pytest.mark.asyncio async def test_run_async_fallback_skips_a_target_the_rate_limit_check_rejects() -> None: checked: Final = [] # mutable-ok: records the targets the check saw diff --git a/tests/unit/router_utils/test_handle_error.py b/tests/unit/router_utils/test_handle_error.py index 22bd6e74190..00350cc357c 100644 --- a/tests/unit/router_utils/test_handle_error.py +++ b/tests/unit/router_utils/test_handle_error.py @@ -1,6 +1,6 @@ import asyncio import importlib -from typing import List +from typing import Final, List from unittest.mock import AsyncMock, MagicMock import pytest @@ -40,6 +40,37 @@ async def test_send_llm_exception_alert_success(): slack_alerting_logger.send_alert.assert_called_once() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("exception", "alerts_sent"), + [ + ( + litellm.MissingMessageRoleError( + message="Missing required parameter: 'messages[0].role'.", model="gpt-5.6", llm_provider="openai" + ), + 0, + ), + ( + litellm.MalformedRequestFieldError( + message="tools: Input should be a valid list", model="gpt-5.6", llm_provider="openai" + ), + 1, + ), + ], +) +async def test_send_llm_exception_alert_skips_only_a_role_less_request(exception: Exception, alerts_sent: int): + slack_alerting_logger: Final = AsyncMock() + ctx: Final = MagicMock() + ctx.observers.return_value = RoutingObservers(slack_alerting_logger=slack_alerting_logger) + request_kwargs: Final = {"model": "gpt-5.6", "messages": [{"content": "Hello"}]} + + from litellm.router_utils.handle_error import send_llm_exception_alert + + await send_llm_exception_alert(ctx, request_kwargs, "Traceback", exception) + + assert slack_alerting_logger.send_alert.call_count == alerts_sent + + @pytest.mark.asyncio async def test_send_llm_exception_alert_no_logger(): """ diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 6fece4d8c66..a1400f53ef8 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -57,6 +57,8 @@ from litellm.litellm_core_utils.thread_pool_executor import executor as logging_ from litellm.llms.base_llm.base_model_iterator import MockResponseIterator from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, headers from litellm.llms.openai_like.json_loader import JSONProviderRegistry +from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup +from litellm.proxy.db.request_error_tracking import status_code_from_metadata from litellm.proxy.utils import is_valid_api_key from litellm.responses.main import aresponses, responses from litellm.types.caching import CachingSupportedCallTypes @@ -119,6 +121,7 @@ from litellm.utils import ( trim_messages, validate_environment, validate_chat_completion_tool_choice, + validate_and_fix_openai_messages, ) from litellm.litellm_core_utils.model_cost_table import publish @@ -8939,6 +8942,57 @@ def test_validate_tool_choice_without_model_is_still_a_400(): assert exc_info.value.model == "" +@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") +@pytest.mark.parametrize( + ("messages", "index"), + [ + ([{"content": "Say hi in two words."}], 0), + ([{"role": "user", "content": "hi"}, {"role": None, "content": "hello"}], 1), + ([{"role": "user", "content": "hi"}, {"role": "", "content": "hello"}], 1), + ], +) +def test_completion_message_without_role_is_a_400(messages: list[dict[str, str | None]], index: int): + """A message with no role is the caller's mistake, rejected before any provider sees it, never sent as assistant.""" + with pytest.raises( + litellm.MissingMessageRoleError, match=re.escape(f"Missing required parameter: 'messages[{index}].role'.") + ) as exc_info: + litellm.completion(model=MODEL, messages=messages, mock_response="Hello there!") + assert exc_info.value.status_code == 400 + assert exc_info.value.model == MODEL + assert exc_info.value.llm_provider == "anthropic" + assert exc_info.value.param == f"messages[{index}].role" + error_information: Final = StandardLoggingPayloadSetup.get_error_information(exc_info.value) + assert status_code_from_metadata({"error_information": error_information}) == 400 + + +@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") +@pytest.mark.parametrize( + "call_turn", + [ + { + "content": "", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}} + ], + }, + {"content": None, "function_call": {"name": "get_weather", "arguments": "{}"}}, + {"content": "ok", "tool_calls": []}, + {"content": "ok", "tool_calls": None}, + {"content": "ok", "function_call": None}, + {"content": "Sunny.", "reasoning_content": "The user wants the weather."}, + {"content": "Sunny.", "reasoning_items": []}, + {"content": "Sunny.", "thinking_blocks": [{"type": "thinking", "thinking": "Weather.", "signature": "sig"}]}, + {"content": None, "refusal": "I can't help with that."}, + {"content": None, "audio": {"id": "audio_1"}}, + ], +) +def test_completion_role_less_assistant_turn_is_sent_as_assistant(call_turn: dict[str, object]): + """Only an assistant turn carries these keys, so one replayed without a role still goes upstream as assistant.""" + messages: Final = [{"role": "user", "content": "Weather in Paris?"}, call_turn] + + assert validate_and_fix_openai_messages(messages)[1]["role"] == "assistant" + + @pytest.fixture() def _vcr_outcome_gate_local_testing(request, vcr): install_live_call_probe(request, vcr) @@ -10731,12 +10785,12 @@ def test_dict_to_response_format_helper(): def test_validate_user_messages_invalid_content_type(): from litellm.utils import validate_chat_completion_user_messages - messages = [{"content": [{"type": "invalid_type", "text": "Hello"}]}] + messages = [{"role": "user", "content": [{"type": "invalid_type", "text": "Hello"}]}] - with pytest.raises(Exception, match="Please ensure all messages are valid OpenAI chat completion") as e: + with pytest.raises(Exception, match="Please ensure all user messages are valid OpenAI chat completion") as e: validate_chat_completion_user_messages(messages) - assert "Invalid message" in str(e) + assert "Invalid user message at index 0" in str(e) print(e) From b63656a5de6a95487d2f4826e2cbe4cccca0d02e Mon Sep 17 00:00:00 2001 From: paveln83 <349044532+paveln83@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:39:19 -0800 Subject: [PATCH 84/86] fix(proxy): stream the guardrail block response when the block comes after an SSE keepalive ping (#51114) * fix(proxy): stream the guardrail block response when the block lands after an SSE keepalive ping * docs(proxy): keep the keepalive docstring's reasons together * fix(proxy): stream the /v1/messages block after a keepalive ping and always end a late block stream A passthrough guardrail block on a streamed /v1/messages request that landed after the first keepalive ping still ended as an api_error event. The route now hands its block stream to the keepalive relay, as chat and text completions do. If a route's block stream itself fails after a ping, the relay sends the usual late failure frame instead of raising, so the stream always ends. The passthrough tests now drive the real routes with a real guardrail. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../proxy/anthropic_endpoints/endpoints.py | 56 +++-- litellm/proxy/common_request_processing.py | 26 ++- litellm/proxy/proxy_server.py | 162 ++++++++------- litellm/utils.py | 1 + .../anthropic_endpoints/test_endpoints.py | 81 ++++++-- .../proxy/test_common_request_processing.py | 63 ++++++ ...t_modify_response_streaming_passthrough.py | 196 +++++++++++++++++- 7 files changed, 457 insertions(+), 128 deletions(-) diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index 1688992f6fa..636ba987ee8 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -7,7 +7,7 @@ from types import MappingProxyType from typing import Final from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import JsonValue, TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict @@ -55,6 +55,7 @@ from litellm.proxy.litellm_pre_call_utils import ( forwarded_anthropic_oauth_token, should_auto_drop_params_for_agentic_cli, ) +from litellm.proxy.utils import ProxyLogging from litellm.router import Router from litellm.types.utils import TokenCountResponse @@ -126,6 +127,32 @@ def _strip_total_tokens_from_anthropic_response(response: object) -> None: usage.pop("total_tokens", None) +async def _record_passthrough_block( + e: ModifyResponseException, proxy_logging_obj: ProxyLogging, user_api_key_dict: UserAPIKeyAuth +) -> None: + await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, + original_exception=e, + request_data=e.request_data, + ) + + +async def _stream_passthrough_block( + e: ModifyResponseException, + proxy_logging_obj: ProxyLogging, + user_api_key_dict: UserAPIKeyAuth, + headers: Mapping[str, str], +) -> StreamingResponse | JSONResponse: + await _record_passthrough_block(e, proxy_logging_obj, user_api_key_dict) + block_chunks: Final = AnthropicMessagesHandler().build_block_sse_chunks(e) + + async def _blocked_stream() -> AsyncGenerator[str, None]: + for chunk in block_chunks: + yield chunk.decode() + + return await create_response(generator=_blocked_stream(), media_type="text/event-stream", headers=headers) + + @router.post( "/v1/messages", tags=["Anthropic `/v1/messages`"], @@ -174,6 +201,9 @@ async def anthropic_response( user_max_tokens=user_max_tokens, user_api_base=user_api_base, version=version, + on_passthrough_block=lambda block: _stream_passthrough_block( + block, proxy_logging_obj, user_api_key_dict, _NO_HEADERS + ), ) # Optionally strip the non-Anthropic `usage.total_tokens` field # LiteLLM adds internally. Anthropic's official /v1/messages spec @@ -189,30 +219,12 @@ async def anthropic_response( _strip_total_tokens_from_anthropic_response(result) return result except ModifyResponseException as e: - # Guardrail flagged content in passthrough mode - return 200 with violation message - _data: Final = e.request_data - await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, - original_exception=e, - request_data=_data, - ) - block_headers: Final = litellm_call_id_headers(base_llm_response_processor.litellm_call_id) or _NO_HEADERS - block_handler: Final = AnthropicMessagesHandler() - if data.get("stream") is True: - block_chunks: Final = block_handler.build_block_sse_chunks(e) - - async def _blocked_stream() -> AsyncGenerator[str, None]: - for chunk in block_chunks: - yield chunk.decode() - - return await create_response( - generator=_blocked_stream(), media_type="text/event-stream", headers=block_headers - ) - + return await _stream_passthrough_block(e, proxy_logging_obj, user_api_key_dict, block_headers) + await _record_passthrough_block(e, proxy_logging_obj, user_api_key_dict) fastapi_response.headers.update(block_headers) - return block_handler.build_block_response(e) + return AnthropicMessagesHandler().build_block_response(e) except AnthropicContextManagementError as e: if e.status_code >= 500: # Server-side polyfill failures hit the failure hook for spend/alert diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index c6981015417..1b004423181 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1662,6 +1662,18 @@ async def _relay_late_response(produced: Response, framing: LateSseFraming) -> A await _aclose_late_response(produced) +async def _late_block_or_failure( + exc: Exception, + on_passthrough_block: Callable[[ModifyResponseException], Awaitable[Response]] | None, +) -> Response | Exception: + if not isinstance(exc, ModifyResponseException) or on_passthrough_block is None: + return exc + try: + return await on_passthrough_block(exc) + except Exception as block_failure: # noqa: BLE001 # the client still needs a frame that ends the stream + return block_failure + + async def _sanitized_late_failure( exc: Exception, on_late_failure: "Callable[[Exception], Awaitable[HTTPException | None]] | None", @@ -1692,6 +1704,7 @@ async def open_sse_before_first_byte( media_type: str = "text/event-stream", on_late_failure: Callable[[Exception], Awaitable[HTTPException | None]] | None = None, late_framing: LateSseFraming = OPENAI_LATE_SSE_FRAMING, + on_passthrough_block: Callable[[ModifyResponseException], Awaitable[Response]] | None = None, ) -> _LateResponseT | StreamingResponse: """Write SSE keepalive comments while the upstream LLM call is still in flight. @@ -1708,6 +1721,8 @@ async def open_sse_before_first_byte( the first ping reaches the client as an SSE error frame under a 200 rather than as an HTTP error status, and LiteLLM's own `x-litellm-*` response headers are not yet known. Both are why this stays off until an operator sets an interval. + A passthrough guardrail block is not a failure: when the route passes + `on_passthrough_block`, the stream it builds for the block is relayed. """ interval: Final = coerce_keepalive_interval(ping_interval_seconds) if interval is None: @@ -1730,14 +1745,19 @@ async def open_sse_before_first_byte( try: produced: Final = produce_task.result() except Exception as exc: # noqa: BLE001 # the status line is already sent; surface it as a frame + late: Final = await _late_block_or_failure(exc, on_passthrough_block) + if isinstance(late, Response): + async for chunk in _relay_late_response(late, late_framing): + yield chunk + return verbose_proxy_logger.exception( - "request failed after its SSE keepalive had opened the response: %s", exc + "request failed after its SSE keepalive had opened the response: %s", late ) # The caller's own `except` never sees this, so its failure hook # would never fire and the failure would go unaudited. The hook # also gets to sanitize what reaches the client, by returning or # raising a replacement, so its answer decides the frame. - for frame in late_framing.failure(await _sanitized_late_failure(exc, on_late_failure)): + for frame in late_framing.failure(await _sanitized_late_failure(late, on_late_failure)): yield frame.encode() return async for chunk in _relay_late_response(produced, late_framing): @@ -3156,6 +3176,7 @@ class ProxyBaseLLMRequestProcessing: contents: list[object] | None = None, skip_pre_call_logic: bool = False, sse_framing: SseFraming = OPENAI_SSE_FRAMING, + on_passthrough_block: Callable[[ModifyResponseException], Awaitable[Response]] | None = None, ) -> object: """Run the request, sending SSE keepalives while the upstream is still silent. @@ -3207,6 +3228,7 @@ class ProxyBaseLLMRequestProcessing: ping_interval_seconds=ttft_keepalive_interval(self.data, llm_router), on_late_failure=_audit_late_failure, late_framing=ANTHROPIC_LATE_SSE_FRAMING if answers_as_anthropic else sse_framing.late, + on_passthrough_block=on_passthrough_block, ) def total_timeout_for( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6fbe562b5d1..8f5db97e7a3 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -210,8 +210,10 @@ from litellm.rust_bridge.catalog import Target from litellm.types.utils import ( PRICING_OVERRIDES_KEY, CallTypes, + Choices, CountContextManagement, LlmProviders, + Message, ModelResponse, ModelResponseStream, TextCompletionResponse, @@ -13263,6 +13265,70 @@ def _blocked_response_usage(original_response: object | None) -> "litellm.Usage" return litellm.Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0) +def _passthrough_block_response(e: ModifyResponseException) -> ModelResponse: + return ModelResponse( + model=e.model, + choices=[Choices(message=Message(content=e.message), finish_reason="content_filter")], + usage=_blocked_response_usage(e.original_response), + ) + + +async def _record_passthrough_block(e: ModifyResponseException, user_api_key_dict: UserAPIKeyAuth) -> None: + await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, + original_exception=e, + request_data=e.request_data, + ) + + +async def _stream_chat_passthrough_block( + e: ModifyResponseException, user_api_key_dict: UserAPIKeyAuth, request: Request +) -> StreamingResponse: + # Read before post_call_failure_hook, which pops it from the request data. + logging_obj: Final = e.request_data.get("litellm_logging_obj") + await _record_passthrough_block(e, user_api_key_dict) + streaming_response: Final = litellm.CustomStreamWrapper( + completion_stream=litellm.utils.ModelResponseIterator( + model_response=_passthrough_block_response(e), convert_to_delta=True + ), + model=e.model, + custom_llm_provider="cached_response", + logging_obj=logging_obj, + ) + return StreamingResponse( + select_data_generator( + response=streaming_response, + user_api_key_dict=user_api_key_dict, + request_data=e.request_data, + request=request, + ), + media_type="text/event-stream", + status_code=200, + ) + + +async def _stream_text_completion_passthrough_block( + e: ModifyResponseException, user_api_key_dict: UserAPIKeyAuth, request: Request +) -> StreamingResponse: + await _record_passthrough_block(e, user_api_key_dict) + streaming_response: Final = litellm.TextCompletionStreamWrapper( + completion_stream=litellm.utils.ModelResponseIterator( + model_response=_passthrough_block_response(e), convert_to_delta=True + ), + model=e.model, + ) + return StreamingResponse( + select_data_generator( + response=streaming_response, + user_api_key_dict=user_api_key_dict, + request_data=e.request_data, + request=request, + ), + media_type="text/event-stream", + status_code=200, + ) + + @router.post( "/v1/chat/completions", dependencies=[Depends(user_api_key_auth)], @@ -13352,50 +13418,17 @@ async def chat_completion( user_max_tokens=user_max_tokens, user_api_base=user_api_base, version=version, + on_passthrough_block=lambda block: _stream_chat_passthrough_block(block, user_api_key_dict, request), ) if isinstance(result, BaseModel): return model_dump_with_preserved_fields(result, exclude_unset=True) else: return result except ModifyResponseException as e: - # Guardrail flagged content in passthrough mode - return 200 with violation message - _data = e.request_data - # Capture logging_obj before post_call_failure_hook pops it from _data. - _logging_obj: Final = _data.get("litellm_logging_obj") - await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, - original_exception=e, - request_data=_data, - ) - _chat_response = litellm.ModelResponse() - _chat_response.model = e.model - _chat_response.choices[0].message.content = e.message - _chat_response.choices[0].finish_reason = "content_filter" - # Report the blocked LLM response's real usage (set before the stream - # branch so both paths carry it); zero for pre-call blocks. - _chat_response.usage = _blocked_response_usage(e.original_response) - - if data.get("stream", None) is not None and data["stream"] is True: - _iterator = litellm.utils.ModelResponseIterator(model_response=_chat_response, convert_to_delta=True) - _streaming_response = litellm.CustomStreamWrapper( - completion_stream=_iterator, - model=e.model, - custom_llm_provider="cached_response", - logging_obj=_logging_obj, - ) - selected_data_generator = select_data_generator( - response=_streaming_response, - user_api_key_dict=user_api_key_dict, - request_data=_data, - request=request, - ) - - return StreamingResponse( - selected_data_generator, - media_type="text/event-stream", - status_code=200, # Return 200 for passthrough mode - ) - return _chat_response + if data.get("stream") is True: + return await _stream_chat_passthrough_block(e, user_api_key_dict, request) + await _record_passthrough_block(e, user_api_key_dict) + return _passthrough_block_response(e) except RejectedRequestError as e: _data = e.request_data await proxy_logging_obj.post_call_failure_hook( @@ -13509,49 +13542,20 @@ async def completion( user_max_tokens=user_max_tokens, user_api_base=user_api_base, version=version, + on_passthrough_block=lambda block: _stream_text_completion_passthrough_block( + block, user_api_key_dict, request + ), ) except ModifyResponseException as e: - # Guardrail flagged content in passthrough mode - return 200 with violation message - _data = e.request_data - await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, - original_exception=e, - request_data=_data, - ) - - if _data.get("stream", None) is not None and _data["stream"] is True: - _text_response: Final = litellm.ModelResponse() - # Set text attribute dynamically for text completion format - setattr(_text_response.choices[0], "text", e.message) - _text_response.model = e.model - _usage = _blocked_response_usage(e.original_response) - # Set usage attribute dynamically (ModelResponse accepts usage in __init__ but it's not in type definition) - setattr(_text_response, "usage", _usage) - _iterator = litellm.utils.ModelResponseIterator(model_response=_text_response, convert_to_delta=True) - _streaming_response = litellm.TextCompletionStreamWrapper( - completion_stream=_iterator, - model=e.model, - ) - - selected_data_generator = select_data_generator( - response=_streaming_response, - user_api_key_dict=user_api_key_dict, - request_data=_data, - request=request, - ) - - return StreamingResponse( - selected_data_generator, - media_type="text/event-stream", - status_code=200, # Return 200 for passthrough mode - ) - else: - _response = litellm.TextCompletionResponse() - _response.choices[0].text = e.message - _response.model = e.model - _usage = _blocked_response_usage(e.original_response) - _response.usage = _usage - return _response + if e.request_data.get("stream") is True: + return await _stream_text_completion_passthrough_block(e, user_api_key_dict, request) + await _record_passthrough_block(e, user_api_key_dict) + text_completion_response: Final = litellm.TextCompletionResponse() + text_completion_response.choices[0].text = e.message + text_completion_response.choices[0].finish_reason = "content_filter" + text_completion_response.model = e.model + text_completion_response.usage = _blocked_response_usage(e.original_response) + return text_completion_response except RejectedRequestError as e: _data = e.request_data await proxy_logging_obj.post_call_failure_hook( diff --git a/litellm/utils.py b/litellm/utils.py index 7fa9d32ae64..fb974b24283 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9228,6 +9228,7 @@ class ModelResponseIterator: if convert_to_delta is True: _stream_response: Final = ModelResponseStream() _stream_response.choices[0].delta.content = model_response.choices[0].message.content + _stream_response.choices[0].finish_reason = model_response.choices[0].finish_reason self.model_response: ModelResponse | ModelResponseStream = _stream_response else: self.model_response = model_response diff --git a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py index 117d148c287..072de5c0eed 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py @@ -2,6 +2,7 @@ Test for anthropic_endpoints/endpoints.py, focusing on handling dictionary objects in streaming responses """ +import asyncio import datetime import json import logging @@ -85,10 +86,15 @@ _BLOCK_CALL_ID: Final = "messages-block" _BLOCK_TEXT: Final = "blocked by guardrail" -class _BlockEveryRequest(CustomGuardrail): +class _BlockEveryRequestOnceReleased(CustomGuardrail): + def __init__(self, released: asyncio.Event) -> None: + super().__init__(guardrail_name="deny-list", event_hook=GuardrailEventHooks.pre_call, default_on=True) + self.released: Final = released + async def async_pre_call_hook( self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, data: dict[str, object], call_type: str ) -> None: + await self.released.wait() self.raise_passthrough_exception(violation_message=_BLOCK_TEXT, request_data=data) @@ -117,21 +123,29 @@ def _messages_request(body: Mapping[str, object]) -> Request: async def _blocked_messages_reply( - body: Mapping[str, object], fastapi_response: Response, rails: tuple[CustomGuardrail, ...] + body: Mapping[str, object], + fastapi_response: Response, + rails: tuple[CustomGuardrail, ...], + keepalive_interval: float | None = None, ) -> tuple[object, str]: import litellm import litellm.proxy.anthropic_endpoints.endpoints as ep import litellm.proxy.proxy_server as proxy_server from litellm.proxy.utils import ProxyLogging - blocker: Final = _BlockEveryRequest( - guardrail_name="deny-list", event_hook=GuardrailEventHooks.pre_call, default_on=True - ) + released: Final = asyncio.Event() + if keepalive_interval is None: + released.set() with ( patch.object( proxy_server, "proxy_logging_obj", new=ProxyLogging(user_api_key_cache=DualCache()) ), # test-quality-ok: module global imported at call time; no injection seam - patch.object(litellm, "callbacks", new=[blocker, *rails]), # test-quality-ok: guardrails register through this global + patch.object( + litellm, "callbacks", new=[_BlockEveryRequestOnceReleased(released), *rails] + ), # test-quality-ok: guardrails register through this global + patch.object( + litellm, "sse_keepalive_ping_interval_seconds", new=keepalive_interval + ), # test-quality-ok: the operator's keepalive interval is read from this global ): response = await ep.anthropic_response( fastapi_response=fastapi_response, @@ -140,12 +154,34 @@ async def _blocked_messages_reply( ) if not isinstance(response, StreamingResponse): return response, "" - streamed: Final = b"".join( - [chunk if isinstance(chunk, bytes) else chunk.encode() async for chunk in response.body_iterator] - ).decode() + + async def chunks() -> AsyncGenerator[bytes, None]: + async for chunk in response.body_iterator: + released.set() + yield chunk if isinstance(chunk, bytes) else str(chunk).encode() + + streamed: Final = b"".join([chunk async for chunk in chunks()]).decode() return response, streamed +def _anthropic_events(streamed: str) -> tuple[tuple[str, Mapping[str, object]], ...]: + frames: Final = tuple(frame for frame in streamed.split("\n\n") if frame.strip() and frame != ": ping") + return tuple( + (frame.split("\n")[0].removeprefix("event: "), json.loads(frame.split("\n")[1].removeprefix("data: "))) + for frame in frames + ) + + +_BLOCK_EVENTS: Final = ( + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", +) + + class TestBlockedResponseUsage: """Blocked responses report the blocked LLM response's real usage.""" @@ -220,26 +256,27 @@ class TestBlockedResponseUsage: {"model": "claude-haiku-5-5", "stream": True}, Response(), rails ) - frames: Final = tuple(frame for frame in streamed.split("\n\n") if frame.strip()) - events: Final = tuple( - (frame.split("\n")[0].removeprefix("event: "), json.loads(frame.split("\n")[1].removeprefix("data: "))) - for frame in frames - ) + events: Final = _anthropic_events(streamed) assert isinstance(response, StreamingResponse) assert response.media_type == "text/event-stream" assert response.headers["x-litellm-call-id"] == _BLOCK_CALL_ID - assert [name for name, _ in events] == [ - "message_start", - "content_block_start", - "content_block_delta", - "content_block_stop", - "message_delta", - "message_stop", - ] + assert [name for name, _ in events] == list(_BLOCK_EVENTS) assert all(payload["type"] == name for name, payload in events) assert events[2][1]["delta"] == {"type": "text_delta", "text": _BLOCK_TEXT} assert events[4][1]["delta"]["stop_reason"] == "end_turn" + @pytest.mark.asyncio + async def test_a_streaming_block_after_a_keepalive_ping_answers_the_block_events(self) -> None: + _, streamed = await _blocked_messages_reply( + {"model": "claude-haiku-5-5", "stream": True}, Response(), (), keepalive_interval=0.05 + ) + + events: Final = _anthropic_events(streamed) + assert streamed.startswith(": ping\n\n") + assert [name for name, _ in events] == list(_BLOCK_EVENTS) + assert events[2][1]["delta"] == {"type": "text_delta", "text": _BLOCK_TEXT} + assert events[4][1]["delta"]["stop_reason"] == "end_turn" + @pytest.mark.asyncio async def test_json_block_answers_with_the_call_id(self) -> None: fastapi_response: Final = Response() diff --git a/tests/unit/proxy/test_common_request_processing.py b/tests/unit/proxy/test_common_request_processing.py index bfe381cb848..ccfac3d4e30 100644 --- a/tests/unit/proxy/test_common_request_processing.py +++ b/tests/unit/proxy/test_common_request_processing.py @@ -11608,6 +11608,69 @@ async def test_a_broken_hook_does_not_replace_the_real_error_with_its_own_bug(): assert "audit backend" not in collected[-2].decode() +async def _block_once_released(release: asyncio.Event) -> Response: + await release.wait() + raise ModifyResponseException( + message="blocked by policy", model="gpt-4o", request_data={"stream": True}, guardrail_name="g" + ) + + +async def _drain_releasing(response: StreamingResponse, release: asyncio.Event) -> tuple[bytes, ...]: + async def chunks() -> AsyncGenerator[bytes, None]: + async for chunk in response.body_iterator: + release.set() + yield chunk if isinstance(chunk, bytes) else str(chunk).encode() + + return tuple([chunk async for chunk in chunks()]) + + +@pytest.mark.asyncio +async def test_a_late_passthrough_block_streams_the_routes_block_response_instead_of_an_error_frame(): + block_frames: Final = ('data: {"choices": [{"delta": {"content": "blocked by policy"}}]}\n\n', "data: [DONE]\n\n") + release: Final = asyncio.Event() + stream_block: Final = AsyncMock(side_effect=lambda _block: _sse_response(block_frames)) + record_failure: Final = AsyncMock(return_value=None) + + response: Final = await open_sse_before_first_byte( + _block_once_released(release), + ping_interval_seconds=0.05, + on_late_failure=record_failure, + on_passthrough_block=stream_block, + ) + collected: Final = await _drain_releasing(response, release) + + assert collected[0] == TTFT_PING + assert [chunk for chunk in collected if chunk != TTFT_PING] == [frame.encode() for frame in block_frames] + assert [call.args[0].message for call in stream_block.await_args_list] == ["blocked by policy"] + record_failure.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_late_passthrough_block_on_a_route_without_a_block_stream_still_ends_the_stream(): + release: Final = asyncio.Event() + response: Final = await open_sse_before_first_byte(_block_once_released(release), ping_interval_seconds=0.05) + collected: Final = await _drain_releasing(response, release) + + assert collected[0] == TTFT_PING + assert "error" in json.loads(collected[-2].decode().removeprefix("data: ").strip()) + assert collected[-1] == b"data: [DONE]\n\n" + + +@pytest.mark.asyncio +async def test_a_late_passthrough_block_whose_block_stream_fails_still_ends_the_stream(): + release: Final = asyncio.Event() + broken_block_stream: Final = AsyncMock(side_effect=RuntimeError("block stream unavailable")) + + response: Final = await open_sse_before_first_byte( + _block_once_released(release), ping_interval_seconds=0.05, on_passthrough_block=broken_block_stream + ) + collected: Final = await _drain_releasing(response, release) + + assert collected[0] == TTFT_PING + assert "error" in json.loads(collected[-2].decode().removeprefix("data: ").strip()) + assert collected[-1] == b"data: [DONE]\n\n" + + @pytest.mark.parametrize( "exc,expect_traceback", [ diff --git a/tests/unit/proxy/test_modify_response_streaming_passthrough.py b/tests/unit/proxy/test_modify_response_streaming_passthrough.py index f4d3b659ee1..5c46f1afac4 100644 --- a/tests/unit/proxy/test_modify_response_streaming_passthrough.py +++ b/tests/unit/proxy/test_modify_response_streaming_passthrough.py @@ -14,16 +14,28 @@ Reading it from the outer body passed ``logging_obj=None`` to ``AttributeError: 'NoneType' object has no attribute 'model_call_details'``. """ +import asyncio +import json +from collections.abc import AsyncGenerator, Mapping +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import Request, Response +from fastapi.responses import StreamingResponse +from starlette.types import Message +import litellm +import litellm.proxy.proxy_server as proxy_server +from litellm.caching.dual_cache import DualCache from litellm.exceptions import RejectedRequestError -from litellm.integrations.custom_guardrail import ModifyResponseException +from litellm.integrations.custom_guardrail import CustomGuardrail, ModifyResponseException +from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy.proxy_server import chat_completion - +from litellm.proxy.proxy_server import chat_completion, completion +from litellm.proxy.utils import ProxyLogging +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import ModelResponse, ModelResponseStream, TextCompletionResponse async def _run_streaming_block_and_get_wrapper(exception): """Drive chat_completion's streaming guardrail-passthrough handler for the @@ -108,3 +120,181 @@ async def test_streaming_rejected_request_uses_request_data_logging_obj(): mock_csw.assert_called_once() assert mock_csw.call_args.kwargs["logging_obj"] is sentinel_logging_obj + + +_BLOCK_TEXT: Final = "blocked by guardrail" +_PING: Final = ": ping" + + +class _BlockEveryRequestOnceReleased(CustomGuardrail): + def __init__(self, released: asyncio.Event) -> None: + super().__init__(guardrail_name="deny-list", event_hook=GuardrailEventHooks.pre_call, default_on=True) + self.released: Final = released + self.failures: tuple[Exception, ...] = () + + async def async_pre_call_hook( + self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, data: dict[str, object], call_type: str + ) -> None: + await self.released.wait() + self.raise_passthrough_exception(violation_message=_BLOCK_TEXT, request_data=data) + + async def async_post_call_failure_hook( + self, + request_data: dict[str, object], + original_exception: Exception, + user_api_key_dict: UserAPIKeyAuth, + traceback_str: str | None = None, + ) -> None: + self.failures = (*self.failures, original_exception) + + +class _RejectEveryRequest(CustomLogger): + async def async_pre_call_hook( + self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, data: dict[str, object], call_type: str + ) -> None: + raise RejectedRequestError( + message="rejected by the pre-call hook", model="gpt-4o", llm_provider="openai", request_data=data + ) + + +def _post(path: str, body: Mapping[str, object]) -> Request: + async def receive() -> Message: + return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False} + + return Request( + { + "type": "http", + "method": "POST", + "path": path, + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + }, + receive, + ) + + +async def _frames_releasing_the_block(response: StreamingResponse, released: asyncio.Event) -> tuple[str, ...]: + async def chunks() -> AsyncGenerator[str, None]: + async for chunk in response.body_iterator: + released.set() + yield chunk if isinstance(chunk, str) else bytes(chunk).decode() + + streamed: Final = "".join([chunk async for chunk in chunks()]) + return tuple(frame for frame in streamed.split("\n\n") if frame) + + +async def _reply( + path: str, + body: Mapping[str, object], + rail: CustomLogger, + released: asyncio.Event, + keepalive_interval: float | None, +) -> tuple[object, tuple[str, ...]]: + endpoint: Final = chat_completion if path == "/v1/chat/completions" else completion + with ( + patch.object( + proxy_server, "proxy_logging_obj", new=ProxyLogging(user_api_key_cache=DualCache()) + ), # test-quality-ok: module global imported at call time; no injection seam + patch.object(litellm, "callbacks", new=[rail]), # test-quality-ok: guardrails register through this global + patch.object( + litellm, "sse_keepalive_ping_interval_seconds", new=keepalive_interval + ), # test-quality-ok: the operator's keepalive interval is read from this global + ): + response: Final = await endpoint( + request=_post(path, body), fastapi_response=Response(), user_api_key_dict=UserAPIKeyAuth() + ) + if not isinstance(response, StreamingResponse): + return response, () + return response, await _frames_releasing_the_block(response, released) + + +async def _blocked_reply( + path: str, body: Mapping[str, object], keepalive_interval: float | None +) -> tuple[object, tuple[str, ...], _BlockEveryRequestOnceReleased]: + released: Final = asyncio.Event() + if keepalive_interval is None: + released.set() + blocker: Final = _BlockEveryRequestOnceReleased(released) + response, frames = await _reply(path, body, blocker, released, keepalive_interval) + return response, frames, blocker + + +def _data_frames(frames: tuple[str, ...]) -> tuple[str, ...]: + return tuple(frame.removeprefix("data: ") for frame in frames if frame.startswith("data: {")) + + +KEEPALIVE_INTERVALS: Final = pytest.mark.parametrize( + "keepalive_interval", [0.05, None], ids=["blocked-after-a-keepalive-ping", "keepalive-off"] +) + + +@pytest.mark.asyncio +@KEEPALIVE_INTERVALS +async def test_a_blocked_chat_stream_gets_the_block_message_and_a_content_filter_finish( + keepalive_interval: float | None, +) -> None: + _, frames, blocker = await _blocked_reply( + "/v1/chat/completions", {"model": "gpt-4o", "messages": [], "stream": True}, keepalive_interval + ) + + chunks: Final = tuple(ModelResponseStream.model_validate_json(frame) for frame in _data_frames(frames)) + assert (frames[0] == _PING) is (keepalive_interval is not None) + assert frames[-1] == "data: [DONE]" + assert not any('"error"' in frame for frame in frames) + assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == _BLOCK_TEXT + assert chunks[-1].choices[0].finish_reason == "content_filter" + assert [type(failure) for failure in blocker.failures] == [ModifyResponseException] + + +@pytest.mark.asyncio +@KEEPALIVE_INTERVALS +async def test_a_blocked_text_completion_stream_gets_the_block_message(keepalive_interval: float | None) -> None: + _, frames, blocker = await _blocked_reply( + "/v1/completions", {"model": "gpt-4o", "prompt": "hi", "stream": True}, keepalive_interval + ) + + chunks: Final = tuple(TextCompletionResponse.model_validate_json(frame) for frame in _data_frames(frames)) + assert (frames[0] == _PING) is (keepalive_interval is not None) + assert frames[-1] == "data: [DONE]" + assert not any('"error"' in frame for frame in frames) + assert "".join(chunk.choices[0].text or "" for chunk in chunks) == _BLOCK_TEXT + assert chunks[-1].choices[0].finish_reason == "content_filter" + assert [type(failure) for failure in blocker.failures] == [ModifyResponseException] + + +@pytest.mark.asyncio +async def test_a_blocked_chat_request_without_streaming_gets_the_block_message_and_a_content_filter_finish() -> None: + response, _, blocker = await _blocked_reply( + "/v1/chat/completions", {"model": "gpt-4o", "messages": [], "stream": False}, None + ) + + assert isinstance(response, ModelResponse) + assert response.choices[0].message.content == _BLOCK_TEXT + assert response.choices[0].finish_reason == "content_filter" + assert [type(failure) for failure in blocker.failures] == [ModifyResponseException] + + +@pytest.mark.asyncio +async def test_a_blocked_text_completion_without_streaming_gets_the_block_text_and_a_content_filter_finish() -> None: + response, _, blocker = await _blocked_reply( + "/v1/completions", {"model": "gpt-4o", "prompt": "hi", "stream": False}, None + ) + + assert isinstance(response, TextCompletionResponse) + assert response.choices[0].text == _BLOCK_TEXT + assert response.choices[0].finish_reason == "content_filter" + assert [type(failure) for failure in blocker.failures] == [ModifyResponseException] + + +@pytest.mark.asyncio +async def test_a_rejected_text_completion_stream_ends_with_a_stop_finish() -> None: + released: Final = asyncio.Event() + released.set() + + _, frames = await _reply( + "/v1/completions", {"model": "gpt-4o", "prompt": "hi", "stream": True}, _RejectEveryRequest(), released, None + ) + + chunks: Final = tuple(TextCompletionResponse.model_validate_json(frame) for frame in _data_frames(frames)) + assert frames[-1] == "data: [DONE]" + assert chunks[-1].choices[0].finish_reason == "stop" From 5e74515c29d62982694fa50aab5b416c2c252949 Mon Sep 17 00:00:00 2001 From: mrinal-berri Date: Wed, 11 Nov 2026 13:57:41 -0800 Subject: [PATCH 85/86] feat(otel): let a team or key OTel v2 destination add its own request metadata keys (#50813) * feat(otel): let a team or key OTel v2 destination add its own request metadata keys A team or key callback can now set baggage_metadata_keys in its callback_vars, a comma-separated list of requester_metadata. paths. The tenant fan-out adds them as litellm.metadata. to that destination's spans only, on top of the global LITELLM_OTEL_BAGGAGE_METADATA_KEYS; the operator's exporter and other destinations never see them. Values are bound when each span ends, an attribute the span already carries wins, and names of proxy identity fields are rejected when saved, along with lists over 20 keys. Resolves LIT-9545 * fix(otel): refuse request headers in a destination's baggage_metadata_keys The proxy copies the request headers into requester_metadata.headers with credential headers masked, but a header a gateway in front of it added (an IAP or OIDC assertion, an internal token) stays as sent. A team or key admin listing requester_metadata.headers. could export it to an endpoint the team controls. Entries under requester_metadata.headers are now rejected when saved, so exporting a header stays an operator decision in LITELLM_OTEL_BAGGAGE_METADATA_KEYS. Also drops an unused import. * fix(otel): hold destination metadata to the configured span limits A destination's extra litellm.metadata.* attributes were added to a span view built from a plain mapping, so they skipped the limits the SDK applies in set_attribute: a 1 MiB caller value survived an operator's OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT. Values are now cut to the length limit, and keys past the attribute-count limit are dropped and counted instead of evicting an attribute the span already carries. The per-destination views are built in one Final tuple ahead of the forwarding loop, since a Final cannot be assigned inside the loop. * fix(otel): apply destination metadata after the destination view, within the provider's limits The span limits were checked against the span as it ended, before the destination view strips prompt content and backend-only attributes, so a model-call span full of llm.input_messages.* could drop the team's keys on a no_content destination that had room. The values are now bound when the span ends and added as the last step before export, counted against what that destination actually sends, so their dropped count also survives the view. The limits come from the tracer provider the fan-out is attached to, read from the live span at start, instead of a separate env-built SpanLimits. * refactor(otel): consolidate span limits, destination validators and metadata key parsing Reads a span's limits in one guarded place that the emitter and the fan-out share, replaces the two parallel lists of per-destination callback-var validators with one mapping, splits and strips a baggage_metadata_keys list once, drops a filter the post-view merge already enforces, and folds the repeated fan-out test setup into one helper. No behavior change. * ci: retrigger checks stranded by the GitHub Actions outage --- litellm/integrations/otel/README.md | 8 +- litellm/integrations/otel/emitter.py | 9 +- litellm/integrations/otel/logger.py | 2 + litellm/integrations/otel/model/baggage.py | 58 +++- .../integrations/otel/model/destination.py | 11 +- litellm/integrations/otel/plumbing/context.py | 19 ++ .../integrations/otel/plumbing/providers.py | 93 ++++++- .../integrations/otel/presets/destinations.py | 2 + .../initialize_dynamic_callback_params.py | 23 +- litellm/proxy/_types.py | 18 +- .../callback_config_validation.py | 34 ++- litellm/proxy/litellm_pre_call_utils.py | 6 +- .../team_callback_endpoints.py | 5 +- litellm/types/utils.py | 7 + .../test_otel_destination_metadata_keys.py | 262 ++++++++++++++++++ .../otel/test_otel_v2_destinations.py | 127 +++++++++ .../test_callback_config_validation.py | 71 ++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 1 + 18 files changed, 700 insertions(+), 56 deletions(-) create mode 100644 tests/integration/observability/test_otel_destination_metadata_keys.py diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md index 06c2da117b5..70384e49f53 100644 --- a/litellm/integrations/otel/README.md +++ b/litellm/integrations/otel/README.md @@ -325,7 +325,13 @@ nothing here imports outside it: on `langfuse_otel`, `arize`, `weave_otel` and `newrelic` callbacks. - [`baggage.py`](./model/baggage.py) — the single definition of which request-identity values are promoted into Baggage (so child spans inherit them) and under which - attribute keys. + attribute keys. A team or key destination can list extra `requester_metadata.` + paths in `baggage_metadata_keys` in its own `callback_vars`. They are never written + into Baggage: the tenant fan-out adds them as `litellm.metadata.` to that + destination's view of each span, on top of the global list, so the operator's + exporter and other destinations never see them. A key the span already carries + wins, and names of proxy identity fields and `requester_metadata.headers.*` (the + request headers the proxy copies there) are rejected when saved. - [`utils.py`](./model/utils.py) — value coercion, JSON serialization, and extractor-table application, shared across the package. diff --git a/litellm/integrations/otel/emitter.py b/litellm/integrations/otel/emitter.py index 87a432ee7ce..813ba6aaa73 100644 --- a/litellm/integrations/otel/emitter.py +++ b/litellm/integrations/otel/emitter.py @@ -6,8 +6,7 @@ from types import MappingProxyType from typing import Final from opentelemetry.context import Context -from opentelemetry.sdk.trace import ReadableSpan, SpanLimits -from opentelemetry.sdk.trace import Span as SdkSpan +from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.trace import Link, Span, Tracer from opentelemetry.trace.status import Status, StatusCode @@ -36,7 +35,7 @@ from litellm.integrations.otel.model.spans import ( ) from litellm.integrations.otel.plumbing.context import destination_backends from litellm.integrations.otel.plumbing.events import GenAIEventRecorder -from litellm.integrations.otel.plumbing.providers import to_otel_span_kind +from litellm.integrations.otel.plumbing.providers import span_limits, to_otel_span_kind # Roles emit() knows how to name and emit. PROXY_REQUEST and the management # routes are SERVER spans owned by the mounted FastAPI instrumentor, so they @@ -88,9 +87,7 @@ def error_attributes(error: SpanError) -> Mapping[str, AttrValue]: def span_attribute_limit(span: Span) -> int | None: """The attribute count limit ``span`` was built with, ``None`` when unbounded.""" - if not isinstance(span, SdkSpan): - return SpanLimits().max_span_attributes - return span._limits.max_span_attributes # pyright: ignore[reportPrivateUsage] # SDK has no public getter + return span_limits(span).max_span_attributes def _carried_keys( diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 41811f836c7..dfb03211927 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -66,6 +66,7 @@ from litellm.integrations.otel.plumbing.context import ( resolve_request_span_context, resolve_service_span_context, set_request_baggage, + set_request_metadata, set_request_root_span, ) from litellm.integrations.otel.plumbing.events import GenAIEventRecorder @@ -748,6 +749,7 @@ class OpenTelemetryV2(CustomLogger): """ try: identity: Final = RequestIdentity.from_user_api_key_auth(user_api_key_dict, request_metadata) + set_request_metadata(identity.metadata) bag: Final = promoted_baggage( identity, model, diff --git a/litellm/integrations/otel/model/baggage.py b/litellm/integrations/otel/model/baggage.py index 606fd79f218..5832a56b076 100644 --- a/litellm/integrations/otel/model/baggage.py +++ b/litellm/integrations/otel/model/baggage.py @@ -18,7 +18,11 @@ from collections.abc import Callable, Mapping from types import MappingProxyType from typing import Final -from litellm.integrations.otel.model.metadata import RequestIdentity, allowlisted_metadata +from litellm.integrations.otel.model.metadata import ( + REQUESTER_METADATA_PATH, + RequestIdentity, + allowlisted_metadata, +) from litellm.integrations.otel.model.semconv import GenAI, LiteLLM # Attribute key -> value extractor over (identity, request_model, @@ -65,6 +69,58 @@ DEFAULT_BAGGAGE_METADATA_KEYS: Final[tuple[str, ...]] = ( # explicitly allowlisted via ``config.baggage_team_metadata_keys``. DEFAULT_BAGGAGE_TEAM_METADATA_KEYS: Final[tuple[str, ...]] = () +# A team or key OTel v2 destination can add caller metadata keys on top of the +# global ``baggage_metadata_keys``, for its own destination only. Each key names +# a ``requester_metadata.`` path, so only what the caller sent can be read, +# and a key whose exported name is a proxy-owned identity field is refused. The +# proxy copies the request headers into ``requester_metadata.headers``, credentials +# masked but anything a gateway in front of it added left as sent, so exporting a +# header stays an operator decision made in the global list. +MAX_DESTINATION_METADATA_KEYS: Final = 20 +_RESERVED_METADATA_PREFIX: Final = "user_api_key_" +_REQUEST_HEADERS_KEY: Final = "headers" + + +def destination_metadata_keys_error(value: str) -> str | None: + """Why ``value`` is not a usable destination ``baggage_metadata_keys`` list, or ``None``.""" + keys: Final = _listed_keys(value) + named: Final = tuple((key, key.removeprefix(REQUESTER_METADATA_PATH)) for key in keys) + if any(name == key or not name for key, name in named): + return f"Invalid baggage_metadata_keys {value!r}: use a comma-separated list of {REQUESTER_METADATA_PATH} entries" + reserved: Final = tuple(key for key, name in named if _is_reserved_metadata_name(name)) + if reserved: + return ( + f"Invalid baggage_metadata_keys {value!r}: {', '.join(reserved)} would overwrite a proxy identity attribute" + ) + headers: Final = tuple(key for key, name in named if _is_request_header(name)) + if headers: + return ( + f"Invalid baggage_metadata_keys {value!r}: {', '.join(headers)} would export request headers, " + "which only the proxy-wide LITELLM_OTEL_BAGGAGE_METADATA_KEYS can do" + ) + if len(frozenset(keys)) > MAX_DESTINATION_METADATA_KEYS: + return f"Invalid baggage_metadata_keys {value!r}: at most {MAX_DESTINATION_METADATA_KEYS} keys" + return None + + +def parse_destination_metadata_keys(value: object) -> tuple[str, ...] | None: + """A destination's ``baggage_metadata_keys`` as a key tuple, ``None`` when unset or invalid.""" + if not isinstance(value, str) or destination_metadata_keys_error(value) is not None: + return None + return tuple(dict.fromkeys(_listed_keys(value))) + + +def _listed_keys(value: str) -> tuple[str, ...]: + return tuple(key.strip() for key in value.split(",")) + + +def _is_reserved_metadata_name(name: str) -> bool: + return name in DEFAULT_BAGGAGE_METADATA_KEYS or name.startswith(_RESERVED_METADATA_PREFIX) + + +def _is_request_header(name: str) -> bool: + return name.partition(".")[0].strip().lower() == _REQUEST_HEADERS_KEY + def promoted_baggage( identity: RequestIdentity, diff --git a/litellm/integrations/otel/model/destination.py b/litellm/integrations/otel/model/destination.py index e760ba3600b..6af28f7c2a7 100644 --- a/litellm/integrations/otel/model/destination.py +++ b/litellm/integrations/otel/model/destination.py @@ -61,6 +61,13 @@ class OtelDestination(LiteLLMBaseModel): default=None, description="An explicit set replaces the global database-span exclusions; ``None`` follows the global set.", ) + metadata_keys: tuple[str, ...] = Field( + default=(), + description=( + "``requester_metadata.`` paths exported as ``litellm.metadata.`` on this destination's " + "spans only, on top of the global ``baggage_metadata_keys``." + ), + ) def captures_content(self, default: str) -> bool: setting: Final = self.capture_message_content @@ -79,8 +86,8 @@ class OtelDestination(LiteLLMBaseModel): def cache_key(self) -> tuple[str, tuple[tuple[str, str], ...], tuple[tuple[str, str], ...], str | None]: """Identity for processor reuse, so one destination means one exporter. - ``span_scope``, the sampling rates, ``capture_message_content`` and - ``excluded_db_systems`` are left out on purpose: they decide which spans reach + ``span_scope``, the sampling rates, ``capture_message_content``, + ``excluded_db_systems`` and ``metadata_keys`` are left out on purpose: they decide which spans reach the processor and what they carry, not how the processor exports them, so two views of the same account share one exporter. """ diff --git a/litellm/integrations/otel/plumbing/context.py b/litellm/integrations/otel/plumbing/context.py index b328b281dde..03d53965360 100644 --- a/litellm/integrations/otel/plumbing/context.py +++ b/litellm/integrations/otel/plumbing/context.py @@ -4,6 +4,7 @@ import os from collections.abc import Generator, Mapping from contextlib import contextmanager from contextvars import ContextVar, Token +from types import MappingProxyType from typing import TYPE_CHECKING, Final from opentelemetry import baggage @@ -478,6 +479,24 @@ def request_destinations() -> 'tuple["OtelDestination", ...]': return _request_destinations.get() +# The request's flattened metadata as identity seeding last read it, kept beside the +# destinations so the fan-out can export the extra ``metadata_keys`` a destination asked +# for onto that destination's spans alone, never into the Baggage every exporter reads. +_request_metadata: Final["ContextVar[Mapping[str, str]]"] = ContextVar( + "litellm_otel_request_metadata", default=MappingProxyType({}) +) + + +def set_request_metadata(metadata: Mapping[str, str]) -> None: + """Record the flattened metadata of the request on this context.""" + _request_metadata.set(metadata) + + +def request_metadata() -> Mapping[str, str]: + """The flattened metadata seeded for this request, empty before seeding or outside one.""" + return _request_metadata.get() + + #: ``litellm_settings: otel_tenant_destination_mode`` and its env equivalent. ADDITIVE_DESTINATION_MODE: Final = "additive" OTEL_TENANT_DESTINATION_MODE_ENV: Final = "LITELLM_OTEL_TENANT_DESTINATION_MODE" diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index b5a5b85fc11..0941c065e74 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -24,7 +24,7 @@ from opentelemetry.sdk._logs.export import ( ) from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import Event, ReadableSpan, SpanProcessor, TracerProvider +from opentelemetry.sdk.trace import Event, ReadableSpan, SpanLimits, SpanProcessor, TracerProvider from opentelemetry.sdk.trace import Span as SDKSpan from opentelemetry.sdk.trace.export import ( BatchSpanProcessor, @@ -49,6 +49,7 @@ from litellm.integrations.otel.mappers.langfuse import ( LANGFUSE_TRACE_NAME, LangfuseMapper, ) +from litellm.integrations.otel.model.baggage import promoted_metadata from litellm.integrations.otel.model.config import ( CaptureMessageContent, ExporterOwner, @@ -71,6 +72,7 @@ from litellm.integrations.otel.model.semconv import ( from litellm.integrations.otel.model.spans import LiteLLMSpanKind from litellm.integrations.otel.plumbing.context import ( request_destinations, + request_metadata, request_traces_excluded, suppressed_backends, ) @@ -469,9 +471,13 @@ def without_message_content(span: ReadableSpan) -> ReadableSpan: class _CountedSpanView(_SpanView): """A ``_SpanView`` of ``inner`` with other attributes that reports the dropped counts ``inner`` reports.""" - def __init__(self, inner: ReadableSpan, attributes: Attributes) -> None: + def __init__(self, inner: ReadableSpan, attributes: Attributes, dropped_attributes: int = 0) -> None: super().__init__(inner, inner.resource, attributes, inner.events, inner.status, parent=inner.parent) - self._dropped_counts: Final = (inner.dropped_attributes, inner.dropped_events, inner.dropped_links) + self._dropped_counts: Final = ( + inner.dropped_attributes + dropped_attributes, + inner.dropped_events, + inner.dropped_links, + ) @property def dropped_attributes(self) -> int: @@ -486,6 +492,60 @@ class _CountedSpanView(_SpanView): return self._dropped_counts[2] +def span_limits(span: object) -> SpanLimits: + """The limits ``span`` was built with; the env-configured defaults for a span the SDK did not build.""" + if not isinstance(span, SDKSpan): + return SpanLimits() + return span._limits # pyright: ignore[reportPrivateUsage] # the SDK has no public accessor for a span's limits + + +class _PendingMetadataSpan(_CountedSpanView): + """``inner`` as it ended, carrying the destination metadata added once the destination's view is built.""" + + def __init__(self, inner: ReadableSpan, metadata: tuple[tuple[str, str], ...], limits: SpanLimits) -> None: + super().__init__(inner, inner.attributes) + self.destination_metadata: Final = metadata + self.limits: Final = limits + + +def with_destination_metadata( + span: ReadableSpan, metadata_keys: tuple[str, ...], metadata: Mapping[str, str], limits: SpanLimits +) -> ReadableSpan: + """``span`` carrying the ``litellm.metadata.*`` values a destination's own ``metadata_keys`` select. + + Bound when the span ends, so a tree held for sampling keeps its own request's values, along + with the span limits it ended under. :func:`with_pending_metadata` adds them once the + destination's view is built. + """ + if not metadata_keys: + return span + promoted: Final = tuple(promoted_metadata(metadata, metadata_keys).items()) + if not promoted: + return span + return _PendingMetadataSpan(span, promoted, limits) + + +def with_pending_metadata(ended: ReadableSpan, exported: ReadableSpan) -> ReadableSpan: + """``exported``, the destination's view of ``ended``, plus the metadata ``ended`` carries. + + Counted against what the destination exports rather than the span as it ended, since the view + can drop prompt content and backend-specific attributes. An exported attribute always wins, so a + destination key never replaces a value the global allowlist or the proxy's identity wrote. + Values are cut to the length limit, and keys past the count limit are dropped and counted. + """ + if not isinstance(ended, _PendingMetadataSpan): + return exported + attributes: Final = exported.attributes or _NO_ATTRIBUTES + extra: Final = tuple((key, value) for key, value in ended.destination_metadata if key not in attributes) + max_attributes: Final = ended.limits.max_span_attributes + max_length: Final = ended.limits.max_span_attribute_length + room: Final = len(extra) if max_attributes is None else max(0, max_attributes - len(attributes)) + kept: Final = {key: value if max_length is None else value[:max_length] for key, value in extra[:room]} + return _CountedSpanView( + exported, MappingProxyType({**attributes, **kept}), dropped_attributes=len(extra) - len(kept) + ) + + def _is_litellm_span(span: ReadableSpan) -> bool: """Whether LiteLLM's own tracer wrote ``span``, rather than application or instrumentation code.""" scope: Final = span.instrumentation_scope @@ -761,9 +821,11 @@ class TenantFanOutSpanProcessor(SpanProcessor): self._span_roots: weakref.WeakKeyDictionary[SDKSpan, int] = weakref.WeakKeyDictionary() self._verdicts: OrderedDict[_VerdictKey, bool] = OrderedDict() # mutable-ok: bounded LRU self._drain: Final = drain_pool if drain_pool is not None else _DrainPool(capacity=pending_drains) + self._span_limits = SpanLimits() def on_start(self, span: SDKSpan, parent_context: Context | None = None) -> None: context: Final = span.context + self._span_limits = span_limits(span) with self._lock: request: Final = self._request_of(span, trace.get_current_span(parent_context)) if context is not None: @@ -782,8 +844,11 @@ class TenantFanOutSpanProcessor(SpanProcessor): if span.context is not None: self._roots.pop(span.context.span_id, None) failed: Final = span.status.status_code is StatusCode.ERROR - for destination in request_destinations(): - if ( + metadata: Final = request_metadata() + deliveries: Final = tuple( + (destination, with_destination_metadata(span, destination.metadata_keys, metadata, self._span_limits)) + for destination in request_destinations() + if not ( self._operator_already_writes(span, destination, suppressed) or not _in_scope(span, destination.span_scope) or _is_excluded_database_span( @@ -792,12 +857,13 @@ class TenantFanOutSpanProcessor(SpanProcessor): if destination.excluded_db_systems is None else destination.excluded_db_systems, ) - ): - continue + ) + ) + for destination, view in deliveries: if not _is_sampled(destination): - self._forward(span, destination) + self._forward(view, destination) continue - for held_span, held_destination in self._route(request, span, destination, failed): + for held_span, held_destination in self._route(request, view, destination, failed): self._forward(held_span, held_destination) for held_span, held_destination in self._settle(request, span, failed): self._forward(held_span, held_destination) @@ -834,12 +900,11 @@ class TenantFanOutSpanProcessor(SpanProcessor): if processor is None: return try: - processor.on_end( - _scoped( - _for_destination(span, destination, self._default_capture_for(destination), self._langfuse_mapped), - destination.span_scope, - ) + exported: Final = _scoped( + _for_destination(span, destination, self._default_capture_for(destination), self._langfuse_mapped), + destination.span_scope, ) + processor.on_end(with_pending_metadata(span, exported)) except Exception as exc: # noqa: BLE001 # one destination's failure must not cost the others their span verbose_logger.debug("OTel V2 fan-out: forwarding to %s failed: %s", destination.endpoint, exc) finally: diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py index 984b385a647..0bd40824066 100644 --- a/litellm/integrations/otel/presets/destinations.py +++ b/litellm/integrations/otel/presets/destinations.py @@ -208,6 +208,7 @@ def destination_for( service_name: str | None = None, capture_message_content: str | None = None, excluded_db_systems: frozenset[str] | None = None, + metadata_keys: tuple[str, ...] | None = None, ) -> OtelDestination | None: """The destination ``params`` names for ``callback_name``, or ``None``. @@ -240,6 +241,7 @@ def destination_for( span_scope=_span_scope(callback_name, params), capture_message_content=capture_message_content, excluded_db_systems=excluded_db_systems, + metadata_keys=metadata_keys or (), success_sampling_rate=success_sampling_rate, error_sampling_rate=error_sampling_rate, ) diff --git a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py index c3769686e45..cdb009fcf77 100644 --- a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py +++ b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py @@ -1,12 +1,16 @@ import re -from collections.abc import Generator, Iterator, Mapping +from collections.abc import Callable, Generator, Iterator, Mapping from contextlib import contextmanager from contextvars import ContextVar +from types import MappingProxyType from typing import Any, Final from litellm.types.utils import ( ARIZE_OTLP_PROTOCOLS, + BAGGAGE_METADATA_KEYS_VAR, CAPTURE_MESSAGE_CONTENT_VALUES, + CAPTURE_MESSAGE_CONTENT_VAR, + EXCLUDED_SERVICES_VAR, OTEL_SPAN_SCOPES, TRUSTED_CALLBACK_VARS_FIELD, StandardCallbackDynamicParams, @@ -99,6 +103,14 @@ def validate_capture_message_content_value(value: str) -> None: ) +def validate_baggage_metadata_keys_value(value: str) -> None: + from litellm.integrations.otel.model.baggage import destination_metadata_keys_error + + error: Final = destination_metadata_keys_error(value) + if error is not None: + raise ValueError(error) + + def validate_excluded_services_value(value: str) -> None: from litellm.integrations.otel.model.config import invalid_excluded_service_names @@ -111,6 +123,15 @@ def validate_excluded_services_value(value: str) -> None: ) +OTEL_DESTINATION_VALIDATORS: Final[Mapping[str, Callable[[str], None]]] = MappingProxyType( + { + CAPTURE_MESSAGE_CONTENT_VAR: validate_capture_message_content_value, + EXCLUDED_SERVICES_VAR: validate_excluded_services_value, + BAGGAGE_METADATA_KEYS_VAR: validate_baggage_metadata_keys_value, + } +) + + # Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict _supported_callback_params: Final[tuple[str, ...]] = ( "langfuse_public_key", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 0d40faf55fb..80819542d68 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -31,9 +31,8 @@ from typing_extensions import ( from litellm._uuid import uuid from litellm.constants import DEFAULT_STAGGER_WINDOW_SECONDS, MCP_STDIO_ALLOWED_COMMANDS from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( + OTEL_DESTINATION_VALIDATORS, validate_arize_otlp_protocol_value, - validate_capture_message_content_value, - validate_excluded_services_value, validate_langfuse_environment_value, validate_langfuse_span_scope_value, validate_no_callback_env_reference, @@ -96,8 +95,7 @@ from litellm.types.router import AllowedModelRegion, RouterErrors, UpdateRouterC from litellm.types.router_weights import validate_router_settings_dict from litellm.types.secret_managers.main import KeyManagementSystem from litellm.types.utils import ( - CAPTURE_MESSAGE_CONTENT_VAR, - EXCLUDED_SERVICES_VAR, + OTEL_DESTINATION_VARS, AzureSpillover, CallTypes, CostBreakdown, @@ -2136,9 +2134,7 @@ class BlockModelRequest(LiteLLMPydanticObjectBase): model_id: str # required -CALLBACK_VAR_NAMES: Final = frozenset( - {*StandardCallbackDynamicParams.__annotations__, CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR} -) +CALLBACK_VAR_NAMES: Final = frozenset({*StandardCallbackDynamicParams.__annotations__, *OTEL_DESTINATION_VARS}) class AddTeamCallback(LiteLLMPydanticObjectBase): @@ -2153,7 +2149,7 @@ class AddTeamCallback(LiteLLMPydanticObjectBase): for key, value in callback_vars.items(): if key not in CALLBACK_VAR_NAMES: raise ValueError(f"Invalid callback variable: {key}. Must be one of {set(CALLBACK_VAR_NAMES)}") - if key in (CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR) and not isinstance(value, str): + if key in OTEL_DESTINATION_VARS and not isinstance(value, str): raise ValueError(f"{key} must be a string, got {type(value).__name__}") callback_vars[key] = str(value) validate_no_callback_env_reference(key, callback_vars[key], source="key/team callback metadata") @@ -2163,10 +2159,8 @@ class AddTeamCallback(LiteLLMPydanticObjectBase): validate_langfuse_span_scope_value(callback_vars[key]) if key == "arize_otlp_protocol": validate_arize_otlp_protocol_value(callback_vars[key]) - if key == CAPTURE_MESSAGE_CONTENT_VAR: - validate_capture_message_content_value(callback_vars[key]) - if key == EXCLUDED_SERVICES_VAR: - validate_excluded_services_value(callback_vars[key]) + if (destination_validator := OTEL_DESTINATION_VALIDATORS.get(key)) is not None: + destination_validator(callback_vars[key]) return values diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py index a486d6665fc..a1c2ff8eb52 100644 --- a/litellm/proxy/common_utils/callback_config_validation.py +++ b/litellm/proxy/common_utils/callback_config_validation.py @@ -14,7 +14,11 @@ from typing import Final, Literal, cast # noqa: TID251 # preserve arbitrary ke from pydantic import TypeAdapter, ValidationError from litellm.litellm_core_utils.credential_accessor import CredentialAccessor -from litellm.types.utils import CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR +from litellm.types.utils import ( + BAGGAGE_METADATA_KEYS_VAR, + EXCLUDED_SERVICES_VAR, + OTEL_DESTINATION_VARS, +) _NEWRELIC_CALLBACK: Final = "newrelic" _NEWRELIC_VAR_PREFIX: Final = "newrelic_" @@ -42,18 +46,12 @@ def callback_config_error( ) if langfuse_error is not None: return langfuse_error - from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( - validate_capture_message_content_value, - validate_excluded_services_value, - ) + from litellm.litellm_core_utils.initialize_dynamic_callback_params import OTEL_DESTINATION_VALIDATORS destination_setting_error: Final = next( ( error - for variable_name, validator in ( - (CAPTURE_MESSAGE_CONTENT_VAR, validate_capture_message_content_value), - (EXCLUDED_SERVICES_VAR, validate_excluded_services_value), - ) + for variable_name, validator in OTEL_DESTINATION_VALIDATORS.items() if ( error := _destination_setting_error( variable_name, callback_name, callback_vars, callback_type, validator @@ -269,11 +267,17 @@ def conflicting_callback_var_error( def _same_callback_var_value(variable_name: str, first: str, second: str) -> bool: - if variable_name != EXCLUDED_SERVICES_VAR: - return first == second - from litellm.integrations.otel.model.config import parse_excluded_services + if variable_name == EXCLUDED_SERVICES_VAR: + from litellm.integrations.otel.model.config import parse_excluded_services - return parse_excluded_services(first) == parse_excluded_services(second) + return parse_excluded_services(first) == parse_excluded_services(second) + if variable_name == BAGGAGE_METADATA_KEYS_VAR: + from litellm.integrations.otel.model.baggage import parse_destination_metadata_keys + + first_keys: Final = parse_destination_metadata_keys(first) + second_keys: Final = parse_destination_metadata_keys(second) + return first_keys is not None and second_keys is not None and frozenset(first_keys) == frozenset(second_keys) + return first == second def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str | None: @@ -331,7 +335,7 @@ def _conflicting_entry_setting_error( return next( ( error - for variable_name in (CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR) + for variable_name in OTEL_DESTINATION_VARS if (error := conflicting_callback_var_error(callback_name, variable_name, callback_vars, earlier)) is not None ), @@ -364,7 +368,7 @@ def _logging_entry_error(entry: object) -> str | None: destination_var_error: Final = next( ( f"{variable_name} must be a string, got {type(callback_vars[variable_name]).__name__}" - for variable_name in (CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR) + for variable_name in OTEL_DESTINATION_VARS if variable_name in callback_vars and not isinstance(callback_vars[variable_name], str) ), None, diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index ff0883b6958..b1eb8f7d8ae 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -332,8 +332,10 @@ from litellm.types.router import BaggageMappedTagsStamp, holds_api_key_secret_re from litellm.types.services import ServiceTypes from litellm.types.utils import ( API_KEY_SECRET_REFERENCE_REJECTION, + BAGGAGE_METADATA_KEYS_VAR, CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR, + OTEL_DESTINATION_VARS, CustomPricingLiteLLMParams, LlmProviders, ProviderSpecificHeader, @@ -342,7 +344,7 @@ from litellm.types.utils import ( SupportedCacheControls, ) -_DESTINATION_ONLY_CALLBACK_VARS: Final[frozenset[str]] = frozenset({CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR}) +_DESTINATION_ONLY_CALLBACK_VARS: Final[frozenset[str]] = frozenset(OTEL_DESTINATION_VARS) _CALLBACK_CREDENTIAL_KEYS: Final = frozenset(StandardCallbackDynamicParams.__annotations__) | frozenset( {TRUSTED_CALLBACK_VARS_FIELD} @@ -1479,6 +1481,7 @@ def resolve_tenant_otel_destinations( def _tenant_destination( name: str, callbacks: Sequence[AddTeamCallback], service_name: str | None ) -> "OtelDestination | None": + from litellm.integrations.otel.model.baggage import parse_destination_metadata_keys from litellm.integrations.otel.model.config import parse_capture_message_content, parse_excluded_services from litellm.integrations.otel.presets.destinations import destination_for @@ -1495,6 +1498,7 @@ def _tenant_destination( service_name, parse_capture_message_content(callback_vars.get(CAPTURE_MESSAGE_CONTENT_VAR)), parse_excluded_services(callback_vars.get(EXCLUDED_SERVICES_VAR)), + parse_destination_metadata_keys(callback_vars.get(BAGGAGE_METADATA_KEYS_VAR)), ) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 5b778392664..1d35d34cbcf 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -60,7 +60,7 @@ from litellm.proxy.management_endpoints.team_endpoints import ( # noqa: F401 # ) from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.repositories.team_repository import TeamRepository -from litellm.types.utils import CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR +from litellm.types.utils import OTEL_DESTINATION_VARS router: Final = APIRouter() @@ -304,6 +304,7 @@ async def add_team_callbacks( An explicit value overrides the global setting for this destination; omitted follows the proxy's own callback for that backend, else the global setting. "span_only" puts prompt and response content on the destination's spans, "no_content" leaves it out - excluded_services: For OTel v2 callbacks, a comma-separated list of datastore names such as "redis,postgres", or "none" to exclude nothing. An explicit value replaces the global exclusions; omitted follows them + - baggage_metadata_keys: For OTel v2 callbacks, a comma-separated list of request metadata keys such as "requester_metadata.correlation_id", exported as litellm.metadata. on this destination's spans only, on top of the global LITELLM_OTEL_BAGGAGE_METADATA_KEYS. At most 20 keys; names of proxy identity fields (user_api_key_*, requester_ip_address) and request headers (requester_metadata.headers.*) are rejected - gcs_bucket_name: The name of the GCS bucket - gcs_path_service_account: The path to the GCS service account - langsmith_api_key: The API key for the Langsmith callback @@ -376,7 +377,7 @@ async def add_team_callbacks( destination_setting_error: Final = next( ( error - for variable_name in (CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR) + for variable_name in OTEL_DESTINATION_VARS if ( error := conflicting_callback_var_error( data.callback_name, variable_name, data.callback_vars, stored_callbacks diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 81d39f848fb..5990135a5cb 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4029,6 +4029,13 @@ ARIZE_OTLP_PROTOCOLS: Final[frozenset[str]] = frozenset(get_args(ArizeOtlpProtoc CAPTURE_MESSAGE_CONTENT_VAR: Final = "capture_message_content" EXCLUDED_SERVICES_VAR: Final = "excluded_services" +BAGGAGE_METADATA_KEYS_VAR: Final = "baggage_metadata_keys" +#: Team and key callback vars that tune that callback's own OTel v2 destination, never sent to the backend. +OTEL_DESTINATION_VARS: Final[tuple[str, ...]] = ( + CAPTURE_MESSAGE_CONTENT_VAR, + EXCLUDED_SERVICES_VAR, + BAGGAGE_METADATA_KEYS_VAR, +) class CaptureMessageContent(str): diff --git a/tests/integration/observability/test_otel_destination_metadata_keys.py b/tests/integration/observability/test_otel_destination_metadata_keys.py new file mode 100644 index 00000000000..33b18fa4c2e --- /dev/null +++ b/tests/integration/observability/test_otel_destination_metadata_keys.py @@ -0,0 +1,262 @@ +"""A team's or key's own OTel v2 destination can list extra request metadata keys in ``baggage_metadata_keys``, +exported as ``litellm.metadata.*`` on that destination's spans only, on top of the operator's global allowlist.""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, Literal + +import httpx +import pytest +from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment +from integration._support.otlp_sink import ( + ConnectSink, + Span, + SpanSinks, + audit_configuration_writer, + owned_connect_sink, + owned_sinks, + recorded_spans, + spans_for_trace, +) +from integration._support.process import owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue + +OPERATION: Final = "gen_ai.operation.name" +SERVER_KIND: Final = 2 +METADATA_PREFIX: Final = "litellm.metadata." +TEAM_KEYS: Final = "requester_metadata.tenant_id, requester_metadata.order.id" + +Backend = Literal["newrelic", "arize"] + + +def _chat(_request: Request) -> Reply: + return Reply( + body=( + b'{"id":"chatcmpl-meta","object":"chat.completion","created":1,"model":"gpt-4o-mini",' + b'"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],' + b'"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}' + ) + ) + + +@dataclass(frozen=True, slots=True) +class Rig: + proxy: Gateway + sinks: SpanSinks + newrelic: ConnectSink + upstream: Wire + + def sink(self, backend: Backend) -> str: + return self.newrelic.control_url if backend == "newrelic" else self.sinks.arize + + def destination(self, backend: Backend, extra: Mapping[str, str]) -> dict[str, JsonValue]: + tag: Final = uuid.uuid4().hex + credentials: Final[Mapping[str, str]] = ( + {"newrelic_api_key": f"nr-{tag}", "newrelic_region": "us"} + if backend == "newrelic" + else {"arize_space_key": f"space-{tag}", "arize_api_key": f"arize-{tag}"} + ) + return { + "callback_name": backend, + "callback_type": "success_and_failure", + "callback_vars": {**credentials, **extra}, + } + + def team_key(self, scenario: Scenario, backend: Backend, extra: Mapping[str, str]) -> str: + team: Final = scenario.team() + attached: Final = self.proxy.request("POST", f"/team/{team}/callback", self.destination(backend, extra)) + assert attached.status_code == 200, attached.text + return scenario.key(team_id=team) + + def send(self, key: str, model: str, metadata: Mapping[str, JsonValue]) -> str: + trace_id: Final = uuid.uuid4().hex + response: Final = self.proxy.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": "hi"}], "metadata": dict(metadata)}, + key=key, + headers={"traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01"}, + ) + assert response.status_code == 200, response.text + return trace_id + + def request_spans(self, url: str, since: int, trace_id: str) -> tuple[Span, ...]: + return _server_and_model_spans( + eventually( + lambda: spans_for_trace(recorded_spans(url, since)[1], trace_id), + lambda found: len(_server_and_model_spans(found)) >= 2, + seconds=40, + ) + ) + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + directory: Final = tmp_path_factory.mktemp("destination-metadata-keys") + with ( + wire_server(_chat) as upstream, + owned_sinks(directory / "sinks") as sinks, + owned_connect_sink(directory / "newrelic") as newrelic, + gateway_from_environment() as gateway, + owned_proxy_process( + gateway, + directory, + { + "LITELLM_OTEL_V2": "1", + "OTEL_BSP_SCHEDULE_DELAY": "300", + "LITELLM_OTEL_BAGGAGE_METADATA_KEYS": "requester_metadata.correlation_id", + "ARIZE_HTTP_ENDPOINT": sinks.arize + "/v1/traces", + "HTTPS_PROXY": newrelic.proxy_url, + "NO_PROXY": "127.0.0.1,localhost", + "REQUESTS_CA_BUNDLE": newrelic.ca_pem, + }, + config=audit_configuration_writer(sinks)(directory, {}), + remove_environment=("OTEL_ENDPOINT", "OTEL_EXPORTER", "OTEL_EXPORTER_OTLP_ENDPOINT"), + workers=2, + ) as owned, + ): + yield Rig(owned.gateway, sinks, newrelic, upstream) + + +def _server_and_model_spans(spans: tuple[Span, ...]) -> tuple[Span, ...]: + return tuple(span for span in spans if span["kind"] == SERVER_KIND or OPERATION in span["attributes"]) + + +def _exported_metadata(spans: tuple[Span, ...]) -> list[dict[str, JsonValue]]: + return [ + {name: value for name, value in span["attributes"].items() if name.startswith(METADATA_PREFIX)} + for span in spans + ] + + +def _request_metadata(marker: str) -> dict[str, JsonValue]: + return { + "correlation_id": f"corr-{marker}", + "tenant_id": f"tenant-{marker}", + "order": {"id": f"order-{marker}"}, + "unlisted": f"unlisted-{marker}", + } + + +@pytest.mark.parametrize("level", ["team", "key"]) +@pytest.mark.parametrize("backend", ["newrelic", "arize"]) +def test_destination_keys_reach_that_destination_and_not_the_operator( + rig: Rig, backend: Backend, level: Literal["team", "key"] +) -> None: + with rig.proxy.scenario() as scenario: + model: Final = scenario.model(api_base=rig.upstream.url + "/v1") + keys: Final = {"baggage_metadata_keys": TEAM_KEYS} + key: Final = ( + rig.team_key(scenario, backend, keys) + if level == "team" + else scenario.key(metadata={"logging": [rig.destination(backend, keys)]}) + ) + tenant_since: Final = recorded_spans(rig.sink(backend))[0] + operator_since: Final = recorded_spans(rig.sinks.operator)[0] + marker: Final = uuid.uuid4().hex + trace_id: Final = rig.send(key, model, _request_metadata(marker)) + tenant: Final = rig.request_spans(rig.sink(backend), tenant_since, trace_id) + operator: Final = rig.request_spans(rig.sinks.operator, operator_since, trace_id) + + assert sorted(span["kind"] == SERVER_KIND for span in tenant) == [False, True], [span["name"] for span in tenant] + assert _exported_metadata(tenant) == [ + { + "litellm.metadata.correlation_id": f"corr-{marker}", + "litellm.metadata.tenant_id": f"tenant-{marker}", + "litellm.metadata.order.id": f"order-{marker}", + } + ] * len(tenant), [(span["name"], sorted(span["attributes"])) for span in tenant] + assert _exported_metadata(operator) == [{"litellm.metadata.correlation_id": f"corr-{marker}"}] * len(operator), [ + (span["name"], sorted(span["attributes"])) for span in operator + ] + + +@pytest.mark.parametrize("backend", ["newrelic", "arize"]) +def test_a_destination_without_keys_and_a_request_body_cannot_add_them(rig: Rig, backend: Backend) -> None: + with rig.proxy.scenario() as scenario: + model: Final = scenario.model(api_base=rig.upstream.url + "/v1") + listing: Final = rig.team_key(scenario, backend, {"baggage_metadata_keys": TEAM_KEYS}) + sibling: Final = rig.team_key(scenario, backend, {}) + since: Final = recorded_spans(rig.sink(backend))[0] + marker: Final = uuid.uuid4().hex + listed_trace: Final = rig.send(listing, model, _request_metadata(f"listed-{marker}")) + sibling_trace: Final = rig.send( + sibling, + model, + { + **_request_metadata(marker), + "baggage_metadata_keys": TEAM_KEYS, + "callback_vars": {"baggage_metadata_keys": TEAM_KEYS}, + }, + ) + listed: Final = rig.request_spans(rig.sink(backend), since, listed_trace) + unlisted: Final = rig.request_spans(rig.sink(backend), since, sibling_trace) + + assert {"litellm.metadata.tenant_id": f"tenant-listed-{marker}"}.items() <= _exported_metadata(listed)[0].items() + assert _exported_metadata(unlisted) == [{"litellm.metadata.correlation_id": f"corr-{marker}"}] * len(unlisted), [ + (span["name"], sorted(span["attributes"])) for span in unlisted + ] + + +def _attach(rig: Rig, team: str, keys: str, *, key: str | None = None) -> httpx.Response: + return rig.proxy.request( + "POST", + f"/team/{team}/callback", + rig.destination("newrelic", {"baggage_metadata_keys": keys}), + key=key, + ) + + +def test_a_team_admin_can_set_keys_and_a_plain_member_cannot(rig: Rig) -> None: + with rig.proxy.scenario() as scenario: + team: Final = scenario.team() + admin_key: Final = scenario.key(user_id=scenario.member(team, role="admin"), team_id=team) + member_key: Final = scenario.key(user_id=scenario.member(team), team_id=team) + + refused: Final = _attach(rig, team, "requester_metadata.tenant_id", key=member_key) + accepted: Final = _attach(rig, team, "requester_metadata.tenant_id", key=admin_key) + + assert refused.status_code == 403, refused.text + assert accepted.status_code == 200, accepted.text + + +@pytest.mark.parametrize( + ("keys", "reason"), + [ + ("requester_metadata.user_api_key_user_id", "proxy identity attribute"), + ("tenant_id", "requester_metadata."), + ("requester_metadata.headers.x-internal-token", "would export request headers"), + (",".join(f"requester_metadata.k{index}" for index in range(21)), "at most 20 keys"), + ], +) +def test_unusable_keys_are_refused_on_team_and_key_callbacks(rig: Rig, keys: str, reason: str) -> None: + with rig.proxy.scenario() as scenario: + team_response: Final = _attach(rig, scenario.team(), keys) + key_response: Final = rig.proxy.request( + "POST", + "/key/generate", + {"metadata": {"logging": [rig.destination("newrelic", {"baggage_metadata_keys": keys})]}}, + ) + + assert [(response.status_code, reason in response.text) for response in (team_response, key_response)] == [ + (422, True), + (400, True), + ], [team_response.text, key_response.text] + + +def test_unset_keys_leave_the_destination_on_the_global_allowlist(rig: Rig) -> None: + with rig.proxy.scenario() as scenario: + model: Final = scenario.model(api_base=rig.upstream.url + "/v1") + key: Final = rig.team_key(scenario, "newrelic", MappingProxyType({})) + since: Final = recorded_spans(rig.newrelic.control_url)[0] + marker: Final = uuid.uuid4().hex + spans: Final = rig.request_spans( + rig.newrelic.control_url, since, rig.send(key, model, _request_metadata(marker)) + ) + + assert _exported_metadata(spans) == [{"litellm.metadata.correlation_id": f"corr-{marker}"}] * len(spans) diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py index 6dd9ff35026..256b9e5e480 100644 --- a/tests/unit/integrations/otel/test_otel_v2_destinations.py +++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py @@ -61,6 +61,7 @@ from litellm.integrations.otel.plumbing.context import ( destination_backends, request_destinations, set_request_destinations, + set_request_metadata, ) from litellm.integrations.otel.plumbing.providers import ( TenantFanOutSpanProcessor, @@ -589,6 +590,132 @@ class TestFanOut: "chat gpt-4", } + def test_destination_metadata_keys_reach_only_that_destination(self): + team, sibling, operator = InMemorySpanExporter(), InMemorySpanExporter(), InMemorySpanExporter() + exporters: Final = {"http://team.local": team, "http://sibling.local": sibling} + provider: Final = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(operator)) + provider.add_span_processor( + TenantFanOutSpanProcessor( + processor_factory=lambda destination: SimpleSpanProcessor(exporters[destination.endpoint]) + ) + ) + + def run(): + set_request_destinations( + ( + OtelDestination( + endpoint="http://team.local", + metadata_keys=("requester_metadata.tenant_id", "requester_metadata.order.id"), + ), + OtelDestination(endpoint="http://sibling.local"), + ) + ) + set_request_metadata( + MappingProxyType( + { + "requester_metadata.tenant_id": "tenant-1", + "requester_metadata.order.id": "order-1", + "requester_metadata.unlisted": "nope", + } + ) + ) + emit(provider) + + in_fresh_context(run) + + team_attributes: Final = {key: value for key, value in (team.get_finished_spans()[0].attributes or {}).items()} + assert {key: value for key, value in team_attributes.items() if key.startswith("litellm.metadata.")} == { + "litellm.metadata.tenant_id": "tenant-1", + "litellm.metadata.order.id": "order-1", + } + assert [ + key + for span in (*sibling.get_finished_spans(), *operator.get_finished_spans()) + for key in span.attributes or {} + if key.startswith("litellm.metadata.") + ] == [] + + @staticmethod + def _team_export( + metadata: Mapping[str, str], + attributes: Mapping[str, str] = MappingProxyType({}), + limits: SpanLimits | None = None, + ) -> ReadableSpan: + team: Final = InMemorySpanExporter() + provider: Final = TracerProvider() if limits is None else TracerProvider(span_limits=limits) + provider.add_span_processor(TenantFanOutSpanProcessor(processor_factory=lambda _: SimpleSpanProcessor(team))) + + def run(): + set_request_destinations((OtelDestination(endpoint="http://team.local", metadata_keys=tuple(metadata)),)) + set_request_metadata(MappingProxyType(dict(metadata))) + with get_tracer(provider, "litellm").start_as_current_span("chat gpt-4") as span: + span.set_attributes(dict(attributes)) + + in_fresh_context(run) + return team.get_finished_spans()[0] + + def test_destination_metadata_key_never_replaces_an_attribute_the_span_carries(self): + exported: Final = self._team_export( + {"requester_metadata.tenant_id": "from-caller"}, + {"litellm.metadata.tenant_id": "from-operator-allowlist"}, + ) + + assert (exported.attributes or {})["litellm.metadata.tenant_id"] == "from-operator-allowlist" + + def test_destination_metadata_obeys_the_span_value_length_limit(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT", "8") + exported: Final = self._team_export({"requester_metadata.tenant_id": "x" * 1_048_576}) + + assert (exported.attributes or {})["litellm.metadata.tenant_id"] == "x" * 8 + + def test_destination_metadata_obeys_a_providers_own_span_limits(self): + exported: Final = self._team_export( + {"requester_metadata.a": "abcdefgh", "requester_metadata.b": "2", "requester_metadata.c": "3"}, + {"litellm.team.id": "team-a", "litellm.metadata.correlation_id": "corr"}, + SpanLimits(max_span_attributes=3, max_span_attribute_length=4), + ) + + assert dict(exported.attributes or {}) == { + "litellm.team.id": "team", + "litellm.metadata.correlation_id": "corr", + "litellm.metadata.a": "abcd", + } + assert exported.dropped_attributes == 2 + + def test_destination_metadata_counts_against_the_view_after_content_is_redacted(self): + prompt: Final = {f"llm.input_messages.{index}.message.content": "hello" for index in range(4)} + exported: Final = self._team_export( + {"requester_metadata.correlation_id": "corr-1"}, prompt, SpanLimits(max_span_attributes=4) + ) + + assert dict(exported.attributes or {}) == {"litellm.metadata.correlation_id": "corr-1"} + assert exported.dropped_attributes == 0 + + def test_sampled_destination_keeps_the_metadata_of_the_request_that_ended_its_spans(self): + team: Final = InMemorySpanExporter() + provider: Final = TracerProvider() + provider.add_span_processor(TenantFanOutSpanProcessor(processor_factory=lambda _: SimpleSpanProcessor(team))) + destination: Final = OtelDestination( + endpoint="http://team.local", + metadata_keys=("requester_metadata.tenant_id",), + success_sampling_rate=1.0, + error_sampling_rate=1.0, + ) + + def run(tenant: str): + set_request_destinations((destination,)) + set_request_metadata(MappingProxyType({"requester_metadata.tenant_id": tenant})) + emit(provider) + + in_fresh_context(run, "tenant-a") + in_fresh_context(run, "tenant-b") + + assert sorted(span.attributes["litellm.metadata.tenant_id"] for span in team.get_finished_spans()) == [ + "tenant-a", + "tenant-b", + ] + def test_team_exclusions_replace_empty_global_and_operator_keeps_all_database_spans(self): team, inherited, operator = InMemorySpanExporter(), InMemorySpanExporter(), InMemorySpanExporter() exporters: Final = {"http://team.local": team, "http://inherited.local": inherited} diff --git a/tests/unit/proxy/common_utils/test_callback_config_validation.py b/tests/unit/proxy/common_utils/test_callback_config_validation.py index ac1fe2d785e..19cac032c8a 100644 --- a/tests/unit/proxy/common_utils/test_callback_config_validation.py +++ b/tests/unit/proxy/common_utils/test_callback_config_validation.py @@ -345,7 +345,7 @@ def test_an_unsupported_capture_message_content_is_rejected_on_key_logging_metad @pytest.mark.usefixtures("otel_v2_on") -@pytest.mark.parametrize("callback_var", ["capture_message_content", "excluded_services"]) +@pytest.mark.parametrize("callback_var", ["capture_message_content", "excluded_services", "baggage_metadata_keys"]) def test_key_logging_metadata_rejects_non_string_destination_callback_vars(callback_var: str) -> None: error: Final = logging_metadata_config_error( { @@ -575,3 +575,72 @@ def test_a_non_string_credential_name_is_not_a_reference(name: object) -> None: credential_reference_rejection({"logging": [entry]}, None, is_proxy_admin=False, is_defined=lambda _: False) is None ) + + +@pytest.mark.usefixtures("otel_v2_on") +@pytest.mark.parametrize("callback_name", ["langfuse_otel", "arize", "weave_otel", "newrelic"]) +def test_baggage_metadata_keys_is_accepted_on_every_otel_v2_destination(callback_name: str) -> None: + assert ( + callback_config_error( + callback_name, {"baggage_metadata_keys": "requester_metadata.tenant_id, requester_metadata.order.id"} + ) + is None + ) + + +@pytest.mark.usefixtures("otel_v2_on") +@pytest.mark.parametrize("callback_name", ["langfuse", "datadog", "otel", "arize_phoenix", None]) +def test_baggage_metadata_keys_is_rejected_where_it_would_never_take_effect(callback_name: str | None) -> None: + error: Final = callback_config_error(callback_name, {"baggage_metadata_keys": "requester_metadata.tenant_id"}) + assert error is not None and "baggage_metadata_keys" in error and "newrelic" in error + + +@pytest.mark.usefixtures("otel_v2_on") +@pytest.mark.parametrize( + ("value", "reason"), + [ + ("tenant_id", "requester_metadata."), + ("requester_metadata.", "requester_metadata."), + ("requester_metadata.tenant_id,", "requester_metadata."), + ("user_api_key_user_id", "requester_metadata."), + ("requester_metadata.user_api_key_user_id", "proxy identity attribute"), + ("requester_metadata.user_api_key_hash", "proxy identity attribute"), + ("requester_metadata.requester_ip_address", "proxy identity attribute"), + ("requester_metadata.headers.x-internal-token", "would export request headers"), + ("requester_metadata.headers", "would export request headers"), + ("requester_metadata.Headers.authorization", "would export request headers"), + (",".join(f"requester_metadata.k{index}" for index in range(21)), "at most 20 keys"), + ], +) +def test_unusable_baggage_metadata_keys_are_rejected(value: str, reason: str) -> None: + error: Final = callback_config_error("newrelic", {"newrelic_api_key": "nr-key", "baggage_metadata_keys": value}) + assert error is not None and reason in error, error + + +def test_baggage_metadata_keys_is_rejected_while_otel_v2_is_off(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.integrations.otel.model.config import is_otel_v2_enabled + + monkeypatch.delenv("LITELLM_OTEL_V2", raising=False) + is_otel_v2_enabled.cache_clear() + try: + error: Final = callback_config_error("newrelic", {"baggage_metadata_keys": "requester_metadata.tenant_id"}) + finally: + is_otel_v2_enabled.cache_clear() + assert error is not None and "baggage_metadata_keys" in error and "LITELLM_OTEL_V2" in error + + +@pytest.mark.parametrize( + ("stored", "rejected"), + [ + ("requester_metadata.b, requester_metadata.a", False), + ("requester_metadata.a", True), + ], +) +def test_entries_for_one_backend_share_one_baggage_metadata_keys_list(stored: str, rejected: bool) -> None: + error: Final = conflicting_callback_var_error( + "newrelic", + "baggage_metadata_keys", + {"baggage_metadata_keys": "requester_metadata.a,requester_metadata.b"}, + [("newrelic", {"baggage_metadata_keys": stored})], + ) + assert (error is not None) is rejected, error diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f5f75e65c83..6daedc3bfbd 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -18283,6 +18283,7 @@ export interface paths { * An explicit value overrides the global setting for this destination; omitted follows the proxy's own callback for that backend, else the global setting. * "span_only" puts prompt and response content on the destination's spans, "no_content" leaves it out * - excluded_services: For OTel v2 callbacks, a comma-separated list of datastore names such as "redis,postgres", or "none" to exclude nothing. An explicit value replaces the global exclusions; omitted follows them + * - baggage_metadata_keys: For OTel v2 callbacks, a comma-separated list of request metadata keys such as "requester_metadata.correlation_id", exported as litellm.metadata. on this destination's spans only, on top of the global LITELLM_OTEL_BAGGAGE_METADATA_KEYS. At most 20 keys; names of proxy identity fields (user_api_key_*, requester_ip_address) and request headers (requester_metadata.headers.*) are rejected * - gcs_bucket_name: The name of the GCS bucket * - gcs_path_service_account: The path to the GCS service account * - langsmith_api_key: The API key for the Langsmith callback From 89149b4544e056d52b46774bf5723b2ac57c385a Mon Sep 17 00:00:00 2001 From: quietjules <349048734+quietjules@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:11:47 -0800 Subject: [PATCH 86/86] refactor(typing): clear 1141 basedpyright Any errors across 182 files (#50711) * refactor(typing): clear 340 basedpyright Any errors (wave 1) * refactor(typing): clear 497 basedpyright Any errors (wave 2) * refactor(typing): clear 74 basedpyright Any errors (wave 3) * refactor(typing): clear 143 basedpyright Any errors (wave 4) * refactor(typing): clear 106 basedpyright Any errors (wave 5) * fix(typing): keep router fence visibility and py310-safe Self import Bind the Protocol-narrowed routers in pass_through_endpoints and rag ingestion to router-named locals so router_surface_check still sees the aadapter_completion and aocr uses, and import Self from typing_extensions in prisma_client for check_py310_typing_imports. * perf(core_helpers): fast-path dicts and lists in the checked JSON helpers as_str_mapping now tries the concrete dict check before the Mapping ABC check, and iter_items returns lists before the runtime-checkable Protocol check. Both return the same object as before, and roughly halve the overhead CodSpeed flagged on the A2A text-extraction benchmarks. * refactor(realtime): keep the base TypeError for a non-mapping translation expires_after * fix(proxy): restore the require_attributes import the main merge dropped * test: cover the retyped realtime, vector store, agent, stream, video and provider response paths * fix(realtime): name the public request types in non-mapping TypeErrors keyword_arguments wraps expires_after and transcription_session so a non-mapping value raises the same TypeError text as before the typing pass. Tests narrow Greptile's pegasus case with isinstance and freeze its model. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../proxy/hooks/managed_vector_stores.py | 2 +- litellm/assistants/main.py | 51 ++- litellm/batches/batch_utils.py | 9 +- litellm/batches/main.py | 13 +- litellm/caching/caching_handler.py | 28 +- litellm/caching/qdrant_semantic_cache.py | 8 +- litellm/google_genai/adapters/handler.py | 9 +- litellm/google_genai/main.py | 13 +- litellm/harness/endpoint.py | 6 +- litellm/images/main.py | 11 +- .../clickhouse/clickhouse_spend_logger.py | 7 +- .../cloudzero/cz_resource_names.py | 40 ++- litellm/integrations/custom_logger.py | 2 +- .../integrations/dotprompt/prompt_manager.py | 16 +- .../focus/destinations/gcs_destination.py | 11 +- litellm/integrations/galileo.py | 77 ++++- .../gitlab/gitlab_prompt_manager.py | 23 +- litellm/integrations/langfuse/langfuse.py | 7 +- .../opik/opik_payload_builder/extractors.py | 19 +- .../handler.py | 12 +- .../chat_completion_agentic_loop.py | 15 +- litellm/litellm_core_utils/core_helpers.py | 36 ++- .../exception_mapping_utils.py | 34 +- .../json_validation_rule.py | 18 +- litellm/litellm_core_utils/litellm_logging.py | 152 ++++++--- .../litellm_core_utils/llm_cost_calc/utils.py | 2 +- .../convert_dict_to_response.py | 19 +- .../prompt_templates/factory.py | 34 +- .../huggingface_template_handler.py | 8 +- .../litellm_core_utils/realtime_streaming.py | 34 +- litellm/litellm_core_utils/redact_messages.py | 2 +- .../streaming_chunk_builder_utils.py | 20 +- .../litellm_core_utils/streaming_handler.py | 234 ++++++++------ litellm/llms/a2a/common_utils.py | 30 +- .../aiohttp_openai/chat/transformation.py | 15 +- litellm/llms/anthropic/chat/transformation.py | 80 +++-- litellm/llms/anthropic/common_utils.py | 6 +- .../messages/adapters/transformation.py | 22 +- .../anthropic/messages/native/mcp_handler.py | 7 +- .../messages/native/streaming_iterator.py | 9 +- litellm/llms/azure_ai/agents/handler.py | 64 ++-- .../azure_ai/vector_stores/transformation.py | 15 +- litellm/llms/base_llm/base_model_iterator.py | 13 +- litellm/llms/base_llm/chat/transformation.py | 4 +- .../llms/base_llm/videos/transformation.py | 2 +- ...mazon_twelvelabs_pegasus_transformation.py | 16 +- .../amazon_nova_canvas_transformation.py | 23 +- .../guardrail_translation/handler.py | 89 ++++-- .../llms/bedrock/realtime/transformation.py | 25 +- .../bedrock/vector_stores/transformation.py | 17 +- .../codestral/completion/transformation.py | 16 +- litellm/llms/cohere/common_utils.py | 8 +- .../llms/cohere/embed/v1_transformation.py | 22 +- litellm/llms/custom_httpx/llm_http_handler.py | 72 +++-- .../llms/deprecated_providers/aleph_alpha.py | 33 +- litellm/llms/edenai/videos/transformation.py | 2 +- .../llms/fireworks_ai/chat/transformation.py | 7 +- litellm/llms/gigachat/chat/transformation.py | 30 +- .../llms/gigachat/embedding/transformation.py | 42 ++- .../huggingface/embedding/transformation.py | 66 +++- litellm/llms/minimax/videos/transformation.py | 2 +- litellm/llms/nlp_cloud/chat/transformation.py | 14 +- .../audio_transcription/riva_grpc.py | 24 +- litellm/llms/ollama/chat/transformation.py | 23 +- litellm/llms/oobabooga/chat/oobabooga.py | 13 +- .../llms/openai/chat/gpt_transformation.py | 27 +- litellm/llms/openai/cost_calculation.py | 16 +- .../llms/openai/image_edit/transformation.py | 14 +- .../llms/openai/image_variations/handler.py | 18 +- .../guardrail_translation/handler.py | 47 ++- .../transcriptions/whisper_transformation.py | 17 +- .../openai/vector_stores/transformation.py | 21 +- litellm/llms/openai/videos/transformation.py | 136 +++++--- litellm/llms/openai_like/chat/handler.py | 32 +- litellm/llms/openai_like/embedding/handler.py | 11 +- litellm/llms/predibase/chat/transformation.py | 11 +- .../ragflow/vector_stores/transformation.py | 7 +- litellm/llms/replicate/chat/transformation.py | 7 +- .../sagemaker/completion/transformation.py | 32 +- litellm/llms/snowflake/chat/transformation.py | 8 +- .../audio_transcription/transformation.py | 27 +- .../stability/image_edit/transformations.py | 18 +- litellm/llms/vertex_ai/common_utils.py | 48 +-- .../llms/vertex_ai/files/transformation.py | 11 +- .../image_generation_handler.py | 16 +- .../llms/vertex_ai/realtime/transformation.py | 7 +- .../llama/transformation.py | 3 +- .../volcengine/embedding/transformation.py | 14 +- .../llms/volcengine/videos/transformation.py | 2 +- litellm/llms/watsonx/common_utils.py | 17 +- .../llms/watsonx/completion/transformation.py | 14 +- litellm/llms/xai/chat/transformation.py | 54 +++- litellm/llms/you_com/search/transformation.py | 27 +- litellm/main.py | 18 +- litellm/models/base.py | 39 ++- litellm/proxy/_lazy_openapi_snapshot.py | 4 +- .../proxy/agent_endpoints/a2a_endpoints.py | 11 +- .../model_deprecation_alerts.py | 8 +- litellm/proxy/auth/handle_jwt.py | 13 +- litellm/proxy/auth/litellm_license.py | 26 +- litellm/proxy/client/cli/commands/keys.py | 38 +-- litellm/proxy/client/cli/commands/models.py | 11 +- litellm/proxy/common_request_processing.py | 59 ++-- litellm/proxy/common_utils/callback_utils.py | 10 +- .../common_utils/encrypt_decrypt_utils.py | 15 +- .../proxy/common_utils/reset_budget_job.py | 6 +- .../proxy/db/budget_window_spend_writer.py | 17 +- litellm/proxy/db/db_spend_update_writer.py | 64 +++- litellm/proxy/db/exception_handler.py | 29 +- litellm/proxy/db/model_usage_rollup.py | 9 +- litellm/proxy/db/prisma_client.py | 164 ++++++++++ litellm/proxy/db/routing_prisma_wrapper.py | 10 + litellm/proxy/db/spend_log_tool_index.py | 12 +- .../agent_skills_archive.py | 21 +- .../proxy/fine_tuning_endpoints/endpoints.py | 48 ++- .../cato_networks/cato_networks.py | 33 +- .../guardrail_hooks/custom_code/primitives.py | 8 +- .../guardrail_hooks/enkryptai/enkryptai.py | 10 +- .../generic_guardrail_api.py | 21 +- .../mcp_jwt_signer/mcp_jwt_signer.py | 6 +- .../guardrail_hooks/microsoft_purview/base.py | 6 +- .../guardrail_hooks/pangea/pangea.py | 40 ++- .../panw_prisma_airs/panw_prisma_airs.py | 27 +- .../guardrail_hooks/tool_permission.py | 26 +- .../proxy/guardrails/guardrail_registry.py | 15 +- litellm/proxy/hooks/batch_rate_limiter.py | 9 +- litellm/proxy/litellm_pre_call_utils.py | 25 +- .../access_group_endpoints.py | 24 +- .../internal_user_endpoints.py | 4 +- .../key_management_endpoints.py | 13 +- .../management_v1/spend_logs.py | 24 +- ...model_access_group_management_endpoints.py | 29 +- .../model_management_endpoints.py | 39 ++- .../organization_endpoints.py | 17 +- .../team_callback_endpoints.py | 4 +- litellm/proxy/mcp_server/db.py | 26 +- .../mcp_server/discoverable_endpoints.py | 35 ++- .../proxy/mcp_server/mcp_server_manager.py | 40 +-- litellm/proxy/mcp_server/operations.py | 2 +- litellm/proxy/mcp_server/rest_endpoints.py | 6 +- litellm/proxy/mcp_server/tool_search.py | 15 +- .../admission_control_middleware.py | 10 +- .../billable_request_metrics_middleware.py | 32 +- .../per_request_root_path_middleware.py | 14 +- .../request_size_limit_middleware.py | 20 +- .../openai_files_endpoints/common_utils.py | 4 +- .../file_content_streaming_handler.py | 12 +- .../openai_files_endpoints/files_endpoints.py | 16 +- .../llm_passthrough_endpoints.py | 32 +- .../anthropic_passthrough_logging_handler.py | 8 +- .../pass_through_endpoints.py | 25 +- litellm/proxy/proxy_server.py | 75 +++-- litellm/proxy/rag_endpoints/endpoints.py | 9 +- litellm/proxy/rerank_endpoints/endpoints.py | 4 +- .../proxy/response_api_endpoints/endpoints.py | 2 +- .../proxy/response_polling/polling_handler.py | 15 +- .../search_tool_management.py | 14 +- .../search_endpoints/search_tool_registry.py | 10 +- .../spend_tracking/key_metadata_recovery.py | 15 +- .../spend_tracking/ptu_flat_cost_rollup.py | 13 +- .../proxy_setting_endpoints.py | 16 +- litellm/proxy/utils.py | 34 +- .../management_endpoints.py | 11 +- litellm/proxy/vector_store_endpoints/utils.py | 17 +- litellm/proxy/video_endpoints/utils.py | 13 +- litellm/rag/ingestion/base_ingestion.py | 29 +- litellm/realtime_api/main.py | 26 +- litellm/repositories/table_repositories.py | 14 +- litellm/rerank_api/main.py | 18 +- .../mcp/litellm_proxy_mcp_handler.py | 3 +- litellm/router.py | 44 ++- litellm/router_api.py | 291 +++++++++++++++++- litellm/router_rust/invoke/streaming.py | 15 +- litellm/router_rust/surface/legacy_members.py | 8 +- .../quality_router/quality_router.py | 2 +- litellm/router_utils/cooldown_callbacks.py | 5 +- litellm/router_utils/cooldown_handlers.py | 2 +- .../secret_managers/secret_manager_handler.py | 41 +-- .../text_completions/streaming_iterator.py | 28 +- litellm/utils.py | 78 +++-- .../vector_stores/vector_store_registry.py | 17 +- litellm/videos/main.py | 58 +++- .../test_streaming_handler.py | 131 ++++++++ .../unit/llms/azure_ai/agents/test_handler.py | 58 +++- ...t_azure_ai_vector_stores_transformation.py | 36 +++ .../test_twelvelabs_pegasus_transformation.py | 46 +++ ...est_bedrock_vector_store_transformation.py | 35 +++ .../llms/cohere/test_cohere_common_utils.py | 20 +- ...st_huggingface_embedding_transformation.py | 106 +++++++ .../test_nlp_cloud_chat_transformation.py | 52 ++++ .../ollama/test_ollama_chat_transformation.py | 59 ++++ tests/unit/realtime_api/test_main.py | 89 +++++- tests/unit/test_main.py | 56 +++- tests/unit/videos/test_main.py | 79 +++++ 194 files changed, 4116 insertions(+), 1332 deletions(-) create mode 100644 tests/unit/llms/huggingface/embedding/test_huggingface_embedding_transformation.py diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py b/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py index fcaf58099b9..6e16ed8f187 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py @@ -123,7 +123,7 @@ class _PROXY_LiteLLMManagedVectorStores( """ # Use the router to create the vector store response: Final = await llm_router.avector_store_create(model=model, **request_data) - return cast(VectorStoreCreateResponse, response) + return response # ============================================================================ # VECTOR STORE CRUD OPERATIONS diff --git a/litellm/assistants/main.py b/litellm/assistants/main.py index a5721c6f047..90a083bed2d 100644 --- a/litellm/assistants/main.py +++ b/litellm/assistants/main.py @@ -5,14 +5,16 @@ import contextvars import os from collections.abc import Coroutine, Iterable, Mapping, Sequence from functools import partial -from typing import Any, Final, Literal +from typing import Any, Final, Literal, Protocol import httpx from openai import AsyncOpenAI, OpenAI from openai.types.beta.assistant import Assistant from openai.types.beta.assistant_deleted import AssistantDeleted +from pydantic import TypeAdapter import litellm +from litellm.litellm_core_utils.core_helpers import require_attributes from litellm.types.router import GenericLiteLLMParams from litellm.utils import ( exception_type, @@ -31,6 +33,21 @@ from .utils import get_optional_params_add_message ####### ENVIRONMENT VARIABLES ################### openai_assistants_api: Final = OpenAIAssistantsAPI() azure_assistants_api: Final = AzureAssistantsAPI() +_EXTRA_BODY: Final = TypeAdapter(object) + + +class _PopsKeys(Protocol): + def pop(self, key: str, default: None, /) -> object: ... + + +def _pop_azure_ad_token(extra_body: object) -> object: + return require_attributes(extra_body, _PopsKeys, "pop").pop("azure_ad_token", None) + + +def _popped_azure_ad_token(extra_body: object) -> str | None: + token: Final = _pop_azure_ad_token(extra_body) + return token if isinstance(token, str) else None + ### ASSISTANTS ### @@ -146,10 +163,10 @@ def get_assistants( or get_secret("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token: str | None = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _popped_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") @@ -331,10 +348,10 @@ def create_assistants( or get_secret("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token: str | None = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _popped_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") @@ -481,10 +498,10 @@ def delete_assistant( or get_secret("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token: str | None = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _popped_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") @@ -657,10 +674,10 @@ def create_thread( api_version: str | None = optional_params.api_version or litellm.api_version or get_secret("AZURE_API_VERSION") - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token: str | None = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _popped_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") @@ -804,10 +821,10 @@ def get_thread( or get_secret("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token: str | None = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _popped_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") @@ -982,10 +999,10 @@ def add_message( or get_secret("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token: str | None = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _popped_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") @@ -1134,10 +1151,10 @@ def get_messages( or get_secret("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token: str | None = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _popped_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") @@ -1333,10 +1350,10 @@ def run_thread( or get_secret("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) azure_ad_token = None if extra_body is not None: - azure_ad_token = extra_body.pop("azure_ad_token", None) + azure_ad_token = _pop_azure_ad_token(extra_body) else: azure_ad_token = get_secret("AZURE_AD_TOKEN") diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 62ccb07f695..1c830e45464 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -12,6 +12,7 @@ import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils import batch_utils as core_batch_utils from litellm.litellm_core_utils.cloud_storage_security import stored_s3_bucket_kwargs +from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.litellm_core_utils.llm_cost_calc.utils import parse_prompt_tokens_details from litellm.llms.base_llm.ocr.transformation import OCRUsageInfo @@ -849,14 +850,14 @@ def _get_batch_job_usage_from_response_body( return usage -def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping[str, Any]) -> Mapping[str, Any]: +def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping[str, object]) -> Mapping[str, object]: """ Get the ``result`` object from a line of an Anthropic message batch results JSONL file. Anthropic batch results lines look like: ``{"custom_id": ..., "result": {"type": "succeeded", "message": {..., "usage": {...}}}}`` """ - return batch_results_line.get("result", None) or {} + return decoded_json_object(batch_results_line.get("result", None) or {}) def _get_response_from_batch_job_output_file( @@ -866,7 +867,9 @@ def _get_response_from_batch_job_output_file( Get the response from the batch job output file """ if custom_llm_provider == "anthropic": - return _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("message", None) or {} + return decoded_json_object( + _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("message", None) or {} + ) if custom_llm_provider == "bedrock": return batch_job_output_file.get("modelOutput", None) or {} _response: Final = cast( # cast-ok: batch output response comes from provider JSON diff --git a/litellm/batches/main.py b/litellm/batches/main.py index d88f549b06b..212205a9d7b 100644 --- a/litellm/batches/main.py +++ b/litellm/batches/main.py @@ -15,13 +15,15 @@ import contextvars import os from collections.abc import Coroutine from functools import partial -from typing import Any, Final, Literal, cast +from typing import Any, Final, Literal, Protocol, cast import httpx from openai.types.batch import BatchRequestCounts +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_logger +from litellm.litellm_core_utils.core_helpers import require_attributes from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.anthropic.batches.handler import AnthropicBatchesHandler from litellm.llms.azure.batches.handler import AzureBatchesAPI @@ -60,10 +62,15 @@ azure_batches_instance: Final = AzureBatchesAPI() vertex_ai_batches_instance: Final = VertexAIBatchPrediction(gcs_bucket_name="") anthropic_batches_instance: Final = AnthropicBatchesHandler() xai_batches_instance: Final = XAIBatchesHandler() +_EXTRA_BODY: Final = TypeAdapter(object) base_llm_http_handler = BaseLLMHTTPHandler() ################################################# +class _PopsKeys(Protocol): + def pop(self, key: str, default: None, /) -> object: ... + + def _resolve_timeout( optional_params: GenericLiteLLMParams, kwargs: dict[str, Any], @@ -485,9 +492,9 @@ def _handle_retrieve_batch_providers_without_provider_config( or get_secret_str("AZURE_API_KEY") ) - extra_body: Final = optional_params.get("extra_body", {}) + extra_body: Final = _EXTRA_BODY.validate_python(optional_params.get("extra_body", {})) if extra_body is not None: - extra_body.pop("azure_ad_token", None) + require_attributes(extra_body, _PopsKeys, "pop").pop("azure_ad_token", None) else: get_secret_str("AZURE_AD_TOKEN") diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py index ab5d2332c21..a3cf54916f1 100644 --- a/litellm/caching/caching_handler.py +++ b/litellm/caching/caching_handler.py @@ -19,7 +19,7 @@ import datetime import inspect import time from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator, Mapping -from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Final, Optional, Protocol, TypeVar from pydantic import ConfigDict, SkipValidation, TypeAdapter, ValidationError from typing_extensions import TypedDict @@ -72,6 +72,8 @@ from litellm.litellm_core_utils.core_helpers import ( as_object_list, as_str_mapping, get_parent_otel_span_from_kwargs, + json_contains, + require_attributes, ) from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper @@ -246,6 +248,15 @@ def _stream_replay_requested(kwargs: Mapping[str, object]) -> bool: return converted_stream_requested(kwargs) and not kwargs.get("_agentic_loop_depth") +class _CacheControls(Protocol): + def get(self, key: str, default: bool, /) -> object: ... + + +def _cache_reads_disabled(kwargs: Mapping[str, object]) -> bool: + cache_controls: Final = require_attributes(kwargs.get("cache", {}), _CacheControls, "get") + return cache_controls.get("no-cache", False) is True + + def _should_defer_streaming_cache_hit_callbacks(*, cached_result: object) -> bool: """ When the cache hit is replayed as a stream, do not run success callbacks at cache-hit time. @@ -403,7 +414,7 @@ class LLMCachingHandler: if ( (kwargs.get("caching", None) is None and litellm.cache is not None) or kwargs.get("caching", False) is True ) and ( - kwargs.get("cache", {}).get("no-cache", False) is not True + not _cache_reads_disabled(kwargs) ): # allow users to control returning cached responses from the completion function args = args or () final_embedding_cached_response: EmbeddingResponse | None = None @@ -521,7 +532,7 @@ class LLMCachingHandler: kwargs: dict[str, Any], args: tuple[object, ...] | None = None, ) -> CachingHandlerResponse: - cached_result: Any | None = None + cached_result: object | None = None # Check if caching should be performed BEFORE doing expensive kwargs copy if _is_response_cache_excluded(model=model, kwargs=kwargs): @@ -550,7 +561,7 @@ class LLMCachingHandler: verbose_logger.debug("LiteLLM Cache: cached response has no output, treating it as a miss") return CachingHandlerResponse(cached_result=None) if cached_result is not None: - if "detail" in cached_result: + if json_contains(cached_result, "detail"): # implies an error occurred pass else: @@ -1460,6 +1471,7 @@ class LLMCachingHandler: Returns: None """ + request_kwargs: Final = dict[str, object](kwargs) litellm_params: Final = { "logger_fn": kwargs.get("logger_fn", None), "acompletion": is_async, @@ -1473,7 +1485,7 @@ class LLMCachingHandler: if litellm.cache is not None: litellm_params["preset_cache_key"] = ( - self.preset_cache_key or litellm.cache.get_preset_cache_key_from_kwargs(**kwargs) + self.preset_cache_key or litellm.cache.get_preset_cache_key_from_kwargs(**request_kwargs) ) else: litellm_params["preset_cache_key"] = None @@ -1483,11 +1495,11 @@ class LLMCachingHandler: user=kwargs.get("user", None), optional_params={}, litellm_params=litellm_params, - input=(kwargs.get("messages", "") if not is_embedding else kwargs.get("input", "")), - api_key=kwargs.get("api_key", None), + input=(request_kwargs.get("messages", "") if not is_embedding else request_kwargs.get("input", "")), + api_key=request_kwargs.get("api_key", None), original_response=str(cached_result), additional_args=None, - stream=kwargs.get("stream", False), + stream=request_kwargs.get("stream", False), custom_llm_provider=custom_llm_provider, ) diff --git a/litellm/caching/qdrant_semantic_cache.py b/litellm/caching/qdrant_semantic_cache.py index 560a5ba06b8..501ac2037e1 100644 --- a/litellm/caching/qdrant_semantic_cache.py +++ b/litellm/caching/qdrant_semantic_cache.py @@ -41,6 +41,7 @@ from .base_cache import BaseCache _WAIT_FOR_INDEXING: Final = MappingProxyType({"wait": "true"}) _EMBEDDING_VECTOR: Final = TypeAdapter(object) +_CACHED_RESPONSE: Final = TypeAdapter(object) if TYPE_CHECKING: from litellm.router import Router @@ -175,14 +176,13 @@ class QdrantSemanticCache(BaseCache): else: raise Exception("Error while creating new collection") - def _get_cache_logic(self, cached_response: Any): + def _get_cache_logic(self, cached_response: Any) -> object: if cached_response is None: return cached_response try: - cached_response = json.loads(cached_response) # Convert string to dictionary + return _CACHED_RESPONSE.validate_python(json.loads(cached_response)) except Exception: - cached_response = ast.literal_eval(cached_response) - return cached_response + return _CACHED_RESPONSE.validate_python(ast.literal_eval(cached_response)) def _get_qdrant_cache_key_filter(self, key: str) -> dict: return { diff --git a/litellm/google_genai/adapters/handler.py b/litellm/google_genai/adapters/handler.py index 7caa0c62571..db090c9b007 100644 --- a/litellm/google_genai/adapters/handler.py +++ b/litellm/google_genai/adapters/handler.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator, Coroutine, Mapping from typing import Final, cast from openai.types.chat import ChatCompletionStreamOptionsParam +from typing_extensions import TypedDict import litellm from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -15,6 +16,10 @@ from .transformation import GoogleGenAIAdapter GOOGLE_GENAI_ADAPTER: Final = GoogleGenAIAdapter() +class _TranslatedCompletionKwargs(TypedDict, total=False, extra_items=object): + pass + + def _mark_failure_logged_by_inner_completion(adapter_kwargs: Mapping[str, object]) -> None: outer_logging_obj: Final = adapter_kwargs.get("litellm_logging_obj") if isinstance(outer_logging_obj, LiteLLMLoggingObj): @@ -55,7 +60,7 @@ class GenerateContentToCompletionHandler: **(extra_kwargs or {}), ) - completion_kwargs: Final = dict(completion_request) + completion_kwargs: Final = dict[str, object](completion_request) # Forward extra_kwargs that should be passed to completion call if extra_kwargs is not None: @@ -76,7 +81,7 @@ class GenerateContentToCompletionHandler: if stream_options is not None: completion_kwargs["stream_options"] = stream_options - return GenerateContentCompletionKwargs(**completion_kwargs) + return GenerateContentCompletionKwargs(**_TranslatedCompletionKwargs(**completion_kwargs)) @staticmethod async def async_generate_content_handler( diff --git a/litellm/google_genai/main.py b/litellm/google_genai/main.py index 40c9c7bc36c..71000fec81a 100644 --- a/litellm/google_genai/main.py +++ b/litellm/google_genai/main.py @@ -40,6 +40,7 @@ else: # Initialize any necessary instances or variables here base_llm_http_handler = BaseLLMHTTPHandler() _GENERATE_CONTENT_RESULT: Final = TypeAdapter(object) +_GENERATE_CONTENT_PAYLOAD: Final = TypeAdapter(object) ################################################# @@ -368,8 +369,8 @@ def generate_content( response: Final = _GENERATE_CONTENT_RESULT.validate_python( base_llm_http_handler.generate_content_handler( model=setup_result.model, - contents=contents, - tools=tools, + contents=_GENERATE_CONTENT_PAYLOAD.validate_python(contents), + tools=_GENERATE_CONTENT_PAYLOAD.validate_python(tools), generate_content_provider_config=setup_result.generate_content_provider_config, generate_content_config_dict=setup_result.generate_content_config_dict, custom_llm_provider=setup_result.custom_llm_provider, @@ -465,10 +466,10 @@ async def agenerate_content_stream( return _GENERATE_CONTENT_RESULT.validate_python( await base_llm_http_handler.generate_content_handler( model=setup_result.model, - contents=contents, + contents=_GENERATE_CONTENT_PAYLOAD.validate_python(contents), generate_content_provider_config=setup_result.generate_content_provider_config, generate_content_config_dict=setup_result.generate_content_config_dict, - tools=tools, + tools=_GENERATE_CONTENT_PAYLOAD.validate_python(tools), custom_llm_provider=setup_result.custom_llm_provider, litellm_params=setup_result.litellm_params, logging_obj=setup_result.litellm_logging_obj, @@ -556,10 +557,10 @@ def generate_content_stream( return _GENERATE_CONTENT_RESULT.validate_python( base_llm_http_handler.generate_content_handler( model=setup_result.model, - contents=contents, + contents=_GENERATE_CONTENT_PAYLOAD.validate_python(contents), generate_content_provider_config=setup_result.generate_content_provider_config, generate_content_config_dict=setup_result.generate_content_config_dict, - tools=tools, + tools=_GENERATE_CONTENT_PAYLOAD.validate_python(tools), custom_llm_provider=setup_result.custom_llm_provider, litellm_params=setup_result.litellm_params, logging_obj=setup_result.litellm_logging_obj, diff --git a/litellm/harness/endpoint.py b/litellm/harness/endpoint.py index 249bb2cebb7..fa8105b30e8 100644 --- a/litellm/harness/endpoint.py +++ b/litellm/harness/endpoint.py @@ -37,7 +37,7 @@ from litellm.constants import ( from litellm.harness.context import GatewayTarget from litellm.harness.errors import HarnessError, HarnessInstallFailed from litellm.harness.types import Harness, Usage -from litellm.litellm_core_utils.core_helpers import has_attributes +from litellm.litellm_core_utils.core_helpers import decoded_json_object, has_attributes from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.litellm_params import STREAM_USAGE_INJECTED_FIELD from litellm.types.llms.custom_http import httpxSpecialProvider @@ -722,7 +722,7 @@ class ModelEndpoint: return await litellm.anthropic.messages.acreate(**kwargs) if route == ROUTE_CHAT: if not kwargs.get("stream"): - return await litellm.acompletion(**kwargs) + return await litellm.acompletion(**decoded_json_object(kwargs)) stream_options = kwargs.get("stream_options") or {} # mutable-ok: empty default for a JSON field return await litellm.acompletion( **{ @@ -731,7 +731,7 @@ class ModelEndpoint: STREAM_USAGE_INJECTED_FIELD: "include_usage" not in stream_options, } ) - return await litellm.aresponses(**kwargs) + return await litellm.aresponses(**decoded_json_object(kwargs)) async def _call_sdk(self, route: str, body: Mapping[str, object]) -> Response: kwargs = self._sdk_kwargs(body) diff --git a/litellm/images/main.py b/litellm/images/main.py index 79bb07fc394..b2da69bb452 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -6,6 +6,7 @@ from functools import partial from typing import TYPE_CHECKING, Final, Literal, cast, get_type_hints, overload from pydantic import TypeAdapter, ValidationError +from typing_extensions import TypedDict if TYPE_CHECKING: from litellm.images.utils import ImageEditRequestUtils @@ -698,7 +699,7 @@ async def aimage_variation(*args, **kwargs) -> ImageResponse: init_response = await loop.run_in_executor(None, func_with_context) if isinstance(init_response, dict) or isinstance(init_response, ImageResponse): ## CACHING SCENARIO if isinstance(init_response, dict): - init_response = ImageResponse(**init_response) + init_response = _image_response_from_fields(init_response) response = init_response elif asyncio.iscoroutine(init_response): response = await init_response @@ -717,6 +718,14 @@ async def aimage_variation(*args, **kwargs) -> ImageResponse: ) +class _ImageResponseFields(TypedDict, total=False, extra_items=object): + pass + + +def _image_response_from_fields(fields: Mapping[str, object]) -> ImageResponse: + return ImageResponse(**_ImageResponseFields(**fields)) + + @client def image_variation( image: FileTypes, diff --git a/litellm/integrations/clickhouse/clickhouse_spend_logger.py b/litellm/integrations/clickhouse/clickhouse_spend_logger.py index ef7442143ca..bc1a142d133 100644 --- a/litellm/integrations/clickhouse/clickhouse_spend_logger.py +++ b/litellm/integrations/clickhouse/clickhouse_spend_logger.py @@ -13,6 +13,7 @@ from types import MappingProxyType from typing import Any, Final, Protocol from pydantic import JsonValue, TypeAdapter, ValidationError +from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_logger @@ -57,6 +58,10 @@ class _SupportsItems(Protocol): def items(self) -> Iterable[tuple[object, object]]: ... +class _SpendLogKwargs(TypedDict, total=False, extra_items=object): + standard_logging_object: ReadOnly[StandardLoggingPayload | None] + + def _request_metadata_fields(value: object) -> Iterator[tuple[str, JsonValue]]: if value is None: return @@ -243,7 +248,7 @@ class ClickHouseSpendLogger(ClickHouseBatchLogger): async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time) -> None: self._log(kwargs) - def _log(self, kwargs: Mapping[str, Any]) -> None: + def _log(self, kwargs: _SpendLogKwargs) -> None: try: payload = kwargs.get("standard_logging_object") if payload is None or _is_trace_ingest(payload): diff --git a/litellm/integrations/cloudzero/cz_resource_names.py b/litellm/integrations/cloudzero/cz_resource_names.py index 9b685996a41..77079818908 100644 --- a/litellm/integrations/cloudzero/cz_resource_names.py +++ b/litellm/integrations/cloudzero/cz_resource_names.py @@ -17,10 +17,16 @@ """CloudZero Resource Names (CZRN) generation and validation for LiteLLM resources.""" import re +from collections.abc import Mapping from enum import Enum -from typing import Any, Final, cast +from typing import Final, Protocol, cast import litellm +from litellm.litellm_core_utils.core_helpers import require_attributes + + +class _SupportsLower(Protocol): + def lower(self) -> str: ... class CZEntityType(str, Enum): @@ -35,7 +41,7 @@ class CZRNGenerator: def __init__(self): """Initialize CZRN generator.""" - def create_from_litellm_data(self, row: dict[str, Any]) -> str: + def create_from_litellm_data(self, row: Mapping[str, object]) -> str: """Create a CZRN from LiteLLM daily spend data. CZRN format: czrn:::::: @@ -49,19 +55,21 @@ class CZRNGenerator: - cloud-local-id: model """ service_type: Final = "litellm" - provider: Final = self._normalize_provider(row.get("custom_llm_provider", "unknown")) + provider: Final = self._normalize_provider( + require_attributes(row.get("custom_llm_provider", "unknown"), _SupportsLower, "lower") + ) region: Final = "cross-region" # Use the actual entity_id (team_id or user_id) as the owner account team_id: Final = row.get("team_id", "unknown") - owner_account_id: Final = self._normalize_component(team_id) + owner_account_id: Final = self._normalize_owner(team_id) resource_type: Final = "llm-usage" # Create a unique identifier with just the model (entity info already in owner_account_id) model: Final = row.get("model", "unknown") - cloud_local_id: Final = model + cloud_local_id: Final = f"{model}" return self.create_from_components( service_type=service_type, @@ -112,7 +120,7 @@ class CZRNGenerator: return cast(tuple[str, str, str, str, str, str], match.groups()) - def _normalize_provider(self, provider: str) -> str: + def _normalize_provider(self, provider: _SupportsLower) -> str: """Normalize provider names to standard CZRN format.""" # Map common provider names to CZRN standards provider_map: Final = { @@ -141,16 +149,14 @@ class CZRNGenerator: return "unknown" # Convert to lowercase unless uppercase is allowed - if not allow_uppercase: - component = component.lower() + return self._clean_component(component if allow_uppercase else component.lower()) - # Replace invalid characters with hyphens - component = re.sub(r"[^a-zA-Z0-9-]", "-", component) + def _normalize_owner(self, owner: object) -> str: + if not owner: + return "unknown" + return self._clean_component(require_attributes(owner, _SupportsLower, "lower").lower()) - # Remove consecutive hyphens - component = re.sub(r"-+", "-", component) - - # Remove leading/trailing hyphens - component = component.strip("-") - - return component or "unknown" + def _clean_component(self, component: str) -> str: + hyphenated: Final = re.sub(r"[^a-zA-Z0-9-]", "-", component) + collapsed: Final = re.sub(r"-+", "-", hyphenated) + return collapsed.strip("-") or "unknown" diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 385522c2435..b582d6b8c4f 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -759,7 +759,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac response: object, plan: AgenticLoopPlan, kwargs: dict, - ) -> Any: + ) -> object: """ Post-process the response returned by the agentic-loop follow-up call. diff --git a/litellm/integrations/dotprompt/prompt_manager.py b/litellm/integrations/dotprompt/prompt_manager.py index 0e52ca0b62f..dfa3cb97f5f 100644 --- a/litellm/integrations/dotprompt/prompt_manager.py +++ b/litellm/integrations/dotprompt/prompt_manager.py @@ -5,14 +5,14 @@ Based on Google's GenAI Kit dotprompt implementation: https://google.github.io/d import re from collections.abc import Mapping from pathlib import Path -from typing import Any, Final +from typing import Any, Final, Protocol import yaml from jinja2 import DictLoader, select_autoescape from jinja2.sandbox import ImmutableSandboxedEnvironment from typing_extensions import NotRequired, ReadOnly, TypedDict -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, require_attributes class _PromptFileJson(TypedDict): @@ -27,6 +27,14 @@ class PromptJsonEntry(TypedDict): metadata: ReadOnly[dict[str, object]] +class _SupportsGet(Protocol): + def get(self, key: str, default: object = None, /) -> object: ... + + +def _metadata_section(metadata: Mapping[str, object], name: str) -> _SupportsGet: + return require_attributes(metadata.get(name, {}), _SupportsGet, "get") + + def strip_version_suffix(prompt_id: str) -> str | None: base, separator, version = prompt_id.rpartition(".v") if separator and base and version.isdigit(): @@ -51,8 +59,8 @@ class PromptTemplate: restricted_keys: Final = ["model", "input", "output"] self.model = self.metadata.get("model") self.input_schema = self.metadata.get("input", {}).get("schema", {}) - self.output_format = self.metadata.get("output", {}).get("format") - self.output_schema = self.metadata.get("output", {}).get("schema", {}) + self.output_format = _metadata_section(self.metadata, "output").get("format") + self.output_schema = _metadata_section(self.metadata, "output").get("schema", {}) self.optional_params = {} for key in self.metadata: if key not in restricted_keys: diff --git a/litellm/integrations/focus/destinations/gcs_destination.py b/litellm/integrations/focus/destinations/gcs_destination.py index 2f7052597ff..82f1a65c217 100644 --- a/litellm/integrations/focus/destinations/gcs_destination.py +++ b/litellm/integrations/focus/destinations/gcs_destination.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import timezone -from typing import Any, Final +from typing import Final from litellm._logging import verbose_logger from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase @@ -21,14 +22,14 @@ class FocusGCSDestination(GCSBucketBase, FocusDestination): self, *, prefix: str, - config: dict[str, Any] | None = None, + config: Mapping[str, str] | None = None, ) -> None: - config = config or {} - bucket_name: Final = config.get("bucket_name") + resolved_config: Final[Mapping[str, str]] = config or {} + bucket_name: Final = resolved_config.get("bucket_name") if not bucket_name: raise ValueError("bucket_name must be provided for GCS destination") super().__init__(bucket_name=bucket_name) - service_account_json: Final = config.get("service_account_json") + service_account_json: Final = resolved_config.get("service_account_json") if service_account_json is not None: self.path_service_account_json = service_account_json self.prefix = prefix.rstrip("/") diff --git a/litellm/integrations/galileo.py b/litellm/integrations/galileo.py index 0f7dcdbcc81..f04604ddad8 100644 --- a/litellm/integrations/galileo.py +++ b/litellm/integrations/galileo.py @@ -6,7 +6,7 @@ import re import uuid from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timezone, tzinfo -from typing import Any, Final, Protocol, cast +from typing import Final, Protocol, cast import httpx from pydantic import ConfigDict, Field, TypeAdapter @@ -109,9 +109,58 @@ class LLMResponse(LiteLLMBaseModel): user_metadata: dict[str, object] | None = None +class _GalileoRecord(TypedDict, total=False): + latency_ms: ReadOnly[int] + status_code: ReadOnly[int] + input_text: ReadOnly[str] + output_text: ReadOnly[str] + node_type: ReadOnly[str] + model: ReadOnly[str] + num_input_tokens: ReadOnly[int] + num_output_tokens: ReadOnly[int] + num_total_tokens: ReadOnly[int] + cost: ReadOnly[float | None] + output_logprobs: ReadOnly[Mapping[str, object] | None] + created_at: ReadOnly[str] + tags: ReadOnly[Sequence[str] | None] + user_metadata: ReadOnly[Mapping[str, object] | None] + messages: ReadOnly[Sequence[object]] + + +class _GalileoLoggingKwargs(TypedDict, total=False, extra_items=object): + standard_logging_object: ReadOnly[GalileoStandardLoggingFields | None] + + +def _record_messages(messages: object) -> object: + return messages.get("messages") if isinstance(messages, dict) else messages + + +def _galileo_record(response: LLMResponse, messages: object) -> _GalileoRecord: + record: Final[_GalileoRecord] = { + "latency_ms": response.latency_ms, + "status_code": response.status_code, + "input_text": response.input_text, + "output_text": response.output_text, + "node_type": response.node_type, + "model": response.model, + "num_input_tokens": response.num_input_tokens, + "num_output_tokens": response.num_output_tokens, + "num_total_tokens": response.num_total_tokens, + "cost": response.cost, + "output_logprobs": response.output_logprobs, + "created_at": response.created_at, + "tags": response.tags, + "user_metadata": response.user_metadata, + } + record_messages: Final = _record_messages(messages) + if isinstance(record_messages, list) and record_messages: + return {**record, "messages": record_messages} + return record + + class GalileoObserve(CustomLogger): def __init__(self) -> None: - self.in_memory_records: list[Mapping[str, object]] = [] + self.in_memory_records: list[_GalileoRecord] = [] self.batch_size = 1 self.api_key = os.getenv("GALILEO_API_KEY") self.project_id = os.getenv("GALILEO_PROJECT_ID") @@ -278,7 +327,7 @@ class GalileoObserve(CustomLogger): return created_at @staticmethod - def _token_metrics_from_record(record: Mapping[str, Any]) -> dict[str, object]: + def _token_metrics_from_record(record: _GalileoRecord) -> dict[str, object]: num_input_tokens: Final = int(record.get("num_input_tokens") or 0) num_output_tokens: Final = int(record.get("num_output_tokens") or 0) num_total_tokens = int(record.get("num_total_tokens") or 0) @@ -296,7 +345,7 @@ class GalileoObserve(CustomLogger): @staticmethod def _record_to_v2_span( - record: Mapping[str, Any], + record: _GalileoRecord, *, trace_id: str, span_id: str, @@ -322,12 +371,13 @@ class GalileoObserve(CustomLogger): **GalileoObserve._token_metrics_from_record(record), }, } - if record.get("tags"): - span["tags"] = record["tags"] + tags: Final = record.get("tags") + if tags: + span["tags"] = tags return span @staticmethod - def _record_to_v2_trace(record: Mapping[str, Any]) -> dict[str, object]: + def _record_to_v2_trace(record: _GalileoRecord) -> dict[str, object]: trace_id: Final = str(uuid.uuid4()) span_id: Final = str(uuid.uuid4()) created_at: Final = GalileoObserve._normalize_created_at(record.get("created_at", "")) @@ -347,7 +397,7 @@ class GalileoObserve(CustomLogger): "spans": [GalileoObserve._record_to_v2_span(record, trace_id=trace_id, span_id=span_id)], } - def _build_traces_payload(self, records: Sequence[Mapping[str, object]]) -> dict[str, object]: + def _build_traces_payload(self, records: Sequence[_GalileoRecord]) -> dict[str, object]: payload: Final[dict[str, object]] = { "traces": [self._record_to_v2_trace(record) for record in records], "logging_method": "api_direct", @@ -668,7 +718,7 @@ class GalileoObserve(CustomLogger): async def async_log_success_event( self, - kwargs: Mapping[str, object], + kwargs: _GalileoLoggingKwargs, response_obj: object, start_time: object, end_time: object, @@ -686,7 +736,7 @@ class GalileoObserve(CustomLogger): async def _async_log_success_event_impl( self, - kwargs: Mapping[str, Any], + kwargs: _GalileoLoggingKwargs, response_obj: object, start_time: object, end_time: object, @@ -752,12 +802,7 @@ class GalileoObserve(CustomLogger): created_at=GalileoObserve._format_created_at(start_ts), ) - request_dict: Final = request_record.model_dump() - if isinstance(messages, dict): - messages = messages.get("messages") - if isinstance(messages, list) and messages: - request_dict["messages"] = messages - self.in_memory_records.append(request_dict) + self.in_memory_records.append(_galileo_record(request_record, messages)) verbose_logger.debug("Galileo Logger: queued record, in_memory=%d", len(self.in_memory_records)) # Bound the buffer so persistent flush failures cannot grow it diff --git a/litellm/integrations/gitlab/gitlab_prompt_manager.py b/litellm/integrations/gitlab/gitlab_prompt_manager.py index adaacd615a8..c51c513b633 100644 --- a/litellm/integrations/gitlab/gitlab_prompt_manager.py +++ b/litellm/integrations/gitlab/gitlab_prompt_manager.py @@ -3,13 +3,14 @@ GitLab prompt manager with configurable prompts folder. """ from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Final, TypeVar +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar from jinja2 import DictLoader, select_autoescape from jinja2.sandbox import ImmutableSandboxedEnvironment from typing_extensions import ReadOnly, TypedDict from litellm.integrations.custom_prompt_management import CustomPromptManagement +from litellm.litellm_core_utils.core_helpers import require_attributes if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -40,6 +41,18 @@ class GitLabCachedPrompt(TypedDict): optional_params: ReadOnly[Mapping[str, object]] +class _SupportsGet(Protocol): + def get(self, key: str, default: object = None, /) -> object: ... + + +def _input_schema(metadata: Mapping[str, object]) -> object: + return require_attributes(metadata.get("input", {}), _SupportsGet, "get").get("schema", {}) + + +def _optional_params(metadata: Mapping[str, object]) -> Mapping[str, object]: + return {k: v for k, v in metadata.items() if k not in ["model", "input", "content"]} + + def encode_prompt_id(raw_id: str) -> str: """Convert GitLab path IDs like 'invoice/extract' → 'gitlab::invoice::extract'""" if raw_id.startswith(GITLAB_PREFIX): @@ -68,8 +81,8 @@ class GitLabPromptTemplate: self.model = model or metadata.get("model") self.temperature = metadata.get("temperature") self.max_tokens = metadata.get("max_tokens") - self.input_schema = metadata.get("input", {}).get("schema", {}) - self.optional_params = {k: v for k, v in metadata.items() if k not in ["model", "input", "content"]} + self.input_schema = _input_schema(metadata) + self.optional_params = _optional_params(metadata) def __repr__(self): return f"GitLabPromptTemplate(id='{self.template_id}', model='{self.model}')" @@ -84,7 +97,7 @@ class GitLabTemplateManager: def __init__( self, - gitlab_config: dict[str, Any], + gitlab_config: Mapping[str, Any], prompt_id: str | None = None, ref: str | None = None, gitlab_client: GitLabClient | None = None, @@ -297,7 +310,7 @@ class GitLabPromptManager(CustomPromptManagement): def __init__( self, - gitlab_config: dict[str, object], + gitlab_config: Mapping[str, object], prompt_id: str | None = None, ref: str | None = None, # tag/branch/SHA override gitlab_client: GitLabClient | None = None, diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index bd68ffe8cd6..842eaaf847d 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -22,6 +22,7 @@ from litellm.integrations.langfuse.langfuse_mock_client import ( from litellm.litellm_core_utils.core_helpers import ( as_str_mapping, filter_exceptions_from_params, + json_contains, reconstruct_model_name, safe_deep_copy, ) @@ -1311,11 +1312,11 @@ def _end_grounding_span(*, tracing: "LangfuseTracing", parent: "LangfuseObservat ).end() -def log_requester_metadata(clean_metadata: Mapping[str, Any]): +def log_requester_metadata(clean_metadata: Mapping[str, object]): returned_metadata: Final = {} - requester_metadata: Final = clean_metadata.get("requester_metadata") or {} + requester_metadata: Final[object] = clean_metadata.get("requester_metadata") or {} for k, v in clean_metadata.items(): - if k not in requester_metadata: + if not json_contains(requester_metadata, k): returned_metadata[k] = v returned_metadata.update({"requester_metadata": requester_metadata}) diff --git a/litellm/integrations/opik/opik_payload_builder/extractors.py b/litellm/integrations/opik/opik_payload_builder/extractors.py index 1ceb9aa763f..d4fefe54333 100644 --- a/litellm/integrations/opik/opik_payload_builder/extractors.py +++ b/litellm/integrations/opik/opik_payload_builder/extractors.py @@ -2,15 +2,28 @@ import json from collections.abc import Mapping -from typing import Any, Final +from typing import Any, Final, Protocol from pydantic import TypeAdapter from litellm import _logging +from litellm.litellm_core_utils.core_helpers import require_attributes _DECODED_JSON: Final = TypeAdapter(object) +class _SpanIdentifiers(Protocol): + @property + def trace_id(self) -> str | None: ... + + @property + def id(self) -> str | None: ... + + +def _trace_and_span_ids(span: _SpanIdentifiers) -> tuple[str | None, str | None]: + return span.trace_id, span.id + + def normalize_provider_name(provider: str | None) -> str | None: """ Normalize LiteLLM provider names to standardized string names. @@ -77,7 +90,7 @@ def extract_opik_metadata( def extract_span_identifiers( - current_span_data: Any, + current_span_data: object, ) -> tuple[str | None, str | None]: """ Extract trace_id and parent_span_id from current_span_data. @@ -95,7 +108,7 @@ def extract_span_identifiers( return (current_span_data.get("trace_id"), current_span_data.get("id")) try: - return current_span_data.trace_id, current_span_data.id + return _trace_and_span_ids(require_attributes(current_span_data, _SpanIdentifiers, "trace_id", "id")) except AttributeError: _logging.verbose_logger.warning("Unexpected current_span_data format: %s", type(current_span_data)) return None, None diff --git a/litellm/interactions/litellm_responses_transformation/handler.py b/litellm/interactions/litellm_responses_transformation/handler.py index f95b5611831..628f35edd50 100644 --- a/litellm/interactions/litellm_responses_transformation/handler.py +++ b/litellm/interactions/litellm_responses_transformation/handler.py @@ -2,8 +2,8 @@ Handler for transforming interactions API requests to litellm.responses requests. """ -from collections.abc import AsyncIterator, Callable, Coroutine, Iterator -from typing import Any, Final +from collections.abc import AsyncIterator, Callable, Coroutine, Iterator, Mapping +from typing import Final import litellm from litellm.interactions.litellm_responses_transformation.streaming_iterator import ( @@ -72,6 +72,7 @@ class LiteLLMResponsesInteractionsHandler: model=model, input=input, optional_params=optional_params, + custom_llm_provider=custom_llm_provider, **kwargs, ) @@ -108,10 +109,11 @@ class LiteLLMResponsesInteractionsHandler: async def async_interactions_api_handler( self, - responses_request: dict[str, Any], + responses_request: Mapping[str, object], model: str, input: InteractionInput | None, optional_params: InteractionsAPIOptionalRequestParams, + custom_llm_provider: str | None = None, **kwargs, ) -> InteractionsAPIResponse | AsyncIterator[InteractionsAPIStreamingResponse]: """Async handler for interactions API requests.""" @@ -128,7 +130,7 @@ class LiteLLMResponsesInteractionsHandler: litellm_custom_stream_wrapper=responses_response, request_input=input, optional_params=optional_params, - custom_llm_provider=responses_request.get("custom_llm_provider"), + custom_llm_provider=custom_llm_provider, litellm_metadata=kwargs.get("litellm_metadata", {}), ) @@ -139,5 +141,5 @@ class LiteLLMResponsesInteractionsHandler: return LiteLLMResponsesInteractionsConfig.transform_responses_response_to_interactions_response( responses_response=responses_api_response, model=model, - custom_llm_provider=responses_request.get("custom_llm_provider"), + custom_llm_provider=custom_llm_provider, ) diff --git a/litellm/litellm_core_utils/chat_completion_agentic_loop.py b/litellm/litellm_core_utils/chat_completion_agentic_loop.py index 53451b04515..93799c72209 100644 --- a/litellm/litellm_core_utils/chat_completion_agentic_loop.py +++ b/litellm/litellm_core_utils/chat_completion_agentic_loop.py @@ -99,6 +99,11 @@ def _post_hook_overridden(callback: CustomLogger) -> bool: _STREAM_OPTIONS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None) +_HOOK_RESPONSE: Final = TypeAdapter(object) + + +def _plan_response_override(plan: AgenticLoopPlan) -> object | None: + return plan.response_override def _stashed_stream_options(kwargs: Mapping[str, object]) -> Mapping[str, object] | None: @@ -396,8 +401,10 @@ async def _execute_chat_completion_agentic_plan( ) if _post_hook_overridden(callback): try: - response_followup = await callback.async_post_agentic_loop_response_hook( - response=response_followup, plan=plan, kwargs=kwargs + response_followup = _HOOK_RESPONSE.validate_python( + await callback.async_post_agentic_loop_response_hook( + response=response_followup, plan=plan, kwargs=kwargs + ) ) except Exception as e: _call_id = getattr(logging_obj, "litellm_call_id", "unknown") @@ -646,8 +653,8 @@ async def run_chat_completion_agentic_hooks( kwargs=hook_kwargs, ) - if plan.response_override is not None: - return plan.response_override + if (response_override := _plan_response_override(plan)) is not None: + return response_override if plan.terminate: verbose_logger.debug( "Agentic chat loop terminated by callback=%s reason=%s", type(callback).__name__, plan.stop_reason diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 8dec2941e02..71b20f92cf7 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -102,7 +102,7 @@ _LAX_BOOL: Final = TypeAdapter(bool) def as_str_mapping(value: object) -> Mapping[str, object] | None: - return value if isinstance(value, Mapping) else None + return value if isinstance(value, (dict, Mapping)) else None def as_object_list(value: object) -> Sequence[object] | None: @@ -180,7 +180,24 @@ def require_attributes(value: object, shape: type[_Shape], *names: str) -> _Shap raise AttributeError(f"'{type(value).__name__}' object has no attribute '{missing}'", name=missing, obj=value) +class KeywordArguments(Protocol): + def keys(self) -> Iterable[str]: ... + + def __getitem__(self, key: str, /) -> object: ... + + +def keyword_arguments(value: object, target: type[object]) -> KeywordArguments: + if has_attributes(value, KeywordArguments, "keys"): + return value + raise TypeError( + f"{target.__module__}.{target.__qualname__}() argument after ** must be a mapping, not {type(value).__name__}" + ) + + def iter_items(value: object) -> Iterable[object]: + listed: Final = as_object_list(value) + if listed is not None: + return listed if isinstance(value, _SupportsIter): return value if isinstance(value, _SupportsGetItem): @@ -746,17 +763,22 @@ def process_response_headers( return {**additional_headers, **({"request-id": request_id} if request_id is not None else {})} -def preserve_upstream_non_openai_attributes( - model_response: "ModelResponseStream", original_chunk: "ModelResponseStream" -): +def preserve_upstream_non_openai_attributes(model_response: "ModelResponseStream", original_chunk: object): """ Preserve non-OpenAI attributes from the original chunk. """ # Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings expected_keys: Final = frozenset(type(model_response).model_fields) | frozenset({"usage"}) + chunk_model: Final = original_chunk if isinstance(original_chunk, BaseModel) else None + if chunk_model is None: + raise AttributeError( + f"type object '{type(original_chunk).__name__}' has no attribute 'model_fields'", + name="model_fields", + obj=type(original_chunk), + ) candidate_keys: Final = tuple( key - for key in (*type(original_chunk).model_fields, *(original_chunk.__pydantic_extra__ or {})) + for key in (*type(chunk_model).model_fields, *(chunk_model.__pydantic_extra__ or {})) if key not in expected_keys ) if not candidate_keys: @@ -766,7 +788,7 @@ def preserve_upstream_non_openai_attributes( key, cast( # cast-ok: Pydantic extras are scalar-checked before assignment object, - getattr(original_chunk, key), + getattr(chunk_model, key), ), ) for key in candidate_keys @@ -774,7 +796,7 @@ def preserve_upstream_non_openai_attributes( if all(value is None or isinstance(value, (str, int, float, bool)) for _, value in candidate_values): _assign_attributes(model_response, candidate_values) return - _assign_attributes(model_response, original_chunk.model_dump(include=frozenset(candidate_keys)).items()) + _assign_attributes(model_response, chunk_model.model_dump(include=set(candidate_keys)).items()) def _assign_attributes(target: BaseModel, values: Iterable[tuple[str, object]]) -> None: diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 6d39f272f3e..16cd076db39 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -17,7 +17,7 @@ from litellm.litellm_core_utils.bug_report import ( build_bug_report, should_report_bug, ) -from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none +from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none, json_contains, subscript from litellm.litellm_core_utils.secret_redaction import redact_string as redact_secret_string from litellm.llms.bedrock.common_utils import bedrock_model_reaches_converse_by_default from litellm.llms.bigmodel.common_utils import raise_bigmodel_business_error @@ -1859,26 +1859,32 @@ def _map_together_ai_exception( extra_information: str, ) -> None: try: - error_response = json.loads(error_str) + error_response = _DECODED_ERROR_BODY.validate_python(json.loads(error_str)) except Exception: error_response = {"error": error_str} - if "error" in error_response and "`inputs` tokens + `max_new_tokens` must be <=" in error_response["error"]: + if json_contains(error_response, "error") and json_contains( + subscript(error_response, "error"), "`inputs` tokens + `max_new_tokens` must be <=" + ): raise ContextWindowExceededError( - message=f"TogetherAIException - {error_response['error']}", + message=f"TogetherAIException - {subscript(error_response, 'error')}", model=model, llm_provider="together_ai", response=getattr(original_exception, "response", None), ) - elif "error" in error_response and "invalid private key" in error_response["error"]: + elif json_contains(error_response, "error") and json_contains( + subscript(error_response, "error"), "invalid private key" + ): raise AuthenticationError( - message=f"TogetherAIException - {error_response['error']}", + message=f"TogetherAIException - {subscript(error_response, 'error')}", llm_provider="together_ai", model=model, response=getattr(original_exception, "response", None), ) - elif "error" in error_response and "INVALID_ARGUMENT" in error_response["error"]: + elif json_contains(error_response, "error") and json_contains( + subscript(error_response, "error"), "INVALID_ARGUMENT" + ): raise BadRequestError( - message=f"TogetherAIException - {error_response['error']}", + message=f"TogetherAIException - {subscript(error_response, 'error')}", model=model, llm_provider="together_ai", response=getattr(original_exception, "response", None), @@ -1890,13 +1896,13 @@ def _map_together_ai_exception( llm_provider="together_ai", ) elif ( - "error" in error_response - and "API key doesn't match expected format." in error_response["error"] - or "error_type" in error_response - and error_response["error_type"] == "validation" + json_contains(error_response, "error") + and json_contains(subscript(error_response, "error"), "API key doesn't match expected format.") + or json_contains(error_response, "error_type") + and subscript(error_response, "error_type") == "validation" ): raise BadRequestError( - message=f"TogetherAIException - {error_response['error']}", + message=f"TogetherAIException - {subscript(error_response, 'error')}", model=model, llm_provider="together_ai", response=getattr(original_exception, "response", None), @@ -1910,7 +1916,7 @@ def _map_together_ai_exception( ) elif original_exception.status_code == 422: raise BadRequestError( - message=f"TogetherAIException - {error_response['error']}", + message=f"TogetherAIException - {subscript(error_response, 'error')}", model=model, llm_provider="together_ai", response=getattr(original_exception, "response", None), diff --git a/litellm/litellm_core_utils/json_validation_rule.py b/litellm/litellm_core_utils/json_validation_rule.py index b6b16aff7a7..030bee48530 100644 --- a/litellm/litellm_core_utils/json_validation_rule.py +++ b/litellm/litellm_core_utils/json_validation_rule.py @@ -1,9 +1,11 @@ import json -from typing import Any, Final +from collections.abc import Mapping +from typing import Final from pydantic import TypeAdapter from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import is_str_keyed_dict _DECODED_RESPONSE: Final = TypeAdapter(object) @@ -76,7 +78,7 @@ def normalize_json_schema_types( return schema -def normalize_tool_schema(tool: dict[str, Any]) -> dict[str, Any]: +def normalize_tool_schema(tool: Mapping[str, object]) -> Mapping[str, object]: """ Normalize a tool's parameter schema to use standard JSON Schema lowercase types. @@ -92,10 +94,14 @@ def normalize_tool_schema(tool: dict[str, Any]) -> dict[str, Any]: normalized_tool: Final = tool.copy() # Normalize function parameters if present - if "function" in tool and isinstance(tool["function"], dict): - normalized_tool["function"] = tool["function"].copy() - if "parameters" in tool["function"]: - normalized_tool["function"]["parameters"] = normalize_json_schema_types(tool["function"]["parameters"]) + if "function" not in tool: + return normalized_tool + function: Final = tool["function"] + if is_str_keyed_dict(function): + normalized_function: Final = function.copy() + normalized_tool["function"] = normalized_function + if "parameters" in function: + normalized_function["parameters"] = normalize_json_schema_types(function["parameters"]) return normalized_tool diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 8f3101915be..5d21b2762cd 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -25,6 +25,7 @@ from httpx import Response, Timeout from openai import AsyncStream as OpenAIAsyncStream from openai import Stream as OpenAIStream from pydantic import BaseModel, JsonValue, TypeAdapter +from typing_extensions import TypedDict import litellm from litellm._internal_context import in_mirrored_call, outside_mirrored_call, post_response_phase @@ -91,8 +92,11 @@ from litellm.litellm_core_utils.classifier_logging import ( ) from litellm.litellm_core_utils.core_helpers import ( as_str_mapping, + attribute_or_none, + decoded_json_object, finite_cost, get_provider_response_headers_from_hidden_params, + has_attributes, is_expected_client_error, native_messages_reported_cost, provider_reported_usage_cost, @@ -103,6 +107,7 @@ from litellm.litellm_core_utils.core_helpers import ( require_attributes, set_response_cost_in_hidden_params, single_log_line, + subscript, ) from litellm.litellm_core_utils.error_normalization import normalize_error from litellm.litellm_core_utils.get_litellm_params import get_litellm_params @@ -669,6 +674,10 @@ class UsageObservation(Enum): ESTIMATED = "estimated" +class _UsageFields(TypedDict, total=False, extra_items=object): + pass + + class _A2AUsageSlot(Protocol): usage: Mapping[str, object] | None @@ -685,6 +694,31 @@ class _ParamsLookup(Protocol): def get(self, key: str, default: object, /) -> object: ... +class _HiddenParamsSlot(Protocol): + get: Callable[[str], object] + __setitem__: Callable[[str, object], None] + + +class _HiddenParamsCopier(Protocol): + copy: Callable[[], object] + + +class _MetadataDefaults(Protocol): + setdefault: Callable[[str, Mapping[str, object]], object] + + +class _MetadataSlot(Protocol): + __setitem__: Callable[[str, object], None] + + +class _MetadataUpdate(Protocol): + update: Callable[[Mapping[str, object]], None] + + +class _ExceptionHeaders(Protocol): + headers: object + + def _callback_control_params(litellm_params: object) -> dict[str, object]: mapping: Final = as_str_mapping(litellm_params) if isinstance(mapping, dict): @@ -1682,8 +1716,10 @@ class Logging(LiteLLMLoggingBaseClass): ) # log raw request to provider (like LangFuse) -- if opted in. if self.log_raw_request_response is True or litellm.log_raw_request_response is True: - _litellm_params: Final = self.model_call_details.get("litellm_params", {}) - _metadata: Final = _litellm_params.get("metadata", {}) or {} + _litellm_params: Final = require_attributes( + self._model_call_details_view.get("litellm_params", dict[str, object]()), _ParamsLookup, "get" + ) + _metadata: Final = _litellm_params.get("metadata", dict[str, object]()) or dict[str, object]() try: self.model_call_details["raw_request_typed_dict"] = RawRequestTypedDict( raw_request_api_base=self._get_masked_api_base(str(additional_args.get("api_base") or "")), @@ -1697,6 +1733,8 @@ class Logging(LiteLLMLoggingBaseClass): error=None, ) if should_redact_message_logging(self.model_call_details): + if not has_attributes(_metadata, _MetadataSlot, "__setitem__"): + raise TypeError(f"'{type(_metadata).__name__}' object does not support item assignment") _metadata["raw_request"] = REDACTED_BY_LITELLM else: curl_command: Final = self._get_request_curl_command( @@ -1705,15 +1743,21 @@ class Logging(LiteLLMLoggingBaseClass): additional_args=additional_args, data=additional_args.get("complete_input_dict", {}), ) - _metadata["raw_request"] = redact_string(str(curl_command)) + redacted_curl_command: Final = redact_string(str(curl_command)) + if not has_attributes(_metadata, _MetadataSlot, "__setitem__"): + raise TypeError(f"'{type(_metadata).__name__}' object does not support item assignment") + _metadata["raw_request"] = redacted_curl_command except Exception as e: self.model_call_details["raw_request_typed_dict"] = RawRequestTypedDict( error=str(e), ) - _metadata["raw_request"] = redact_string( + unlogged_raw_request: Final = redact_string( f"Unable to Log \ raw request: {e}" ) + if not has_attributes(_metadata, _MetadataSlot, "__setitem__"): + raise TypeError(f"'{type(_metadata).__name__}' object does not support item assignment") + _metadata["raw_request"] = unlogged_raw_request if getattr(self, "logger_fn", None) and callable(self.logger_fn): try: self.logger_fn( @@ -2344,7 +2388,7 @@ class Logging(LiteLLMLoggingBaseClass): def _record_zero_cost_diagnostic( self, result: object, - response_cost: float | None, + response_cost: object, *, calculation_failed: bool = False, litellm_model_name: str | None = None, @@ -2375,7 +2419,7 @@ class Logging(LiteLLMLoggingBaseClass): def _zero_cost_finding( self, result: object, - response_cost: float | None, + response_cost: object, *, calculation_failed: bool, litellm_model_name: str | None, @@ -3079,19 +3123,21 @@ class Logging(LiteLLMLoggingBaseClass): elif self.relays_llm_passthrough and isinstance(result, Response): from litellm.utils import ProviderConfigManager - provider_config: Final = ProviderConfigManager.get_provider_passthrough_config( - provider=self.model_call_details.get("custom_llm_provider", ""), - model=self.model, - ) - if provider_config is not None: - logging_result = provider_config.logging_non_streaming_response( + passthrough_provider: Final = self._model_call_details_view.get("custom_llm_provider", "") + if isinstance(passthrough_provider, str): + provider_config: Final = ProviderConfigManager.get_provider_passthrough_config( + provider=passthrough_provider, model=self.model, - custom_llm_provider=self.model_call_details.get("custom_llm_provider", ""), - httpx_response=result, - request_data=self.model_call_details.get("request_data", {}), - logging_obj=self, - endpoint=self.model_call_details.get("endpoint", ""), ) + if provider_config is not None: + logging_result = provider_config.logging_non_streaming_response( + model=self.model, + custom_llm_provider=passthrough_provider, + httpx_response=result, + request_data=self.model_call_details.get("request_data", {}), + logging_obj=self, + endpoint=self.model_call_details.get("endpoint", ""), + ) return logging_result def _surface_response_headers_from_result(self, logging_result: object) -> None: @@ -3138,19 +3184,25 @@ class Logging(LiteLLMLoggingBaseClass): """ if logging_result is None: return - hidden_params: Final = getattr(logging_result, HIDDEN_PARAMS_ATTR, None) + hidden_params: Final = attribute_or_none(logging_result, HIDDEN_PARAMS_ATTR) if not hidden_params: return if self.model_call_details.get("litellm_params") is None: return - metadata_hidden_params: Final = hidden_params.copy() + metadata_hidden_params: Final = require_attributes( + require_attributes(hidden_params, _HiddenParamsCopier, "copy").copy(), _HiddenParamsSlot, "get" + ) response_cost: Final[object] = self.model_call_details.get("response_cost") if metadata_hidden_params.get("response_cost") is None and response_cost is not None: metadata_hidden_params["response_cost"] = response_cost - litellm_params: Final = self.model_call_details["litellm_params"] - metadata: Final = litellm_params.get("metadata") or {} + litellm_params: Final = require_attributes( + self._model_call_details_view["litellm_params"], _HiddenParamsSlot, "get" + ) + metadata: Final = litellm_params.get("metadata") or dict[str, object]() litellm_params["metadata"] = metadata + if not has_attributes(metadata, _MetadataSlot, "__setitem__"): + raise TypeError(f"'{type(metadata).__name__}' object does not support item assignment") metadata["hidden_params"] = metadata_hidden_params def _process_hidden_params_and_response_cost( @@ -3166,7 +3218,9 @@ class Logging(LiteLLMLoggingBaseClass): hidden_params: Final = getattr(logging_result, HIDDEN_PARAMS_ATTR, {}) if hidden_params: if self.model_call_details.get("litellm_params") is not None: - self.model_call_details["litellm_params"].setdefault("metadata", {}) + require_attributes( + self._model_call_details_view["litellm_params"], _MetadataDefaults, "setdefault" + ).setdefault("metadata", {}) if self.model_call_details["litellm_params"]["metadata"] is None: self.model_call_details["litellm_params"]["metadata"] = {} self.model_call_details["litellm_params"]["metadata"]["hidden_params"] = getattr( @@ -3179,8 +3233,9 @@ class Logging(LiteLLMLoggingBaseClass): self.model_call_details["response_cost"] = 0.0 self._add_failed_attempts_spend(get_usage_object(completion_response=logging_result)) elif "response_cost" in hidden_params: - self.model_call_details["response_cost"] = hidden_params["response_cost"] - self._record_zero_cost_diagnostic(logging_result, hidden_params["response_cost"]) + hidden_response_cost: Final = subscript(hidden_params, "response_cost") + self.model_call_details["response_cost"] = hidden_response_cost + self._record_zero_cost_diagnostic(logging_result, hidden_response_cost) self._add_failed_attempts_spend(get_usage_object(completion_response=logging_result)) elif (existing_cost := self.model_call_details.get("response_cost")) is not None and ( existing_cost != 0 or self.call_type == CallTypes.pass_through.value # pyright: ignore[reportUnknownMemberType] # Logging.call_type is untyped @@ -4659,10 +4714,23 @@ class Logging(LiteLLMLoggingBaseClass): elif self.model_call_details.get("combined_usage_object") is None: self.model_call_details["response_cost"] = 0 - if hasattr(exception, "headers") and isinstance(exception.headers, dict): + raised_exception: Final[object] = exception + exception_headers: Final = ( + as_str_mapping(raised_exception.headers) + if has_attributes(raised_exception, _ExceptionHeaders, "headers") + else None + ) + if isinstance(exception_headers, dict): self.model_call_details.setdefault("litellm_params", {}) - metadata: Final = self.model_call_details["litellm_params"].get("metadata", {}) or {} - metadata.update(exception.headers) + metadata: Final = require_attributes( + require_attributes(self._model_call_details_view["litellm_params"], _ParamsLookup, "get").get( + "metadata", dict[str, object]() + ) + or dict[str, object](), + _MetadataUpdate, + "update", + ) + metadata.update(exception_headers) ## STANDARDIZED LOGGING PAYLOAD @@ -6412,7 +6480,7 @@ def build_custom_logger_compatible_class( return callback # Get global BitBucket config - gitlab_config: Final = getattr(litellm, "global_gitlab_config", None) + gitlab_config: Final = litellm.global_gitlab_config if hasattr(litellm, "global_gitlab_config") else None if gitlab_config is None: raise ValueError("Gitlab configuration not found. Please set litellm.global_gitlab_config first.") @@ -7198,11 +7266,15 @@ class StandardLoggingPayloadSetup: if isinstance(reported, Usage): return reported normalized: Final = StandardLoggingPayloadSetup.get_usage_as_dict(response_obj) - return Usage(**normalized) if normalized else Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0) + return ( + Usage(**_UsageFields(**decoded_json_object(normalized))) + if normalized + else Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0) + ) @staticmethod def get_usage_as_dict( - response_obj: dict[str, Any] | None, + response_obj: Mapping[str, object] | None, combined_usage_object: Usage | None = None, ) -> dict[str, Any]: """ @@ -7226,19 +7298,23 @@ class StandardLoggingPayloadSetup: reported: Final = response_obj.get("usage_info") if isinstance(reported, BaseModel): return reported.model_dump(exclude_none=True) + reported_fields: Final = as_str_mapping(reported) return ( - {key: value for key, value in reported.items() if value is not None} - if isinstance(reported, dict) + {key: value for key, value in reported_fields.items() if value is not None} + if isinstance(reported_fields, dict) else dict(get_rerank_usage(_RESPONSE_MAPPING.validate_python(response_obj)) or _empty) ) if isinstance(_raw, ResponseAPIUsage): return ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(_raw).model_dump() - if isinstance(_raw, dict): - if ResponseAPILoggingUtils.is_response_api_usage(_raw): - return ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(_raw).model_dump() - if InteractionsUsageObjectTransformation.is_interactions_usage_object(_raw): - return InteractionsUsageObjectTransformation.transform_interactions_usage_object(_raw).model_dump() - return _raw + raw_fields: Final = as_str_mapping(_raw) + if isinstance(raw_fields, dict): + if ResponseAPILoggingUtils.is_response_api_usage(raw_fields): + return ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(raw_fields).model_dump() + if InteractionsUsageObjectTransformation.is_interactions_usage_object(raw_fields): + return InteractionsUsageObjectTransformation.transform_interactions_usage_object( + raw_fields + ).model_dump() + return raw_fields if isinstance(_raw, Usage): return _raw.model_dump() return _empty diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 1f6a66f699a..4ff82f705d9 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -1429,7 +1429,7 @@ def get_regional_uplift_multiplier(model_info: ModelInfo, data_residency: str | residency: Final = data_residency.lower() if residency not in _VALID_DATA_RESIDENCIES: return 1.0 - multiplier: Final = model_info.get(f"regional_processing_uplift_multiplier_{residency}") + multiplier: Final[object] = model_info.get(f"regional_processing_uplift_multiplier_{residency}") if multiplier is None: return 1.0 if not isinstance(multiplier, (str, Buffer, SupportsFloat, SupportsIndex)): diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 0fed2cb2fce..20d1205c005 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -18,6 +18,7 @@ from litellm.constants import ( STREAM_FINISH_CHOICE_FIELDS, VLLM_CHOICE_FIELDS, ) +from litellm.litellm_core_utils.core_helpers import iter_items, subscript from litellm.litellm_core_utils.hidden_params import get_hidden_params from litellm.litellm_core_utils.prompt_templates.reasoning_utils import ( extract_reasoning_content, @@ -68,6 +69,14 @@ _MODEL_RESPONSE_FIELDS: Final[frozenset] = frozenset(ModelResponse.model_fields. _ANNOTATION_ENTRIES: Final = TypeAdapter(tuple[object, ...]) +class _ImageResponseFields(TypedDict, total=False, extra_items=object): + pass + + +def _image_response_from_fields(fields: Mapping[str, object]) -> ImageResponse: + return ImageResponse(**_ImageResponseFields(**fields)) + + def _is_provider_specific_annotation(annotation: object) -> bool: match annotation: case {"type": "url_citation"}: @@ -639,7 +648,7 @@ class LiteLLMResponseObjectHandler: model_response_dict: Final = model_response_object.model_dump() model_response_dict.update(response_object) - model_response_object = ImageResponse(**model_response_dict) + model_response_object = _image_response_from_fields(model_response_dict) return model_response_object @staticmethod @@ -683,12 +692,12 @@ class LiteLLMResponseObjectHandler: choices_list: Final[list[TextChoices]] = [] # Convert each choice to TextChoices - for choice in response["choices"]: + for choice in iter_items(subscript(response, "choices")): text_choices = TextChoices() - text_choices["text"] = choice["message"]["content"] - text_choices["index"] = choice["index"] + text_choices["text"] = subscript(subscript(choice, "message"), "content") + text_choices["index"] = subscript(choice, "index") text_choices["logprobs"] = transformed_logprobs - text_choices["finish_reason"] = choice["finish_reason"] + text_choices["finish_reason"] = subscript(choice, "finish_reason") choices_list.append(text_choices) text_completion_response["choices"] = choices_list diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index ae36484b294..704af2b9ebd 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -13,7 +13,7 @@ from types import MappingProxyType from typing import Annotated, Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast, overload from jinja2.sandbox import ImmutableSandboxedEnvironment -from pydantic import BaseModel, BeforeValidator, ConfigDict, JsonValue, TypeAdapter, ValidationError, model_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, TypeAdapter, ValidationError, model_validator from pydantic_core import ErrorDetails, PydanticCustomError import litellm @@ -22,7 +22,12 @@ import litellm.types.llms from litellm import verbose_logger from litellm._uuid import uuid from litellm.constants import REDACTED_BY_LITELLM -from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none, require_attributes +from litellm.litellm_core_utils.core_helpers import ( + as_object_list, + as_str_mapping, + attribute_or_none, + require_attributes, +) from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import ( anthropic_system_messages, find_anthropic_server_tool_result, @@ -1455,22 +1460,25 @@ def convert_to_gemini_tool_call_invoke( raise Exception(f"Unable to convert openai tool calls={message} to gemini tool calls. Received error={e}") -def _json_children(node: JsonValue) -> Iterable[JsonValue]: - if isinstance(node, dict): - return node.values() - if isinstance(node, list): - return (child for child in node if not isinstance(child, list)) +def _json_children(node: object) -> Iterable[object]: + mapping: Final = as_str_mapping(node) + if mapping is not None: + return mapping.values() + items: Final = as_object_list(node) + if items is not None: + return (child for child in items if not isinstance(child, list)) return () -def _next_json_level(level: tuple[JsonValue, ...], _: None) -> tuple[JsonValue, ...]: +def _next_json_level(level: tuple[object, ...], _: None) -> tuple[object, ...]: return tuple(itertools.chain.from_iterable(_json_children(node) for node in level)) -def _is_part_reference(node: JsonValue) -> bool: - if not isinstance(node, dict): +def _is_part_reference(node: object) -> bool: + mapping: Final = as_str_mapping(node) + if mapping is None: return False - ref: Final[JsonValue] = node.get("$ref") + ref: Final = mapping.get("$ref") return isinstance(ref, str) and ref != "" @@ -1482,7 +1490,7 @@ def _may_spell_ref_key(raw_json: str) -> bool: return '"$ref"' in raw_json or _REF_CHAR_ESCAPE.search(raw_json) is not None -def _contains_part_reference(payload: JsonValue) -> bool: +def _contains_part_reference(payload: object) -> bool: """Gemini reads a {"$ref": ""} object in a function_response as a pointer to one of its parts, unless it sits under a list nested directly in a list @@ -1739,7 +1747,7 @@ def convert_to_gemini_tool_call_result( try: if content_str.strip().startswith("{") or content_str.strip().startswith("["): # Try to parse as JSON (for Computer Use structured responses) - parsed: Final[JsonValue] = json.loads(content_str) + parsed: Final = as_str_mapping(_DECODED_JSON.validate_python(json.loads(content_str))) if isinstance(parsed, dict) and not (_may_spell_ref_key(content_str) and _contains_part_reference(parsed)): response_data = parsed # Use the parsed JSON directly else: diff --git a/litellm/litellm_core_utils/prompt_templates/huggingface_template_handler.py b/litellm/litellm_core_utils/prompt_templates/huggingface_template_handler.py index d2531e70d6b..39baaed25e6 100644 --- a/litellm/litellm_core_utils/prompt_templates/huggingface_template_handler.py +++ b/litellm/litellm_core_utils/prompt_templates/huggingface_template_handler.py @@ -1,6 +1,6 @@ import json from datetime import datetime -from typing import Any, Final, Literal +from typing import Final, Literal from pydantic import TypeAdapter from typing_extensions import NotRequired, ReadOnly, TypedDict @@ -28,6 +28,10 @@ class _ChatTemplateFileResult(TypedDict): chat_template: NotRequired[ReadOnly[str]] +class _AddedToken(TypedDict, total=False, extra_items=object): + content: ReadOnly[str] + + def strftime_now(fmt: str) -> str: """ Custom function for templates that need current date/time formatting (e.g., gpt-oss) @@ -159,7 +163,7 @@ async def aget_chat_template_file(hf_model_name: str) -> _ChatTemplateFileResult _aget_chat_template_file = aget_chat_template_file -def extract_token_value(token_value: None | str | dict[str, Any]) -> str: +def extract_token_value(token_value: None | str | _AddedToken) -> str: """ Extract token string from various formats (string, dict, etc.) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index 01aa7296b0f..6d40d963d76 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -26,7 +26,7 @@ from litellm.constants import ( REALTIME_SESSION_SUCCESS_LOGGED_KEY, ) from litellm.litellm_core_utils.audio_utils.utils import normalized_audio_duration_seconds -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, decoded_json_object, json_contains from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.llms.base_llm.realtime.transcription_protocol import RealtimeTranscriptionProtocolError from litellm.llms.base_llm.realtime.transformation import ( @@ -220,9 +220,13 @@ def _unscreened_audio_error_event(client_event_id: object) -> Mapping[str, objec } +def _decode_json(payload: str) -> object: + return _DECODED_JSON.validate_python(json.loads(payload)) + + def _decode_json_object(payload: str) -> Mapping[str, object]: """Decode a realtime frame into its top-level field mapping.""" - return json.loads(payload) + return decoded_json_object(_decode_json(payload)) _GUARDRAIL_TURN_DETECTION_GATE: Final[Mapping[str, object]] = MappingProxyType( @@ -511,7 +515,7 @@ class RealTimeStreaming: self._held_client_content: tuple[str, ...] = () self._held_client_content_bytes: int = 0 self._held_client_content_deadline: float | None = None - self._replay_scope: Final = client_websocket_scope(websocket) + self._replay_scope: Final = client_websocket_scope(self.websocket) self.backend_ws = backend_ws self.logging_obj = logging_obj self._logging_worker = logging_worker @@ -533,7 +537,7 @@ class RealTimeStreaming: self._translation_usage_finalized = False # Detect whether the client is explicitly opting into the beta protocol. - self._client_wants_beta = self.detect_beta_header(websocket) + self._client_wants_beta = self.detect_beta_header(self.websocket) self._backend_uses_beta_protocol = not translation_session and ( self._client_wants_beta if backend_uses_beta_protocol is None else backend_uses_beta_protocol ) @@ -674,7 +678,7 @@ class RealTimeStreaming: if not self._should_store_message(message_obj): return try: - event_type: Final = message_obj.get("type", "") + event_type: Final = decoded_json_object(message_obj).get("type", "") if event_type in self._SESSION_EVENT_TYPES: typed_obj: OpenAIRealtimeEvents = OpenAIRealtimeStreamSessionEvents(**message_obj) else: @@ -1020,7 +1024,7 @@ class RealTimeStreaming: sent = True continue try: - msg_obj = _decode_json_object(msg) + msg_obj = as_str_mapping(_decode_json(msg)) except (json.JSONDecodeError, TypeError): msg_obj = None if isinstance(msg_obj, dict) and self.provider_config.is_setup_message(msg_obj): @@ -1162,13 +1166,15 @@ class RealTimeStreaming: return _session_update_frame(self._held_session_update or _NO_SESSION_FIELDS) async def _hold_session_update(self, message: str) -> None: - client_event: Final = _decode_json_object(message) + client_event: Final = _decode_json(message) if self._held_session_update_bytes + len(message.encode("utf-8")) > RealTimeStreaming._MAX_BUFFERED_BYTES: await self.websocket.send_text( - json.dumps(_HELD_SESSION_UPDATE_TOO_LARGE.error_event(client_event.get("event_id"))) + json.dumps( + _HELD_SESSION_UPDATE_TOO_LARGE.error_event(decoded_json_object(client_event).get("event_id")) + ) ) return - session: Final = client_event.get("session") + session: Final = decoded_json_object(client_event).get("session") update: Final = _SESSION_FIELDS.validate_python(session) if isinstance(session, Mapping) else _NO_SESSION_FIELDS held: Final = _merge_session_fields(self._held_session_update or _NO_SESSION_FIELDS, update) self._held_session_update = held @@ -1182,7 +1188,7 @@ class RealTimeStreaming: _session_update_frame(session), self.model, None ) return next( - (frame for frame in preview if isinstance(frame, str) and "setup" in _decode_json_object(frame)), None + (frame for frame in preview if isinstance(frame, str) and json_contains(_decode_json(frame), "setup")), None ) def _leaves_applied_setup_unchanged(self, update: Mapping[str, object]) -> bool: @@ -1194,7 +1200,7 @@ class RealTimeStreaming: return ( applied_setup is not None and merged_setup is not None - and _decode_json_object(applied_setup) == _decode_json_object(merged_setup) + and _decode_json(applied_setup) == _decode_json(merged_setup) ) async def _acknowledge_session_setup(self, session: Mapping[str, object]) -> None: @@ -1522,8 +1528,8 @@ class RealTimeStreaming: ``return_new_content_delta_events`` modality lookup, ...). """ try: - message_obj: Final = _decode_json_object(transformed_message) - if "setup" in message_obj: + message_obj: Final = _decode_json(transformed_message) + if json_contains(message_obj, "setup"): self.session_configuration_request = transformed_message except (json.JSONDecodeError, TypeError): return @@ -1852,7 +1858,7 @@ class RealTimeStreaming: def _parse_backend_event(raw_response: str) -> dict[str, object] | None: """Parse a backend frame once. Returns None for non-JSON or non-object frames.""" try: - event: Final = _decode_json_object(raw_response) + event: Final = as_str_mapping(_decode_json(raw_response)) except (json.JSONDecodeError, TypeError): return None return event if isinstance(event, dict) else None diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index c13c9bc31fb..3760d7f3059 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -351,7 +351,7 @@ def _redacted_web_search_action(action: object) -> object: def _redacted_web_search_call(item: object) -> object: if isinstance(item, BaseModel): - action: Final = getattr(item, "action", None) + action: Final = attribute_or_none(item, "action") return item if action is None else item.model_copy(update={"action": _redacted_web_search_action(action)}) mapping: Final = _as_mapping(item) if mapping is None or mapping.get("action") is None: diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index e2b651f8885..ee70152636f 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Final, Optional, Protocol, TypeAlias, TypedDic from pydantic import BaseModel, TypeAdapter, ValidationError from typing_extensions import ReadOnly, Required +from typing_extensions import TypedDict as ExtraItemsTypedDict import litellm from litellm._logging import verbose_logger @@ -19,6 +20,7 @@ from litellm.litellm_core_utils.core_helpers import ( chunk_usage, finite_cost, provider_reported_usage_cost, + require_attributes, stamp_litellm_bill_on_usage, ) from litellm.litellm_core_utils.hidden_params import ( @@ -82,6 +84,18 @@ if TYPE_CHECKING: ) +class _UsageFields(ExtraItemsTypedDict, total=False, extra_items=object): + pass + + +def _usage_fields(fields: Mapping[str, object]) -> _UsageFields: + return _UsageFields(**fields) + + +class _SupportsModelDump(Protocol): + def model_dump(self) -> Mapping[str, object]: ... + + class _ThinkingBlockFragment(TypedDict, total=False): type: str | None data: str | None @@ -946,12 +960,12 @@ class ChunkProcessor: usage_chunk = chunk.hidden_params.get("usage", None) if isinstance(usage_chunk, dict): - return Usage(**usage_chunk) + return Usage(**_usage_fields(usage_chunk)) if isinstance(usage_chunk, UnreportedUsage): return None if usage_chunk is None or isinstance(usage_chunk, Usage): return usage_chunk - return Usage(**usage_chunk.model_dump()) + return Usage(**_usage_fields(require_attributes(usage_chunk, _SupportsModelDump, "model_dump").model_dump())) def _calculate_usage_per_chunk( self, @@ -1275,7 +1289,7 @@ class ChunkProcessor: if value is not None } - returned_usage = Usage(**returned_usage.model_dump(), **provider_pricing_fields) + returned_usage = Usage(**_usage_fields(returned_usage.model_dump()), **provider_pricing_fields) return returned_usage diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 079b29d5509..62ba7bb0e04 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -27,11 +27,17 @@ from litellm.constants import STREAM_CHUNK_CHOICE_FIELDS, STREAM_FINISH_CHOICE_F from litellm.litellm_core_utils.asyncify import asyncify from litellm.litellm_core_utils.core_helpers import ( UPSTREAM_USAGE_COST, + as_str_mapping, attribute_or_none, chunk_usage, decoded_json_object, + has_attributes, + json_contains, provider_reported_usage_cost, + require_attributes, stamp_litellm_bill_on_usage, + subscript, + without, ) from litellm.litellm_core_utils.hidden_params import ( HIDDEN_PARAMS_ATTR, @@ -195,6 +201,10 @@ def _response_choice_index(response_obj: Mapping[str, object]) -> int: return _upstream_choice_index(response_obj.get("original_chunk")) +def _delta_choice_index(completion_fields: Mapping[str, object], response_fields: Mapping[str, object]) -> object: + return completion_fields.get("index", response_fields.get("choice_index")) + + def _non_null_upstream_choice_fields(upstream_chunk: object, fields: Sequence[str]) -> Mapping[str, object]: first_choice: Final = _upstream_first_choice(upstream_chunk) upstream_extras: Final = first_choice.model_extra if first_choice is not None else None @@ -231,13 +241,21 @@ def _holds_only_scalars(fields: Iterable[tuple[str, object]]) -> bool: return all(value is None or isinstance(value, (str, int, float, bool)) for _, value in fields) +class _KeywordFields(TypedDict, total=False, extra_items=object): + pass + + +def _keyword_fields(fields: Mapping[str, object]) -> _KeywordFields: + return _KeywordFields(**fields) + + def _delta_from_upstream(upstream_delta: "BaseModel | _UpstreamDeltaLike | None") -> Delta | None: if upstream_delta is None: return Delta() if not isinstance(upstream_delta, BaseModel): return None try: - return Delta(**dict(iter(upstream_delta))) + return Delta(**_keyword_fields(dict(iter(upstream_delta)))) except Exception: return None @@ -275,7 +293,7 @@ def print_verbose(print_statement: object): @dataclass(frozen=True, slots=True) class _ProviderChunkParsed: - response_obj: dict[str, object] + response_obj: Mapping[str, object] @dataclass(frozen=True, slots=True) @@ -286,19 +304,8 @@ class _ProviderChunkEarlyReturn: _ProviderChunkResult = _ProviderChunkParsed | _ProviderChunkEarlyReturn -class _PredibaseStreamData(TypedDict): - token: NotRequired[Mapping[str, str]] - details: Mapping[str, str] - generated_text: str | None - error: str | None - - -class _NlpCloudStreamData(TypedDict): - generated_text: str - - -class _AlephAlphaStreamData(TypedDict): - completions: Sequence[Mapping[str, str]] +class _Replaceable(Protocol): + def replace(self, old: str, new: str, /) -> object: ... class _DeltaDumpDict(TypedDict): @@ -365,6 +372,32 @@ class _VertexChunkLike(Protocol): candidates: Sequence[_VertexCandidateLike] +class _TokenTotalsLike(Protocol): + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +class _ParamsLookupLike(Protocol): + def get(self, key: str, default: object, /) -> object: ... + + +class _IdentifiedChunkLike(Protocol): + id: str + + +class _Decodable(Protocol): + def decode(self, encoding: str, /) -> str: ... + + +class _VllmRequestOutputLike(Protocol): + outputs: object + + +class _VllmCompletionOutputLike(Protocol): + text: object + + class _ParsedChunkHiddenParams(LiteLLMBaseModel): provider_specific_fields: Mapping[str, object] | None = None upstream_provider: str | None = None @@ -783,28 +816,33 @@ class CustomStreamWrapper: self.holding_chunk = "" return hold, curr_chunk - def handle_predibase_chunk(self, chunk): + def handle_predibase_chunk(self, chunk: object): try: if not isinstance(chunk, str): - chunk = chunk.decode("utf-8") # DO NOT REMOVE this: This is required for HF inference API + Streaming + chunk = require_attributes(chunk, _Decodable, "decode").decode( + "utf-8" + ) # DO NOT REMOVE this: This is required for HF inference API + Streaming text = "" is_finished = False finish_reason = "" print_verbose(f"chunk: {chunk}") if chunk.startswith("data:"): - data_json: Final[_PredibaseStreamData] = json.loads(chunk[5:]) + data_json: Final = _DECODED_JSON.validate_python(json.loads(chunk[5:])) print_verbose(f"data json: {data_json}") - if "token" in data_json and "text" in data_json["token"]: - text = data_json["token"]["text"] - if data_json.get("details", False) and data_json["details"].get("finish_reason", False): + if json_contains(data_json, "token") and json_contains(subscript(data_json, "token"), "text"): + text = subscript(subscript(data_json, "token"), "text") + data_lookup: Final = require_attributes(data_json, _ParamsLookupLike, "get") + if data_lookup.get("details", False) and require_attributes( + subscript(data_json, "details"), _ParamsLookupLike, "get" + ).get("finish_reason", False): is_finished = True - finish_reason = data_json["details"]["finish_reason"] - elif data_json.get("generated_text", False): # if full generated text exists, then stream is complete + finish_reason = subscript(subscript(data_json, "details"), "finish_reason") + elif data_lookup.get("generated_text", False): # if full generated text exists, then stream is complete text = "" # don't return the final bos token is_finished = True finish_reason = "stop" - elif data_json.get("error", False): - raise Exception(data_json.get("error")) + elif data_lookup.get("error", False): + raise Exception(data_lookup.get("error", None)) return { "text": text, "is_finished": is_finished, @@ -828,11 +866,11 @@ class CustomStreamWrapper: if self.model and "dolphin" in self.model: chunk = self.process_chunk(chunk=chunk) else: - data_json: Final[_NlpCloudStreamData] = json.loads(chunk) - chunk = data_json["generated_text"] + data_json: Final = _DECODED_JSON.validate_python(json.loads(chunk)) + chunk = subscript(data_json, "generated_text") text = chunk - if "[DONE]" in text: - text = text.replace("[DONE]", "") + if json_contains(text, "[DONE]"): + text = require_attributes(text, _Replaceable, "replace").replace("[DONE]", "") is_finished = True finish_reason = "stop" return { @@ -845,9 +883,9 @@ class CustomStreamWrapper: def handle_aleph_alpha_chunk(self, chunk): chunk = chunk.decode("utf-8") - data_json: Final[_AlephAlphaStreamData] = json.loads(chunk) + data_json: Final = _DECODED_JSON.validate_python(json.loads(chunk)) try: - text: Final = data_json["completions"][0]["completion"] + text: Final = subscript(subscript(subscript(data_json, "completions"), 0), "completion") is_finished: Final = True finish_reason: Final = "stop" return { @@ -1014,20 +1052,17 @@ class CustomStreamWrapper: def model_response_creator( self, - chunk: dict | None = None, + chunk: dict[str, object] | None = None, hidden_params: Mapping[str, object] | None = None, delta: Delta | None = None, ) -> ModelResponseStream: _model: Final = self._served_openrouter_model() or self._cached_model_name _logging_obj_llm_provider: Final = self._cached_logging_llm_provider - if chunk is None: - args: dict[str, Any] = {"model": _model} - else: + if chunk is not None: chunk.pop("model", None) - args = {"model": _model} - if chunk: - args.update({k: v for k, v in chunk.items() if k != "stream"}) + chunk_fields: Final[Mapping[str, object]] = {k: v for k, v in chunk.items() if k != "stream"} if chunk else {} + args: Final = _keyword_fields({"model": _model, **chunk_fields}) model_response: Final = ( ModelResponseStream(**args) @@ -1108,7 +1143,8 @@ class CustomStreamWrapper: provider_specific_fields: Final = getattr(original_chunk, "provider_specific_fields", None) if provider_specific_fields is not None: model_response.provider_specific_fields = provider_specific_fields - _apply_provider_fields(model_response, provider_specific_fields) + if model_response.provider_specific_fields is not None: + _apply_provider_fields(model_response, model_response.provider_specific_fields) return model_response def is_chunk_non_empty( @@ -1169,10 +1205,8 @@ class CustomStreamWrapper: model_response.choices[0].delta["role"] = "assistant" self.sent_first_chunk = True elif self.sent_first_chunk is True and hasattr(model_response.choices[0].delta, "role"): - _initial_delta: Final = model_response.choices[0].delta.model_dump() - - _initial_delta.pop("role", None) - set_extra_attribute(model_response.choices[0], "delta", Delta(**_initial_delta)) + _initial_delta: Final = without(model_response.choices[0].delta.model_dump(), "role") + set_extra_attribute(model_response.choices[0], "delta", Delta(**_keyword_fields(_initial_delta))) return model_response def _prebuilt_plain_delta(self, chunk: object) -> Delta | None: @@ -1275,7 +1309,7 @@ class CustomStreamWrapper: self, completion_obj: dict[str, Any], model_response: ModelResponseStream, - response_obj: dict[str, Any], + response_obj: Mapping[str, Any], plain_delta_created: bool = False, ): from litellm.litellm_core_utils.core_helpers import ( @@ -1300,19 +1334,15 @@ class CustomStreamWrapper: if hold is False: ## check if openai/azure chunk - original_chunk: Final = response_obj.get("original_chunk", None) + original_chunk: Final[object] = response_obj.get("original_chunk", None) if original_chunk: - upstream_chunk: Final[_UpstreamChunkLike] = original_chunk + upstream_chunk: Final = require_attributes(original_chunk, _UpstreamChunkLike, "choices") if len(upstream_chunk.choices) > 0: - original_chunk_for_reuse: Final[object] = cast( # cast-ok: runtime type-check before access - object, - original_chunk, - ) upstream_choice: Final = ( - original_chunk_for_reuse.choices[0] + original_chunk.choices[0] if plain_delta_created - and isinstance(original_chunk_for_reuse, (ChatCompletionChunk, ModelResponseStream)) - and len(original_chunk_for_reuse.choices) == 1 + and isinstance(original_chunk, (ChatCompletionChunk, ModelResponseStream)) + and len(original_chunk.choices) == 1 else None ) upstream_delta: Final = cast( # cast-ok: [LIT006] runtime delta may be absent @@ -1343,13 +1373,13 @@ class CustomStreamWrapper: for choice in upstream_chunk.choices: try: if isinstance(choice, BaseModel): - choice_json = choice.model_dump() - choice_json.pop( - "finish_reason", None - ) # for mistral etc. which return a value in their last chunk (not-openai compatible). - choice_json.pop("logprobs", None) choices.append( - StreamingChoices(**choice_json, logprobs=getattr(choice, "logprobs", None)) + StreamingChoices( + **_keyword_fields( + without(choice.model_dump(), "finish_reason", "logprobs") + ), # for mistral etc. which return a value in their last chunk (not-openai compatible). + logprobs=getattr(choice, "logprobs", None), + ) ) except Exception: choices.append(StreamingChoices()) @@ -1377,10 +1407,10 @@ class CustomStreamWrapper: self.sent_first_chunk = True if response_obj.get("provider_specific_fields") is not None: completion_obj["provider_specific_fields"] = response_obj["provider_specific_fields"] - model_response.choices[0].delta = Delta(**completion_obj) - _index: Final[int | None] = completion_obj.get("index", response_obj.get("choice_index")) + model_response.choices[0].delta = Delta(**_keyword_fields(completion_obj)) + _index: Final = _delta_choice_index(completion_obj, response_obj) if _index is not None: - model_response.choices[0].index = _index + set_extra_attribute(model_response.choices[0], "index", _index) _apply_choice_fields(model_response.choices[0], chunk_choice_fields) if sibling_finish_reason is not None: @@ -1436,7 +1466,7 @@ class CustomStreamWrapper: # Preserve custom attributes from original chunk (applies to both # empty and non-empty delta final chunks). - _original_chunk: Final = response_obj.get("original_chunk", None) + _original_chunk: Final[object] = response_obj.get("original_chunk", None) if _original_chunk is not None: preserve_upstream_non_openai_attributes( model_response=model_response, @@ -1504,7 +1534,7 @@ class CustomStreamWrapper: def _dispatch_provider_chunk( self, - chunk: Any, + chunk: object, model_response: ModelResponseStream, completion_obj: dict[str, object], ) -> _ProviderChunkResult: @@ -1532,19 +1562,23 @@ class CustomStreamWrapper: chunk.choices[0].finish_reason = None return _ProviderChunkEarlyReturn(chunk) + chunk_fields: Final = as_str_mapping(chunk) + generic_chunk: Final = chunk_fields if isinstance(chunk_fields, dict) else None if ( - isinstance(chunk, dict) - and generic_chunk_has_all_required_fields(chunk=chunk) # check if chunk is a generic streaming chunk + # check if chunk is a generic streaming chunk + generic_chunk is not None and generic_chunk_has_all_required_fields(chunk=generic_chunk) ) or (self.custom_llm_provider and self.custom_llm_provider in litellm._custom_providers): if self.received_finish_reason is not None: - _chunk_has_content: Final = isinstance(chunk, dict) and ( - bool(chunk.get("text", "")) - or chunk.get("tool_use") is not None + _chunk_has_content: Final = generic_chunk is not None and ( + bool(generic_chunk.get("text", "")) + or generic_chunk.get("tool_use") is not None # Usage-only final chunks are valid and needed to surface # finish_reason/usage to downstream translators. - or chunk.get("usage") is not None + or generic_chunk.get("usage") is not None ) - if not _chunk_has_content and (not isinstance(chunk, dict) or "provider_specific_fields" not in chunk): + if not _chunk_has_content and ( + generic_chunk is None or "provider_specific_fields" not in generic_chunk + ): raise StopIteration anthropic_response_obj: Final[GChunk] = cast(GChunk, chunk) completion_obj["content"] = anthropic_response_obj["text"] @@ -1582,7 +1616,12 @@ class CustomStreamWrapper: if response_obj["is_finished"]: self.received_finish_reason = response_obj["finish_reason"] elif self.custom_llm_provider and self.custom_llm_provider == "vllm": - completion_obj["content"] = chunk[0].outputs[0].text + vllm_output: Final = require_attributes( + subscript(require_attributes(subscript(chunk, 0), _VllmRequestOutputLike, "outputs").outputs, 0), + _VllmCompletionOutputLike, + "text", + ) + completion_obj["content"] = vllm_output.text elif ( self.custom_llm_provider and self.custom_llm_provider == "aleph_alpha" ): # aleph alpha doesn't provide streaming @@ -1689,7 +1728,13 @@ class CustomStreamWrapper: if response_obj["is_finished"] and response_obj["sibling_finish_reason"] is None: self.received_finish_reason = response_obj["finish_reason"] if response_obj["usage"] is not None: - _text_completion_usage: Final[Usage] = response_obj["usage"] + _text_completion_usage: Final = require_attributes( + _DECODED_JSON.validate_python(response_obj["usage"]), + _TokenTotalsLike, + "prompt_tokens", + "completion_tokens", + "total_tokens", + ) setattr( model_response, "usage", @@ -1711,7 +1756,13 @@ class CustomStreamWrapper: if response_obj["is_finished"]: self.received_finish_reason = response_obj["finish_reason"] if "usage" in response_obj is not None: - _codestral_usage: Final[Usage] = response_obj["usage"] + _codestral_usage: Final = require_attributes( + _DECODED_JSON.validate_python(response_obj["usage"]), + _TokenTotalsLike, + "prompt_tokens", + "completion_tokens", + "total_tokens", + ) setattr( model_response, "usage", @@ -1813,14 +1864,14 @@ class CustomStreamWrapper: ) return _ProviderChunkParsed(response_obj) - def chunk_creator(self, chunk: Any): + def chunk_creator(self, chunk: object): if isinstance(chunk, ModelResponseStream) and chunk.provider_specific_fields: self.logging_obj.record_native_bedrock_guardrail(chunk.provider_specific_fields) if self.custom_llm_provider != "cached_response" and isinstance( chunk, (ChatCompletionChunk, ModelResponseStream) ): self._ollama_response_ids.observe(chunk) - if hasattr(chunk, "id"): + if has_attributes(chunk, _IdentifiedChunkLike, "id"): self.response_id = chunk.id if self._ollama_response_ids.response_id is not None: self.response_id = self._ollama_response_ids.response_id @@ -1840,10 +1891,10 @@ class CustomStreamWrapper: ), delta=prebuilt_delta, ) - response_obj: dict[str, Any] = {} + response_obj: Mapping[str, object] = {} try: # return this for all models - completion_obj: Final[dict[str, Any]] = {"content": ""} + completion_obj: Final[dict[str, object]] = {"content": ""} dispatch_result: Final = self._dispatch_provider_chunk( chunk=chunk, model_response=model_response, @@ -1855,24 +1906,23 @@ class CustomStreamWrapper: model_response.model = self._served_openrouter_model() or self.model ## FUNCTION CALL PARSING - original_chunk: Final[_UpstreamChunkLike | None] = ( - response_obj.get("original_chunk") if response_obj is not None else None - ) + original_chunk: Final = response_obj.get("original_chunk") if response_obj is not None else None if ( original_chunk is not None ): # function / tool calling branch - only set for openai/azure compatible endpoints # enter this branch when no content has been passed in response - if hasattr(original_chunk, "id"): + if has_attributes(original_chunk, _IdentifiedChunkLike, "id"): model_response = self.set_model_id(original_chunk.id, model_response) if stored_attribute(original_chunk, "provider_specific_fields") is not None: model_response = self.copy_model_response_level_provider_specific_fields( original_chunk, model_response ) - if original_chunk.choices and len(original_chunk.choices) > 0: - delta: Final = original_chunk.choices[0].delta + upstream_chunk: Final = require_attributes(original_chunk, _UpstreamChunkLike, "choices") + if upstream_chunk.choices and len(upstream_chunk.choices) > 0: + delta: Final = upstream_chunk.choices[0].delta if delta is not None and (delta.function_call is not None or delta.tool_calls is not None): try: - model_response.system_fingerprint = original_chunk.system_fingerprint + model_response.system_fingerprint = upstream_chunk.system_fingerprint ## AZURE - check if arguments is not None if delta.function_call is not None: if delta.function_call.arguments is None: @@ -1922,7 +1972,7 @@ class CustomStreamWrapper: if "tool_calls" in completion_obj and _entry_len(completion_obj, "tool_calls") > 0: if self.is_function_call is True: # user passed in 'functions' param - completion_obj["function_call"] = completion_obj["tool_calls"][0]["function"] + completion_obj["function_call"] = subscript(subscript(completion_obj["tool_calls"], 0), "function") completion_obj["tool_calls"] = None self.tool_call = True @@ -1931,7 +1981,7 @@ class CustomStreamWrapper: if isinstance(chunk_usage, Usage): model_response.usage = chunk_usage elif isinstance(chunk_usage, BaseModel): - model_response.usage = Usage(**chunk_usage.model_dump()) + model_response.usage = Usage(**_keyword_fields(chunk_usage.model_dump())) ## RETURN ARG if isinstance(chunk, ModelResponseStream) and chunk.provider_specific_fields: @@ -2126,7 +2176,11 @@ class CustomStreamWrapper: else: asyncio.run(self.logging_obj.async_success_handler(processed_chunk, None, None, cache_hit)) ## SYNC LOGGING — only for sync SDK entrypoints; async proxy paths export via async_success_handler - litellm_params: Final[Mapping[str, object]] = self.logging_obj.model_call_details.get("litellm_params", {}) + litellm_params: Final = require_attributes( + _DECODED_JSON.validate_python(self.logging_obj.model_call_details.get("litellm_params", {})), + _ParamsLookupLike, + "get", + ) if self.logging_obj.is_sync_litellm_request(litellm_params): self.logging_obj.success_handler(processed_chunk, None, None, cache_hit) @@ -3015,9 +3069,9 @@ class CustomStreamWrapper: _strip_sse_data_from_chunk = strip_sse_data_from_chunk -def _usage_from_payload(payload: Mapping[str, Any]) -> Usage: +def _usage_from_payload(payload: Mapping[str, object]) -> Usage: """Build ``Usage`` from a wire-shaped usage dict, forwarding every key.""" - return litellm.Usage(**payload) + return litellm.Usage(**_keyword_fields(payload)) def _cache_token_count(details: PromptTokensDetailsWrapper | None, keys: tuple[str, ...]) -> int: diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py index f6ff6d3bc47..d9dd4b2b395 100644 --- a/litellm/llms/a2a/common_utils.py +++ b/litellm/llms/a2a/common_utils.py @@ -2,11 +2,12 @@ Common utilities for A2A (Agent-to-Agent) Protocol """ -from collections.abc import Awaitable, Callable, Mapping -from typing import Any, Final +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Final from pydantic import BaseModel +from litellm.litellm_core_utils.core_helpers import decoded_json_object, iter_items from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, ) @@ -67,7 +68,15 @@ def convert_messages_to_prompt(messages: list[AllMessageValues]) -> str: return "\n".join(conversation_parts) -def extract_text_from_a2a_message(message: dict[str, Any], depth: int = 0, max_depth: int = 10) -> str: +def _join_with_spaces(texts: Sequence[object]) -> str: + strings: Final = [text for text in texts if isinstance(text, str)] + if len(strings) == len(texts): + return " ".join(strings) + first_bad: Final = next(position for position, text in enumerate(texts) if not isinstance(text, str)) + raise TypeError(f"sequence item {first_bad}: expected str instance, {type(texts[first_bad]).__name__} found") + + +def extract_text_from_a2a_message(message: object, depth: int = 0, max_depth: int = 10) -> str: """ Extract text content from A2A message parts. @@ -82,19 +91,20 @@ def extract_text_from_a2a_message(message: dict[str, Any], depth: int = 0, max_d if message is None or depth >= max_depth: return "" - parts: Final = message.get("parts", []) - text_parts: Final[list[str]] = [] + parts: Final = iter_items(decoded_json_object(message).get("parts", [])) + text_parts: Final[list[object]] = [] for part in parts: - if part.get("kind") == "text": - text_parts.append(part.get("text", "")) + part_fields = decoded_json_object(part) + if part_fields.get("kind") == "text": + text_parts.append(part_fields.get("text", "")) # Handle nested parts if they exist - elif "parts" in part: - nested_text = extract_text_from_a2a_message(part, depth + 1, max_depth) + elif "parts" in part_fields: + nested_text = extract_text_from_a2a_message(part_fields, depth + 1, max_depth) if nested_text: text_parts.append(nested_text) - return " ".join(text_parts) + return _join_with_spaces(text_parts) def extract_text_from_a2a_response(response_dict: Mapping[str, object], max_depth: int = 10) -> str: diff --git a/litellm/llms/aiohttp_openai/chat/transformation.py b/litellm/llms/aiohttp_openai/chat/transformation.py index 9253ca389ee..9ad5a585e66 100644 --- a/litellm/llms/aiohttp_openai/chat/transformation.py +++ b/litellm/llms/aiohttp_openai/chat/transformation.py @@ -11,7 +11,8 @@ from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, Final from aiohttp import ClientResponse -from pydantic import ConfigDict, TypeAdapter +from pydantic import ConfigDict, JsonValue, TypeAdapter +from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm.litellm_core_utils.url_utils import ensure_api_route from litellm.llms.openai_like.chat.transformation import OpenAILikeChatConfig @@ -31,6 +32,16 @@ _JSON_OBJECTS: Final = TypeAdapter( ) +class _ChatCompletionBody(TypedDict, extra_items=object): + id: ReadOnly[str] + created: ReadOnly[int] + model: ReadOnly[str] + object: ReadOnly[str] + choices: ReadOnly[JsonValue] + system_fingerprint: ReadOnly[NotRequired[str | None]] + service_tier: ReadOnly[NotRequired[str | None]] + + class AiohttpOpenAIChatConfig(OpenAILikeChatConfig): def get_complete_url( self, @@ -73,7 +84,7 @@ class AiohttpOpenAIChatConfig(OpenAILikeChatConfig): api_key: str | None = None, json_mode: bool | None = None, ) -> ModelResponse: - _json_response: Final = await raw_response.json() + _json_response: Final[_ChatCompletionBody] = await raw_response.json() model_response.id = _json_response.get("id") model_response.choices = [ Choices.model_validate(choice) for choice in _JSON_OBJECTS.validate_python(_json_response.get("choices")) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index c777f1f6ac9..1d24cfab131 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from functools import partial from itertools import chain from types import MappingProxyType -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Final, NoReturn, cast +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Final, NoReturn, Protocol, cast, runtime_checkable import httpx from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch @@ -31,6 +31,7 @@ from litellm.constants import ( ) from litellm.litellm_core_utils.asyncify import is_event_loop_running from litellm.litellm_core_utils.core_helpers import ( + as_object_list, as_str_mapping, coerce_token_limit, drops_unsupported_params, @@ -450,6 +451,46 @@ def _enum_conflicts_with_declared_type(schema: Mapping[str, object]) -> bool: return check is not None and not all(check(value) for value in enum_values) +def _as_json_object(value: object) -> Mapping[str, object] | None: + return value if isinstance(value, dict) else None + + +def _filtered_output_subschema(value: object) -> object: + subschema: Final = _as_json_object(value) + return value if subschema is None else AnthropicConfig.filter_anthropic_output_schema(subschema) + + +def _filtered_output_subschemas(value: object) -> object: + subschemas: Final = _as_json_object(value) + if subschemas is None: + return value + return {name: _filtered_output_subschema(subschema) for name, subschema in subschemas.items()} + + +def _filtered_output_branches(branches: Sequence[object]) -> Sequence[object]: + return [_filtered_output_subschema(branch) for branch in branches] + + +@runtime_checkable +class _SupportsAdd(Protocol): + def __add__(self, other: object, /) -> object: ... + + +def _concatenated(left: object, right: str | Sequence[object]) -> object: + if isinstance(left, _SupportsAdd): + return left + right + raise TypeError(f"unsupported operand type(s) for +: '{type(left).__name__}' and '{type(right).__name__}'") + + +def _extended_any_of(result: Mapping[str, object], branches: Sequence[object]) -> object: + filtered: Final = _filtered_output_branches(branches) + return _concatenated(result["anyOf"], filtered) if "anyOf" in result else filtered + + +def _described_with_note(description: object, note: str) -> object: + return _concatenated(_concatenated(description, " "), note) if description else note + + # Single, internal-only key on ``litellm_params`` used to thread the per- # request reverse map (sanitized -> original) from request build to response # parsing. ``litellm_params`` is never serialized to a provider; ``optional_ @@ -927,7 +968,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): return params @staticmethod - def filter_anthropic_output_schema(schema: dict[str, Any]) -> dict[str, Any]: + def filter_anthropic_output_schema(schema: Mapping[str, object]) -> Mapping[str, object]: """ Filter out unsupported fields from JSON schema for Anthropic's output_format API. @@ -1019,16 +1060,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): note_value = json.dumps(value) if isinstance(value, (dict, list)) else value constraint_descriptions.append(label.format(note_value)) - result: Final[dict[str, Any]] = {} + result: Final[dict[str, object]] = {} # Update description with removed constraint info if constraint_descriptions: - existing_desc: Final = schema.get("description", "") constraint_note: Final = "Note: " + ", ".join(constraint_descriptions) + "." - if existing_desc: - result["description"] = existing_desc + " " + constraint_note - else: - result["description"] = constraint_note + result["description"] = _described_with_note(schema.get("description"), constraint_note) drops_conflicting_type: Final = _enum_conflicts_with_declared_type(schema) @@ -1041,26 +1078,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # Already handled above continue - if key in ("properties", "$defs", "definitions") and isinstance(value, dict): - nested_schemas: dict[str, Any] = value # mutable-ok: filter_anthropic_output_schema takes a dict - result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in nested_schemas.items()} - elif key == "items" and isinstance(value, dict): - nested_schema: dict[str, Any] = value # mutable-ok: filter_anthropic_output_schema takes a dict - result[key] = AnthropicConfig.filter_anthropic_output_schema(nested_schema) - elif key == "anyOf" and isinstance(value, list): - result["anyOf"] = result.get("anyOf", []) + [ - AnthropicConfig.filter_anthropic_output_schema(item) for item in value - ] - elif key == "allOf" and isinstance(value, list): - result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value] + branches = as_object_list(value) + if key in ("properties", "$defs", "definitions"): + result[key] = _filtered_output_subschemas(value) + elif key == "items": + result[key] = _filtered_output_subschema(value) + elif key == "anyOf" and branches is not None: + result["anyOf"] = _extended_any_of(result, branches) + elif key == "allOf" and branches is not None: + result[key] = _filtered_output_branches(branches) elif key == "$ref" and isinstance(value, str): result[key] = schema_fragment_ref(value) - elif key == "oneOf" and isinstance(value, list): + elif key == "oneOf" and branches is not None: # Anthropic rejects oneOf ("Schema type 'oneOf' is not supported"); # the Anthropic SDK rewrites it to anyOf, so do the same. - result["anyOf"] = result.get("anyOf", []) + [ - AnthropicConfig.filter_anthropic_output_schema(item) for item in value - ] + result["anyOf"] = _extended_any_of(result, branches) else: result[key] = value diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 937afeae402..e32754adf03 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -2570,7 +2570,7 @@ def changed_server_tool_call_arguments( def write_anthropic_hosted_web_call_arguments(holder: Message | Delta, arguments_by_tool_id: Mapping[str, str]) -> None: - fields: Final = holder.provider_specific_fields + fields: Final[Mapping[str, object]] = holder.provider_specific_fields or {} if not arguments_by_tool_id or not fields or fields.get("web_search_calls") is None: return holder.provider_specific_fields = { # rebind-ok: the hosted copy of a rewritten call must not keep its old input @@ -2598,7 +2598,7 @@ def _jsonable_hosted_web_call(item: object, arguments_by_tool_id: Mapping[str, s call: Final = _with_hosted_web_call_arguments(item, arguments_by_tool_id) if not isinstance(call, ResponseFunctionWebSearch): return call - return _WEB_SEARCH_CALL_ADAPTER.dump_python(call, mode="json", exclude_none=True) + return _DECODED_JSON.validate_python(_WEB_SEARCH_CALL_ADAPTER.dump_python(call, mode="json", exclude_none=True)) def _with_hosted_web_call_arguments(item: object, arguments_by_tool_id: Mapping[str, str]) -> object: @@ -2613,7 +2613,7 @@ def _with_hosted_web_call_arguments(item: object, arguments_by_tool_id: Mapping[ def without_anthropic_server_tool_calls(holder: Message, tool_ids: frozenset[str]) -> None: - fields: Final = holder.provider_specific_fields or {} + fields: Final[Mapping[str, object]] = holder.provider_specific_fields or {} holder.provider_specific_fields = { # rebind-ok: a denied call must leave every copy of the message it was on **fields, "server_tool_calls": [ diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index 0c564a194dc..e68360e69b9 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -4,12 +4,17 @@ import json from collections.abc import AsyncIterator, Iterator, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias, TypeVar, cast from pydantic import JsonValue, TypeAdapter import litellm -from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_items, normalize_drop_params +from litellm.litellm_core_utils.core_helpers import ( + as_str_mapping, + attribute_items, + normalize_drop_params, + require_attributes, +) from litellm.litellm_core_utils.hidden_params import get_hidden_params from litellm.llms.anthropic.messages.convertible_blocks import tool_result_text_part from litellm.llms.anthropic.messages.utils import ( @@ -254,6 +259,11 @@ if TYPE_CHECKING: ToolResultContent: TypeAlias = str | list[ToolMessageContentPart] +class _CarriesUsage(Protocol): + @property + def usage(self) -> Usage: ... + + def target_supports_mid_conversation_system(model: str | None, custom_llm_provider: str | None) -> bool: if not model: return False @@ -681,7 +691,7 @@ class LiteLLMAnthropicMessagesAdapter: ## ASSISTANT MESSAGE ## assistant_message_str: str | None = None - assistant_content_list: list[dict[str, Any]] = [] # For content blocks with cache_control + assistant_content_list: list[ChatCompletionTextObject] = [] # For content blocks with cache_control has_cache_control_in_text = False tool_calls: list[ChatCompletionAssistantToolCall] = [] thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] = [] @@ -694,7 +704,7 @@ class LiteLLMAnthropicMessagesAdapter: assistant_message_str = str(content) elif isinstance(content, dict): if content.get("type") == "text": - text_block: dict[str, object] = { + text_block: ChatCompletionTextObject = { "type": "text", "text": content.get("text", ""), } @@ -753,7 +763,7 @@ class LiteLLMAnthropicMessagesAdapter: ): # Use list format if any text block has cache_control, otherwise use string if has_cache_control_in_text and len(assistant_content_list) > 0: - assistant_content: Any = assistant_content_list + assistant_content: str | Sequence[ChatCompletionTextObject] | None = assistant_content_list elif len(assistant_content_list) > 0 and not has_cache_control_in_text: # Concatenate text blocks into string when no cache_control assistant_content = "".join(block.get("text", "") for block in assistant_content_list) @@ -1795,7 +1805,7 @@ class LiteLLMAnthropicMessagesAdapter: else translated_finish_reason ) # extract usage - usage: Final[Usage] = getattr(response, "usage") + usage: Final = require_attributes(response, _CarriesUsage, "usage").usage message_usage: Final = self.translate_openai_usage_to_anthropic_usage(usage) polyfill_iterations: Final = polyfill_result.iterations_usage if polyfill_result is not None else None anthropic_usage: Final[AnthropicUsage] = ( diff --git a/litellm/llms/anthropic/messages/native/mcp_handler.py b/litellm/llms/anthropic/messages/native/mcp_handler.py index 8fe18d79f24..cefd6c6a6d9 100644 --- a/litellm/llms/anthropic/messages/native/mcp_handler.py +++ b/litellm/llms/anthropic/messages/native/mcp_handler.py @@ -46,6 +46,10 @@ def _get_stop_reason(response: AnthropicMessagesResponse) -> str | None: return stop_reason if isinstance(stop_reason, str) else None +def _requests_stream(kwargs: Mapping[str, object]) -> bool: + return bool(kwargs.get("stream", False)) + + def _build_tool_result_message(tool_results: Sequence[Mapping[str, object]]) -> AnthropicMessagesUserMessageParam: """Turn executed tool results into the user message Anthropic expects.""" return AnthropicMessagesUserMessageParam( @@ -120,7 +124,8 @@ async def anthropic_messages_with_mcp( should_auto_execute: Final = LiteLLM_Proxy_MCP_Handler.should_auto_execute_tools( mcp_tools_with_litellm_proxy=mcp_references ) - stream: Final = bool(kwargs.pop("stream", False)) + stream: Final = _requests_stream(kwargs) + kwargs.pop("stream", None) base_call_args: Final[Mapping[str, object]] = { "max_tokens": max_tokens, diff --git a/litellm/llms/anthropic/messages/native/streaming_iterator.py b/litellm/llms/anthropic/messages/native/streaming_iterator.py index 52b3f1042f8..1195652dfd2 100644 --- a/litellm/llms/anthropic/messages/native/streaming_iterator.py +++ b/litellm/llms/anthropic/messages/native/streaming_iterator.py @@ -28,6 +28,7 @@ from litellm.llms.anthropic.messages.utils import anthropic_sse_frame from litellm.proxy.pass_through_endpoints.success_handler import ( PassThroughEndpointLogging, ) +from litellm.responses.mcp.parked_stream_dispatch import DeferredStreamRelease from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType from litellm.types.utils import GenericStreamingChunk, ModelResponseStream @@ -647,8 +648,12 @@ class BaseAnthropicMessagesStreamingIterator: disconnect (GeneratorExit / CancelledError) re-raises past it. Without this dispatch that window loses the spend row entirely. """ - deferred_cb: Final = getattr(self.litellm_logging_obj, "_on_deferred_stream_complete", None) - deferred_args: Final = getattr(self.litellm_logging_obj, "_deferred_stream_complete_args", None) + deferred_cb: Final[DeferredStreamRelease | None] = getattr( + self.litellm_logging_obj, "_on_deferred_stream_complete", None + ) + deferred_args: Final[tuple[object, ...] | None] = getattr( + self.litellm_logging_obj, "_deferred_stream_complete_args", None + ) if deferred_cb is None or deferred_args is None: return self.litellm_logging_obj._on_deferred_stream_complete = None diff --git a/litellm/llms/azure_ai/agents/handler.py b/litellm/llms/azure_ai/agents/handler.py index 5dae6cc9fc3..5ed1030aba9 100644 --- a/litellm/llms/azure_ai/agents/handler.py +++ b/litellm/llms/azure_ai/agents/handler.py @@ -29,6 +29,7 @@ import httpx from typing_extensions import ReadOnly from litellm._logging import verbose_logger +from litellm.litellm_core_utils.core_helpers import decoded_json_object, subscript from litellm.litellm_core_utils.hidden_params import set_hidden_param from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base from litellm.llms.azure_ai.agents.transformation import ( @@ -81,19 +82,6 @@ class _AzureMessagesData(TypedDict, total=False): data: ReadOnly[list[_AzureMessage]] -class _CreatedObject(TypedDict): - id: ReadOnly[str] - - -class _RunError(TypedDict, total=False): - message: ReadOnly[str] - - -class _RunStatus(TypedDict, total=False): - status: ReadOnly[str] - last_error: ReadOnly[_RunError] - - class _SSEDelta(TypedDict, total=False): content: ReadOnly[list[_AzureContentItem]] @@ -114,6 +102,14 @@ class _AsyncAgentRequest(Protocol): ) -> Awaitable[httpx.Response]: ... +class _JsonResponse(Protocol): + def json(self) -> object: ... + + +def _json_body(response: _JsonResponse) -> object: + return response.json() + + class AzureAIAgentsHandler: """ Handler for Azure AI Agent Service. @@ -133,21 +129,21 @@ class AzureAIAgentsHandler: def _build_thread_url(self, api_base: str, api_version: str) -> str: return f"{join_api_base(api_base, 'threads')}?api-version={api_version}" - def _build_messages_url(self, api_base: str, thread_id: str, api_version: str) -> str: + def _build_messages_url(self, api_base: str, thread_id: object, api_version: str) -> str: encoded_thread_id: Final = encode_url_path_segment(thread_id, field_name="thread_id") return f"{join_api_base(api_base, f'threads/{encoded_thread_id}/messages')}?api-version={api_version}" - def _build_runs_url(self, api_base: str, thread_id: str, api_version: str) -> str: + def _build_runs_url(self, api_base: str, thread_id: object, api_version: str) -> str: encoded_thread_id: Final = encode_url_path_segment(thread_id, field_name="thread_id") return f"{join_api_base(api_base, f'threads/{encoded_thread_id}/runs')}?api-version={api_version}" - def _build_run_status_url(self, api_base: str, thread_id: str, run_id: str, api_version: str) -> str: + def _build_run_status_url(self, api_base: str, thread_id: object, run_id: object, api_version: str) -> str: encoded_thread_id: Final = encode_url_path_segment(thread_id, field_name="thread_id") encoded_run_id: Final = encode_url_path_segment(run_id, field_name="run_id") run_status_route: Final = f"threads/{encoded_thread_id}/runs/{encoded_run_id}" return f"{join_api_base(api_base, run_status_route)}?api-version={api_version}" - def _build_list_messages_url(self, api_base: str, thread_id: str, api_version: str) -> str: + def _build_list_messages_url(self, api_base: str, thread_id: object, api_version: str) -> str: encoded_thread_id: Final = encode_url_path_segment(thread_id, field_name="thread_id") return f"{join_api_base(api_base, f'threads/{encoded_thread_id}/messages')}?api-version={api_version}" @@ -217,7 +213,7 @@ class AzureAIAgentsHandler: model: str, content: str, model_response: ModelResponse, - thread_id: str, + thread_id: object, messages: list[dict[str, object]], annotations: list[_TransformedAnnotation] | None = None, ) -> ModelResponse: @@ -354,10 +350,10 @@ class AzureAIAgentsHandler: api_base: str, api_version: str, agent_id: str, - thread_id: str | None, + thread_id: object, messages: list[dict[str, object]], optional_params: dict, - ) -> tuple[str, str, list[_TransformedAnnotation] | None]: + ) -> tuple[object, str, list[_TransformedAnnotation] | None]: """Execute the agent flow synchronously. Returns (thread_id, content, annotations).""" # Step 1: Create thread if not provided @@ -365,8 +361,7 @@ class AzureAIAgentsHandler: verbose_logger.debug("Creating thread at: %s", self._build_thread_url(api_base, api_version)) response = make_request("POST", self._build_thread_url(api_base, api_version), {}) self._check_response(response, [200, 201], "Failed to create thread") - thread_data: Final[_CreatedObject] = response.json() - thread_id = thread_data["id"] + thread_id = subscript(_json_body(response), "id") verbose_logger.debug("Created thread: %s", thread_id) # At this point thread_id is guaranteed to be a string @@ -386,8 +381,7 @@ class AzureAIAgentsHandler: response = make_request("POST", self._build_runs_url(api_base, thread_id, api_version), run_payload) self._check_response(response, [200, 201], "Failed to create run") - run_data: Final[_CreatedObject] = response.json() - run_id: Final = run_data["id"] + run_id: Final = subscript(_json_body(response), "id") verbose_logger.debug("Created run: %s", run_id) # Step 4: Poll for completion @@ -396,15 +390,15 @@ class AzureAIAgentsHandler: response = make_request("GET", status_url) self._check_response(response, [200], "Failed to get run status") - status_data: _RunStatus = response.json() + status_data = decoded_json_object(_json_body(response)) status = status_data.get("status") verbose_logger.debug("Run status: %s", status) if status == "completed": break elif status in ["failed", "cancelled", "expired"]: - error_data: _RunStatus = response.json() - error_msg = error_data.get("last_error", {}).get("message", "Unknown error") + error_data = decoded_json_object(_json_body(response)) + error_msg = decoded_json_object(error_data.get("last_error", {})).get("message", "Unknown error") raise AzureAIAgentsError(status_code=500, message=f"Run {status}: {error_msg}") time.sleep(self.config.POLL_INTERVAL_SECONDS) @@ -482,10 +476,10 @@ class AzureAIAgentsHandler: api_base: str, api_version: str, agent_id: str, - thread_id: str | None, + thread_id: object, messages: list[dict[str, object]], optional_params: dict, - ) -> tuple[str, str, list[_TransformedAnnotation] | None]: + ) -> tuple[object, str, list[_TransformedAnnotation] | None]: """Execute the agent flow asynchronously. Returns (thread_id, content, annotations).""" # Step 1: Create thread if not provided @@ -493,8 +487,7 @@ class AzureAIAgentsHandler: verbose_logger.debug("Creating thread at: %s", self._build_thread_url(api_base, api_version)) response = await make_request("POST", self._build_thread_url(api_base, api_version), {}) self._check_response(response, [200, 201], "Failed to create thread") - thread_data: Final[_CreatedObject] = response.json() - thread_id = thread_data["id"] + thread_id = subscript(_json_body(response), "id") verbose_logger.debug("Created thread: %s", thread_id) # At this point thread_id is guaranteed to be a string @@ -514,8 +507,7 @@ class AzureAIAgentsHandler: response = await make_request("POST", self._build_runs_url(api_base, thread_id, api_version), run_payload) self._check_response(response, [200, 201], "Failed to create run") - run_data: Final[_CreatedObject] = response.json() - run_id: Final = run_data["id"] + run_id: Final = subscript(_json_body(response), "id") verbose_logger.debug("Created run: %s", run_id) # Step 4: Poll for completion @@ -524,15 +516,15 @@ class AzureAIAgentsHandler: response = await make_request("GET", status_url) self._check_response(response, [200], "Failed to get run status") - status_data: _RunStatus = response.json() + status_data = decoded_json_object(_json_body(response)) status = status_data.get("status") verbose_logger.debug("Run status: %s", status) if status == "completed": break elif status in ["failed", "cancelled", "expired"]: - error_data: _RunStatus = response.json() - error_msg = error_data.get("last_error", {}).get("message", "Unknown error") + error_data = decoded_json_object(_json_body(response)) + error_msg = decoded_json_object(error_data.get("last_error", {})).get("message", "Unknown error") raise AzureAIAgentsError(status_code=500, message=f"Run {status}: {error_msg}") await asyncio.sleep(self.config.POLL_INTERVAL_SECONDS) diff --git a/litellm/llms/azure_ai/vector_stores/transformation.py b/litellm/llms/azure_ai/vector_stores/transformation.py index a4777be146f..59254ac6c0a 100644 --- a/litellm/llms/azure_ai/vector_stores/transformation.py +++ b/litellm/llms/azure_ai/vector_stores/transformation.py @@ -4,7 +4,9 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final import httpx +from pydantic import TypeAdapter +from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.azure.common_utils import BaseAzureLLM from litellm.llms.base_llm.vector_store.transformation import ( @@ -30,6 +32,8 @@ if TYPE_CHECKING: else: LiteLLMLoggingObj = Any +_DECODED_JSON: Final = TypeAdapter(object) + class AzureAIVectorStoreConfig(BaseQueryEmbeddingVectorStoreConfig, BaseAzureLLM): """ @@ -230,13 +234,14 @@ class AzureAIVectorStoreConfig(BaseQueryEmbeddingVectorStoreConfig, BaseAzureLLM # Build attributes with all available metadata # Exclude system fields and already-processed fields - attributes = {} - for key, value in result.items(): - if key not in ["id", "content", "contentVector", "@search.score"]: - attributes[key] = value + metadata = { + key: value + for key, value in decoded_json_object(_DECODED_JSON.validate_python(result)).items() + if key not in ["id", "content", "contentVector", "@search.score"] + } # Always include document_id in attributes - attributes["document_id"] = document_id + attributes = {**metadata, "document_id": document_id} result_obj = VectorStoreSearchResult( score=score, diff --git a/litellm/llms/base_llm/base_model_iterator.py b/litellm/llms/base_llm/base_model_iterator.py index 3afc685b3dd..a705cfe20ad 100644 --- a/litellm/llms/base_llm/base_model_iterator.py +++ b/litellm/llms/base_llm/base_model_iterator.py @@ -1,7 +1,10 @@ import json from abc import abstractmethod +from collections.abc import Mapping from typing import TYPE_CHECKING, Final, cast +from typing_extensions import TypedDict + import litellm from litellm.constants import STREAM_SSE_DONE_STRING @@ -27,10 +30,18 @@ def _indexed_thinking_block( return {**block, "index": index} +class _DeltaFields(TypedDict, total=False, extra_items=object): + pass + + +def _message_fields_without_thinking_blocks(message: Message) -> Mapping[str, object]: + return message.model_dump(exclude={"thinking_blocks"}) + + def _streaming_delta(message: Message) -> Delta: thinking_blocks: Final = message.thinking_blocks if hasattr(message, "thinking_blocks") else None return Delta( - **message.model_dump(exclude={"thinking_blocks"}), + **_DeltaFields(**_message_fields_without_thinking_blocks(message)), thinking_blocks=( None if thinking_blocks is None diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index b87a783e244..08f986de85a 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -73,8 +73,8 @@ _NO_ATTRIBUTION_HEADERS: Final[Mapping[str, str]] = types.MappingProxyType({}) def with_attribution_headers( attribution_headers: Mapping[str, str], - headers: dict[str, str] | None, # mutable-ok: returned as-is when there is nothing to add -) -> dict[str, str] | None: # mutable-ok: becomes the request's outbound headers + headers: dict[str, object] | None, # mutable-ok: returned as-is when there is nothing to add +) -> dict[str, object] | None: # mutable-ok: becomes the request's outbound headers """ `headers` plus any attribution header the caller didn't already set (names compared case-insensitively). Builds a new dict; `headers` is never mutated. diff --git a/litellm/llms/base_llm/videos/transformation.py b/litellm/llms/base_llm/videos/transformation.py index d7b643eb7f4..3e48c6f2ff2 100644 --- a/litellm/llms/base_llm/videos/transformation.py +++ b/litellm/llms/base_llm/videos/transformation.py @@ -235,7 +235,7 @@ class BaseVideoConfig(ABC): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, - ) -> dict[str, str]: + ) -> object: pass @abstractmethod diff --git a/litellm/llms/bedrock/chat/invoke_transformations/amazon_twelvelabs_pegasus_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/amazon_twelvelabs_pegasus_transformation.py index dc14ef005b5..f9e834e9d37 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/amazon_twelvelabs_pegasus_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/amazon_twelvelabs_pegasus_transformation.py @@ -7,10 +7,11 @@ https://docs.twelvelabs.io/docs/models/pegasus import json import time +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx -from pydantic import ConfigDict, TypeAdapter, with_config +from pydantic import BaseModel, ConfigDict, TypeAdapter, with_config from typing_extensions import NotRequired, ReadOnly, TypedDict import litellm @@ -43,6 +44,7 @@ class _PegasusResponse(TypedDict): _PEGASUS_RESPONSE: Final = TypeAdapter(_PegasusResponse) +_OPTIONAL_PARAM: Final = TypeAdapter(object) class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig): @@ -65,7 +67,7 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig): def map_openai_params( self, - non_default_params: dict, + non_default_params: Mapping[str, object], optional_params: dict, model: str, drop_params: bool, @@ -79,7 +81,7 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig): optional_params["responseFormat"] = self._normalize_response_format(value) return optional_params - def _normalize_response_format(self, value: Any) -> object: + def _normalize_response_format(self, value: object) -> object: """Normalize response_format to TwelveLabs format. TwelveLabs expects: @@ -110,7 +112,11 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig): return value # Otherwise return the dict as is return value - return type_to_response_format_param(response_format=value) or value + if value is None: + return None + if isinstance(value, type) and issubclass(value, BaseModel): + return type_to_response_format_param(response_format=value) or value + raise TypeError(f"Unsupported response_format type - {value}") def transform_request( self, @@ -134,7 +140,7 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig): # Handle responseFormat - transform to TwelveLabs format if "responseFormat" in optional_params: - response_format: Final = optional_params["responseFormat"] + response_format: Final = _OPTIONAL_PARAM.validate_python(optional_params["responseFormat"]) transformed_format: Final = self._normalize_response_format(response_format) if transformed_format: request_data["responseFormat"] = transformed_format diff --git a/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py b/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py index 6f972986e56..5685a7b3ef7 100644 --- a/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py @@ -1,8 +1,9 @@ import types from collections.abc import Mapping -from typing import Any, Final +from typing import Final from openai.types.image import Image +from typing_extensions import TypedDict from litellm.llms.bedrock.common_utils import get_cached_model_info from litellm.types.llms.bedrock import ( @@ -19,6 +20,10 @@ from litellm.types.llms.bedrock import ( from litellm.types.utils import ImageResponse +class _NovaCanvasTaskFields(TypedDict, total=False, extra_items=object): + pass + + class AmazonNovaCanvasConfig: """ Reference: https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/model-catalog/serverless/amazon.nova-canvas-v1:0 @@ -82,10 +87,12 @@ class AmazonNovaCanvasConfig: image_generation_config = {**image_generation_config, **optional_params} if task_type == "TEXT_IMAGE": - text_to_image_params: dict[str, Any] = image_generation_config.pop("textToImageParams", {}) + text_to_image_params: Mapping[str, object] = image_generation_config.pop("textToImageParams", {}) text_to_image_params = {"text": text, **text_to_image_params} try: - text_to_image_params_typed: Final = AmazonNovaCanvasTextToImageParams(**text_to_image_params) + text_to_image_params_typed: Final = AmazonNovaCanvasTextToImageParams( + **_NovaCanvasTaskFields(**text_to_image_params) + ) except Exception as e: raise ValueError( f"Error transforming text to image params: {e}. Got params: {text_to_image_params}, Expected params: {AmazonNovaCanvasTextToImageParams.__annotations__}" @@ -104,7 +111,7 @@ class AmazonNovaCanvasConfig: imageGenerationConfig=image_generation_config_typed, ) if task_type == "COLOR_GUIDED_GENERATION": - color_guided_generation_params: dict[str, Any] = image_generation_config.pop( + color_guided_generation_params: Mapping[str, object] = image_generation_config.pop( "colorGuidedGenerationParams", {} ) color_guided_generation_params = { @@ -113,7 +120,7 @@ class AmazonNovaCanvasConfig: } try: color_guided_generation_params_typed: Final = AmazonNovaCanvasColorGuidedGenerationParams( - **color_guided_generation_params + **_NovaCanvasTaskFields(**color_guided_generation_params) ) except Exception as e: raise ValueError( @@ -133,10 +140,12 @@ class AmazonNovaCanvasConfig: imageGenerationConfig=image_generation_config_typed, ) if task_type == "INPAINTING": - inpainting_params: dict[str, Any] = image_generation_config.pop("inpaintingParams", {}) + inpainting_params: Mapping[str, object] = image_generation_config.pop("inpaintingParams", {}) inpainting_params = {"text": text, **inpainting_params} try: - inpainting_params_typed: Final = AmazonNovaCanvasInpaintingParams(**inpainting_params) + inpainting_params_typed: Final = AmazonNovaCanvasInpaintingParams( + **_NovaCanvasTaskFields(**inpainting_params) + ) except Exception as e: raise ValueError( f"Error transforming inpainting params: {e}. Got params: {inpainting_params}, Expected params: {AmazonNovaCanvasInpaintingParams.__annotations__}" diff --git a/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py b/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py index a20eae0b59d..be23e450b19 100644 --- a/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py +++ b/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py @@ -8,13 +8,13 @@ from dataclasses import dataclass from functools import partial, reduce from itertools import accumulate, chain from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Optional, Protocol, TypeAlias, TypeVar +from typing import TYPE_CHECKING, Final, Optional, Protocol, TypeAlias, TypeVar, runtime_checkable from pydantic import InstanceOf, TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger -from litellm.litellm_core_utils.core_helpers import as_str_mapping, decoded_json_object +from litellm.litellm_core_utils.core_helpers import as_str_mapping, decoded_json_object, subscript from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler from litellm.llms.base_llm.guardrail_translation.base_translation import ( BaseTranslation, @@ -171,10 +171,33 @@ def _reads_flat_texts_only(guardrail: "CustomGuardrail") -> bool: return is_compression_guardrail(guardrail) or not guardrail.supports_structured_messages() -_StringHolder = tuple[Any, str | int] +_StringHolder: TypeAlias = tuple[object, str | int] +_StringLeaf: TypeAlias = tuple[_StringHolder, str] _LeafIdentity: TypeAlias = tuple[int, str | int] +@runtime_checkable +class _SupportsSetItem(Protocol): + def __setitem__(self, key: str | int, value: object, /) -> None: ... + + +@runtime_checkable +class _SupportsDelItem(Protocol): + def __delitem__(self, key: str | int, /) -> None: ... + + +def _assignable(container: object) -> _SupportsSetItem: + if isinstance(container, _SupportsSetItem): + return container + raise TypeError(f"'{type(container).__name__}' object does not support item assignment") + + +def _deletable(container: object) -> _SupportsDelItem: + if isinstance(container, _SupportsDelItem): + return container + raise TypeError(f"'{type(container).__name__}' object doesn't support item deletion") + + @dataclass(frozen=True, slots=True) class _Assign: holder: _StringHolder @@ -194,7 +217,7 @@ _OBJECT_BY_REFERENCE: Final = TypeAdapter(InstanceOf[Mapping[str, object]]) _ARRAY_BY_REFERENCE: Final = TypeAdapter(InstanceOf[list[object]]) -def _collect_strings(node: object, holders: list[_StringHolder]) -> None: +def _collect_strings(node: object, leaves: list[_StringLeaf]) -> None: """ Record a (container, key) holder for every non-empty string value nested under an arbitrary JSON node, so prompt content a caller hides in fields @@ -209,28 +232,28 @@ def _collect_strings(node: object, holders: list[_StringHolder]) -> None: for key, value in current.items(): if isinstance(value, str): if value: - holders.append((current, key)) + leaves.append(((current, key), value)) else: stack.append(value) elif isinstance(current, list): for index, value in enumerate(current): if isinstance(value, str): if value: - holders.append((current, index)) + leaves.append(((current, index), value)) else: stack.append(value) -def _collect_block_text(block: dict, holders: list[_StringHolder]) -> None: +def _collect_block_text(block: dict, leaves: list[_StringLeaf]) -> None: text: Final = block.get("text") if isinstance(text, str) and text: - holders.append((block, "text")) + leaves.append(((block, "text"), text)) -def _request_field_holders(body: Mapping[str, object]) -> tuple[_StringHolder, ...]: - holders: Final = list[_StringHolder]() - _collect_strings(body.get("additionalModelRequestFields"), holders) - return tuple(holders) +def _request_field_leaves(body: Mapping[str, object]) -> tuple[_StringLeaf, ...]: + leaves: Final = list[_StringLeaf]() + _collect_strings(body.get("additionalModelRequestFields"), leaves) + return tuple(leaves) def _extract_converse_texts( @@ -267,15 +290,15 @@ def _extract_converse_texts( that model. Scanning it stays the fail-closed default. Its strings come last, in the same order as they follow the latest user message in the rows. """ - system_holders: Final = list[_StringHolder]() + system_leaves: Final = list[_StringLeaf]() if not skip_system and not scan_only_tool_results: for block in body.get("system") or []: if isinstance(block, dict): - _collect_block_text(block, system_holders) + _collect_block_text(block, system_leaves) - request_field_holders: Final = () if scan_only_tool_results else _request_field_holders(body) + request_field_leaves: Final = () if scan_only_tool_results else _request_field_leaves(body) - message_holders: Final = list[_StringHolder]() + message_leaves: Final = list[_StringLeaf]() for message in body.get("messages") or []: if not isinstance(message, dict): continue @@ -286,20 +309,19 @@ def _extract_converse_texts( continue if scan_only_tool_results and "toolResult" not in block: continue - _collect_block_text(block, message_holders) + _collect_block_text(block, message_leaves) tool_use = block.get("toolUse") if isinstance(tool_use, dict): - _collect_strings(tool_use.get("input"), message_holders) + _collect_strings(tool_use.get("input"), message_leaves) tool_result = block.get("toolResult") if isinstance(tool_result, dict): for inner in tool_result.get("content") or []: if isinstance(inner, dict): - _collect_block_text(inner, message_holders) - _collect_strings(inner.get("json"), message_holders) + _collect_block_text(inner, message_leaves) + _collect_strings(inner.get("json"), message_leaves) - holders: Final = [*system_holders, *message_holders, *request_field_holders] - texts: Final = [container[key] for container, key in holders] - return texts, holders + leaves: Final = (*system_leaves, *message_leaves, *request_field_leaves) + return [text for _, text in leaves], [holder for holder, _ in leaves] @dataclass(frozen=True, slots=True) @@ -464,9 +486,9 @@ def _apply(writes: Sequence[_Assign | _Remove]) -> None: for write in writes: match write: case _Assign(holder=(container, key), value=value): - container[key] = value + _assignable(container)[key] = value case _Remove(holder=(container, key)): - del container[key] + del _deletable(container)[key] def _block_text_holder(block: object) -> _ContentHolder | None: @@ -545,7 +567,7 @@ def _edits_drop_a_text_change( if writes is None: return True _apply(writes) - edited: Final = [container[key] for container, key in holders] + edited: Final = [subscript(container, key) for container, key in holders] return any( returned != before and after == before for before, returned, after in zip(texts, guardrailed_texts, edited) ) @@ -562,26 +584,25 @@ def _extract_converse_output_texts( ``citationsContent.content[].text`` -- while leaving structural values such as reasoning signatures and citation sources untouched. """ - holders: Final[list[_StringHolder]] = [] + leaves: Final[list[_StringLeaf]] = [] for block in content_blocks: if not isinstance(block, dict): continue - _collect_block_text(block, holders) + _collect_block_text(block, leaves) tool_use = block.get("toolUse") if isinstance(tool_use, dict): - _collect_strings(tool_use.get("input"), holders) + _collect_strings(tool_use.get("input"), leaves) reasoning = block.get("reasoningContent") if isinstance(reasoning, dict): reasoning_text = reasoning.get("reasoningText") if isinstance(reasoning_text, dict): - _collect_block_text(reasoning_text, holders) + _collect_block_text(reasoning_text, leaves) citations = block.get("citationsContent") if isinstance(citations, dict): for cited in citations.get("content") or []: if isinstance(cited, dict): - _collect_block_text(cited, holders) - texts: Final = [container[key] for container, key in holders] - return texts, holders + _collect_block_text(cited, leaves) + return [text for _, text in leaves], [holder for holder, _ in leaves] def _write_back_texts( @@ -598,7 +619,7 @@ def _write_back_texts( for idx, (container, key) in enumerate(holders): if idx >= len(guardrailed_texts): break - container[key] = guardrailed_texts[idx] + _assignable(container)[key] = guardrailed_texts[idx] _GroupKey: TypeAlias = str | tuple[str, int] diff --git a/litellm/llms/bedrock/realtime/transformation.py b/litellm/llms/bedrock/realtime/transformation.py index 25498fef46f..c59baf2e07c 100644 --- a/litellm/llms/bedrock/realtime/transformation.py +++ b/litellm/llms/bedrock/realtime/transformation.py @@ -18,6 +18,7 @@ from pydantic import ConfigDict, JsonValue, TypeAdapter from litellm._logging import verbose_logger from litellm._uuid import uuid +from litellm.litellm_core_utils.core_helpers import decoded_json_object, subscript from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig, RealtimeSessionUpdateRejection from litellm.llms.bedrock.common_utils import BedrockError @@ -918,7 +919,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): def transform_content_start_event( self, - event: dict, + event: object, current_response_id: str | None, current_output_item_id: str | None, current_conversation_id: str | None, @@ -942,7 +943,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): Returns: Tuple of (events, response_id, output_item_id, conversation_id, delta_type) """ - content_start: Final = event["contentStart"] + content_start: Final = decoded_json_object(subscript(event, "contentStart")) role: Final = content_start.get("role") if role != "ASSISTANT": @@ -1228,7 +1229,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): def transform_content_end_event( self, - event: dict, + event: object, current_output_item_id: str | None, current_response_id: str | None, current_delta_type: str | None, @@ -1247,7 +1248,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): Returns: Tuple of (events, reset_delta_chunks) """ - content_end: Final = event["contentEnd"] + content_end: Final = subscript(event, "contentEnd") verbose_logger.debug("Handling contentEnd: %s", content_end) if self._assistant_final_text_active: @@ -1338,7 +1339,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): def transform_prompt_end_event( self, - event: dict, + event: object, current_response_id: str | None, current_conversation_id: str | None, ) -> tuple[ @@ -1400,7 +1401,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): def transform_tool_use_event( self, - event: dict, + event: object, current_response_id: str | None, current_conversation_id: str | None, ) -> tuple[list[OpenAIRealtimeEvents], str, str]: @@ -1416,7 +1417,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): Tuple of (events, response_id, conversation_id) """ verbose_logger.debug("Handling toolUse") - tool_use: Final = BedrockToolUse.model_validate(event["toolUse"]) + tool_use: Final = BedrockToolUse.model_validate(subscript(event, "toolUse")) verbose_logger.debug("Tool use event: %s (ID: %s)", tool_use.toolName, tool_use.toolUseId) response_id: Final = current_response_id or f"resp_{uuid.uuid4()}" @@ -1668,7 +1669,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): current_conversation_id, current_delta_type, ) = self.transform_content_start_event( - event, + _JSON_VALUE.validate_python(event), current_response_id, current_output_item_id, current_conversation_id, @@ -1710,7 +1711,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): elif "contentEnd" in event: events, current_delta_chunks = self.transform_content_end_event( - event, + _JSON_VALUE.validate_python(event), current_output_item_id, current_response_id, current_delta_type, @@ -1735,7 +1736,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): elif "toolUse" in event: events, current_response_id, current_conversation_id = self.transform_tool_use_event( - event, current_response_id, current_conversation_id + _JSON_VALUE.validate_python(event), current_response_id, current_conversation_id ) returned_messages.extend(events) @@ -1745,7 +1746,9 @@ class BedrockRealtimeConfig(BaseRealtimeConfig): current_output_item_id, current_response_id, current_delta_type, - ) = self.transform_prompt_end_event(event, current_response_id, current_conversation_id) + ) = self.transform_prompt_end_event( + _JSON_VALUE.validate_python(event), current_response_id, current_conversation_id + ) returned_messages.extend(events) return { diff --git a/litellm/llms/bedrock/vector_stores/transformation.py b/litellm/llms/bedrock/vector_stores/transformation.py index b2b3a9b0faf..afcbe61cd75 100644 --- a/litellm/llms/bedrock/vector_stores/transformation.py +++ b/litellm/llms/bedrock/vector_stores/transformation.py @@ -4,9 +4,11 @@ from typing import TYPE_CHECKING, Any, Final, Protocol, cast, runtime_checkable from urllib.parse import urlparse import httpx +from pydantic import TypeAdapter +from typing_extensions import TypedDict from litellm._logging import verbose_logger -from litellm.litellm_core_utils.core_helpers import decoded_json_object +from litellm.litellm_core_utils.core_helpers import decoded_json_object, keyword_arguments from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM @@ -61,6 +63,13 @@ def _vector_search_configuration(retrieval_config: object) -> Mapping[str, objec return decoded_json_object(decoded_json_object(retrieval_config).get("vectorSearchConfiguration", {})) +_DECODED_JSON: Final = TypeAdapter(object) + + +class _KBResponseFields(TypedDict, total=False, extra_items=object): + pass + + class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): """Vector store configuration for AWS Bedrock Knowledge Bases.""" @@ -361,7 +370,11 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj ) -> VectorStoreSearchResponse: try: - response_data: Final = BedrockKBResponse(**response.json()) + response_data: Final = BedrockKBResponse( + **_KBResponseFields( + **keyword_arguments(_DECODED_JSON.validate_python(response.json()), BedrockKBResponse) + ) + ) results: Final[list[VectorStoreSearchResult]] = [] for item in response_data.get("retrievalResults", []) or []: content: BedrockKBContent | None = item.get("content") diff --git a/litellm/llms/codestral/completion/transformation.py b/litellm/llms/codestral/completion/transformation.py index 636760b093d..c22bdf9d887 100644 --- a/litellm/llms/codestral/completion/transformation.py +++ b/litellm/llms/codestral/completion/transformation.py @@ -1,10 +1,20 @@ import json from typing import Final +from pydantic import TypeAdapter +from typing_extensions import TypedDict + import litellm +from litellm.litellm_core_utils.core_helpers import keyword_arguments from litellm.llms.openai.completion.transformation import OpenAITextCompletionConfig from litellm.types.llms.databricks import GenericStreamingChunk +_DECODED_JSON: Final = TypeAdapter(object) + + +class _StreamChunkFields(TypedDict, total=False, extra_items=object): + pass + class CodestralTextCompletionConfig(OpenAITextCompletionConfig): """ @@ -100,7 +110,11 @@ class CodestralTextCompletionConfig(OpenAITextCompletionConfig): "finish_reason": finish_reason, } - original_chunk: Final = litellm.ModelResponseStream(**chunk_data_dict) + original_chunk: Final = litellm.ModelResponseStream( + **_StreamChunkFields( + **keyword_arguments(_DECODED_JSON.validate_python(chunk_data_dict), litellm.ModelResponseStream) + ) + ) _choices: Final = chunk_data_dict.get("choices", []) or [] if len(_choices) == 0: return { diff --git a/litellm/llms/cohere/common_utils.py b/litellm/llms/cohere/common_utils.py index 4ba5f9bd57b..f5d084ea800 100644 --- a/litellm/llms/cohere/common_utils.py +++ b/litellm/llms/cohere/common_utils.py @@ -397,8 +397,12 @@ class CohereV2ModelResponseIterator: if index != -1: str_line = str_line[index:] - data_json: Final = json.loads(str_line) - return self.chunk_parser(chunk=data_json) + data_json: Final = _DECODED_JSON.validate_python(json.loads(str_line)) + if isinstance(data_json, dict): + return self.chunk_parser(chunk=data_json) + raise ValueError( + f"Failed to parse v2 chunk: {type(data_json).__name__!r} object has no attribute 'get', chunk: {data_json}" + ) # Async iterator def __aiter__(self): diff --git a/litellm/llms/cohere/embed/v1_transformation.py b/litellm/llms/cohere/embed/v1_transformation.py index 056b5bce742..ca096e369e1 100644 --- a/litellm/llms/cohere/embed/v1_transformation.py +++ b/litellm/llms/cohere/embed/v1_transformation.py @@ -2,12 +2,14 @@ Legacy /v1/embedding transformation logic for Bedrock Cohere. """ -from collections.abc import Sized +from collections.abc import Mapping, Sized from typing import Final, Protocol import httpx +from pydantic import TypeAdapter from litellm import COHERE_DEFAULT_EMBEDDING_INPUT_TYPE +from litellm.litellm_core_utils.core_helpers import as_str_mapping, subscript from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.custom_httpx.http_handler import header_value from litellm.types.llms.bedrock import ( @@ -24,6 +26,17 @@ class _SupportsEncode(Protocol): def encode(self, text: str, /) -> Sized: ... +_DECODED_JSON: Final = TypeAdapter(object) + + +def _embed_response_body(response_json: object) -> Mapping[str, object]: + body: Final = as_str_mapping(response_json) + if body is None: + subscript(response_json, "embeddings") + raise TypeError(f"'{type(response_json).__name__}' object is not subscriptable") + return body + + def _bedrock_billed_input_tokens(response: httpx.Response) -> int | None: count: Final = header_value(response.headers, "x-amzn-bedrock-input-token-count") return int(count) if count is not None and count.isdigit() else None @@ -119,7 +132,7 @@ class CohereEmbeddingConfig: encoding: _SupportsEncode, input: list, ) -> EmbeddingResponse: - response_json: Final = response.json() + response_json: Final = _DECODED_JSON.validate_python(response.json()) ## LOGGING logging_obj.post_call( input=input, @@ -127,13 +140,14 @@ class CohereEmbeddingConfig: additional_args={"complete_input_dict": data}, original_response=response_json, ) + billed_input_tokens: Final = _bedrock_billed_input_tokens(response) if "images" in data else None return self._populate_embedding_response( - response_json=response_json, + response_json=dict(_embed_response_body(response_json)), model_response=model_response, model=model, encoding=encoding, input=input, - billed_input_tokens=_bedrock_billed_input_tokens(response) if "images" in data else None, + billed_input_tokens=billed_input_tokens, ) def transform_response( diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index efb8b302664..f65c4205944 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -632,6 +632,7 @@ _OPTIONAL_PARAMS: Final = TypeAdapter(Mapping[str, object]) _JSON_OBJECT: Final = TypeAdapter(dict[str, object]) _JSON_OBJECTS: Final = TypeAdapter(list[dict[str, object]]) _DECODED_JSON: Final = TypeAdapter(object) +_AGENTIC_HOOK_RESULT: Final = TypeAdapter(object) def _decoded_body_headers(response: httpx.Response) -> httpx.Headers: @@ -906,7 +907,7 @@ class BaseLLMHTTPHandler: stream: bool | None = False, fake_stream: bool = False, api_key: str | None = None, - headers: dict[str, Any] | None = None, + headers: dict[str, object] | None = None, client: HTTPHandler | AsyncHTTPHandler | None = None, provider_config: BaseConfig | None = None, shared_session: Optional["ClientSession"] = None, @@ -1407,7 +1408,7 @@ class BaseLLMHTTPHandler: api_key: str | None = None, client: HTTPHandler | AsyncHTTPHandler | None = None, aembedding: bool | None = False, - headers: dict[str, Any] | None = None, + headers: dict[str, object] | None = None, ) -> EmbeddingResponse: provider_config: Final = ProviderConfigManager.get_provider_embedding_config( model=model, provider=litellm.LlmProviders(custom_llm_provider) @@ -1588,7 +1589,7 @@ class BaseLLMHTTPHandler: api_key: str | None = None, api_base: str | None = None, client: HTTPHandler | AsyncHTTPHandler | None = None, - litellm_params: dict[str, Any] | None = None, + litellm_params: dict[str, object] | None = None, ) -> RerankResponse: # get config from model, custom llm provider headers = provider_config.validate_environment( @@ -6146,7 +6147,7 @@ class BaseLLMHTTPHandler: fingerprint: str, stream: bool = False, callback: Optional["CustomLogger"] = None, - ) -> AnthropicMessagesResponse | AsyncIterator[object]: + ) -> object: from litellm.anthropic_interface import messages as anthropic_messages patch: Final = plan.request_patch or AgenticLoopRequestPatch() @@ -6187,7 +6188,7 @@ class BaseLLMHTTPHandler: kwargs_for_followup["max_agentic_loops"] = max_loops kwargs_for_followup["_agentic_loop_fingerprints"] = [*fingerprints, fingerprint] - response: AnthropicMessagesResponse | AsyncIterator[object] = await anthropic_messages.acreate( + response: Final[AnthropicMessagesResponse | AsyncIterator[object]] = await anthropic_messages.acreate( **{ "max_tokens": max_tokens, "messages": patch.messages, @@ -6200,9 +6201,7 @@ class BaseLLMHTTPHandler: if callback is not None: try: - response = await callback.async_post_agentic_loop_response_hook( - response=response, plan=plan, kwargs=kwargs - ) + return await callback.async_post_agentic_loop_response_hook(response=response, plan=plan, kwargs=kwargs) except Exception as e: _call_id: Final = getattr(logging_obj, "litellm_call_id", "unknown") verbose_logger.exception( @@ -6227,7 +6226,7 @@ class BaseLLMHTTPHandler: fingerprints: Sequence[str], fingerprint: str, callback: Optional["CustomLogger"] = None, - ) -> ResponsesAPIResponse | BaseResponsesAPIStreamingIterator: + ) -> object: patch: Final = plan.request_patch or AgenticLoopRequestPatch() if patch.messages is None: raise ValueError("Agentic loop plan missing patched responses input") @@ -6263,7 +6262,7 @@ class BaseLLMHTTPHandler: try: with internal_sub_call(logging_obj.litellm_call_id): - response: ResponsesAPIResponse | BaseResponsesAPIStreamingIterator = await litellm.aresponses( + response: Final[ResponsesAPIResponse | BaseResponsesAPIStreamingIterator] = await litellm.aresponses( model=patch.model or model, input=patch.messages, # pyright: ignore[reportArgumentType] # pre-existing mismatch surfaced by the Router import; patch messages are valid response input at runtime **optional_params, @@ -6272,7 +6271,7 @@ class BaseLLMHTTPHandler: if callback is not None: try: - response = await callback.async_post_agentic_loop_response_hook( + return await callback.async_post_agentic_loop_response_hook( response=response, plan=plan, kwargs=kwargs ) except Exception as e: @@ -6501,21 +6500,23 @@ class BaseLLMHTTPHandler: callback.__class__.async_build_agentic_loop_plan is not CustomLogger.async_build_agentic_loop_plan ) if not build_plan_overridden: - agentic_result: object = await bill_first_round_on_failure( - callback.async_run_agentic_loop( - tools=tool_calls, - model=model, - messages=messages, - response=response, - anthropic_messages_provider_config=anthropic_messages_provider_config, - anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + agentic_result: object = _AGENTIC_HOOK_RESULT.validate_python( + await bill_first_round_on_failure( + callback.async_run_agentic_loop( + tools=tool_calls, + model=model, + messages=messages, + response=response, + anthropic_messages_provider_config=anthropic_messages_provider_config, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + stream=stream, + kwargs=kwargs_with_provider, + ), logging_obj=logging_obj, + response=response, stream=stream, - kwargs=kwargs_with_provider, - ), - logging_obj=logging_obj, - response=response, - stream=stream, + ) ) return self._maybe_wrap_in_fake_stream(agentic_result, logging_obj, api_surface) @@ -6536,8 +6537,9 @@ class BaseLLMHTTPHandler: stream=stream, ) - if plan.response_override is not None: - return self._maybe_wrap_in_fake_stream(plan.response_override, logging_obj, api_surface) + response_override: object = plan.response_override + if response_override is not None: + return self._maybe_wrap_in_fake_stream(response_override, logging_obj, api_surface) if plan.terminate: verbose_logger.debug( "Agentic loop terminated by callback=%s reason=%s", @@ -6545,10 +6547,12 @@ class BaseLLMHTTPHandler: plan.stop_reason, ) return self._maybe_wrap_in_fake_stream( - await callback.async_post_agentic_loop_response_hook( - response=self._finalize_refused_agentic_response(response=response, tool_calls=tool_calls), - plan=plan, - kwargs=kwargs_with_provider, + _AGENTIC_HOOK_RESULT.validate_python( + await callback.async_post_agentic_loop_response_hook( + response=self._finalize_refused_agentic_response(response=response, tool_calls=tool_calls), + plan=plan, + kwargs=kwargs_with_provider, + ) ), logging_obj, api_surface, @@ -12396,10 +12400,10 @@ class BaseLLMHTTPHandler: def generate_content_handler( self, model: str, - contents: Any, + contents: object, generate_content_provider_config: BaseGoogleGenAIGenerateContentConfig, generate_content_config_dict: dict, - tools: Any, + tools: object, custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, @@ -12517,10 +12521,10 @@ class BaseLLMHTTPHandler: async def async_generate_content_handler( self, model: str, - contents: Any, + contents: object, generate_content_provider_config: BaseGoogleGenAIGenerateContentConfig, generate_content_config_dict: dict, - tools: Any, + tools: object, custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, diff --git a/litellm/llms/deprecated_providers/aleph_alpha.py b/litellm/llms/deprecated_providers/aleph_alpha.py index cb56717993e..fae2c49a978 100644 --- a/litellm/llms/deprecated_providers/aleph_alpha.py +++ b/litellm/llms/deprecated_providers/aleph_alpha.py @@ -1,14 +1,30 @@ import json import time import types -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sized from typing import Final import httpx +from pydantic import TypeAdapter import litellm +from litellm.litellm_core_utils.core_helpers import iter_items, json_contains, subscript from litellm.utils import Choices, Message, ModelResponse, Usage +_DECODED_JSON: Final = TypeAdapter(object) +_STR: Final = TypeAdapter(str) + + +def _has_length(value: object) -> bool: + if isinstance(value, Sized): + return len(value) > 0 + raise TypeError(f"object of type '{type(value).__name__}' has no len()") + + +def _completion_message(item: object) -> Message: + completion: Final = subscript(item, "completion") + return Message(content=_STR.validate_python(completion) if _has_length(completion) else None) + class AlephAlphaError(Exception): def __init__(self, status_code, message): @@ -249,22 +265,19 @@ def completion( ) print_verbose(f"raw model_response: {response.text}") ## RESPONSE OBJECT - completion_response: Final = response.json() - if "error" in completion_response: + completion_response: Final = _DECODED_JSON.validate_python(response.json()) + if json_contains(completion_response, "error"): raise AlephAlphaError( - message=completion_response["error"], + message=subscript(completion_response, "error"), status_code=response.status_code, ) else: try: choices_list: Final = [] - for idx, item in enumerate(completion_response["completions"]): - if len(item["completion"]) > 0: - message_obj = Message(content=item["completion"]) - else: - message_obj = Message(content=None) + for idx, item in enumerate(iter_items(subscript(completion_response, "completions"))): + message_obj = _completion_message(item) choice_obj = Choices( - finish_reason=item["finish_reason"], + finish_reason=subscript(item, "finish_reason"), index=idx + 1, message=message_obj, ) diff --git a/litellm/llms/edenai/videos/transformation.py b/litellm/llms/edenai/videos/transformation.py index 0cb9c5afaec..97235a0ffb8 100644 --- a/litellm/llms/edenai/videos/transformation.py +++ b/litellm/llms/edenai/videos/transformation.py @@ -142,7 +142,7 @@ class EdenAIVideoConfig(OpenAIVideoConfig): raw_response: httpx.Response, logging_obj: "LiteLLMLoggingObj", custom_llm_provider: str | None = None, - ) -> dict[str, str]: # mutable-ok: inherited contract + ) -> object: raw_response.raise_for_status() # the shared GET helpers return error bodies instead of raising return super().transform_video_list_response( raw_response=raw_response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 55a75423042..ec8de5af5e2 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Final, Literal, cast import httpx from pydantic import ConfigDict, TypeAdapter +from typing_extensions import TypedDict import litellm from litellm._logging import verbose_logger @@ -65,6 +66,10 @@ _STR: Final = TypeAdapter(str) _DECODED_JSON: Final = TypeAdapter(object) +class _FunctionFields(TypedDict, total=False, extra_items=object): + pass + + def _map_reasoning_effort(value: object) -> object: effort: Final[object] = cast(Mapping[str, object], value).get("effort") if isinstance(value, Mapping) else value if effort is True: @@ -718,7 +723,7 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): """ if tool_calls is not None and message.content is not None and message.tool_calls is None: try: - function: Final = Function(**json.loads(message.content)) + function: Final = Function(**_FunctionFields(**_JSON_DICT.validate_python(json.loads(message.content)))) if function.name != RESPONSE_FORMAT_TOOL_NAME and function.name in [ tool["function"]["name"] for tool in tool_calls ]: diff --git a/litellm/llms/gigachat/chat/transformation.py b/litellm/llms/gigachat/chat/transformation.py index 7bc783b960f..59058b03d67 100644 --- a/litellm/llms/gigachat/chat/transformation.py +++ b/litellm/llms/gigachat/chat/transformation.py @@ -17,6 +17,7 @@ import httpx from pydantic import TypeAdapter from litellm._logging import verbose_logger +from litellm.litellm_core_utils.core_helpers import as_str_mapping, decoded_json_object from litellm.litellm_core_utils.tool_fields import tool_params_without_hosted_mcp from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException @@ -452,36 +453,37 @@ class GigaChatConfig(BaseConfig): choices: Final[list[Choices]] = [] # mutable-ok: accumulator for building response choices for choice in response_json.get("choices", []): message_data = choice.get("message", {}) - finish_reason = choice.get("finish_reason", "stop") + finish_reason = _JSON_VALUE.validate_python(choice.get("finish_reason", "stop")) - if finish_reason == "function_call" and message_data.get("function_call"): - func_call = message_data["function_call"] + function_call = ( + decoded_json_object(_JSON_VALUE.validate_python(message_data)).get("function_call") + if finish_reason == "function_call" + else None + ) + if function_call: + func_call = decoded_json_object(function_call) args = func_call.get("arguments", {}) + args_mapping = as_str_mapping(args) if is_structured_output: - if isinstance(args, dict): - content = json.dumps(args, ensure_ascii=False) - else: - content = str(args) - message_data["content"] = content - message_data.pop("function_call", None) - message_data.pop("functions_state_id", None) + message_data["content"] = ( + json.dumps(args_mapping, ensure_ascii=False) if args_mapping is not None else str(args) + ) finish_reason = "stop" else: - if isinstance(args, dict): - args = json.dumps(args, ensure_ascii=False) + arguments = json.dumps(args_mapping, ensure_ascii=False) if args_mapping is not None else args message_data["tool_calls"] = [ { "id": f"call_{uuid.uuid4().hex[:24]}", "type": "function", "function": { "name": func_call.get("name", ""), - "arguments": args, + "arguments": arguments, }, } ] - message_data.pop("function_call", None) finish_reason = "tool_calls" + message_data.pop("function_call", None) message_data.pop("functions_state_id", None) diff --git a/litellm/llms/gigachat/embedding/transformation.py b/litellm/llms/gigachat/embedding/transformation.py index 30ca7147865..5a988edc144 100644 --- a/litellm/llms/gigachat/embedding/transformation.py +++ b/litellm/llms/gigachat/embedding/transformation.py @@ -9,12 +9,13 @@ from __future__ import annotations import types from collections.abc import Mapping -from typing import Final +from typing import Final, Protocol, runtime_checkable import httpx from pydantic import ConfigDict, TypeAdapter from litellm import LlmProviders +from litellm.litellm_core_utils.core_helpers import iter_items, json_contains, subscript from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -26,6 +27,28 @@ from litellm.types.utils import EmbeddingResponse from ..authenticator import get_access_token _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) +_DECODED_JSON: Final = TypeAdapter(object) + + +@runtime_checkable +class _SupportsDelItem(Protocol): + def __delitem__(self, key: str, /) -> None: ... + + +@runtime_checkable +class _SupportsSetItem(Protocol): + def __setitem__(self, key: str, value: object, /) -> None: ... + + +def _add_prompt_tokens(total_tokens: float, embedding: object) -> float: + if not (json_contains(embedding, "usage") and json_contains(subscript(embedding, "usage"), "prompt_tokens")): + return total_tokens + prompt_tokens: Final = subscript(subscript(embedding, "usage"), "prompt_tokens") + if isinstance(prompt_tokens, int | float): + return total_tokens + prompt_tokens + raise TypeError( + f"unsupported operand type(s) for +=: '{type(total_tokens).__name__}' and '{type(prompt_tokens).__name__}'" + ) class GigaChatEmbeddingError(BaseLLMException): @@ -144,7 +167,7 @@ class GigaChatEmbeddingConfig(BaseEmbeddingConfig): "model": "Embeddings" } """ - response_json: Final = raw_response.json() + response_json: Final = _DECODED_JSON.validate_python(raw_response.json()) # Log response logging_obj.post_call( @@ -155,16 +178,19 @@ class GigaChatEmbeddingConfig(BaseEmbeddingConfig): ) # Calculate total tokens from individual embeddings - total_tokens = 0 - if "data" in response_json: - for emb in response_json["data"]: - if "usage" in emb and "prompt_tokens" in emb["usage"]: - total_tokens += emb["usage"]["prompt_tokens"] + total_tokens: float = 0 + if json_contains(response_json, "data"): + for emb in iter_items(subscript(response_json, "data")): + total_tokens = _add_prompt_tokens(total_tokens, emb) # Remove usage from individual embeddings (not part of OpenAI format) - if "usage" in emb: + if json_contains(emb, "usage"): + if not isinstance(emb, _SupportsDelItem): + raise TypeError(f"'{type(emb).__name__}' object does not support item deletion") del emb["usage"] # Set overall usage + if not isinstance(response_json, _SupportsSetItem): + raise TypeError(f"'{type(response_json).__name__}' object does not support item assignment") response_json["usage"] = { "prompt_tokens": total_tokens, "total_tokens": total_tokens, diff --git a/litellm/llms/huggingface/embedding/transformation.py b/litellm/llms/huggingface/embedding/transformation.py index bb836eb1b35..e3210482b2a 100644 --- a/litellm/llms/huggingface/embedding/transformation.py +++ b/litellm/llms/huggingface/embedding/transformation.py @@ -1,13 +1,15 @@ import json import os import time -from collections.abc import Mapping, Sequence +from collections.abc import Mapping, Sequence, Sized from copy import deepcopy from typing import TYPE_CHECKING, Any, Final, Protocol import httpx +from pydantic import TypeAdapter import litellm +from litellm.litellm_core_utils.core_helpers import iter_items, subscript from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, ) @@ -44,6 +46,27 @@ def _header_value(headers: Mapping[str, str], key: str, default: str) -> str: return headers.get(key, default) +_DECODED_JSON: Final = TypeAdapter(object) + + +def _json_len(value: object) -> int: + if isinstance(value, Sized): + return len(value) + raise TypeError(f"object of type '{type(value).__name__}' has no len()") + + +def _generated_text(value: object) -> str: + if isinstance(value, str): + return value + raise AttributeError(f"'{type(value).__name__}' object has no attribute 'strip'", name="strip", obj=value) + + +def _add_logprob(total: float, logprob: object) -> float: + if isinstance(logprob, (int, float)): + return total + logprob + raise TypeError(f"unsupported operand type(s) for +=: '{type(total).__name__}' and '{type(logprob).__name__}'") + + tgi_models_cache = None conv_models_cache = None @@ -407,33 +430,41 @@ class HuggingFaceEmbeddingConfig(BaseConfig): headers=None, ) - if len(completion_response[0]["generated_text"]) > 0: - model_response.choices[0].message.content = output_parser(completion_response[0]["generated_text"]) + if _json_len(_DECODED_JSON.validate_python(completion_response[0]["generated_text"])) > 0: + model_response.choices[0].message.content = output_parser( + _generated_text(_DECODED_JSON.validate_python(completion_response[0]["generated_text"])) + ) ## GETTING LOGPROBS + FINISH REASON if "details" in completion_response[0] and "tokens" in completion_response[0]["details"]: model_response.choices[0].finish_reason = completion_response[0]["details"]["finish_reason"] sum_logprob = 0 - for token in completion_response[0]["details"]["tokens"]: - if token["logprob"] is not None: - sum_logprob += token["logprob"] + for token in iter_items(_DECODED_JSON.validate_python(completion_response[0]["details"]["tokens"])): + logprob = subscript(token, "logprob") + if logprob is not None: + sum_logprob = _add_logprob(sum_logprob, logprob) setattr(model_response.choices[0].message, "_logprob", sum_logprob) if "best_of" in optional_params and optional_params["best_of"] > 1: if "details" in completion_response[0] and "best_of_sequences" in completion_response[0]["details"]: choices_list: Final = [] - for idx, item in enumerate(completion_response[0]["details"]["best_of_sequences"]): + for idx, item in enumerate( + iter_items( + _DECODED_JSON.validate_python(completion_response[0]["details"]["best_of_sequences"]) + ) + ): sum_logprob = 0 - for token in item["tokens"]: - if token["logprob"] is not None: - sum_logprob += token["logprob"] - if len(item["generated_text"]) > 0: + for token in iter_items(subscript(item, "tokens")): + logprob = subscript(token, "logprob") + if logprob is not None: + sum_logprob = _add_logprob(sum_logprob, logprob) + if _json_len(subscript(item, "generated_text")) > 0: message_obj = Message( - content=output_parser(item["generated_text"]), + content=output_parser(_generated_text(subscript(item, "generated_text"))), logprobs=sum_logprob, ) else: message_obj = Message(content=None) choice_obj = Choices( - finish_reason=item["finish_reason"], + finish_reason=subscript(item, "finish_reason"), index=idx + 1, message=message_obj, ) @@ -442,8 +473,13 @@ class HuggingFaceEmbeddingConfig(BaseConfig): elif task == "text-classification": model_response.choices[0].message.content = json.dumps(completion_response) else: - if isinstance(completion_response, list) and len(completion_response[0]["generated_text"]) > 0: - model_response.choices[0].message.content = output_parser(completion_response[0]["generated_text"]) + if ( + isinstance(completion_response, list) + and _json_len(_DECODED_JSON.validate_python(completion_response[0]["generated_text"])) > 0 + ): + model_response.choices[0].message.content = output_parser( + _generated_text(_DECODED_JSON.validate_python(completion_response[0]["generated_text"])) + ) ## CALCULATING USAGE prompt_tokens = 0 try: diff --git a/litellm/llms/minimax/videos/transformation.py b/litellm/llms/minimax/videos/transformation.py index aff99542d9b..fd014a3d521 100644 --- a/litellm/llms/minimax/videos/transformation.py +++ b/litellm/llms/minimax/videos/transformation.py @@ -803,7 +803,7 @@ class MinimaxVideoConfig(BaseVideoConfig): params.update(extra_query) return f"{api_base}/v2/query/video_generation", params - def transform_video_list_response( # pyright: ignore[reportIncompatibleMethodOverride] # base declares dict[str, str] but the payload is a heterogeneous list body + def transform_video_list_response( self, raw_response: httpx.Response, logging_obj: "Logging", diff --git a/litellm/llms/nlp_cloud/chat/transformation.py b/litellm/llms/nlp_cloud/chat/transformation.py index 0cca299de93..8301bee9b90 100644 --- a/litellm/llms/nlp_cloud/chat/transformation.py +++ b/litellm/llms/nlp_cloud/chat/transformation.py @@ -1,9 +1,10 @@ import json import time -from collections.abc import Mapping +from collections.abc import Mapping, Sized from typing import TYPE_CHECKING, Any, Final import httpx +from pydantic import TypeAdapter from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, @@ -23,6 +24,15 @@ else: LoggingClass = Any +_DECODED_JSON: Final = TypeAdapter(object) + + +def _json_len(value: object) -> int: + if isinstance(value, Sized): + return len(value) + raise TypeError(f"object of type '{type(value).__name__}' has no len()") + + class NLPCloudConfig(BaseConfig): """ Reference: https://docs.nlpcloud.com/#generation @@ -199,7 +209,7 @@ class NLPCloudConfig(BaseConfig): ) else: try: - if len(completion_response["generated_text"]) > 0: + if _json_len(_DECODED_JSON.validate_python(completion_response["generated_text"])) > 0: model_response.choices[0].message.content = completion_response["generated_text"] except Exception: raise NLPCloudError( diff --git a/litellm/llms/nvidia_riva/audio_transcription/riva_grpc.py b/litellm/llms/nvidia_riva/audio_transcription/riva_grpc.py index 8367853673f..8f8111be3ce 100644 --- a/litellm/llms/nvidia_riva/audio_transcription/riva_grpc.py +++ b/litellm/llms/nvidia_riva/audio_transcription/riva_grpc.py @@ -6,11 +6,12 @@ request carries them exactly once over both plaintext and TLS. """ from collections.abc import Iterator, Mapping, Sequence -from typing import Any, Final, TypeAlias +from typing import Final, Protocol, SupportsIndex, SupportsInt, TypeAlias, runtime_checkable import grpc from google.protobuf.message import Message from pydantic import TypeAdapter +from typing_extensions import Buffer from litellm.llms.nvidia_riva.audio_transcription import riva_asr_schema as schema from litellm.llms.nvidia_riva.common_utils import NvidiaRivaException @@ -38,16 +39,29 @@ def open_channel(api_base: str, use_ssl: bool) -> grpc.Channel: return grpc.insecure_channel(api_base) -def recognition_config(config: Mapping[str, Any]) -> Message: +@runtime_checkable +class _SupportsTrunc(Protocol): + def __trunc__(self) -> int: ... + + +def _native_int(value: object) -> int: + if isinstance(value, (str, Buffer, SupportsInt, SupportsIndex, _SupportsTrunc)): + return int(value) + raise TypeError( + f"int() argument must be a string, a bytes-like object or a real number, not '{type(value).__name__}'" + ) + + +def recognition_config(config: Mapping[str, object]) -> Message: endpointing: Final = _endpointing_config(config.get("endpointing_config")) return schema.RecognitionConfig( encoding=schema.LINEAR_PCM, - sample_rate_hertz=int(config["sample_rate_hertz"]), + sample_rate_hertz=_native_int(config["sample_rate_hertz"]), language_code=config["language_code"], - audio_channel_count=int(config["audio_channel_count"]), + audio_channel_count=_native_int(config["audio_channel_count"]), enable_automatic_punctuation=bool(config.get("enable_automatic_punctuation", True)), enable_word_time_offsets=bool(config.get("enable_word_time_offsets", False)), - max_alternatives=int(config.get("max_alternatives", 1)), + max_alternatives=_native_int(config.get("max_alternatives", 1)), model=config.get("model", "") or "", verbatim_transcripts=bool(config.get("verbatim_transcripts", False)), profanity_filter=bool(config.get("profanity_filter", False)), diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 1e3e6186dd1..22e09584b1d 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, TypeAdapter import litellm from litellm._uuid import uuid -from litellm.litellm_core_utils.core_helpers import map_finish_reason +from litellm.litellm_core_utils.core_helpers import decoded_json_object, map_finish_reason from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, extract_images_from_message, @@ -47,6 +47,15 @@ else: LiteLLMLoggingObj = Any _REQUEST_PARAMS: Final = TypeAdapter(dict[str, object]) +_DECODED_JSON: Final = TypeAdapter(object) + + +def _json_document(value: object) -> str | bytes | bytearray: + if isinstance(value, (str, bytes, bytearray)): + return value + raise TypeError(f"the JSON object must be str, bytes or bytearray, not {value.__class__.__name__}") + + _TOOL_CALL_ARGUMENTS: Final = TypeAdapter(dict[str, object]) @@ -372,7 +381,7 @@ class OllamaChatConfig(BaseConfig): # remap 'thinking' to 'reasoning_content' response_json_message["reasoning_content"] = response_json_message["thinking"] del response_json_message["thinking"] - elif response_json_message.get("content") is not None: + elif decoded_json_object(_DECODED_JSON.validate_python(response_json_message)).get("content") is not None: # parse reasoning content from content from litellm.litellm_core_utils.prompt_templates.reasoning_utils import ( parse_content_for_reasoning, @@ -387,14 +396,20 @@ class OllamaChatConfig(BaseConfig): and litellm_params.get("function_name") is not None and response_json_message is not None ): - function_call: Final = json.loads(response_json_message["content"]) + function_call: Final = decoded_json_object( + _DECODED_JSON.validate_python( + json.loads(_json_document(_DECODED_JSON.validate_python(response_json_message["content"]))) + ) + ) message: Final = litellm.Message( content=None, tool_calls=[ { "id": f"call_{uuid.uuid4()}", "function": { - "name": function_call.get("name", litellm_params.get("function_name")), + "name": function_call.get( + "name", _DECODED_JSON.validate_python(litellm_params.get("function_name")) + ), "arguments": json.dumps(function_call.get("arguments", function_call)), }, "type": "function", diff --git a/litellm/llms/oobabooga/chat/oobabooga.py b/litellm/llms/oobabooga/chat/oobabooga.py index b4dc57ff5ed..a473ef18630 100644 --- a/litellm/llms/oobabooga/chat/oobabooga.py +++ b/litellm/llms/oobabooga/chat/oobabooga.py @@ -1,7 +1,9 @@ import json -from collections.abc import Callable +from collections.abc import Callable, Sized from typing import TYPE_CHECKING, Final +from pydantic import TypeAdapter + import litellm from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.custom_httpx.http_handler import get_httpx_client @@ -15,6 +17,13 @@ if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj oobabooga_config: Final = OobaboogaConfig() +_DECODED_JSON: Final = TypeAdapter(object) + + +def _json_len(value: object) -> int: + if isinstance(value, Sized): + return len(value) + raise TypeError(f"object of type '{type(value).__name__}' has no len()") def completion( @@ -149,7 +158,7 @@ def embedding( } ] - num_tokens: Final = len(completion_response["data"][0]["embedding"]) + num_tokens: Final = _json_len(_DECODED_JSON.validate_python(completion_response["data"][0]["embedding"])) # Adding metadata to response setattr( model_response, diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index f7bd46536a1..c4c72ab6be6 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -8,15 +8,21 @@ import os from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping, Sequence from contextvars import ContextVar from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast, overload +from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, cast, overload from urllib.parse import urlparse import httpx from pydantic import TypeAdapter, ValidationError +from typing_extensions import TypedDict import litellm from litellm.constants import OPENAI_SYSTEM_MESSAGES_FIRST_PROVIDERS -from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, map_finish_reason +from litellm.litellm_core_utils.core_helpers import ( + drops_unsupported_params, + map_finish_reason, + require_attributes, + subscript, +) from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( extract_reasoning_content, handle_invalid_parallel_tool_calls, @@ -147,6 +153,14 @@ _CHOICE_MAPPINGS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) _DECODED_JSON: Final = TypeAdapter(object) +class _ModelResponseStreamFields(TypedDict, total=False, extra_items=object): + pass + + +class _SupportsGetWithDefault(Protocol): + def get(self, key: str, default: str, /) -> object: ... + + def _litellm_params_str(litellm_params: Mapping[str, object] | None, key: str) -> str | None: value: Final = litellm_params.get(key) if litellm_params is not None else None return value if isinstance(value, str) else None @@ -808,7 +822,10 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): convert_tool_call_to_json_mode=json_mode, ): # to support response_format on claude models - json_mode_content_str: str | None = str(new_tool_calls[0]["function"].get("arguments", "")) or None + tool_call_function = require_attributes( + subscript(new_tool_calls[0], "function"), _SupportsGetWithDefault, "get" + ) + json_mode_content_str: str | None = str(tool_call_function.get("arguments", "")) or None if json_mode_content_str is not None: translated_message = Message(content=json_mode_content_str) finish_reason = "stop" @@ -1086,7 +1103,7 @@ class OpenAIChatCompletionStreamingHandler(BaseModelResponseIterator): choices: Final = self._map_reasoning_to_reasoning_content(raw_choices) fingerprint: Final = _OBJECT_MAPPING.validate_python(chunk).get("system_fingerprint") - kwargs: Final[dict[str, Any]] = { + kwargs: Final[dict[str, object]] = { "id": chunk.get("id"), "system_fingerprint": fingerprint, "object": "chat.completion.chunk", @@ -1099,6 +1116,6 @@ class OpenAIChatCompletionStreamingHandler(BaseModelResponseIterator): service_tier: Final = chunk.get("service_tier") if isinstance(service_tier, str) and service_tier: kwargs["service_tier"] = service_tier - return ModelResponseStream(**kwargs) + return ModelResponseStream(**_ModelResponseStreamFields(**kwargs)) except Exception as e: raise e diff --git a/litellm/llms/openai/cost_calculation.py b/litellm/llms/openai/cost_calculation.py index bc8aaab3185..a6dbc79254e 100644 --- a/litellm/llms/openai/cost_calculation.py +++ b/litellm/llms/openai/cost_calculation.py @@ -4,7 +4,9 @@ Helper util for handling openai-specific cost calculation """ from collections.abc import Mapping -from typing import Any, Final, Literal +from typing import Final, Literal, SupportsFloat, SupportsIndex + +from typing_extensions import Buffer from litellm._logging import verbose_logger from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token @@ -100,8 +102,14 @@ def _video_resolution_to_cost_field_suffix(resolution: str) -> str | None: return safe +def _native_float(value: object) -> float: + if isinstance(value, (str, Buffer, SupportsFloat, SupportsIndex)): + return float(value) + raise TypeError(f"float() argument must be a string or a real number, not '{type(value).__name__}'") + + def video_output_cost_per_second( - model_info: Mapping[str, Any], + model_info: Mapping[str, object], video_resolution: str | None, ) -> float | None: """ @@ -118,10 +126,10 @@ def video_output_cost_per_second( tier_key: Final = f"output_cost_per_second_{suffix}" tier_rate: Final = model_info.get(tier_key) if tier_rate is not None: - return float(tier_rate) + return _native_float(tier_rate) out: Final = model_info.get("output_cost_per_second") if out is not None: - return float(out) + return _native_float(out) return None diff --git a/litellm/llms/openai/image_edit/transformation.py b/litellm/llms/openai/image_edit/transformation.py index 0bba58096ef..92fdffebe34 100644 --- a/litellm/llms/openai/image_edit/transformation.py +++ b/litellm/llms/openai/image_edit/transformation.py @@ -3,9 +3,12 @@ from typing import TYPE_CHECKING, Any, Final, cast import httpx from httpx._types import RequestFiles +from pydantic import TypeAdapter +from typing_extensions import TypedDict import litellm from litellm.images.utils import ImageEditRequestUtils +from litellm.litellm_core_utils.core_helpers import keyword_arguments from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.secret_managers.main import get_secret_str @@ -28,6 +31,13 @@ else: LiteLLMLoggingObj = Any +_DECODED_JSON: Final = TypeAdapter(object) + + +class _ImageResponseFields(TypedDict, total=False, extra_items=object): + pass + + class OpenAIImageEditConfig(BaseImageEditConfig): """ Base configuration for OpenAI image edit API. @@ -149,10 +159,10 @@ class OpenAIImageEditConfig(BaseImageEditConfig): ) -> ImageResponse: """No transform applied since outputs are in OpenAI spec already""" try: - raw_response_json: Final = raw_response.json() + raw_response_json: Final = _DECODED_JSON.validate_python(raw_response.json()) except Exception: raise OpenAIError(message=raw_response.text, status_code=raw_response.status_code) - return ImageResponse(**raw_response_json) + return ImageResponse(**_ImageResponseFields(**keyword_arguments(raw_response_json, ImageResponse))) def validate_environment( self, diff --git a/litellm/llms/openai/image_variations/handler.py b/litellm/llms/openai/image_variations/handler.py index 504dfb50015..0200d9acbb0 100644 --- a/litellm/llms/openai/image_variations/handler.py +++ b/litellm/llms/openai/image_variations/handler.py @@ -7,6 +7,8 @@ from typing import Final import httpx from openai import AsyncOpenAI, OpenAI +from pydantic import BaseModel +from typing_extensions import TypedDict import litellm from litellm.types.utils import FileTypes, ImageResponse, LlmProviders @@ -18,6 +20,14 @@ from ..common_utils import OpenAIError, upstream_error_response from ..workload_identity import build_async_openai_client, build_openai_client +class _ImageResponseFields(TypedDict, total=False, extra_items=object): + pass + + +def _dumped_fields(model: BaseModel) -> Mapping[str, object]: + return model.model_dump() + + class OpenAIImageVariationsHandler: def get_sync_client( self, @@ -94,7 +104,7 @@ class OpenAIImageVariationsHandler: raw_response: Final = await client.images.with_raw_response.create_variation(**data) response: Final = raw_response.parse() - response_json: Final = response.model_dump() + response_json: Final = _dumped_fields(response) ## LOGGING logging_obj.post_call( @@ -109,7 +119,7 @@ class OpenAIImageVariationsHandler: ## RESPONSE OBJECT return provider_config.transform_response_image_variation( model=model, - model_response=ImageResponse(**response_json), + model_response=ImageResponse(**_ImageResponseFields(**response_json)), raw_response=httpx.Response( status_code=200, request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object @@ -215,7 +225,7 @@ class OpenAIImageVariationsHandler: raw_response: Final = client.images.with_raw_response.create_variation(**json_data) response: Final = raw_response.parse() - response_json: Final = response.model_dump() + response_json: Final = _dumped_fields(response) ## LOGGING logging_obj.post_call( @@ -230,7 +240,7 @@ class OpenAIImageVariationsHandler: ## RESPONSE OBJECT return provider_config.transform_response_image_variation( model=model, - model_response=ImageResponse(**response_json), + model_response=ImageResponse(**_ImageResponseFields(**response_json)), raw_response=httpx.Response( status_code=200, request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 8e9f2a0a0bd..77c0223b375 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -36,7 +36,7 @@ from collections.abc import Iterable, Mapping, MutableSequence, Sequence from dataclasses import dataclass from itertools import accumulate, chain, groupby, repeat from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeVar, Union, cast, runtime_checkable from openai.types.responses.response_output_refusal import ResponseOutputRefusal from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError @@ -52,7 +52,7 @@ from litellm.completion_extras.litellm_responses_transformation.transformation i OpenAiResponsesToChatCompletionStreamIterator, tool_call_dict_from_output_item, ) -from litellm.litellm_core_utils.core_helpers import has_attributes +from litellm.litellm_core_utils.core_helpers import as_object_list, has_attributes from litellm.llms.base_llm.guardrail_translation.base_translation import ( BaseTranslation, StreamingScanKey, @@ -694,6 +694,24 @@ def _is_textless_tool_row(message: Mapping[str, object], texts: tuple[str, ...] return _is_string_tool_row(message) and (not message.get("content") or texts == ()) +@runtime_checkable +class _SupportsSetItem(Protocol): + def __setitem__(self, key: str, value: object, /) -> None: ... + + +def _assignable(item: object) -> _SupportsSetItem: + if isinstance(item, _SupportsSetItem): + return item + raise TypeError(f"'{type(item).__name__}' object does not support item assignment") + + +def _summarized_encrypted_content(item: Mapping[str, object]) -> str | None: + if compaction_summary(item) is None: + return None + encrypted_content: Final = item["encrypted_content"] + return encrypted_content if isinstance(encrypted_content, str) else None + + def _rewritten_whole_text(item: object, field: str, guardrail_response: str) -> object | None: if isinstance(stream_item_field(item, field), str): return guardrail_response @@ -1134,7 +1152,7 @@ class OpenAIResponsesHandler(BaseTranslation): async def _apply_guardrail_responses_to_input( self, - messages: Any, # Can be List[Dict[str, Any]] or ResponseInputParam + messages: Sequence[Mapping[str, object]], responses: Sequence[str], task_mappings: Sequence[tuple[int, int | None]], ) -> None: @@ -1149,26 +1167,27 @@ class OpenAIResponsesHandler(BaseTranslation): for guardrail_response, mapping in zip(responses, task_mappings): msg_idx = cast(int, mapping[0]) content_idx_optional = cast(int | None, mapping[1]) + item = messages[msg_idx] - if compaction_summary(messages[msg_idx]) is not None: - messages[msg_idx]["encrypted_content"] = with_compaction_summary( # rebind-ok: in-place input rewrite - messages[msg_idx]["encrypted_content"], guardrail_response - ) + encrypted_content = _summarized_encrypted_content(item) + if encrypted_content is not None: + _assignable(item)["encrypted_content"] = with_compaction_summary(encrypted_content, guardrail_response) continue - field = _scanned_text_field(messages[msg_idx]) - content = messages[msg_idx].get(field, None) + field = _scanned_text_field(item) + content = item.get(field, None) if content is None: continue + content_parts = as_object_list(content) if content_idx_optional is None: - rewritten = _rewritten_whole_text(messages[msg_idx], field, guardrail_response) + rewritten = _rewritten_whole_text(item, field, guardrail_response) if rewritten is not None: - messages[msg_idx][field] = rewritten + _assignable(item)[field] = rewritten - elif isinstance(content, list): + elif content_parts is not None: # Replace specific text item in list content - self._write_content_part_text(content[content_idx_optional], guardrail_response) + self._write_content_part_text(content_parts[content_idx_optional], guardrail_response) async def process_output_response( self, @@ -1299,7 +1318,7 @@ class OpenAIResponsesHandler(BaseTranslation): request_data: dict | None = None, stream_transform_sink: StreamTransformSink | None = None, deliver_ended_stream_rewrites: bool = False, - ) -> list[Any]: + ) -> Sequence[object]: """ Process output streaming response by applying guardrails to text content. diff --git a/litellm/llms/openai/transcriptions/whisper_transformation.py b/litellm/llms/openai/transcriptions/whisper_transformation.py index 947d6175122..21fdf202a52 100644 --- a/litellm/llms/openai/transcriptions/whisper_transformation.py +++ b/litellm/llms/openai/transcriptions/whisper_transformation.py @@ -3,7 +3,10 @@ from collections.abc import Mapping from typing import Final from httpx import Headers, Response +from pydantic import TypeAdapter +from typing_extensions import TypedDict +from litellm.litellm_core_utils.core_helpers import json_contains, keyword_arguments from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.audio_transcription.transformation import ( AudioTranscriptionRequestData, @@ -19,6 +22,12 @@ from litellm.types.utils import FileTypes, TranscriptionResponse from ..common_utils import OpenAIError +_DECODED_JSON: Final = TypeAdapter(object) + + +class _TranscriptionResponseFields(TypedDict, total=False, extra_items=object): + pass + def _header_value(headers: Mapping[str, str], key: str, default: str) -> str: return headers.get(key, default) @@ -131,14 +140,16 @@ class OpenAIWhisperAudioTranscriptionConfig(BaseAudioTranscriptionConfig): raw_response: Response, ) -> TranscriptionResponse: try: - raw_response_json: Final = raw_response.json() + raw_response_json: Final = _DECODED_JSON.validate_python(raw_response.json()) except json.JSONDecodeError: content_type: Final = _header_value(raw_response.headers, "content-type", "").lower() if "application/json" in content_type: raise return TranscriptionResponse(text=raw_response.text) - if any(key in raw_response_json for key in TranscriptionResponse.model_fields): - return TranscriptionResponse(**raw_response_json) + if any(json_contains(raw_response_json, key) for key in TranscriptionResponse.model_fields): + return TranscriptionResponse( + **_TranscriptionResponseFields(**keyword_arguments(raw_response_json, TranscriptionResponse)) + ) else: raise ValueError("Invalid response format. Received response does not match the expected format.") diff --git a/litellm/llms/openai/vector_stores/transformation.py b/litellm/llms/openai/vector_stores/transformation.py index d108802f7f9..7fee55ade1c 100644 --- a/litellm/llms/openai/vector_stores/transformation.py +++ b/litellm/llms/openai/vector_stores/transformation.py @@ -1,8 +1,11 @@ from typing import TYPE_CHECKING, Any, Final, cast import httpx +from pydantic import TypeAdapter +from typing_extensions import TypedDict import litellm +from litellm.litellm_core_utils.core_helpers import keyword_arguments from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.llms.openai.workload_identity import resolve_openai_bearer_token @@ -27,6 +30,12 @@ if TYPE_CHECKING: else: LiteLLMLoggingObj = Any +_DECODED_JSON: Final = TypeAdapter(object) + + +class _VectorStoreResponseFields(TypedDict, total=False, extra_items=object): + pass + class OpenAIVectorStoreConfig(BaseVectorStoreConfig): ASSISTANTS_HEADER_KEY = "OpenAI-Beta" @@ -118,8 +127,10 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig): self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj ) -> VectorStoreSearchResponse: try: - response_json: Final = response.json() - return VectorStoreSearchResponse(**response_json) + response_json: Final = _DECODED_JSON.validate_python(response.json()) + return VectorStoreSearchResponse( + **_VectorStoreResponseFields(**keyword_arguments(response_json, VectorStoreSearchResponse)) + ) except Exception as e: raise self.get_error_class( error_message=str(e), @@ -149,8 +160,10 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig): def transform_create_vector_store_response(self, response: httpx.Response) -> VectorStoreCreateResponse: try: - response_json: Final = response.json() - return VectorStoreCreateResponse(**response_json) + response_json: Final = _DECODED_JSON.validate_python(response.json()) + return VectorStoreCreateResponse( + **_VectorStoreResponseFields(**keyword_arguments(response_json, VectorStoreCreateResponse)) + ) except Exception as e: raise self.get_error_class( error_message=str(e), diff --git a/litellm/llms/openai/videos/transformation.py b/litellm/llms/openai/videos/transformation.py index abd157a9a08..4d3efc29fa7 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -2,12 +2,21 @@ import mimetypes from collections.abc import Mapping from io import BufferedReader, BytesIO from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, Protocol, cast from urllib.parse import quote import httpx from httpx._types import FileContent, FileTypes, RequestFiles +from pydantic import TypeAdapter +from litellm.litellm_core_utils.core_helpers import ( + as_object_list, + decoded_json_object, + iter_items, + json_contains, + require_attributes, + subscript, +) from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base from litellm.llms.base_llm.videos.async_task_utils import unsupported_video_fields from litellm.llms.base_llm.videos.transformation import BaseVideoConfig @@ -39,6 +48,59 @@ else: BaseLLMException = Any +_REQUEST_VALUE: Final = TypeAdapter(object) + + +def _dict_fields(value: object) -> Mapping[str, object] | None: + return value if isinstance(value, dict) else None + + +class _JsonResponse(Protocol): + def json(self) -> object: ... + + +def _json_body(response: _JsonResponse) -> object: + return response.json() + + +def _model_label(model: object) -> str | None: + return format(model) if model else None + + +def _encoded_video_id(video_id: object, provider: str, model: object) -> object: + if not video_id: + return video_id + return encode_video_id_with_provider( + require_attributes(video_id, str, "startswith"), + provider, + _model_label(model), + ) + + +def _with_encoded_id(video: object, provider: str) -> object: + fields: Final = _dict_fields(video) + if fields is None or "id" not in fields: + return video + return {**fields, "id": _encoded_video_id(fields["id"], provider, fields.get("model"))} + + +def _edge_model(data: object, index: int) -> object: + if not data: + return None + edge: Final = _dict_fields(subscript(data, index)) + return None if edge is None else edge.get("model") + + +def _decoded_character(character: object) -> object: + fields: Final = _dict_fields(character) + if fields is None: + return character + character_id: Final = fields.get("id") + if not isinstance(character_id, str): + return character + return {**fields, "id": extract_original_character_id(character_id)} + + class OpenAIVideoConfig(BaseVideoConfig): """ Configuration class for OpenAI video generation. @@ -157,25 +219,11 @@ class OpenAIVideoConfig(BaseVideoConfig): `character_`, convert it back to the original provider id before forwarding upstream. """ - raw_characters: Final = request_dict.get("characters") - if not isinstance(raw_characters, list): + raw_characters: Final = as_object_list(_REQUEST_VALUE.validate_python(request_dict.get("characters"))) + if raw_characters is None: return request_dict - decoded_characters: Final[list[Any]] = [] - for character in raw_characters: - if not isinstance(character, dict): - decoded_characters.append(character) - continue - - character_id = character.get("id") - if isinstance(character_id, str): - decoded_character = dict(character) - decoded_character["id"] = extract_original_character_id(character_id) - decoded_characters.append(decoded_character) - else: - decoded_characters.append(character) - - request_dict["characters"] = decoded_characters + request_dict["characters"] = [_decoded_character(character) for character in raw_characters] return request_dict def transform_video_create_response( @@ -343,41 +391,27 @@ class OpenAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, - ) -> dict[str, str]: - response_data: Final = raw_response.json() + ) -> object: + response_data: Final = _json_body(raw_response) + if not custom_llm_provider or not json_contains(response_data, "data"): + return response_data - if custom_llm_provider and "data" in response_data: - for video_obj in response_data.get("data", []): - if isinstance(video_obj, dict) and "id" in video_obj: - video_obj["id"] = encode_video_id_with_provider( - video_obj["id"], - custom_llm_provider, - video_obj.get("model"), - ) + fields: Final = decoded_json_object(response_data) + data: Final = fields.get("data", []) + encoded_items: Final = [_with_encoded_id(item, custom_llm_provider) for item in iter_items(data)] + encoded_data: Final = encoded_items if as_object_list(data) is not None else data - # Encode pagination cursor IDs so they remain consistent - # with the wrapped data[].id format - data_list: Final = response_data.get("data", []) - if response_data.get("first_id"): - first_model = None - if data_list and isinstance(data_list[0], dict): - first_model = data_list[0].get("model") - response_data["first_id"] = encode_video_id_with_provider( - response_data["first_id"], - custom_llm_provider, - first_model, - ) - if response_data.get("last_id"): - last_model = None - if data_list and isinstance(data_list[-1], dict): - last_model = data_list[-1].get("model") - response_data["last_id"] = encode_video_id_with_provider( - response_data["last_id"], - custom_llm_provider, - last_model, - ) - - return response_data + # Encode pagination cursor IDs so they remain consistent + # with the wrapped data[].id format + first_id: Final = fields.get("first_id") + encoded_first: Final[Mapping[str, object]] = ( + {"first_id": _encoded_video_id(first_id, custom_llm_provider, _edge_model(data, 0))} if first_id else {} + ) + last_id: Final = fields.get("last_id") + encoded_last: Final[Mapping[str, object]] = ( + {"last_id": _encoded_video_id(last_id, custom_llm_provider, _edge_model(data, -1))} if last_id else {} + ) + return {**fields, "data": encoded_data, **encoded_first, **encoded_last} def transform_video_delete_request( self, diff --git a/litellm/llms/openai_like/chat/handler.py b/litellm/llms/openai_like/chat/handler.py index a450947afdf..5a5d038979b 100644 --- a/litellm/llms/openai_like/chat/handler.py +++ b/litellm/llms/openai_like/chat/handler.py @@ -5,14 +5,16 @@ For handling OpenAI-like chat completions, like IBM WatsonX, etc. """ import json -from collections.abc import Callable, Mapping, Sequence -from typing import Final, TypedDict +from collections.abc import Callable +from typing import Final import httpx -from typing_extensions import ReadOnly +from pydantic import TypeAdapter +from typing_extensions import TypedDict import litellm from litellm import LlmProviders +from litellm.litellm_core_utils.core_helpers import keyword_arguments from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.base_model_iterator import MockResponseIterator from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler @@ -26,22 +28,16 @@ from litellm.utils import CustomStreamWrapper, ProviderConfigManager from ..common_utils import OpenAILikeBase, OpenAILikeError from .transformation import OpenAILikeChatConfig - -class _OpenAILikeChatCompletion(TypedDict, total=False): - """The chat-completion JSON body an OpenAI-like provider returns for a non-streamed call.""" - - id: ReadOnly[str] - choices: ReadOnly[Sequence[Mapping[str, object]]] - created: ReadOnly[int] - model: ReadOnly[str] - system_fingerprint: ReadOnly[str] - usage: ReadOnly[Mapping[str, object]] - object: ReadOnly[str] +_DECODED_JSON: Final = TypeAdapter(object) -def _fake_streamed_model_response(payload: _OpenAILikeChatCompletion) -> ModelResponse: +class _ModelResponseFields(TypedDict, total=False, extra_items=object): + pass + + +def _fake_streamed_model_response(payload: object) -> ModelResponse: """Build the single response a fake-streamed provider call replays as one chunk.""" - return ModelResponse(**payload) + return ModelResponse(**_ModelResponseFields(**keyword_arguments(payload, ModelResponse))) async def make_call( @@ -63,7 +59,7 @@ async def make_call( if streaming_decoder is not None: completion_stream = streaming_decoder.aiter_bytes(response.aiter_bytes(chunk_size=1024)) elif fake_stream: - model_response: Final = _fake_streamed_model_response(response.json()) + model_response: Final = _fake_streamed_model_response(_DECODED_JSON.validate_python(response.json())) completion_stream = MockResponseIterator(model_response=model_response) else: completion_stream = ModelResponseIterator( @@ -103,7 +99,7 @@ def make_sync_call( if streaming_decoder is not None: completion_stream = streaming_decoder.iter_bytes(response.iter_bytes(chunk_size=1024)) elif fake_stream: - model_response: Final = _fake_streamed_model_response(response.json()) + model_response: Final = _fake_streamed_model_response(_DECODED_JSON.validate_python(response.json())) completion_stream = MockResponseIterator(model_response=model_response) else: completion_stream = ModelResponseIterator( diff --git a/litellm/llms/openai_like/embedding/handler.py b/litellm/llms/openai_like/embedding/handler.py index 540feeca6e7..b064505b445 100644 --- a/litellm/llms/openai_like/embedding/handler.py +++ b/litellm/llms/openai_like/embedding/handler.py @@ -10,9 +10,11 @@ from typing import Final import httpx from pydantic import TypeAdapter +from typing_extensions import TypedDict import litellm from litellm._logging import verbose_logger +from litellm.litellm_core_utils.core_helpers import keyword_arguments from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -40,6 +42,11 @@ _TRANSPORT_HEADER_NAMES: Final = frozenset( ) _HEADER_NAME: Final = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") _HEADER_VALUE: Final = re.compile(r"[\t\x20-\x7e]*") +_DECODED_JSON: Final = TypeAdapter(object) + + +class _EmbeddingResponseFields(TypedDict, total=False, extra_items=object): + pass def _header_text(name: str, value: object) -> str | None: @@ -108,7 +115,7 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase): response.raise_for_status() - response_json: Final = response.json() + response_json: Final = _DECODED_JSON.validate_python(response.json()) except httpx.HTTPStatusError as e: raise OpenAILikeError( status_code=e.response.status_code, @@ -126,7 +133,7 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase): additional_args={"complete_input_dict": data}, original_response=response_json, ) - return EmbeddingResponse(**response_json) + return EmbeddingResponse(**_EmbeddingResponseFields(**keyword_arguments(response_json, EmbeddingResponse))) except Exception as e: ## LOGGING logging_obj.post_call( diff --git a/litellm/llms/predibase/chat/transformation.py b/litellm/llms/predibase/chat/transformation.py index b30713fa119..94e64dfb95b 100644 --- a/litellm/llms/predibase/chat/transformation.py +++ b/litellm/llms/predibase/chat/transformation.py @@ -5,10 +5,11 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Literal from httpx import Headers, Response +from pydantic import TypeAdapter import litellm from litellm.constants import DEFAULT_MAX_TOKENS -from litellm.litellm_core_utils.core_helpers import map_finish_reason +from litellm.litellm_core_utils.core_helpers import json_contains, map_finish_reason, subscript from litellm.litellm_core_utils.prompt_templates.factory import ( prompt_factory, render_custom_prompt_template, @@ -28,6 +29,8 @@ if TYPE_CHECKING: else: LiteLLMLoggingObj = Any +_DECODED_JSON: Final = TypeAdapter(object) + class PredibaseConfig(BaseConfig): """ @@ -163,13 +166,13 @@ class PredibaseConfig(BaseConfig): additional_args={"complete_input_dict": request_data}, ) try: - completion_response: Final = raw_response.json() + completion_response: Final = _DECODED_JSON.validate_python(raw_response.json()) except Exception: raise PredibaseError(message=raw_response.text, status_code=422) - if "error" in completion_response: + if json_contains(completion_response, "error"): raise PredibaseError( - message=str(completion_response["error"]), + message=str(subscript(completion_response, "error")), status_code=raw_response.status_code, ) elif not isinstance(completion_response, dict): diff --git a/litellm/llms/ragflow/vector_stores/transformation.py b/litellm/llms/ragflow/vector_stores/transformation.py index ef44b6e4c0f..421bd769778 100644 --- a/litellm/llms/ragflow/vector_stores/transformation.py +++ b/litellm/llms/ragflow/vector_stores/transformation.py @@ -41,6 +41,11 @@ class _RAGFlowCreateResponseRoot(RootModel[_RAGFlowCreateResponse]): model_config = ConfigDict(frozen=True, hide_input_in_errors=True) +class _RAGFlowStatus(TypedDict): + code: ReadOnly[NotRequired[int]] + message: ReadOnly[NotRequired[str]] + + _RAGFLOW_EMPTY_DATA: Final[_RAGFlowDataset] = {} @@ -197,7 +202,7 @@ class RAGFlowVectorStoreConfig(BaseVectorStoreConfig): } """ try: - response_json: Final = response.json() + response_json: Final[_RAGFlowStatus] = response.json() # Check for RAGFlow error response if response_json.get("code") != 0: diff --git a/litellm/llms/replicate/chat/transformation.py b/litellm/llms/replicate/chat/transformation.py index 62dfac43bd7..4553a02e451 100644 --- a/litellm/llms/replicate/chat/transformation.py +++ b/litellm/llms/replicate/chat/transformation.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Final import httpx from pydantic import ConfigDict, TypeAdapter +from typing_extensions import NotRequired, ReadOnly, TypedDict import litellm from litellm.constants import REPLICATE_MODEL_NAME_WITH_ID_LENGTH @@ -33,6 +34,10 @@ _TEXTS: Final = TypeAdapter(Iterable[str], config=ConfigDict(strict=True, hide_i _JSON_DICT: Final = TypeAdapter(dict[str, object], config=ConfigDict(hide_input_in_errors=True)) +class _ReplicatePrediction(TypedDict): + urls: ReadOnly[NotRequired[Mapping[str, str]]] + + class ReplicateConfig(BaseConfig): """ Reference: https://replicate.com/meta/llama-2-70b-chat/api @@ -292,7 +297,7 @@ class ReplicateConfig(BaseConfig): "urls":{"cancel":"https://api.replicate.com/v1/predictions/gqsmqmp1pdrj00cknr08dgmvb4/cancel","get":"https://api.replicate.com/v1/predictions/gqsmqmp1pdrj00cknr08dgmvb4","stream":"https://stream-b.svc.rno2.c.replicate.net/v1/streams/eot4gbydowuin4snhncydwxt57dfwgsc3w3snycx5nid7oef7jga"} } """ - response_json: Final = response.json() + response_json: Final[_ReplicatePrediction] = response.json() prediction_url: Final = response_json.get("urls", {}).get("get") if prediction_url is None: raise ReplicateError( diff --git a/litellm/llms/sagemaker/completion/transformation.py b/litellm/llms/sagemaker/completion/transformation.py index 5f6bef4246e..88f953091b7 100644 --- a/litellm/llms/sagemaker/completion/transformation.py +++ b/litellm/llms/sagemaker/completion/transformation.py @@ -11,9 +11,11 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final from httpx._models import Headers, Response +from pydantic import TypeAdapter import litellm from litellm.litellm_core_utils.asyncify import asyncify +from litellm.litellm_core_utils.core_helpers import as_object_list, json_contains, subscript from litellm.litellm_core_utils.prompt_templates.factory import ( prompt_factory, render_custom_prompt_template, @@ -33,6 +35,14 @@ if TYPE_CHECKING: else: LiteLLMLoggingObj = Any +_DECODED_JSON: Final = TypeAdapter(object) + + +def _concatenated(prefix: str, value: object) -> str: + if isinstance(value, str): + return prefix + value + raise TypeError(f'can only concatenate str (not "{type(value).__name__}") to str') + class SagemakerConfig(BaseConfig): """ @@ -199,7 +209,7 @@ class SagemakerConfig(BaseConfig): api_key: str | None = None, json_mode: bool | None = None, ) -> ModelResponse: - completion_response: Final = raw_response.json() + completion_response: Final = _DECODED_JSON.validate_python(raw_response.json()) ## LOGGING logging_obj.post_call( input=messages, @@ -212,15 +222,19 @@ class SagemakerConfig(BaseConfig): ## RESPONSE OBJECT try: - if isinstance(completion_response, list): - completion_response_choices = completion_response[0] - else: - completion_response_choices = completion_response + completion_response_list: Final = as_object_list(completion_response) + completion_response_choices: Final = ( + completion_response if completion_response_list is None else completion_response_list[0] + ) completion_output = "" - if "generation" in completion_response_choices: - completion_output += completion_response_choices["generation"] - elif "generated_text" in completion_response_choices: - completion_output += completion_response_choices["generated_text"] + if json_contains(completion_response_choices, "generation"): + completion_output = _concatenated( + completion_output, subscript(completion_response_choices, "generation") + ) + elif json_contains(completion_response_choices, "generated_text"): + completion_output = _concatenated( + completion_output, subscript(completion_response_choices, "generated_text") + ) # check if the prompt template is part of output, if so - filter it out if completion_output.startswith(prompt) and "" in prompt: diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 928dcc3bc52..161cf908a6f 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict import httpx from typing_extensions import ReadOnly -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, iter_items from litellm.litellm_core_utils.prompt_templates.factory import ( anthropic_process_openai_file_message, convert_to_anthropic_tool_result, @@ -354,13 +354,15 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): if isinstance(block, Mapping) and block.get("type") == "text" ) elif role == "assistant": - tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None) + tool_calls: object = ( + msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None) + ) thinking_blocks = _signed_thinking_blocks(msg) if tool_calls: content_blocks: list[dict[str, object]] = list(thinking_blocks) # mutable-ok: JSON wire blocks if content: content_blocks.append({"type": "text", "text": content}) - for tc in tool_calls: + for tc in iter_items(tool_calls): func = tc.get("function", {}) if isinstance(tc, dict) else getattr(tc, "function", {}) tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "") func_name = func.get("name", "") if isinstance(func, dict) else getattr(func, "name", "") diff --git a/litellm/llms/soniox/audio_transcription/transformation.py b/litellm/llms/soniox/audio_transcription/transformation.py index 30d872f248f..cefb195f4a5 100644 --- a/litellm/llms/soniox/audio_transcription/transformation.py +++ b/litellm/llms/soniox/audio_transcription/transformation.py @@ -13,11 +13,13 @@ import re from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType -from typing import Any, Final, NoReturn +from typing import Any, Final, NoReturn, SupportsFloat, SupportsIndex from httpx import Headers, Response from pydantic import TypeAdapter, ValidationError +from typing_extensions import Buffer +from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.llms.base_llm.audio_transcription.transformation import ( AudioTranscriptionRequestData, BaseAudioTranscriptionConfig, @@ -172,6 +174,12 @@ def _nested_form_param(param: str, fields: tuple[_BracketedField, ...]) -> Mappi } +def _as_float(value: object) -> float: + if isinstance(value, (str, Buffer, SupportsFloat, SupportsIndex)): + return float(value) + raise TypeError(f"float() argument must be a string or a real number, not {type(value).__name__!r}") + + def _is_soniox_context(value: object) -> bool: try: _STRUCTURED_CONTEXT.validate_python(value) @@ -345,7 +353,7 @@ class SonioxAudioTranscriptionConfig(BaseAudioTranscriptionConfig): response_format: str | None = None, ) -> TranscriptionResponse: """Shared response-building logic (also used by the handler).""" - transcription_meta: dict[str, Any] = {} + transcription_meta: object = {} transcript: dict[str, Any] if isinstance(payload, dict) and "transcript" in payload: @@ -385,9 +393,10 @@ class SonioxAudioTranscriptionConfig(BaseAudioTranscriptionConfig): response["task"] = "transcribe" # Best-effort metadata fields matching OpenAI's verbose_json shape. - if transcription_meta.get("audio_duration_ms") is not None: + audio_duration_ms: Final = decoded_json_object(transcription_meta).get("audio_duration_ms") + if audio_duration_ms is not None: try: - response["duration"] = float(transcription_meta["audio_duration_ms"]) / 1000.0 + response["duration"] = _as_float(audio_duration_ms) / 1000.0 except (TypeError, ValueError): pass @@ -403,10 +412,12 @@ class SonioxAudioTranscriptionConfig(BaseAudioTranscriptionConfig): words: Final[list[dict[str, object]]] = [] for token in tokens: word_entry: dict[str, object] = {"word": token.get("text", "")} - if token.get("start_ms") is not None: - word_entry["start"] = float(token["start_ms"]) / 1000.0 - if token.get("end_ms") is not None: - word_entry["end"] = float(token["end_ms"]) / 1000.0 + start_ms: object = token.get("start_ms") + if start_ms is not None: + word_entry["start"] = _as_float(start_ms) / 1000.0 + end_ms: object = token.get("end_ms") + if end_ms is not None: + word_entry["end"] = _as_float(end_ms) / 1000.0 words.append(word_entry) if words: response["words"] = words diff --git a/litellm/llms/stability/image_edit/transformations.py b/litellm/llms/stability/image_edit/transformations.py index 974996c5e48..65277973d39 100644 --- a/litellm/llms/stability/image_edit/transformations.py +++ b/litellm/llms/stability/image_edit/transformations.py @@ -6,13 +6,13 @@ Handles transformation between OpenAI-compatible format and Stability AI API for API Reference: https://platform.stability.ai/docs/api-reference """ -from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Final, cast import httpx from httpx._types import RequestFiles -from typing_extensions import ReadOnly, TypedDict +from pydantic import TypeAdapter +from litellm.litellm_core_utils.core_helpers import decoded_json_object, json_contains, subscript from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -31,12 +31,7 @@ if TYPE_CHECKING: else: LiteLLMLoggingObj = Any - -class _StabilityImageEditResponse(TypedDict, total=False): - image: ReadOnly[str] - finish_reason: ReadOnly[str] - seed: ReadOnly[int] - errors: ReadOnly[Sequence[str]] +_DECODED_JSON: Final = TypeAdapter(object) class StabilityImageEditConfig(BaseImageEditConfig): @@ -268,7 +263,7 @@ class StabilityImageEditConfig(BaseImageEditConfig): OpenAI expects: {"data": [{"b64_json": "base64..."}], "created": timestamp} """ try: - response_data: Final[_StabilityImageEditResponse] = raw_response.json() + response_json: Final = _DECODED_JSON.validate_python(raw_response.json()) except Exception as e: raise self.get_error_class( error_message=f"Error parsing Stability AI response: {e}", @@ -277,14 +272,15 @@ class StabilityImageEditConfig(BaseImageEditConfig): ) # Check for errors in response - if "errors" in response_data: + if json_contains(response_json, "errors"): raise self.get_error_class( - error_message=f"Stability AI error: {response_data['errors']}", + error_message=f"Stability AI error: {subscript(response_json, 'errors')}", status_code=raw_response.status_code, headers=raw_response.headers, ) # Check finish_reason + response_data: Final = decoded_json_object(response_json) finish_reason: Final = response_data.get("finish_reason", "") if finish_reason == "CONTENT_FILTERED": raise self.get_error_class( diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index d0de71e0305..aa275f254b4 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -7,7 +7,7 @@ from enum import Enum from functools import lru_cache from itertools import accumulate, chain, repeat, takewhile from types import MappingProxyType -from typing import Any, Final, Literal, TypeVar, cast, get_type_hints +from typing import Final, Literal, TypeVar, cast, get_type_hints import httpx from pydantic import JsonValue, TypeAdapter, ValidationError @@ -1384,6 +1384,13 @@ class VertexAIModelInfo(BaseLLMModelInfo): raise NotImplementedError("Vertex AI models are not supported yet") +_COUNT_TOKENS_REQUEST_FIELDS: Final = frozenset({"model", "contents", "system_instruction", "tools"}) + + +class _ForwardedCountTokensParams(TypedDict, total=False, extra_items=object): + pass + + class VertexAITokenCounter(BaseTokenCounter): """Token counter implementation for Google AI Studio provider.""" @@ -1446,7 +1453,7 @@ class VertexAITokenCounter(BaseTokenCounter): model_to_use: str, messages: list[dict[str, object]] | None, contents: list[dict[str, object]] | None, - deployment: dict[str, Any] | None = None, + deployment: dict[str, object] | None = None, request_model: str = "", tools: list[dict[str, object]] | None = None, system: object | None = None, @@ -1458,6 +1465,7 @@ class VertexAITokenCounter(BaseTokenCounter): ) -> TokenCountResponse | None: import copy + from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.llms.anthropic.common_utils import context_management_with_edits from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import count_tokens_publisher from litellm.llms.vertex_ai.vertex_ai_partner_models.main import ( @@ -1467,19 +1475,19 @@ class VertexAITokenCounter(BaseTokenCounter): VertexAIPartnerModels, ) - deployment = deployment or {} - count_tokens_params_request: Final = copy.deepcopy(deployment.get("litellm_params", {})) + resolved_deployment: Final = deployment or {} + raw_litellm_params: Final = copy.deepcopy(resolved_deployment.get("litellm_params", {})) if not VertexAIPartnerModels.is_vertex_partner_model(model_to_use) and not uses_vertex_gemini_api( - model_to_use, count_tokens_params_request + model_to_use, decoded_json_object(raw_litellm_params) if raw_litellm_params else None ): return None without_count_route: Final = ( VertexAIPartnerModels.is_vertex_partner_model(model_to_use) and count_tokens_publisher(model_to_use) is None ) - model_info: Final[Mapping[str, object]] = deployment.get("model_info") or {} - if without_count_route and messages and model_info.get("custom_tokenizer") is not None: + model_info: Final[object] = resolved_deployment.get("model_info") or {} + if without_count_route and messages and decoded_json_object(model_info).get("custom_tokenizer") is not None: return None if without_count_route: return TokenCountResponse( @@ -1497,7 +1505,7 @@ class VertexAITokenCounter(BaseTokenCounter): translated_count: Final = await self._count_translated_claude_request( model_to_use=model_to_use, messages=messages, - deployment=deployment, + deployment=resolved_deployment, request_model=request_model, tools=tools, system=system, @@ -1513,7 +1521,7 @@ class VertexAITokenCounter(BaseTokenCounter): partner_models_handler: Final = VertexAIPartnerModels() # Extract vertex-specific params from litellm_params - partner_litellm_params: Final[dict[str, object]] = count_tokens_params_request + partner_litellm_params: Final = dict(decoded_json_object(raw_litellm_params)) vertex_project = partner_litellm_params.get("vertex_project") or partner_litellm_params.get( "vertex_ai_project" ) @@ -1585,11 +1593,12 @@ class VertexAITokenCounter(BaseTokenCounter): ) from litellm.llms.vertex_ai.count_tokens.handler import VertexAITokenCounter + gemini_litellm_params: Final = decoded_json_object(raw_litellm_params) request_view: Final = await inference_request_view( messages or [], system, messages_format, - count_context_edits(context_management, tools, count_tokens_params_request, model_to_use), + count_context_edits(context_management, tools, gemini_litellm_params, model_to_use), ) if isinstance(request_view, InferenceRequestViewError): return count_context_error_response(request_view, request_model, model_to_use) @@ -1600,7 +1609,7 @@ class VertexAITokenCounter(BaseTokenCounter): return count_context_error_response(count_context, request_model, model_to_use) blocks_error: Final = unconvertible_messages_error( request_view.messages, messages_format - ) or malformed_tool_result_text_count_error(messages, messages_format, deployment) + ) or malformed_tool_result_text_count_error(messages, messages_format, resolved_deployment) if blocks_error is not None: return count_context_error_response(blocks_error, request_model, model_to_use) @@ -1608,22 +1617,21 @@ class VertexAITokenCounter(BaseTokenCounter): contents if contents is not None else await gemini_count_contents( - request_view.messages, messages_format, model_to_use, "vertex_ai", count_tokens_params_request + request_view.messages, messages_format, model_to_use, "vertex_ai", gemini_litellm_params ) ) if isinstance(resolved_contents, VertexGeminiCountContextError): return count_context_error_response(resolved_contents, request_model, model_to_use) - count_tokens_params: Final = { - "model": model_to_use, - "contents": resolved_contents, - "system_instruction": count_context.system_instruction, - "tools": count_context.tools, - } - count_tokens_params_request.update(count_tokens_params) try: result = await VertexAITokenCounter().acount_tokens( - **count_tokens_params_request, + **_ForwardedCountTokensParams( + **{k: v for k, v in gemini_litellm_params.items() if k not in _COUNT_TOKENS_REQUEST_FIELDS} + ), + model=model_to_use, + contents=resolved_contents, + system_instruction=count_context.system_instruction, + tools=count_context.tools, ) except litellm.APIError as error: return provider_error_response(error, request_model, model_to_use) diff --git a/litellm/llms/vertex_ai/files/transformation.py b/litellm/llms/vertex_ai/files/transformation.py index 1f03de4625c..7a7afcb83f1 100644 --- a/litellm/llms/vertex_ai/files/transformation.py +++ b/litellm/llms/vertex_ai/files/transformation.py @@ -10,7 +10,7 @@ from contextlib import aclosing from dataclasses import dataclass from functools import partial from types import MappingProxyType -from typing import IO, Any, Final, TypedDict +from typing import IO, Final, TypedDict from urllib.parse import quote, unquote import httpx @@ -827,7 +827,7 @@ def _openai_batch_jsonl_entry_to_vertex_anthropic_row( def _openai_batch_jsonl_entry_to_vertex_rows( - openai_entry: dict[str, Any], + openai_entry: Mapping[str, object], map_openai_to_vertex_params: Callable[[dict[str, object]], dict[str, object]], batch_model: str | None = None, deployment_params: Mapping[str, object] = MappingProxyType({}), @@ -842,7 +842,7 @@ def _openai_batch_jsonl_entry_to_vertex_rows( Example Vertex jsonl {"request":{"contents": [{"role": "user", "parts": [{"text": "What is the relation between the following video and image samples?"}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/video/animals.mp4", "mimeType": "video/mp4"}}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/image/cricket.jpeg", "mimeType": "image/jpeg"}}]}]}} """ - entry_body: Final = openai_entry.get("body") + entry_body: Final = as_str_mapping(openai_entry.get("body")) model: Final = batch_model or (str(entry_body.get("model") or "") if isinstance(entry_body, dict) else "") is_anthropic_model: Final = is_vertex_anthropic_batch_model(model) if _is_embeddings_batch_entry(openai_entry): @@ -855,11 +855,10 @@ def _openai_batch_jsonl_entry_to_vertex_rows( if not isinstance(entry_body, dict): raise TypeError("`body` on batch requests must be a JSON object, but was missing or not an object") - openai_request_body: Final = openai_entry["body"] chat_request_body: Final = ( - responses_batch_body_to_chat_body(openai_request_body, custom_llm_provider="vertex_ai") + responses_batch_body_to_chat_body(entry_body, custom_llm_provider="vertex_ai") if _is_responses_batch_entry(openai_entry) - else openai_request_body + else entry_body ) chat_messages: Final = map_developer_role_to_system_role(chat_request_body.get("messages", [])) if is_anthropic_model: diff --git a/litellm/llms/vertex_ai/image_generation/image_generation_handler.py b/litellm/llms/vertex_ai/image_generation/image_generation_handler.py index ec915e10fcd..56843d2e1a1 100644 --- a/litellm/llms/vertex_ai/image_generation/image_generation_handler.py +++ b/litellm/llms/vertex_ai/image_generation/image_generation_handler.py @@ -7,6 +7,7 @@ from openai.types.image import Image from pydantic import ConfigDict, TypeAdapter import litellm +from litellm.litellm_core_utils.core_helpers import json_contains, subscript from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -20,23 +21,24 @@ if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj _PREDICTIONS: Final = TypeAdapter(Iterable[Mapping[str, object]], config=ConfigDict(hide_input_in_errors=True)) +_DECODED_JSON: Final = TypeAdapter(object) class VertexImageGeneration(VertexLLM): def process_image_generation_response( self, - json_response: Mapping[str, object], + json_response: object, model_response: ImageResponse, model: str | None = None, ) -> ImageResponse: - if "predictions" not in json_response: + if not json_contains(json_response, "predictions"): raise litellm.InternalServerError( message=f"image generation response does not contain 'predictions', got {json_response}", llm_provider="vertex_ai", model=model, ) - predictions: Final = _PREDICTIONS.validate_python(json_response["predictions"]) + predictions: Final = _PREDICTIONS.validate_python(subscript(json_response, "predictions")) response_data: Final[list[Image]] = [] for prediction in predictions: @@ -168,7 +170,7 @@ class VertexImageGeneration(VertexLLM): if response.status_code != 200: raise Exception(f"Error: {response.status_code} {response.text}") - json_response: Final[Mapping[str, object]] = response.json() + json_response: Final = _DECODED_JSON.validate_python(response.json()) return self.process_image_generation_response(json_response, model_response, model) async def aimage_generation( @@ -272,11 +274,11 @@ class VertexImageGeneration(VertexLLM): if response.status_code != 200: raise Exception(f"Error: {response.status_code} {response.text}") - json_response: Final[Mapping[str, object]] = response.json() + json_response: Final = _DECODED_JSON.validate_python(response.json()) return self.process_image_generation_response(json_response, model_response, model) - def is_image_generation_response(self, json_response: dict[str, Any]) -> bool: + def is_image_generation_response(self, json_response: Mapping[str, object]) -> bool: if "predictions" in json_response: - if "bytesBase64Encoded" in json_response["predictions"][0]: + if json_contains(subscript(json_response["predictions"], 0), "bytesBase64Encoded"): return True return False diff --git a/litellm/llms/vertex_ai/realtime/transformation.py b/litellm/llms/vertex_ai/realtime/transformation.py index 7635cd32a06..5781481dde8 100644 --- a/litellm/llms/vertex_ai/realtime/transformation.py +++ b/litellm/llms/vertex_ai/realtime/transformation.py @@ -20,7 +20,10 @@ import json from collections.abc import Awaitable, Callable from typing import Final +from pydantic import TypeAdapter + from litellm import verbose_logger +from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig from litellm.llms.vertex_ai.audio_transcription.realtime_transformation import ( @@ -30,6 +33,8 @@ from litellm.llms.vertex_ai.audio_transcription.realtime_transformation import ( from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase +_DECODED_JSON: Final = TypeAdapter(object) + class VertexAIRealtimeConfig(GeminiRealtimeConfig): """ @@ -217,7 +222,7 @@ class VertexAIRealtimeConfig(GeminiRealtimeConfig): ``setup`` closes the connection with a 1007 policy error. """ json_message: Final = json.loads(message) - msg_type: Final = json_message.get("type") + msg_type: Final = decoded_json_object(_DECODED_JSON.validate_python(json_message)).get("type") if msg_type == "session.update": if session_configuration_request is None: diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/llama/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/llama/transformation.py index d11be440172..e26b26af278 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/llama/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/llama/transformation.py @@ -131,7 +131,8 @@ class VertexAILlama3Config(OpenAIGPTConfig): ## RESPONSE OBJECT try: - completion_response: Final = OpenAIChatCompletionResponse(**raw_response.json()) + payload: Final[OpenAIChatCompletionResponse] = raw_response.json() + completion_response: Final = OpenAIChatCompletionResponse(**payload) except Exception as e: response_headers: Final = getattr(raw_response, "headers", None) raise VertexAIError( diff --git a/litellm/llms/volcengine/embedding/transformation.py b/litellm/llms/volcengine/embedding/transformation.py index cc1e7d59caa..283b013917b 100644 --- a/litellm/llms/volcengine/embedding/transformation.py +++ b/litellm/llms/volcengine/embedding/transformation.py @@ -8,7 +8,10 @@ from collections.abc import Mapping from typing import Final import httpx +from pydantic import TypeAdapter +from typing_extensions import TypedDict +from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.url_utils import ensure_api_route, join_api_base from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -18,6 +21,12 @@ from litellm.types.utils import EmbeddingResponse from ..common_utils import get_volcengine_base_url, get_volcengine_headers +_DECODED_JSON: Final = TypeAdapter(object) + + +class _VolcengineEmbeddingFields(TypedDict, total=False, extra_items=object): + pass + class VolcEngineEmbeddingConfig(BaseEmbeddingConfig): """ @@ -159,9 +168,10 @@ class VolcEngineEmbeddingConfig(BaseEmbeddingConfig): ) -> EmbeddingResponse: """Transform Volcengine response to EmbeddingResponse""" try: - response_json: Final = raw_response.json() + decoded_response: Final = _DECODED_JSON.validate_python(raw_response.json()) except Exception as e: raise ValueError(f"Failed to parse Volcengine response as JSON: {e}") + response_json: Final = decoded_json_object(decoded_response) # Volcengine response format matches OpenAI format closely # Just need to ensure all required fields are present @@ -177,7 +187,7 @@ class VolcEngineEmbeddingConfig(BaseEmbeddingConfig): transformed_response["id"] = response_json["id"] # Create EmbeddingResponse from transformed data - return EmbeddingResponse(**transformed_response) + return EmbeddingResponse(**_VolcengineEmbeddingFields(**transformed_response)) def validate_environment( self, diff --git a/litellm/llms/volcengine/videos/transformation.py b/litellm/llms/volcengine/videos/transformation.py index 5af1080b9a0..ca4d3546636 100644 --- a/litellm/llms/volcengine/videos/transformation.py +++ b/litellm/llms/volcengine/videos/transformation.py @@ -874,7 +874,7 @@ class VolcEngineVideoConfig(BaseVideoConfig): **(extra_query or {}), } - def transform_video_list_response( # pyright: ignore[reportIncompatibleMethodOverride] # base declares dict[str, str] but the payload is a heterogeneous list body + def transform_video_list_response( self, raw_response: httpx.Response, logging_obj: "Logging", diff --git a/litellm/llms/watsonx/common_utils.py b/litellm/llms/watsonx/common_utils.py index 6d3686e9f85..5d2aa418173 100644 --- a/litellm/llms/watsonx/common_utils.py +++ b/litellm/llms/watsonx/common_utils.py @@ -1,10 +1,12 @@ from typing import Final, cast import httpx +from pydantic import TypeAdapter import litellm from litellm import verbose_logger from litellm.caching import InMemoryCache +from litellm.litellm_core_utils.core_helpers import subscript from litellm.litellm_core_utils.prompt_templates import factory as ptf from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.secret_managers.main import get_secret_str @@ -23,6 +25,13 @@ class WatsonXAIError(BaseLLMException): iam_token_cache: Final = InMemoryCache() +_IAM_TOKEN_RESPONSE: Final = TypeAdapter(object) + + +def _ttl_with_buffer(expires_in: object) -> float: + if isinstance(expires_in, (int, float)): + return expires_in - 10 + raise TypeError(f"unsupported operand type(s) for -: '{type(expires_in).__name__}' and 'int'") def get_watsonx_iam_url(): @@ -30,7 +39,7 @@ def get_watsonx_iam_url(): def generate_iam_token(api_key=None, **params) -> str: - result: str | None = iam_token_cache.get_cache(api_key) + result: object = iam_token_cache.get_cache(api_key) if result is None: headers: Final = {} @@ -58,13 +67,13 @@ def generate_iam_token(api_key=None, **params) -> str: ) response: Final = litellm.module_level_client.post(url=iam_token_url, data=data, headers=headers) response.raise_for_status() - json_data: Final = response.json() + json_data: Final = _IAM_TOKEN_RESPONSE.validate_python(response.json()) - result = json_data["access_token"] + result = subscript(json_data, "access_token") iam_token_cache.set_cache( key=api_key, value=result, - ttl=json_data["expires_in"] - 10, # leave some buffer + ttl=_ttl_with_buffer(subscript(json_data, "expires_in")), ) return cast(str, result) diff --git a/litellm/llms/watsonx/completion/transformation.py b/litellm/llms/watsonx/completion/transformation.py index 9ea062ea2f8..fd1ecaf29dd 100644 --- a/litellm/llms/watsonx/completion/transformation.py +++ b/litellm/llms/watsonx/completion/transformation.py @@ -4,7 +4,9 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Final import httpx +from pydantic import TypeAdapter +from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.types.llms.openai import AllMessageValues, ChatCompletionUsageBlock @@ -30,6 +32,9 @@ else: LiteLLMLoggingObj = Any +_DECODED_JSON: Final = TypeAdapter(object) + + class IBMWatsonXAIConfig(IBMWatsonXMixin, BaseConfig): """ Reference: https://cloud.ibm.com/apidocs/watsonx-ai#text-generation @@ -305,12 +310,15 @@ class IBMWatsonXAIConfig(IBMWatsonXMixin, BaseConfig): completion_tokens: Final = json_resp["results"][0]["generated_token_count"] model_response.choices[0].message.content = generated_text model_response.choices[0].finish_reason = map_finish_reason(json_resp["results"][0]["stop_reason"]) - if json_resp.get("created_at"): + created_at: Final = decoded_json_object(_DECODED_JSON.validate_python(json_resp)).get("created_at") + if created_at: + if not isinstance(created_at, str): + raise TypeError("fromisoformat: argument must be str") try: - created_datetime = datetime.fromisoformat(json_resp["created_at"]) + created_datetime = datetime.fromisoformat(created_at) except ValueError: # datetime.fromisoformat cannot handle 'Z' in Python 3.10 - created_datetime = datetime.fromisoformat(f"{json_resp['created_at'].rstrip('Z')}+00:00") + created_datetime = datetime.fromisoformat(f"{created_at.rstrip('Z')}+00:00") model_response.created = int(created_datetime.timestamp()) else: model_response.created = int(time.time()) diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index f53b71a5a9a..da8d673ce9f 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -1,15 +1,17 @@ import copy from collections.abc import AsyncIterator, Iterator, Mapping from types import MappingProxyType -from typing import Any, Final +from typing import Final, Protocol, SupportsIndex, SupportsInt, runtime_checkable import httpx from pydantic import ConfigDict, TypeAdapter +from typing_extensions import Buffer import litellm from litellm._logging import verbose_logger from litellm.constants import XAI_API_BASE from litellm.exceptions import AuthenticationError +from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none from litellm.litellm_core_utils.prompt_templates.common_utils import ( filter_value_from_dict, strip_name_from_messages, @@ -36,6 +38,28 @@ _RESPONSE_BODY: Final = TypeAdapter(dict[object, object], config=ConfigDict(hide _FILTERED_TOOL: Final = TypeAdapter(object) +@runtime_checkable +class _SupportsTrunc(Protocol): + def __trunc__(self) -> int: ... + + +def _token_count(value: object) -> int: + if isinstance(value, (str, Buffer, SupportsInt, SupportsIndex, _SupportsTrunc)): + return int(value) + raise TypeError( + f"int() argument must be a string, a bytes-like object or a real number, not '{type(value).__name__}'" + ) + + +def _reasoning_tokens(details: object) -> int: + if not details: + return 0 + details_fields: Final = as_str_mapping(details) + if details_fields is not None and isinstance(details, dict): + return _token_count(details_fields.get("reasoning_tokens") or 0) + return _token_count(attribute_or_none(details, "reasoning_tokens") or 0) + + def _usage_restated_from_xai_ticks(usage: Usage | None) -> Usage | None: reported_cost: Final = xai_reported_cost_in_usd(getattr(usage, "cost_in_usd_ticks", None)) if usage is None or reported_cost is None: @@ -324,7 +348,7 @@ class XAIChatConfig(OpenAIGPTConfig): @staticmethod def fold_reasoning_tokens_into_completion( - target: ModelResponse | Usage | dict[str, Any] | None, + target: ModelResponse | Usage | dict[str, object] | None, ) -> None: """Reconcile xAI Usage to the OpenAI invariant. @@ -341,24 +365,20 @@ class XAIChatConfig(OpenAIGPTConfig): return if isinstance(target, ModelResponse): - usage: Usage | dict[str, Any] | None = getattr(target, "usage", None) + usage: Usage | dict[str, object] | None = getattr(target, "usage", None) else: usage = target if usage is None: return if isinstance(usage, dict): - details = usage.get("completion_tokens_details") or {} - if isinstance(details, dict): - reasoning_tokens = int(details.get("reasoning_tokens") or 0) - else: - reasoning_tokens = int(getattr(details, "reasoning_tokens", 0) or 0) + reasoning_tokens = _reasoning_tokens(usage.get("completion_tokens_details")) if reasoning_tokens <= 0: return - prompt_tokens = int(usage.get("prompt_tokens") or 0) - completion_tokens = int(usage.get("completion_tokens") or 0) - total_tokens = int(usage.get("total_tokens") or 0) + prompt_tokens = _token_count(usage.get("prompt_tokens") or 0) + completion_tokens = _token_count(usage.get("completion_tokens") or 0) + total_tokens = _token_count(usage.get("total_tokens") or 0) if total_tokens == prompt_tokens + completion_tokens: return @@ -370,8 +390,8 @@ class XAIChatConfig(OpenAIGPTConfig): usage["completion_tokens"] = completion_tokens + reasoning_tokens return - details = getattr(usage, "completion_tokens_details", None) - reasoning_tokens = int(getattr(details, "reasoning_tokens", 0) or 0) if details else 0 + details = attribute_or_none(usage, "completion_tokens_details") + reasoning_tokens = _token_count(attribute_or_none(details, "reasoning_tokens") or 0) if details else 0 if reasoning_tokens <= 0: return @@ -407,15 +427,15 @@ class XAIChatConfig(OpenAIGPTConfig): @staticmethod def _normalize_openai_compatible_usage_totals( - usage: Usage | dict[str, Any] | None, + usage: Usage | dict[str, object] | None, ) -> None: if usage is None: return if isinstance(usage, dict): - prompt_tokens = int(usage.get("prompt_tokens") or 0) - completion_tokens = int(usage.get("completion_tokens") or 0) + prompt_tokens = _token_count(usage.get("prompt_tokens") or 0) + completion_tokens = _token_count(usage.get("completion_tokens") or 0) expected_total = prompt_tokens + completion_tokens - if int(usage.get("total_tokens") or 0) < expected_total: + if _token_count(usage.get("total_tokens") or 0) < expected_total: usage["total_tokens"] = expected_total return prompt_tokens = int(usage.prompt_tokens or 0) diff --git a/litellm/llms/you_com/search/transformation.py b/litellm/llms/you_com/search/transformation.py index 9228deda26b..b56b547a949 100644 --- a/litellm/llms/you_com/search/transformation.py +++ b/litellm/llms/you_com/search/transformation.py @@ -8,7 +8,9 @@ OpenAPI spec: https://you.com/specs/openapi_search_v1.yaml from typing import Final, TypedDict import httpx +from pydantic import TypeAdapter +from litellm.litellm_core_utils.core_helpers import decoded_json_object, iter_items, subscript from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.search.transformation import ( @@ -18,6 +20,8 @@ from litellm.llms.base_llm.search.transformation import ( ) from litellm.secret_managers.main import get_secret_str +_DECODED_JSON: Final = TypeAdapter(object) + class _YouComSearchRequestRequired(TypedDict): """Required fields for You.com Search API request.""" @@ -169,23 +173,26 @@ class YouComSearchConfig(BaseSearchConfig): - snippets[0] → SearchResult.snippet (falls back to `description`) - page_age → SearchResult.date """ - response_json: Final = raw_response.json() - raw_results: Final = response_json.get("results") or {} + response_json: Final = decoded_json_object(_DECODED_JSON.validate_python(raw_response.json())) + raw_results: Final = decoded_json_object(response_json.get("results") or {}) web_results: Final = raw_results.get("web") or [] news_results: Final = raw_results.get("news") or [] results: Final[list[SearchResult]] = [] - for item in list(web_results) + list(news_results): + for entry in list(iter_items(web_results)) + list(iter_items(news_results)): + item = decoded_json_object(entry) snippets = item.get("snippets") or [] - snippet = snippets[0] if snippets else item.get("description", "") + snippet = subscript(snippets, 0) if snippets else item.get("description", "") results.append( - SearchResult( - title=item.get("title", ""), - url=item.get("url", ""), - snippet=snippet, - date=item.get("page_age"), - last_updated=None, + SearchResult.model_validate( + { + "title": item.get("title", ""), + "url": item.get("url", ""), + "snippet": snippet, + "date": item.get("page_age"), + "last_updated": None, + } ) ) diff --git a/litellm/main.py b/litellm/main.py index df8e8f29483..c86a888aa96 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3657,8 +3657,10 @@ def _complete_openrouter(ctx: CompletionDispatchContext) -> _CompletionDispatchR or get_secret_str("OR_API_KEY") ) - openrouter_site_url: Final = get_secret("OR_SITE_URL") or "https://litellm.ai" - openrouter_app_name: Final = get_secret("OR_APP_NAME") or "liteLLM" + openrouter_site_url: Final = _OPAQUE_VALUE_ADAPTER.validate_python( + get_secret("OR_SITE_URL") or "https://litellm.ai" + ) + openrouter_app_name: Final = _OPAQUE_VALUE_ADAPTER.validate_python(get_secret("OR_APP_NAME") or "liteLLM") openrouter_headers: Final = { "HTTP-Referer": openrouter_site_url, @@ -3762,8 +3764,10 @@ def _complete_vercel_ai_gateway( api_key = api_key or litellm.api_key or get_secret("VERCEL_AI_GATEWAY_API_KEY") - vercel_site_url: Final = get_secret("VERCEL_SITE_URL") or "https://litellm.ai" - vercel_app_name: Final = get_secret("VERCEL_APP_NAME") or "liteLLM" + vercel_site_url: Final = _OPAQUE_VALUE_ADAPTER.validate_python( + get_secret("VERCEL_SITE_URL") or "https://litellm.ai" + ) + vercel_app_name: Final = _OPAQUE_VALUE_ADAPTER.validate_python(get_secret("VERCEL_APP_NAME") or "liteLLM") vercel_headers: Final = { "http-referer": vercel_site_url, @@ -7349,8 +7353,10 @@ def embedding( or get_secret_str("OR_API_KEY") ) - openrouter_site_url: Final = get_secret("OR_SITE_URL") or "https://litellm.ai" - openrouter_app_name: Final = get_secret("OR_APP_NAME") or "liteLLM" + openrouter_site_url: Final = _OPAQUE_VALUE_ADAPTER.validate_python( + get_secret("OR_SITE_URL") or "https://litellm.ai" + ) + openrouter_app_name: Final = _OPAQUE_VALUE_ADAPTER.validate_python(get_secret("OR_APP_NAME") or "liteLLM") openrouter_headers: Final = { "HTTP-Referer": openrouter_site_url, diff --git a/litellm/models/base.py b/litellm/models/base.py index 3105fc9b91d..728fea1cff7 100644 --- a/litellm/models/base.py +++ b/litellm/models/base.py @@ -2,14 +2,34 @@ Base model class for domain models. """ +from collections.abc import Iterable, Mapping from datetime import datetime -from typing import Any +from typing import Final, Protocol from pydantic import ConfigDict +from typing_extensions import TypedDict +from litellm.litellm_core_utils.core_helpers import as_str_mapping, has_attributes from litellm.types.llms.base import LiteLLMBaseModel +class _RecordFields(TypedDict, total=False, extra_items=object): + pass + + +class _ModelDumpRecord(Protocol): + def model_dump(self) -> Mapping[str, object]: ... + + +class _DictRecord(Protocol): + def dict(self) -> Mapping[str, object]: ... + + +def _as_dict(record: object) -> Mapping[str, object] | None: + fields: Final = as_str_mapping(record) + return fields if isinstance(record, dict) else None + + class DomainModel(LiteLLMBaseModel): """Base class for all domain models.""" @@ -23,17 +43,18 @@ class DomainModel(LiteLLMBaseModel): updated_at: datetime | None = None @classmethod - def from_db_record(cls, record: Any) -> "DomainModel": + def from_db_record(cls, record: Mapping[str, object] | Iterable[tuple[str, object]] | None) -> "DomainModel": """Create a domain model from a database record.""" if record is None: raise ValueError("Cannot create domain model from None record") - if isinstance(record, dict): - return cls(**record) - if hasattr(record, "model_dump") and callable(record.model_dump): - return cls(**record.model_dump()) - if hasattr(record, "dict") and callable(record.dict): - return cls(**record.dict()) - return cls(**dict(record)) + fields: Final = _as_dict(record) + if fields is not None: + return cls(**_RecordFields(**fields)) + if has_attributes(record, _ModelDumpRecord, "model_dump") and callable(record.model_dump): + return cls(**_RecordFields(**record.model_dump())) + if has_attributes(record, _DictRecord, "dict") and callable(record.dict): + return cls(**_RecordFields(**record.dict())) + return cls(**_RecordFields(**dict(record))) def to_db_dict(self, exclude_unset: bool = False) -> dict[str, object]: """Convert domain model to a dictionary for database operations.""" diff --git a/litellm/proxy/_lazy_openapi_snapshot.py b/litellm/proxy/_lazy_openapi_snapshot.py index c5a63b0f990..e428017badd 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.py +++ b/litellm/proxy/_lazy_openapi_snapshot.py @@ -13,7 +13,7 @@ the JSON, then run `npm run gen:api` in ui/litellm-dashboard and commit schema.d import json import re import sys -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import MappingProxyType @@ -43,7 +43,7 @@ def _stabilize_multi_method_route_ids(routes) -> None: """FastAPI derives route IDs from a set of methods; make snapshots stable.""" for route in routes: - methods = sorted(getattr(route, "methods", None) or []) + methods: Sequence[str] = sorted(getattr(route, "methods", None) or []) if len(methods) <= 1 or not getattr(route, "path_format", None): continue diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index f0a4d47f1e4..bff270b8983 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -23,7 +23,7 @@ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError import litellm from litellm._logging import verbose_proxy_logger -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, keyword_arguments from litellm.litellm_core_utils.url_utils import SSRFError, validate_url from litellm.llms.a2a.common_utils import resolve_a2a_hop_auth_header from litellm.llms.azure.common_utils import caused_by_entra_rejection @@ -145,19 +145,20 @@ def _to_jsonrpc_object(chunk: object) -> object: return chunk -def _build_message_send_params(params: dict[str, Any]) -> "MessageSendParams": +def _build_message_send_params(params: object) -> "MessageSendParams": """Build MessageSendParams from wire (0.3) or A2A 1.0 JSON-RPC params.""" from a2a.compat.v0_3.types import MessageSendParams + fields: Final = dict(**keyword_arguments(params, MessageSendParams)) try: - return MessageSendParams(**params) + return MessageSendParams.model_validate(fields) except ValidationError: from a2a.compat.v0_3.conversions import pb2_v10, to_compat_send_message_request from google.protobuf.json_format import ParseDict, ParseError pb: Final = pb2_v10.SendMessageRequest() try: - ParseDict(params, pb, ignore_unknown_fields=True) + ParseDict(fields, pb, ignore_unknown_fields=True) except ParseError as e: raise ValueError(f"Invalid message/send params: {e}") from e return to_compat_send_message_request(pb, "").params @@ -1027,7 +1028,7 @@ async def invoke_agent_a2a( from a2a.compat.v0_3.types import SendMessageRequest try: - message_send_params: Final = _build_message_send_params(params) + message_send_params: Final = _build_message_send_params(_DECODED_JSON.validate_python(params)) except (ValidationError, ValueError) as e: return _jsonrpc_error(request_id, -32602, f"Invalid params: {e}") diff --git a/litellm/proxy/alerting_endpoints/model_deprecation_alerts.py b/litellm/proxy/alerting_endpoints/model_deprecation_alerts.py index a69dd6ee025..0e9fe830f3f 100644 --- a/litellm/proxy/alerting_endpoints/model_deprecation_alerts.py +++ b/litellm/proxy/alerting_endpoints/model_deprecation_alerts.py @@ -68,7 +68,13 @@ async def update_deprecation_alert_state( ) -> DeprecationAlertState: _require_admin(user, write=True) state: Final = DeprecationAlertState( - **data.model_dump(), updated_at=datetime.now(timezone.utc), updated_by=user.user_id + model=data.model, + deprecation_date=data.deprecation_date, + status=data.status, + last_seen_date=data.last_seen_date, + last_seen_requests=data.last_seen_requests, + updated_at=datetime.now(timezone.utc), + updated_by=user.user_id, ) await repository.set_param(_state_key(data.model, data.deprecation_date), state.model_dump(mode="json")) return state diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 4c4e506ce38..6b39511d89b 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -30,6 +30,7 @@ from typing_extensions import ReadOnly, TypedDict from litellm._internal_context import with_service_target from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.core_helpers import json_contains, subscript from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value from litellm.llms.custom_httpx.httpx_handler import HTTPHandler from litellm.proxy._types import ( @@ -164,6 +165,10 @@ class _UserInfoResponse(Protocol): def json(self) -> dict[str, object]: ... +class _JWKSResponse(Protocol): + def json(self) -> object: ... + + @dataclass(frozen=True, slots=True) class JWTIdentity: user_id: str | None @@ -218,6 +223,10 @@ def _userinfo_document(response: _UserInfoResponse) -> dict[str, object]: return response.json() +def _jwks_document(response: _JWKSResponse) -> object: + return response.json() + + def jwks_unavailable_exception(error: JWKSUnreachableError) -> ProxyException: return ProxyException( message=( @@ -960,12 +969,12 @@ class JWTHandler: ) try: - response_json: Final = response.json() + response_json: Final = _jwks_document(response) except Exception as e: verbose_proxy_logger.error("Error parsing response: %s. Original Response: %s", e, response.text) raise Exception(f"Error parsing response: {e}. Check server logs for original response.") - keys: Final = response_json["keys"] if "keys" in response_json else response_json + keys: Final = subscript(response_json, "keys") if json_contains(response_json, "keys") else response_json return cast(JWKKeyValue, keys) # cast-ok: JWTKeyItem declares only `kid`, validating would drop key material async def _get_public_key_from_jwks_url(self, jwks_url: str, kid: str | None) -> JWTKeyItem: diff --git a/litellm/proxy/auth/litellm_license.py b/litellm/proxy/auth/litellm_license.py index e1d6b2e090c..e828afe999c 100644 --- a/litellm/proxy/auth/litellm_license.py +++ b/litellm/proxy/auth/litellm_license.py @@ -3,14 +3,17 @@ import base64 import json import os +from collections.abc import Mapping from datetime import datetime from typing import TYPE_CHECKING, Final import httpx from pydantic import TypeAdapter +from typing_extensions import TypedDict from litellm._logging import verbose_proxy_logger from litellm.constants import NON_LLM_CONNECTION_TIMEOUT +from litellm.litellm_core_utils.core_helpers import as_str_mapping from litellm.llms.custom_httpx.http_handler import HTTPHandler if TYPE_CHECKING: @@ -23,6 +26,23 @@ AUTO_ROUTER_LICENSE_REMEDY: Final = "A LiteLLM license with the 'auto_router' fe _LICENSE_VERDICT: Final = TypeAdapter(object) +class _LicenseFields(TypedDict, total=False, extra_items=object): + pass + + +def _license_fields(license_data: object) -> Mapping[str, object]: + fields: Final = as_str_mapping(license_data) + if fields is None: + raise TypeError(f"'{type(license_data).__name__}' object is not subscriptable") + return fields + + +def _expiration_text(expiration_date: object) -> str: + if isinstance(expiration_date, str): + return expiration_date + raise TypeError(f"strptime() argument 1 must be str, not {type(expiration_date).__name__}") + + class LicenseCheck: """ - Check if license in env @@ -201,15 +221,15 @@ class LicenseCheck: ) # Decode and parse the data - license_data: Final = json.loads(message.decode()) + license_data: Final = _license_fields(_LICENSE_VERDICT.validate_python(json.loads(message.decode()))) # Check expiration date - expiration_date: Final = datetime.strptime(license_data["expiration_date"], "%Y-%m-%d") + expiration_date: Final = datetime.strptime(_expiration_text(license_data["expiration_date"]), "%Y-%m-%d") if expiration_date < datetime.now(): self.airgapped_license_data = None return False, "License has expired" - self.airgapped_license_data = EnterpriseLicenseData(**license_data) + self.airgapped_license_data = EnterpriseLicenseData(**_LicenseFields(**license_data)) return True diff --git a/litellm/proxy/client/cli/commands/keys.py b/litellm/proxy/client/cli/commands/keys.py index 558c9139f5b..1b6db2b0d5c 100644 --- a/litellm/proxy/client/cli/commands/keys.py +++ b/litellm/proxy/client/cli/commands/keys.py @@ -2,7 +2,7 @@ import builtins import json from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Any, Final, Literal, Protocol +from typing import Final, Literal, Protocol import click import requests @@ -33,6 +33,22 @@ class _JsonBodyView(TypedDict): body: ReadOnly[object] +class _KeyImportFields(TypedDict, total=False, extra_items=object): + pass + + +_KEY_IMPORT_FIELD_NAMES: Final = ( + "models", + "aliases", + "spend", + "key_alias", + "team_id", + "user_id", + "budget_id", + "config", +) + + def _cli_context(view: _CliContextView) -> _CliContext: return view.obj @@ -302,25 +318,9 @@ def _display_dry_run_table(source_keys: Sequence[Mapping[str, object]]) -> None: rich.print(table) -def _prepare_key_import_data(key: Mapping[str, object]) -> dict[str, Any]: +def _prepare_key_import_data(key: Mapping[str, object]) -> _KeyImportFields: """Prepare key data for import by extracting relevant fields.""" - import_data: Final = {} - - # Copy relevant fields if they exist - for field in [ - "models", - "aliases", - "spend", - "key_alias", - "team_id", - "user_id", - "budget_id", - "config", - ]: - if key.get(field): - import_data[field] = key[field] - - return import_data + return _KeyImportFields(**{field: key[field] for field in _KEY_IMPORT_FIELD_NAMES if key.get(field)}) def _import_keys_to_destination( diff --git a/litellm/proxy/client/cli/commands/models.py b/litellm/proxy/client/cli/commands/models.py index 538b42a8c83..ae3751cff69 100644 --- a/litellm/proxy/client/cli/commands/models.py +++ b/litellm/proxy/client/cli/commands/models.py @@ -10,12 +10,17 @@ from typing import TYPE_CHECKING, Any, Final, Literal import click import rich import yaml +from pydantic import TypeAdapter from typing_extensions import NotRequired, ReadOnly, TypedDict +from litellm.litellm_core_utils.core_helpers import json_contains, subscript + # local imports from ... import Client from ._cli_context import cli_context_values +_DECODED_YAML: Final = TypeAdapter(object) + if TYPE_CHECKING: from rich.console import JustifyMethod @@ -385,10 +390,10 @@ def _print_summary_table(provider_counts): def get_model_list_from_yaml_file(yaml_file: str) -> list[dict[str, object]]: """Load and validate the model list from a YAML file.""" with open(yaml_file, "r") as f: - data: Final = yaml.safe_load(f) - if not data or "model_list" not in data: + data: Final = _DECODED_YAML.validate_python(yaml.safe_load(f)) + if not data or not json_contains(data, "model_list"): raise click.ClickException("YAML file must contain a 'model_list' key with a list of models.") - model_list: Final = data["model_list"] + model_list: Final = subscript(data, "model_list") if not isinstance(model_list, list): raise click.ClickException("'model_list' must be a list of model definitions.") return model_list diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 1b004423181..e585cd7b1ff 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -73,6 +73,7 @@ from litellm.litellm_core_utils.core_helpers import ( is_expected_client_error, proxy_request_headers, redact_nested_match_and_regex_keys, + require_attributes, stream_cost_injection_requested, ) from litellm.litellm_core_utils.dd_tracing import NullTracer, tracer @@ -175,7 +176,7 @@ from litellm.types.guardrails import GuardrailEventHooks from litellm.types.router import RouterRateLimitError from litellm.types.router_weights import RouterWeights, validate_router_weights -_LateResponseT = TypeVar("_LateResponseT", bound=Response) +_LateResponseT = TypeVar("_LateResponseT") _LlmCallT = TypeVar("_LlmCallT") _ResponseT: Final = TypeVar("_ResponseT") _ChunkT: Final = TypeVar("_ChunkT") @@ -1365,7 +1366,17 @@ class LateSseFraming: """How a response the keepalive already opened ends once its upstream fails late.""" failure: Callable[[Exception], tuple[str, ...]] - body: Callable[[Response], tuple[bytes, ...]] + body: Callable[[object], tuple[bytes, ...]] + + +class _RenderedBody(Protocol): + @property + def body(self) -> bytes | memoryview: ... + + +class _CarriesStatusCode(Protocol): + @property + def status_code(self) -> int: ... def _openai_late_failure_frames(exc: Exception) -> tuple[str, ...]: @@ -1373,8 +1384,11 @@ def _openai_late_failure_frames(exc: Exception) -> tuple[str, ...]: return _sse_error_frames(error_obj) -def _openai_late_body_frames(produced: Response) -> tuple[bytes, ...]: - return b"data: " + (bytes(produced.body) or b"{}") + b"\n\n", b"data: [DONE]\n\n" +def _openai_late_body_frames(produced: object) -> tuple[bytes, ...]: + return ( + b"data: " + (bytes(require_attributes(produced, _RenderedBody, "body").body) or b"{}") + b"\n\n", + b"data: [DONE]\n\n", + ) def _anthropic_late_failure_frames(exc: Exception) -> tuple[str, ...]: @@ -1393,10 +1407,14 @@ def _responses_late_failure_frames(exc: Exception) -> tuple[str, ...]: return _responses_error_frames(sse_error_payload(exc)[1]) -def _responses_late_body_frames(produced: Response) -> tuple[bytes, ...]: - body: Final = bytes(produced.body) +def _responses_late_body_frames(produced: object) -> tuple[bytes, ...]: + body: Final = bytes(require_attributes(produced, _RenderedBody, "body").body) error_obj: Final = sse_error_object(body or b"{}") or {"message": body.decode()} - with_status: Final = {**error_obj, "code": error_obj.get("code") or str(produced.status_code)} + with_status: Final = { + **error_obj, + "code": error_obj.get("code") + or str(require_attributes(produced, _CarriesStatusCode, "status_code").status_code), + } return tuple(frame.encode() for frame in _responses_error_frames(with_status)) @@ -1620,7 +1638,7 @@ def ttft_keepalive_interval(request_data: Mapping[str, object], llm_router: Rout return resolve_ttft_keepalive_interval(deployments, litellm.sse_keepalive_ping_interval_seconds) -async def _aclose_late_response(produced: Response) -> None: +async def _aclose_late_response(produced: object) -> None: """Run the cleanup Starlette would have run, for a response it never called. Closing an already-closed async generator is a no-op, so this is safe to call @@ -1643,7 +1661,7 @@ async def _aclose_late_response(produced: Response) -> None: verbose_proxy_logger.debug("error closing relayed streaming generator: %s", exc) -async def _relay_late_response(produced: Response, framing: LateSseFraming) -> AsyncGenerator[bytes, None]: +async def _relay_late_response(produced: object, framing: LateSseFraming) -> AsyncGenerator[bytes, None]: """Replay a Response that was built after a keepalive had already opened the wire.""" if not isinstance(produced, StreamingResponse): # The status line is already on the wire, so a non-streaming body, an error @@ -3303,7 +3321,7 @@ class ProxyBaseLLMRequestProcessing: contents: list[object] | None = None, # Add contents parameter skip_pre_call_logic: bool = False, first_chunk_framing: FirstChunkSseFraming = OPENAI_FIRST_CHUNK_FRAMING, - ) -> Any: + ) -> object: """ Common request processing logic for both chat completions and responses API endpoints """ @@ -3571,20 +3589,21 @@ class ProxyBaseLLMRequestProcessing: ) # Non-streaming response - fall through to normal response handling elif select_data_generator: - selected_data_generator = select_data_generator( + produced_generator: Final[AsyncGenerator[str, None]] = select_data_generator( response=response, user_api_key_dict=user_api_key_dict, request_data=self.data, request=request, ) - if route_type in ("aresponses", "aget_responses"): - selected_data_generator = ( - ProxyBaseLLMRequestProcessing._wrap_responses_stream_for_container_ownership( - original_stream_response=response, - wrapped_generator=selected_data_generator, - user_api_key_dict=user_api_key_dict, - ) + selected_data_generator = ( + ProxyBaseLLMRequestProcessing._wrap_responses_stream_for_container_ownership( + original_stream_response=response, + wrapped_generator=produced_generator, + user_api_key_dict=user_api_key_dict, ) + if route_type in ("aresponses", "aget_responses") + else produced_generator + ) if selected_data_generator is not None: return await create_response( generator=selected_data_generator, @@ -3826,9 +3845,9 @@ class ProxyBaseLLMRequestProcessing: @staticmethod async def _wrap_responses_stream_for_container_ownership( original_stream_response: object, - wrapped_generator: Any, + wrapped_generator: AsyncIterator[_ChunkT], user_api_key_dict: UserAPIKeyAuth, - ): + ) -> AsyncGenerator[_ChunkT, None]: """Forward SSE chunks and record container ownership before the terminal chunk goes out. Streaming ``/v1/responses`` short-circuits out of diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index 0aa8d2291ef..82120023b21 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -93,6 +93,10 @@ class _CallbackNotDispatchable: _CallbackLoadError: TypeAlias = _CallbackResolvedToClass | _CallbackNotDispatchable +class _PresidioOptions(TypedDict, total=False, extra_items=object): + pass + + def _classify_loaded_callback(entry: str, loaded: object) -> CustomLogger | Callable[..., object] | _CallbackLoadError: """ Decide whether what a ``litellm_settings.callbacks`` dotted path resolved to can be dispatched. @@ -193,15 +197,15 @@ def initialize_callbacks_on_proxy( if presidio_logging_only is not None: presidio_logging_only = bool(presidio_logging_only) # validate boolean given - _presidio_params = {} + _presidio_params: Mapping[str, object] = {} if "presidio" in callback_specific_params and isinstance(callback_specific_params["presidio"], dict): _presidio_params = callback_specific_params["presidio"] - params: dict[str, Any] = { + params: Mapping[str, object] = { "logging_only": presidio_logging_only, **_presidio_params, } - pii_masking_object = OPTIONAL_PresidioPIIMasking(**params) + pii_masking_object = OPTIONAL_PresidioPIIMasking(**_PresidioOptions(**params)) imported_list.append(pii_masking_object) elif isinstance(callback, str) and callback == "llamaguard_moderations": try: diff --git a/litellm/proxy/common_utils/encrypt_decrypt_utils.py b/litellm/proxy/common_utils/encrypt_decrypt_utils.py index 644a35ef4ef..5b7ce5c8f3e 100644 --- a/litellm/proxy/common_utils/encrypt_decrypt_utils.py +++ b/litellm/proxy/common_utils/encrypt_decrypt_utils.py @@ -3,7 +3,7 @@ import ctypes import os import re from collections.abc import Mapping -from typing import Final, Literal, cast +from typing import Final, Literal, TypeVar, cast, overload from pydantic import TypeAdapter, ValidationError @@ -201,7 +201,18 @@ def decrypt_bearer_token(token: str, prefix: str) -> str | None: return None -def encrypt_value_helper(value: str, new_encryption_key: str | None = None): +_Passthrough: Final = TypeVar("_Passthrough") + + +@overload +def encrypt_value_helper(value: str, new_encryption_key: str | None = None) -> str: ... + + +@overload +def encrypt_value_helper(value: _Passthrough, new_encryption_key: str | None = None) -> _Passthrough: ... + + +def encrypt_value_helper(value: object, new_encryption_key: str | None = None) -> object: signing_key: Final = new_encryption_key or get_salt_key() try: diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 9e3071f020e..ae91cbb1f2a 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -1200,7 +1200,7 @@ class ResetBudgetJob: if updated_keys: await self._write_key_reset_updates(updated_keys=updated_keys) for k in updated_keys: - token = getattr(k.row, "token", None) + token = k.row.token if token: await self._invalidate_spend_counter(key_counter(str(token))) @@ -1317,7 +1317,7 @@ class ResetBudgetJob: if updated_users: await self._write_user_reset_updates(updated_users=updated_users) for u in updated_users: - user_id = getattr(u.row, "user_id", None) + user_id = u.row.user_id if user_id: await self._invalidate_spend_counter(user_counter(str(user_id))) if user_id == LITELLM_PROXY_BUDGET_NAME: @@ -1438,7 +1438,7 @@ class ResetBudgetJob: if updated_teams: await self._write_team_reset_updates(updated_teams=updated_teams) for t in updated_teams: - team_id = getattr(t.row, "team_id", None) + team_id = t.row.team_id if team_id: await self._invalidate_spend_counter(team_counter(str(team_id))) diff --git a/litellm/proxy/db/budget_window_spend_writer.py b/litellm/proxy/db/budget_window_spend_writer.py index d2b69f24397..ccabd9dcde6 100644 --- a/litellm/proxy/db/budget_window_spend_writer.py +++ b/litellm/proxy/db/budget_window_spend_writer.py @@ -18,6 +18,7 @@ from collections.abc import Mapping, Sequence from contextlib import nullcontext from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from types import TracebackType from typing import TYPE_CHECKING, Final, Protocol from pydantic import TypeAdapter @@ -149,7 +150,13 @@ class _UpsertBatch(Protocol): class _UpsertBatchManager(Protocol): async def __aenter__(self) -> _UpsertBatch: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... class _UpsertTransaction(Protocol): @@ -161,7 +168,13 @@ class _UpsertTransaction(Protocol): class _UpsertTransactionManager(Protocol): async def __aenter__(self) -> _UpsertTransaction: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... def _seed_totals_query(prisma_client: "PrismaClient") -> _SeedTotalsQuery: diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 12267120965..5a9a52447b7 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -18,7 +18,7 @@ from contextlib import asynccontextmanager from contextvars import ContextVar from datetime import datetime, timedelta, timezone from functools import reduce -from types import MappingProxyType +from types import MappingProxyType, TracebackType from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias, TypeVar, cast, overload from urllib.parse import quote, unquote @@ -249,21 +249,43 @@ class SpendUpsertTable(Protocol): class _SpendBatch(Protocol): - litellm_usertable: BatchTable - litellm_endusertable: SpendUpsertTable - litellm_dailymodelusage: SpendUpsertTable + @property + def litellm_usertable(self) -> BatchTable: ... + + @property + def litellm_endusertable(self) -> SpendUpsertTable: ... + + @property + def litellm_dailymodelusage(self) -> SpendUpsertTable: ... def execute_raw(self, query: LiteralString, *args: object) -> None: ... - litellm_verificationtoken: BatchTable - litellm_teamtable: BatchTable - litellm_teammembership: BatchTable - litellm_organizationtable: BatchTable - litellm_organizationmembership: BatchTable - litellm_projecttable: BatchTable - litellm_tagtable: BatchTable - litellm_agentstable: BatchTable - litellm_modelaccessgroupbudgettable: BatchTable + @property + def litellm_verificationtoken(self) -> BatchTable: ... + + @property + def litellm_teamtable(self) -> BatchTable: ... + + @property + def litellm_teammembership(self) -> BatchTable: ... + + @property + def litellm_organizationtable(self) -> BatchTable: ... + + @property + def litellm_organizationmembership(self) -> BatchTable: ... + + @property + def litellm_projecttable(self) -> BatchTable: ... + + @property + def litellm_tagtable(self) -> BatchTable: ... + + @property + def litellm_agentstable(self) -> BatchTable: ... + + @property + def litellm_modelaccessgroupbudgettable(self) -> BatchTable: ... _EntitySpendTable: TypeAlias = Literal[ @@ -288,7 +310,13 @@ def _entity_spend_table(batcher: _SpendBatch, table_accessor: _EntitySpendTable) class SpendBatchManager(Protocol): async def __aenter__(self) -> _SpendBatch: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... class _SpendTransaction(Protocol): @@ -300,7 +328,13 @@ class _SpendTransaction(Protocol): class _SpendTransactionManager(Protocol): async def __aenter__(self) -> _SpendTransaction: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... _DailySpendTransactionT = TypeVar("_DailySpendTransactionT", bound=BaseDailySpendTransaction) diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index c2d58512fea..33d973cccfb 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -1,11 +1,12 @@ import re -from collections.abc import Awaitable, Callable, Iterator +from collections.abc import Awaitable, Callable, Iterator, Mapping from http import HTTPStatus from typing import Final, Protocol, TypeVar from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.core_helpers import require_attributes from litellm.proxy._types import ( DB_CONNECTION_ERROR_TYPES, DB_RETRY_SAFE_ERROR_TYPES, @@ -93,6 +94,28 @@ def _exception_types(*candidates: object) -> tuple[type[BaseException], ...]: return tuple(c for c in candidates if isinstance(c, type) and issubclass(c, BaseException)) +class _CodedError(Protocol): + @property + def code(self) -> object: ... + + +def _error_code(error: _CodedError) -> object: + return error.code + + +class _FrameGlobals(Protocol): + @property + def f_globals(self) -> Mapping[str, object]: ... + + +class _PrefixMatchable(Protocol): + def startswith(self, prefix: str, /) -> bool: ... + + +def _frame_module_name(frame: _FrameGlobals) -> _PrefixMatchable: + return require_attributes(frame.f_globals.get("__name__", ""), _PrefixMatchable, "startswith") + + def _prisma_error_code(e: Exception) -> str | None: import prisma.engine.errors @@ -317,7 +340,7 @@ class PrismaDBExceptionHandler: if not isinstance(e, _exception_types(prisma.errors.PrismaError)): return False - if isinstance(e, prisma.errors.DataError) and e.code == _PRISMA_POOL_TIMEOUT_CODE: + if isinstance(e, prisma.errors.DataError) and _error_code(e) == _PRISMA_POOL_TIMEOUT_CODE: return True if PrismaDBExceptionHandler.postgres_sqlstate(e) == "53300": return True @@ -433,7 +456,7 @@ class PrismaDBExceptionHandler: return False tb = e.__traceback__ if hasattr(e, "__traceback__") else None while tb is not None: - if tb.tb_frame.f_globals.get("__name__", "").startswith("prisma.engine"): + if _frame_module_name(tb.tb_frame).startswith("prisma.engine"): return True tb = tb.tb_next return False diff --git a/litellm/proxy/db/model_usage_rollup.py b/litellm/proxy/db/model_usage_rollup.py index f8cdabe9296..1a5f15fe294 100644 --- a/litellm/proxy/db/model_usage_rollup.py +++ b/litellm/proxy/db/model_usage_rollup.py @@ -6,6 +6,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime from itertools import groupby +from types import TracebackType from typing import TYPE_CHECKING, Final, Protocol from pydantic import TypeAdapter, ValidationError @@ -42,7 +43,13 @@ class ModelUsageBatch(Protocol): class ModelUsageBatchManager(Protocol): async def __aenter__(self) -> ModelUsageBatch: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... @dataclass(frozen=True, slots=True) diff --git a/litellm/proxy/db/prisma_client.py b/litellm/proxy/db/prisma_client.py index a7f18769461..e0be2e1e5ef 100644 --- a/litellm/proxy/db/prisma_client.py +++ b/litellm/proxy/db/prisma_client.py @@ -35,12 +35,170 @@ from litellm.proxy.db.token_auth import ( from litellm.secret_managers.main import str_to_bool if TYPE_CHECKING: + from collections.abc import Sequence + from types import TracebackType + from prisma import Prisma from prisma import models as prisma_models from prisma.types import HttpConfig + from typing_extensions import Self from litellm.repositories.prisma_protocols import TableActions + _DEFAULT_TX_MAX_WAIT: Final = timedelta(seconds=2) + _DEFAULT_TX_TIMEOUT: Final = timedelta(seconds=5) + + class PrismaBatchActions(Protocol): + def create(self, data: Mapping[str, object], include: Mapping[str, object] | None = None) -> None: ... + + def create_many(self, data: Sequence[Mapping[str, object]], *, skip_duplicates: bool | None = None) -> None: ... + + def delete(self, where: Mapping[str, object], include: Mapping[str, object] | None = None) -> None: ... + + def update( + self, + data: Mapping[str, object], + where: Mapping[str, object], + include: Mapping[str, object] | None = None, + ) -> None: ... + + def upsert( + self, + where: Mapping[str, object], + data: Mapping[str, object], + include: Mapping[str, object] | None = None, + ) -> None: ... + + def update_many(self, data: Mapping[str, object], where: Mapping[str, object]) -> None: ... + + def delete_many(self, where: Mapping[str, object] | None = None) -> None: ... + + class PrismaBatchSession(Protocol): + @property + def litellm_agentstable(self) -> PrismaBatchActions: ... + + @property + def litellm_budgettable(self) -> PrismaBatchActions: ... + + @property + def litellm_dailymodelusage(self) -> PrismaBatchActions: ... + + @property + def litellm_dailyteamspend(self) -> PrismaBatchActions: ... + + @property + def litellm_dailytoolspend(self) -> PrismaBatchActions: ... + + @property + def litellm_endusertable(self) -> PrismaBatchActions: ... + + @property + def litellm_modelaccessgroupbudgettable(self) -> PrismaBatchActions: ... + + @property + def litellm_organizationmembership(self) -> PrismaBatchActions: ... + + @property + def litellm_organizationtable(self) -> PrismaBatchActions: ... + + @property + def litellm_projecttable(self) -> PrismaBatchActions: ... + + @property + def litellm_proxymodeltable(self) -> PrismaBatchActions: ... + + @property + def litellm_tagtable(self) -> PrismaBatchActions: ... + + @property + def litellm_teammembership(self) -> PrismaBatchActions: ... + + @property + def litellm_teamtable(self) -> PrismaBatchActions: ... + + @property + def litellm_usertable(self) -> PrismaBatchActions: ... + + @property + def litellm_verificationtoken(self) -> PrismaBatchActions: ... + + def execute_raw(self, query: str, *args: object) -> None: ... + + async def commit(self) -> None: ... + + async def __aenter__(self) -> Self: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + /, + ) -> None: ... + + class PrismaTransaction(Protocol): + @property + def litellm_accessgrouptable(self) -> TableActions[prisma_models.LiteLLM_AccessGroupTable]: ... + + @property + def litellm_agentstable(self) -> TableActions[prisma_models.LiteLLM_AgentsTable]: ... + + @property + def litellm_budgettable(self) -> TableActions[prisma_models.LiteLLM_BudgetTable]: ... + + @property + def litellm_invitationlink(self) -> TableActions[prisma_models.LiteLLM_InvitationLink]: ... + + @property + def litellm_mcpservertable(self) -> TableActions[prisma_models.LiteLLM_MCPServerTable]: ... + + @property + def litellm_mcpuserenvvars(self) -> TableActions[prisma_models.LiteLLM_MCPUserEnvVars]: ... + + @property + def litellm_objectpermissiontable(self) -> TableActions[prisma_models.LiteLLM_ObjectPermissionTable]: ... + + @property + def litellm_organizationtable(self) -> TableActions[prisma_models.LiteLLM_OrganizationTable]: ... + + @property + def litellm_proxymodeltable(self) -> TableActions[prisma_models.LiteLLM_ProxyModelTable]: ... + + @property + def litellm_teamtable(self) -> TableActions[prisma_models.LiteLLM_TeamTable]: ... + + @property + def litellm_usertable(self) -> TableActions[prisma_models.LiteLLM_UserTable]: ... + + @property + def litellm_verificationtoken(self) -> TableActions[prisma_models.LiteLLM_VerificationToken]: ... + + @property + def litellm_workflowevent(self) -> TableActions[prisma_models.LiteLLM_WorkflowEvent]: ... + + @property + def litellm_workflowrun(self) -> TableActions[prisma_models.LiteLLM_WorkflowRun]: ... + + def batch_(self) -> PrismaBatchSession: ... + + async def execute_raw(self, query: str, *args: object) -> int: ... + + async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: ... + + async def query_first(self, query: str, *args: object) -> Mapping[str, object] | None: ... + + class PrismaTransactionManager(Protocol): + async def __aenter__(self) -> PrismaTransaction: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + /, + ) -> None: ... + + _ENVIRONMENT_VARIABLES_REVISION_MIGRATION: Final = "20261015120000_add_environment_variables_revision_trigger" __all__ = ( @@ -925,6 +1083,12 @@ class PrismaWrapper: async def query_first(self, query: str, *args: object) -> Mapping[str, object] | None: ... + def tx( + self, *, max_wait: int | timedelta = _DEFAULT_TX_MAX_WAIT, timeout: int | timedelta = _DEFAULT_TX_TIMEOUT + ) -> PrismaTransactionManager: ... + + def batch_(self) -> PrismaBatchSession: ... + @property def litellm_deprecatedverificationtoken( self, diff --git a/litellm/proxy/db/routing_prisma_wrapper.py b/litellm/proxy/db/routing_prisma_wrapper.py index c71e2206f45..e6a436a9b1e 100644 --- a/litellm/proxy/db/routing_prisma_wrapper.py +++ b/litellm/proxy/db/routing_prisma_wrapper.py @@ -19,8 +19,12 @@ if TYPE_CHECKING: from prisma import models as prisma_models from prisma.types import HttpConfig + from litellm.proxy.db.prisma_client import PrismaBatchSession, PrismaTransactionManager from litellm.repositories.prisma_protocols import TableActions + _DEFAULT_TX_MAX_WAIT: Final = timedelta(seconds=2) + _DEFAULT_TX_TIMEOUT: Final = timedelta(seconds=5) + # Per-model action methods that read from the database. These are routed to # the read replica when one is configured. _MODEL_READ_METHODS: Final = frozenset( @@ -311,6 +315,12 @@ class RoutingPrismaWrapper: async def query_first(self, query: str, *args: object) -> Mapping[str, object] | None: ... + def tx( + self, *, max_wait: int | timedelta = _DEFAULT_TX_MAX_WAIT, timeout: int | timedelta = _DEFAULT_TX_TIMEOUT + ) -> PrismaTransactionManager: ... + + def batch_(self) -> PrismaBatchSession: ... + @property def litellm_deprecatedverificationtoken( self, diff --git a/litellm/proxy/db/spend_log_tool_index.py b/litellm/proxy/db/spend_log_tool_index.py index 92181d546eb..e2cebc691a5 100644 --- a/litellm/proxy/db/spend_log_tool_index.py +++ b/litellm/proxy/db/spend_log_tool_index.py @@ -19,6 +19,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone from itertools import chain, groupby +from types import TracebackType from typing import TYPE_CHECKING, Final, Literal, Protocol from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError @@ -175,7 +176,8 @@ class _ToolRollupTable(Protocol): class _ToolRollupBatch(Protocol): - litellm_dailytoolspend: _ToolRollupTable + @property + def litellm_dailytoolspend(self) -> _ToolRollupTable: ... def execute_raw(self, query: LiteralString, *args: object) -> None: ... @@ -183,7 +185,13 @@ class _ToolRollupBatch(Protocol): class _ToolRollupBatchManager(Protocol): async def __aenter__(self) -> _ToolRollupBatch: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... def _tool_rollup_batch(prisma_client: PrismaClient) -> _ToolRollupBatchManager: diff --git a/litellm/proxy/discovery_endpoints/agent_skills_archive.py b/litellm/proxy/discovery_endpoints/agent_skills_archive.py index e45ca7e10f2..1665cb4caaf 100644 --- a/litellm/proxy/discovery_endpoints/agent_skills_archive.py +++ b/litellm/proxy/discovery_endpoints/agent_skills_archive.py @@ -17,7 +17,7 @@ import zlib from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType -from typing import ClassVar, Final +from typing import ClassVar, Final, Protocol import yaml from pydantic import ConfigDict, TypeAdapter, ValidationError @@ -64,6 +64,15 @@ _YAML_PAIRS: Final = TypeAdapter( ) +class _YamlValueNode(Protocol): + @property + def value(self) -> object: ... + + +def _yaml_node_value(node: _YamlValueNode) -> object: + return node.value + + class _AgentSkillFrontmatterLoader(yaml.BaseLoader): yaml_implicit_resolvers: ClassVar[Mapping[str | None, Sequence[tuple[str, re.Pattern[str]]]]] = { # pyright: ignore[reportIncompatibleVariableOverride] # PyYAML resolver tables are class state None: list(_AGENT_SKILL_YAML_SCALARS.items()) @@ -259,7 +268,7 @@ def agent_skill_frontmatter(manifest: bytes) -> Mapping[str, str]: return MappingProxyType({}) if not isinstance(node, yaml.MappingNode) or not _valid_frontmatter_node(node): return MappingProxyType({}) - pairs: Final = _YAML_PAIRS.validate_python(node.value) + pairs: Final = _YAML_PAIRS.validate_python(_yaml_node_value(node)) fields: Final = { key_text: value_text for key, value in pairs @@ -273,7 +282,7 @@ def agent_skill_frontmatter(manifest: bytes) -> Mapping[str, str]: def _frontmatter_string(node: yaml.Node) -> str | None: if not isinstance(node, yaml.ScalarNode) or node.tag != "tag:yaml.org,2002:str": return None - value: Final = _DECODED_YAML.validate_python(node.value) + value: Final = _yaml_node_value(node) return value if isinstance(value, str) else None @@ -282,18 +291,18 @@ def _valid_frontmatter_node(node: yaml.Node, depth: int = 0) -> bool: return False if isinstance(node, yaml.ScalarNode): pattern: Final = _AGENT_SKILL_YAML_SCALARS.get(node.tag) - scalar: Final = _DECODED_YAML.validate_python(node.value) + scalar: Final = _yaml_node_value(node) return isinstance(scalar, str) and ( node.tag == "tag:yaml.org,2002:str" or (pattern is not None and pattern.fullmatch(scalar) is not None) ) if isinstance(node, yaml.SequenceNode): - values: Final = _YAML_NODES.validate_python(node.value) + values: Final = _YAML_NODES.validate_python(_yaml_node_value(node)) return node.tag == "tag:yaml.org,2002:seq" and all( _valid_frontmatter_node(value, depth + 1) for value in values ) if not isinstance(node, yaml.MappingNode) or node.tag != "tag:yaml.org,2002:map": return False - pairs: Final = _YAML_PAIRS.validate_python(node.value) + pairs: Final = _YAML_PAIRS.validate_python(_yaml_node_value(node)) keys: Final = tuple(_frontmatter_string(key) for key, _ in pairs) return ( None not in keys diff --git a/litellm/proxy/fine_tuning_endpoints/endpoints.py b/litellm/proxy/fine_tuning_endpoints/endpoints.py index 030f8d7a46a..850a8508428 100644 --- a/litellm/proxy/fine_tuning_endpoints/endpoints.py +++ b/litellm/proxy/fine_tuning_endpoints/endpoints.py @@ -6,12 +6,14 @@ ########################################################################## import asyncio -from typing import Final, cast +from typing import Final from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.core_helpers import decoded_json_object from litellm.litellm_core_utils.hidden_params import set_hidden_param from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -25,6 +27,7 @@ from litellm.proxy.utils import handle_exception_on_proxy from litellm.types.utils import LiteLLMFineTuningJob router: Final = APIRouter() +_DECODED_JSON: Final = TypeAdapter(object) from litellm.types.llms.openai import LiteLLMFineTuningJobCreate @@ -50,7 +53,7 @@ def set_fine_tuning_config(config): # Function to search for specific custom_llm_provider and return its configuration def get_fine_tuning_provider_config( - custom_llm_provider: str, + custom_llm_provider: object, ): global fine_tuning_config if fine_tuning_config is None: @@ -61,6 +64,13 @@ def get_fine_tuning_provider_config( return None +async def _decoded_request_body(request: Request) -> object: + try: + return _DECODED_JSON.validate_python(await request.json()) + except Exception: + return {} + + @router.post( "/v1/fine_tuning/jobs", dependencies=[Depends(user_api_key_auth)], @@ -163,7 +173,7 @@ async def create_fine_tuning_job( detail={"error": "LLM Router not initialized. Ensure models added to proxy."}, ) - response = cast(LiteLLMFineTuningJob, await llm_router.acreate_fine_tuning_job(**data)) + response = await llm_router.acreate_fine_tuning_job(**data) response.training_file = training_file response.validation_file = fine_tuning_request.validation_file set_hidden_param(response, "unified_file_id", unified_file_id) @@ -285,12 +295,8 @@ async def retrieve_fine_tuning_job( route_type=CallTypes.aretrieve_fine_tuning_job.value, ) - try: - request_body = await request.json() - except Exception: - request_body = {} - - custom_llm_provider = request_body.get("custom_llm_provider", None) or custom_llm_provider + request_body: Final = decoded_json_object(await _decoded_request_body(request)) + requested_provider: Final = request_body.get("custom_llm_provider", None) or custom_llm_provider ## CHECK IF MANAGED FILE ID unified_finetuning_job_id: str | Literal[False] = False @@ -305,16 +311,13 @@ async def retrieve_fine_tuning_job( status_code=500, detail={"error": "LLM Router not initialized. Ensure models added to proxy."}, ) - response = cast( - LiteLLMFineTuningJob, - await llm_router.aretrieve_fine_tuning_job( - **data, - ), + response = await llm_router.aretrieve_fine_tuning_job( + **data, ) set_hidden_param(response, "unified_finetuning_job_id", unified_finetuning_job_id) - elif custom_llm_provider: + elif requested_provider: # get configs for custom_llm_provider - llm_provider_config: Final = get_fine_tuning_provider_config(custom_llm_provider=custom_llm_provider) + llm_provider_config: Final = get_fine_tuning_provider_config(custom_llm_provider=requested_provider) if llm_provider_config is not None: data.update(llm_provider_config) @@ -560,11 +563,7 @@ async def cancel_fine_tuning_job( route_type=CallTypes.acancel_fine_tuning_job.value, ) - try: - request_body = await request.json() - except Exception: - request_body = {} - + request_body: Final = decoded_json_object(await _decoded_request_body(request)) custom_llm_provider: Final = request_body.get("custom_llm_provider", None) ## CHECK IF MANAGED FILE ID @@ -580,11 +579,8 @@ async def cancel_fine_tuning_job( status_code=500, detail={"error": "LLM Router not initialized. Ensure models added to proxy."}, ) - response = cast( - LiteLLMFineTuningJob, - await llm_router.acancel_fine_tuning_job( - **data, - ), + response = await llm_router.acancel_fine_tuning_job( + **data, ) set_hidden_param(response, "unified_finetuning_job_id", unified_finetuning_job_id) else: diff --git a/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py b/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py index c773e88c769..03f83616fea 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py +++ b/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py @@ -11,7 +11,7 @@ import os import ssl from collections.abc import AsyncGenerator, AsyncIterable, Iterator, Mapping, Sequence from ssl import SSLContext -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias from fastapi import HTTPException from pydantic import BaseModel @@ -23,7 +23,7 @@ from litellm import DualCache from litellm._logging import verbose_proxy_logger from litellm._version import version as litellm_version from litellm.integrations.custom_guardrail import CustomGuardrail -from litellm.litellm_core_utils.core_helpers import attribute_items, iter_items +from litellm.litellm_core_utils.core_helpers import attribute_items, iter_items, require_attributes, subscript from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field from litellm.llms.anthropic.common_utils import ( anthropic_server_tool_calls, @@ -60,6 +60,19 @@ if TYPE_CHECKING: from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel +_ToolCallKind: TypeAlias = Literal["server_tool_call", "tool_call"] +_OutputTarget: TypeAlias = tuple[Literal["content"], None] | tuple[_ToolCallKind, int] + + +class _ToolCallFunction(Protocol): + arguments: str + + +class _FunctionToolCall(Protocol): + @property + def function(self) -> _ToolCallFunction: ... + + class CatoNetworksGuardrailMissingSecrets(Exception): pass @@ -522,14 +535,16 @@ class CatoNetworksGuardrail(CustomGuardrail): ) @staticmethod - def _output_fragments(message: Message) -> Sequence[tuple[tuple[str, int | None], str]]: + def _output_fragments(message: Message) -> Sequence[tuple[_OutputTarget, str]]: """Assistant text the proxy returns to the caller: ``content`` plus every tool call's ``function.arguments`` string, Claude's server tool calls first, each tagged with where a redaction must be written back. ``content`` is only included when present so a tool-call-only choice keeps its ``None`` content (the text-vs-tool-call signal downstream consumers rely on) while its arguments are still inspected.""" - content: Final = () if message.content is None else ((("content", None), message.content),) + content: Final[tuple[tuple[_OutputTarget, str], ...]] = ( + () if message.content is None else ((("content", None), message.content),) + ) return ( *content, *CatoNetworksGuardrail._tool_call_fragments("server_tool_call", anthropic_server_tool_calls(message)), @@ -537,7 +552,9 @@ class CatoNetworksGuardrail(CustomGuardrail): ) @staticmethod - def _tool_call_fragments(kind: str, tool_calls: Sequence[object]) -> tuple[tuple[tuple[str, int | None], str], ...]: + def _tool_call_fragments( + kind: _ToolCallKind, tool_calls: Sequence[object] + ) -> tuple[tuple[tuple[_ToolCallKind, int], str], ...]: return tuple( ((kind, idx), arguments) for idx, arguments in enumerate( @@ -547,14 +564,16 @@ class CatoNetworksGuardrail(CustomGuardrail): ) @staticmethod - def _apply_output_fragment(message: Any, target: tuple[str, int | None], redacted: str) -> None: + def _apply_output_fragment(message: Message, target: _OutputTarget, redacted: str) -> None: match target: case ("content", _): message.content = redacted case ("server_tool_call", int() as idx): write_anthropic_server_tool_call_arguments(message, idx, redacted) case (_, idx): - message.tool_calls[idx].function.arguments = redacted + require_attributes( + subscript(message.tool_calls, idx), _FunctionToolCall, "function" + ).function.arguments = redacted @staticmethod def _responses_output_field(item: object, key: str) -> str | Sequence[object] | None: diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py index 55f7abd40a8..51b849751c8 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py @@ -13,7 +13,7 @@ from typing import Final, Literal from urllib.parse import urlparse import httpx -from pydantic import JsonValue +from pydantic import JsonValue, TypeAdapter from typing_extensions import ReadOnly, TypedDict import litellm @@ -22,6 +22,8 @@ from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get, vali from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider +_DECODED_JSON: Final = TypeAdapter(object) + # ============================================================================= # Result Types - Used by Starlark code to return guardrail decisions # ============================================================================= @@ -200,7 +202,7 @@ class JsonSchemaNode(TypedDict, total=False): properties: ReadOnly[Mapping[str, "JsonSchemaNode"]] -def json_parse(text: str) -> JsonValue: +def json_parse(text: str) -> object: """ Parse a JSON string into a Python object. @@ -211,7 +213,7 @@ def json_parse(text: str) -> JsonValue: Parsed Python object, or None if parsing fails """ try: - return json.loads(text) + return _DECODED_JSON.validate_python(json.loads(text)) except (json.JSONDecodeError, TypeError) as e: verbose_proxy_logger.debug("Starlark json_parse error: %s", e) return None diff --git a/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py b/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py index 1b9bfc725db..8c698934796 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py @@ -8,7 +8,7 @@ import os from collections.abc import AsyncGenerator, AsyncIterable from datetime import datetime -from typing import TYPE_CHECKING, Any, Final, Literal, Optional +from typing import TYPE_CHECKING, Final, Literal, Optional import httpx @@ -186,7 +186,6 @@ class EnkryptAIGuardrails(CustomGuardrail): details: Final = response.get("details", {}) detected_attacks: Final[list[str]] = [] - attack_details: Final[dict[str, Any]] = {} for key, value in summary.items(): # Check if attack is detected @@ -195,13 +194,14 @@ class EnkryptAIGuardrails(CustomGuardrail): if key == "toxicity": if isinstance(value, list) and len(value) > 0: detected_attacks.append(key) - attack_details[key] = details.get(key, {}) else: if value == 1: detected_attacks.append(key) - attack_details[key] = details.get(key, {}) - return {"attacks_detected": detected_attacks, "attack_details": attack_details} + return { + "attacks_detected": detected_attacks, + "attack_details": {key: details.get(key, {}) for key in detected_attacks}, + } def _determine_guardrail_status(self, response_json: EnkryptAIResponse) -> GuardrailStatus: """ diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 7e052796545..85136c46a67 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -21,7 +21,7 @@ from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, ) -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, get_async_httpx_client, @@ -46,6 +46,7 @@ _CHAT_ROW: Final[TypeAdapter[AllMessageValues]] = TypeAdapter(AllMessageValues) _SENT_ROW: Final = TypeAdapter(Mapping[str, object]) _SENT_ROWS: Final = TypeAdapter(Sequence[Mapping[str, object]]) _JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) +_DECODED_JSON: Final = TypeAdapter(object) # Headers whose values are forwarded as-is (case-insensitive). Glob patterns supported (e.g. x-stainless-*, x-litellm*). _HEADER_VALUE_ALLOWLIST: Final = frozenset( @@ -242,6 +243,16 @@ def _structured_rows_to_write_back( ) +def _guardrail_response_from_json(response_json: object) -> GenericGuardrailAPIResponse: + if isinstance(response_json, dict): + return GenericGuardrailAPIResponse.from_dict(response_json) + raise AttributeError( + f"{type(response_json).__name__!r} object has no attribute 'get'", + name="get", + obj=response_json, + ) + + class GenericGuardrailAPI(CustomGuardrail): """ Generic Guardrail API integration for LiteLLM. @@ -396,7 +407,7 @@ class GenericGuardrailAPI(CustomGuardrail): input_type: Literal["request", "response"], logging_obj: Optional["LiteLLMLoggingObj"], error: Exception, - http_status_code: int | None = None, + http_status_code: object = None, ) -> GenericGuardrailAPIInputs: status_suffix: Final = f" http_status_code={http_status_code}" if http_status_code else "" error_type: Final = "HTTPStatusError" if isinstance(error, httpx.HTTPStatusError) else type(error).__name__ @@ -482,7 +493,7 @@ class GenericGuardrailAPI(CustomGuardrail): ) -> GenericGuardrailAPIInputs: unreachable_fail_open: Final = is_unreachable and self.unreachable_fallback == "fail_open" if unreachable_fail_open or not self.fail_on_error: - http_status_code: Final = getattr(getattr(error, "response", None), "status_code", None) + http_status_code: Final = attribute_or_none(attribute_or_none(error, "response"), "status_code") return self._fail_open_passthrough( inputs=inputs, input_type=input_type, @@ -608,11 +619,11 @@ class GenericGuardrailAPI(CustomGuardrail): ) response.raise_for_status() - response_json: Final = response.json() + response_json: Final = _DECODED_JSON.validate_python(response.json()) verbose_proxy_logger.debug("Generic Guardrail API response: %s", response_json) - guardrail_response: Final = GenericGuardrailAPIResponse.from_dict(response_json) + guardrail_response: Final = _guardrail_response_from_json(response_json) # Handle the response if guardrail_response.action == "BLOCKED": diff --git a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py index 9463023854e..85bf39c481b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py +++ b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py @@ -135,7 +135,7 @@ _mcp_jwt_signer_instance: Optional["MCPJWTSigner"] = None _MCP_JWT_CALL_TYPES: Final = frozenset({"call_mcp_tool", "list_mcp_tools"}) # Simple in-memory JWKS cache: keyed by JWKS URI → (keys_list, fetched_at). -_jwks_cache: Final[dict[str, tuple[Sequence[Mapping[str, object]], float]]] = {} +_jwks_cache: Final[dict[str, tuple[object, float]]] = {} _JWKS_CACHE_TTL: Final = 3600 # 1 hour @@ -181,7 +181,7 @@ def _compute_kid(public_key: RSAPublicKey) -> str: return hashlib.sha256(der_bytes).hexdigest()[:16] -async def _fetch_jwks(jwks_uri: str, timeout: float | httpx.Timeout | None = None) -> Sequence[Mapping[str, object]]: +async def _fetch_jwks(jwks_uri: str, timeout: float | httpx.Timeout | None = None) -> object: """ Fetch and cache a JWKS from the given URI. @@ -202,7 +202,7 @@ async def _fetch_jwks(jwks_uri: str, timeout: float | httpx.Timeout | None = Non client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) resp: Final = await client.get(jwks_uri, headers={"Accept": "application/json"}, timeout=timeout) resp.raise_for_status() - jwks_body: Final[Mapping[str, Sequence[Mapping[str, object]]]] = resp.json() + jwks_body: Final = decoded_json_object(_DECODED_JSON.validate_python(resp.json())) fetched_keys: Final = jwks_body.get("keys", []) _jwks_cache[jwks_uri] = (fetched_keys, now) return fetched_keys diff --git a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py index 1790f335548..16a820e1c0b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py +++ b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py @@ -9,7 +9,7 @@ import httpx from typing_extensions import NotRequired, TypedDict from litellm._logging import verbose_proxy_logger -from litellm.litellm_core_utils.core_helpers import decoded_json_object, iter_items, json_contains +from litellm.litellm_core_utils.core_helpers import attribute_or_none, decoded_json_object, iter_items, json_contains from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, ) @@ -445,10 +445,10 @@ class PurviewGuardrailBase: tool_calls: Final = tool_calls_with_server_tool_calls(message) if tool_calls: for tc in tool_calls: - fn = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None) + fn = tc.get("function") if isinstance(tc, dict) else attribute_or_none(tc, "function") if fn is None: continue - arguments = fn.get("arguments") if isinstance(fn, dict) else getattr(fn, "arguments", None) + arguments = fn.get("arguments") if isinstance(fn, dict) else attribute_or_none(fn, "arguments") if isinstance(arguments, str) and arguments.strip(): args.append(arguments) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py b/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py index 152515c50ed..1f7f93bb9cb 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py @@ -1,6 +1,6 @@ # litellm/proxy/guardrails/guardrail_hooks/pangea.py import os -from collections.abc import Callable +from collections.abc import Callable, Mapping, Sized from typing import TYPE_CHECKING, Final from fastapi import HTTPException @@ -12,7 +12,7 @@ from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, ) -from litellm.litellm_core_utils.core_helpers import decoded_json_object +from litellm.litellm_core_utils.core_helpers import decoded_json_object, iter_items, subscript from litellm.llms.anthropic.common_utils import ( message_dict_with_server_tool_calls, message_dict_with_server_tool_calls_split_out, @@ -40,6 +40,12 @@ if TYPE_CHECKING: _DECODED_JSON: Final = TypeAdapter(object) +def _json_len(value: object) -> int: + if isinstance(value, Sized): + return len(value) + raise TypeError(f"object of type '{type(value).__name__}' has no len()") + + def _choice_with_message(choice: object, rewrite: Callable[[object], object]) -> object: mapping: Final = validated_object_mapping(choice) if mapping is None or "message" not in mapping: @@ -59,9 +65,9 @@ class _TextCompletionRequest: return [{"role": "user", "content": self.body["prompt"]}] # This mutates the original dict, but we'll still return it anyways - def update_original_body(self, prompt_messages: list[dict]) -> dict[str, object]: - assert len(prompt_messages) == 1 - self.body["prompt"] = prompt_messages[0]["content"] + def update_original_body(self, prompt_messages: object) -> dict[str, object]: + assert _json_len(prompt_messages) == 1 + self.body["prompt"] = subscript(subscript(prompt_messages, 0), "content") return self.body @@ -117,7 +123,7 @@ class PangeaHandler(CustomGuardrail): self.api_base, ) - async def _call_pangea_ai_guard(self, api: str, payload: dict, hook_name: str) -> dict: + async def _call_pangea_ai_guard(self, api: str, payload: dict, hook_name: str) -> Mapping[str, object]: """ Makes the API call to the Pangea AI Guard endpoint. The function itself will raise an error in the case that a response @@ -153,8 +159,7 @@ class PangeaHandler(CustomGuardrail): ) response.raise_for_status() - result: Final = response.json() - decoded_result: Final = decoded_json_object(_DECODED_JSON.validate_python(result)) + decoded_result: Final = decoded_json_object(_DECODED_JSON.validate_python(response.json())) verdict: Final = decoded_json_object(decoded_result.get("result", {})) if verdict.get("blocked"): @@ -172,7 +177,7 @@ class PangeaHandler(CustomGuardrail): "Pangea Guardrail (%s): Request passed. Response: %s", hook_name, verdict.get("detectors") ) - return result + return decoded_result async def _async_pre_call_hook( self, @@ -200,14 +205,15 @@ class PangeaHandler(CustomGuardrail): ai_guard_response = await self._call_pangea_ai_guard("v1beta/guard", ai_guard_payload, "async_pre_call_hook") add_guardrail_to_applied_guardrails_header(request_data=data, guardrail_name=self.guardrail_name) - if not ai_guard_response.get("result", {}).get("transformed"): + pangea_result: Final = decoded_json_object(ai_guard_response.get("result", {})) + if not pangea_result.get("transformed"): return - output: Final = ai_guard_response.get("result", {}).get("output", {}) + output: Final = pangea_result.get("output", {}) if call_type == "text_completion" or call_type == "atext_completion": - data = transformer.update_original_body(output["messages"]) + data = transformer.update_original_body(subscript(output, "messages")) else: - data["messages"] = output["messages"] + data["messages"] = subscript(output, "messages") return data @log_guardrail_information @@ -290,12 +296,14 @@ class PangeaHandler(CustomGuardrail): ai_guard_response = await self._call_pangea_ai_guard("v1beta/guard", ai_guard_payload, "async_pre_call_hook") add_guardrail_to_applied_guardrails_header(request_data=data, guardrail_name=self.guardrail_name) - if not ai_guard_response.get("result", {}).get("transformed"): + pangea_result: Final = decoded_json_object(ai_guard_response.get("result", {})) + if not pangea_result.get("transformed"): return - output: Final = ai_guard_response.get("result", {}).get("output", {}) + output: Final = pangea_result.get("output", {}) response.choices = [ # pyright: ignore[reportAttributeAccessIssue] # Pangea returns untyped choice dicts - _choice_with_message(choice, message_dict_with_server_tool_calls_split_out) for choice in output["choices"] + _choice_with_message(choice, message_dict_with_server_tool_calls_split_out) + for choice in iter_items(subscript(output, "choices")) ] return response diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py index 73ccd82d37f..f4b153d86ba 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py @@ -28,7 +28,7 @@ from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, ) -from litellm.litellm_core_utils.core_helpers import decoded_json_object +from litellm.litellm_core_utils.core_helpers import decoded_json_object, json_contains from litellm.llms.anthropic.common_utils import ( anthropic_server_tool_call_params, tool_calls_with_server_tool_calls, @@ -145,6 +145,7 @@ class _ResponsesInputItem(LiteLLMBaseModel): _ResponsesInput: TypeAlias = str | tuple[_ResponsesInputItem, ...] | None _RESPONSES_INPUT: Final[TypeAdapter[_ResponsesInput]] = TypeAdapter(_ResponsesInput) _DECODED_ARGUMENTS: Final = TypeAdapter(object) +_DECODED_JSON: Final = TypeAdapter(object) _NO_METADATA: Final[Mapping[str, object]] = MappingProxyType({}) @@ -394,7 +395,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): metadata: Mapping[str, object] | None = None, call_id: object = None, tool_event: _AirsToolEvent | None = None, - ) -> dict[str, object]: + ) -> Mapping[str, object]: """Call PANW Prisma AIRS API to scan content or a tool_event.""" if tool_event is None and not content.strip(): @@ -506,30 +507,32 @@ class PanwPrismaAirsHandler(CustomGuardrail): ) response.raise_for_status() - result: Final[dict[str, object]] = response.json() + result: Final = _DECODED_JSON.validate_python(response.json()) # Validate response format - if "action" not in result: + if not json_contains(result, "action"): verbose_proxy_logger.error("PANW Prisma AIRS: Invalid API response format: %s", result) return {"action": "block", "category": "api_error"} + body: Final = decoded_json_object(result) + # Check for profile-related errors from PANW API - if result.get("action") == "block" and "error" in result: - error_msg: Final = str(result.get("error", "")).lower() + if body.get("action") == "block" and "error" in body: + error_msg: Final = str(body.get("error", "")).lower() if "profile" in error_msg and ( "not found" in error_msg or "required" in error_msg or "invalid" in error_msg ): verbose_proxy_logger.error( "PANW Prisma AIRS: Profile configuration error. Ensure your API key has a linked profile in Strata Cloud Manager, or provide 'profile_name' or 'profile_id' in config/metadata. PANW API response: %s", - result, + body, ) verbose_proxy_logger.debug( "PANW Prisma AIRS: Scan result - Action: %s, Category: %s", - result.get("action"), - result.get("category", "unknown"), + body.get("action"), + body.get("category", "unknown"), ) - return result + return body except httpx.HTTPStatusError as e: status: Final = e.response.status_code @@ -839,7 +842,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): def _handle_api_error_with_logging( self, - scan_result: dict[str, object], + scan_result: Mapping[str, object], data: dict[str, object], start_time: datetime, event_type: GuardrailEventHooks, @@ -1363,7 +1366,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): assembled_model_response: ModelResponse, request_data: dict, start_time: datetime, - ) -> tuple[bool, ModelResponse, dict[str, object]]: + ) -> tuple[bool, ModelResponse, Mapping[str, object]]: """ Scan assembled streaming response and apply masking if needed. Returns (content_was_modified, response, scan_result). diff --git a/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py b/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py index f2697c0aa11..09f92f9c8ee 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py +++ b/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py @@ -5,11 +5,11 @@ from dataclasses import dataclass from functools import partial, reduce from itertools import chain, groupby from types import MappingProxyType -from typing import Any, Final, Literal, TypeAlias, TypedDict, TypeVar +from typing import Final, Literal, TypeAlias, TypeVar from fastapi import HTTPException from pydantic import TypeAdapter -from typing_extensions import ReadOnly, Required +from typing_extensions import TypedDict from litellm._logging import verbose_proxy_logger from litellm.caching.dual_cache import DualCache @@ -22,7 +22,7 @@ from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, ) -from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none +from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none, keyword_arguments from litellm.litellm_core_utils.tool_fields import declared_function_name, qualified_member_name from litellm.llms.anthropic.common_utils import ( anthropic_server_tool_call_params, @@ -472,19 +472,13 @@ def _rewritten_tool_config(tool_config: object, removed_names: frozenset[str], f } -class _ToolPermissionRuleFields(TypedDict, total=False): - """The config-file shape a :class:`ToolPermissionRule` is built from.""" - - id: ReadOnly[Required[str]] - tool_name: ReadOnly[str | None] - tool_type: ReadOnly[str | None] - decision: ReadOnly[Required[Literal["allow", "deny"]]] - allowed_param_patterns: ReadOnly[dict[str, str] | None] +class _ToolPermissionRuleFields(TypedDict, total=False, extra_items=object): + pass -def _rule_from_fields(fields: _ToolPermissionRuleFields) -> ToolPermissionRule: +def _rule_from_fields(fields: object) -> ToolPermissionRule: """Validate one config-file rule entry into a :class:`ToolPermissionRule`.""" - return ToolPermissionRule(**fields) + return ToolPermissionRule.model_validate(_ToolPermissionRuleFields(**keyword_arguments(fields, ToolPermissionRule))) def _is_tool_use_block(block: object) -> bool: @@ -529,7 +523,7 @@ class ToolPermissionGuardrail(CustomGuardrail): self.default_action, ) - def _load_rules(self, rules: list[Any] | None) -> None: + def _load_rules(self, rules: Sequence[object] | None) -> None: """Parse ``rules`` and (re)build the compiled target/pattern lookups. ``self.rules`` plus ``_compiled_rule_targets`` / ``_compiled_rule_patterns`` @@ -948,10 +942,10 @@ class ToolPermissionGuardrail(CustomGuardrail): return is_allowed, None, message @staticmethod - def _get_mapping_value(item: object, key: str) -> Any: + def _get_mapping_value(item: object, key: str) -> object: if isinstance(item, dict): return item.get(key) - return getattr(item, key, None) + return attribute_or_none(item, key) @staticmethod def _legacy_function_call_id(choice_index: int) -> str: diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index 2f2294db522..cafd79626ef 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -10,6 +10,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Optional, Protocol, TypeAlias, TypeVar, cast from pydantic import BaseModel, TypeAdapter, ValidationError +from typing_extensions import TypedDict import litellm from litellm import Router @@ -17,6 +18,7 @@ from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH, GUARDRAIL_ROTATION_ATTEMPTS from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.litellm_core_utils.core_helpers import as_str_mapping, keyword_arguments, subscript from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.base_llm.guardrail_translation.utils import ( effective_scan_only_tool_results_for_guardrail, @@ -102,6 +104,10 @@ def _as_json_array(value: object) -> list[object] | None: return _JSON_ARRAY.validate_python(value) if isinstance(value, list) else None +class _CategoryThresholdFields(TypedDict, total=False, extra_items=object): + pass + + def _extra_param_items(litellm_params: LitellmParams) -> Iterable[tuple[str, object]]: return ( litellm_params.model_dump(exclude_none=True).items() @@ -775,7 +781,11 @@ class InMemoryGuardrailHandler: litellm_params = litellm_params_data if "category_thresholds" in litellm_params_data and litellm_params_data["category_thresholds"]: - lakera_category_thresholds: Final = LakeraCategoryThresholds(**litellm_params_data["category_thresholds"]) + lakera_category_thresholds: Final = LakeraCategoryThresholds( + **_CategoryThresholdFields( + **keyword_arguments(subscript(litellm_params_data, "category_thresholds"), LakeraCategoryThresholds) + ) + ) litellm_params.category_thresholds = lakera_category_thresholds if litellm_params.api_key and litellm_params.api_key.startswith("os.environ/"): @@ -871,7 +881,8 @@ class InMemoryGuardrailHandler: params: Final = guardrail.get("litellm_params") if not isinstance(params, (dict, LitellmParams)): return - litellm_params: Final = LitellmParams(**params) if isinstance(params, dict) else params + params_mapping: Final = as_str_mapping(params) + litellm_params: Final = LitellmParams(**params_mapping) if params_mapping is not None else params guardrail_name: Final = guardrail.get("guardrail_name", "Unknown") for custom_guardrail_callback in self._tracked_callbacks(guardrail_id): for error in ( diff --git a/litellm/proxy/hooks/batch_rate_limiter.py b/litellm/proxy/hooks/batch_rate_limiter.py index b4d343b3932..625e1e2df7e 100644 --- a/litellm/proxy/hooks/batch_rate_limiter.py +++ b/litellm/proxy/hooks/batch_rate_limiter.py @@ -26,6 +26,7 @@ from urllib.parse import parse_qs, urlsplit from fastapi import HTTPException from pydantic import Field, TypeAdapter, ValidationError +from typing_extensions import TypedDict import litellm from litellm._internal_context import with_service_target @@ -105,6 +106,10 @@ _DECODED_JSON: Final = TypeAdapter(object) _WINDOW_START_ADAPTER: Final[TypeAdapter[int | float | str | None]] = TypeAdapter(int | float | str | None) +class _FileContentFetchKwargs(TypedDict, total=False, extra_items=object): + pass + + def _batch_create_routing_model(data: Mapping[str, object]) -> str | None: body_model: Final = data.get("model") if isinstance(body_model, str) and body_model: @@ -537,7 +542,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): custom_llm_provider: str, data: dict, user_api_key_dict: UserAPIKeyAuth | None = None, - ) -> tuple[str, dict[str, Any]]: + ) -> tuple[str, dict[str, object]]: """ Map proxy-facing file IDs to provider file IDs and credentials. @@ -954,7 +959,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): file_content = await litellm.afile_content( file_id=provider_file_id, user_api_key_dict=user_api_key_dict, - **fetch_kwargs, + **_FileContentFetchKwargs(**fetch_kwargs), ) file_content_bytes: Final = getattr(file_content, "content", None) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index b1eb8f7d8ae..8abc91a72ed 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -54,7 +54,10 @@ from litellm.integrations.custom_guardrail import merged_guardrail_entries, with from litellm.litellm_core_utils.core_helpers import ( as_str_mapping, attribute_or_none, + decoded_json_object, is_codex_user_agent, + iter_items, + keyword_arguments, normalize_drop_params, ) from litellm.litellm_core_utils.credential_accessor import CredentialAccessor @@ -1251,6 +1254,10 @@ def convert_key_logging_metadata_to_callback( return team_callback_settings_obj +class _CallbackModelFields(TypedDict, total=False, extra_items=object): + pass + + def get_validated_callback_metadata(item: object, *, source: str) -> AddTeamCallback | None: if not isinstance(item, Mapping): raise TypeError(f"{source} metadata.logging must be a list of objects, got an item of {type(item).__name__}") @@ -1280,9 +1287,9 @@ class KeyAndTeamLoggingSettings: return None @staticmethod - def get_team_dynamic_logging_settings(user_api_key_dict: UserAPIKeyAuth): + def get_team_dynamic_logging_settings(user_api_key_dict: UserAPIKeyAuth) -> object: if user_api_key_dict.team_metadata is not None and "logging" in user_api_key_dict.team_metadata: - return decrypt_callback_vars(user_api_key_dict.team_metadata).get("logging") + return decoded_json_object(decrypt_callback_vars(user_api_key_dict.team_metadata)).get("logging") return None @@ -1291,14 +1298,14 @@ def get_dynamic_logging_metadata( ) -> TeamCallbackMetadata | None: callback_settings_obj: TeamCallbackMetadata | None = None key_dynamic_logging_settings: Final = KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict) - team_dynamic_logging_settings: Final[dict | None] = KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings( + team_dynamic_logging_settings: Final = KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings( user_api_key_dict ) ######################################################################################### # Key-based callbacks ######################################################################################### if key_dynamic_logging_settings is not None: - for item in key_dynamic_logging_settings: + for item in iter_items(key_dynamic_logging_settings): callback = get_validated_callback_metadata(item=item, source="key-level") if callback is None: continue @@ -1310,7 +1317,7 @@ def get_dynamic_logging_metadata( # Team-based callbacks ######################################################################################### elif team_dynamic_logging_settings is not None: - for item in team_dynamic_logging_settings: + for item in iter_items(team_dynamic_logging_settings): callback = get_validated_callback_metadata(item=item, source="team-level") if callback is None: continue @@ -1331,9 +1338,11 @@ def get_dynamic_logging_metadata( } } """ - team_metadata: Final = decrypt_callback_vars(user_api_key_dict.team_metadata) + team_metadata: Final = decoded_json_object(decrypt_callback_vars(user_api_key_dict.team_metadata)) callback_settings: Final = team_metadata.get("callback_settings", None) or {} - callback_settings_obj = TeamCallbackMetadata(**callback_settings) + callback_settings_obj = TeamCallbackMetadata( + **_CallbackModelFields(**keyword_arguments(callback_settings, TeamCallbackMetadata)) + ) verbose_proxy_logger.debug("Team callback settings activated: %s", callback_settings_obj) ######################################################################################### # Enter here when configured on the config.yaml file. @@ -1465,7 +1474,7 @@ def resolve_tenant_otel_destinations( disabled: Final = _dynamically_disabled_backends(user_api_key_dict, request_headers) callbacks: Final = tuple( callback - for item in entries + for item in iter_items(entries) if (callback := get_validated_callback_metadata(item=item, source="otel-destination")) is not None if callback.callback_name.lower() not in disabled ) diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 4cbc2155af0..10bdd1d2353 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -1,7 +1,7 @@ import asyncio from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass -from types import MappingProxyType +from types import MappingProxyType, TracebackType from typing import TYPE_CHECKING, Final, Protocol from fastapi import APIRouter, Depends, HTTPException, status @@ -98,11 +98,11 @@ class _KeyRecord(Protocol): class _AccessGroupTable(Protocol): async def find_unique(self, where: Mapping[str, object]) -> _AccessGroupRecord | None: ... - async def find_many(self, order: Mapping[str, object]) -> Sequence[_AccessGroupRecord]: ... + async def find_many(self, *, order: Mapping[str, object]) -> Sequence[_AccessGroupRecord]: ... async def create(self, data: Mapping[str, object]) -> _AccessGroupRecord: ... - async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _AccessGroupRecord: ... + async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> _AccessGroupRecord | None: ... async def delete(self, where: Mapping[str, object]) -> object: ... @@ -118,9 +118,9 @@ class _TeamTable(Protocol): class _KeyTable(Protocol): async def find_unique(self, where: Mapping[str, object]) -> _KeyRecord | None: ... - async def find_many(self, where: Mapping[str, object]) -> Sequence[_KeyRecord]: ... + async def find_many(self, *, where: Mapping[str, object]) -> Sequence[_KeyRecord]: ... - async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> object: ... + async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> object: ... class _AgentRecord(Protocol): @@ -132,9 +132,9 @@ class _AgentRecord(Protocol): class _AgentTable(Protocol): - async def find_many(self, where: Mapping[str, object]) -> Sequence[_AgentRecord]: ... + async def find_many(self, *, where: Mapping[str, object]) -> Sequence[_AgentRecord]: ... - async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> object: ... + async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> object: ... class _HasSomeFilter(TypedDict): @@ -170,7 +170,13 @@ class _AccessGroupTx(Protocol): class _AccessGroupTransaction(Protocol): async def __aenter__(self) -> _AccessGroupTx: ... - async def __aexit__(self, *exc_info: object) -> None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> None: ... def _access_group_transaction(prisma_client: PrismaClient) -> _AccessGroupTransaction: @@ -731,6 +737,8 @@ async def update_access_group( from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + if record is None: + raise AttributeError("'NoneType' object has no attribute 'dict'", name="dict", obj=None) await _cache_access_group_record(record) await _patch_team_caches_add_access_group(teams_to_add, access_group_id, user_api_key_cache, proxy_logging_obj) await _patch_team_caches_remove_access_group( diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 21ac82754b4..3307f3050d9 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1122,13 +1122,13 @@ async def _target_in_caller_admin_org( caller_user_id: str, target_user_id: str, ) -> bool: - admin_memberships: Final = await prisma_client.db.litellm_organizationmembership.find_many( + admin_memberships: Final = await _organization_membership_table(prisma_client).find_many( where={"user_id": caller_user_id, "user_role": LitellmUserRoles.ORG_ADMIN.value} ) if not admin_memberships: return False admin_org_ids: Final = {membership.organization_id for membership in admin_memberships} - target_memberships: Final = await prisma_client.db.litellm_organizationmembership.find_many( + target_memberships: Final = await _organization_membership_table(prisma_client).find_many( where={"user_id": target_user_id} ) return any(membership.organization_id in admin_org_ids for membership in target_memberships) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index b59606a8dcf..90779f732c7 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -20,7 +20,7 @@ import secrets import traceback from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence from datetime import datetime, timedelta, timezone -from types import MappingProxyType +from types import MappingProxyType, TracebackType from typing import TYPE_CHECKING, Final, Literal, Optional, Protocol, TypeVar, cast import fastapi @@ -245,13 +245,20 @@ class _UserRowLike(Protocol): class _TxTables(Protocol): - litellm_proxymodeltable: TableActions[object] + @property + def litellm_proxymodeltable(self) -> TableActions[object]: ... class _ModelReencryptTransaction(Protocol): async def __aenter__(self) -> _TxTables: ... - async def __aexit__(self, *exc_info: object) -> None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> None: ... class _ModelParamsUpdate(TypedDict): diff --git a/litellm/proxy/management_endpoints/management_v1/spend_logs.py b/litellm/proxy/management_endpoints/management_v1/spend_logs.py index da84c2d0013..378d3b34d12 100644 --- a/litellm/proxy/management_endpoints/management_v1/spend_logs.py +++ b/litellm/proxy/management_endpoints/management_v1/spend_logs.py @@ -1,12 +1,11 @@ """`/management/v1/spend_logs` facets.""" -from collections.abc import Sequence from datetime import datetime, timezone from functools import partial -from typing import Annotated, Final, Literal, Protocol +from typing import Annotated, Final, Literal from fastapi import APIRouter, Depends, Query, Request -from typing_extensions import ReadOnly, TypedDict +from pydantic import TypeAdapter from litellm._logging import verbose_proxy_logger from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth @@ -21,7 +20,7 @@ from litellm.proxy.list_api.common import ( reject_unknown_query_params, ) from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX -from litellm.proxy.utils import PrismaClient +from litellm.proxy.utils import query_raw_rows from litellm.types.proxy.management_endpoints.management_v1 import ( FacetListResponse, PageMeta, @@ -35,18 +34,7 @@ router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) # query, so both reads of the same table stop at the same depth. SPEND_LOGS_FACET_SCAN_CAP: Final = 10000 - -class _FacetRow(TypedDict, total=False): - end_user: ReadOnly[str | None] - user: ReadOnly[str | None] - - -class _FacetQuery(Protocol): - async def __call__(self, query: str, *args: object) -> Sequence[_FacetRow]: ... - - -def _facet_query(prisma_client: PrismaClient) -> _FacetQuery: - return prisma_client.db.query_raw +_FACET_VALUES: Final = TypeAdapter(list[str]) def _as_utc(value: datetime) -> datetime: @@ -141,8 +129,8 @@ async def _list_spend_log_facet( f" ORDER BY {column_sql} ASC" f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}" ) - rows: Final = await _facet_query(prisma_client)(facet_sql, *params) - values: Final[list[str]] = [value for row in rows if (value := row.get(column))] + rows: Final = await query_raw_rows(prisma_client.db, facet_sql, *params) + values: Final = _FACET_VALUES.validate_python([value for row in rows if (value := row.get(column))]) has_more: Final = len(values) > page_size return FacetListResponse( diff --git a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py index 3a2de12d108..abc2fe4651a 100644 --- a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py @@ -20,13 +20,14 @@ import time from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence from datetime import datetime, timedelta from types import MappingProxyType -from typing import TYPE_CHECKING, Annotated, Any, Final, Protocol +from typing import TYPE_CHECKING, Annotated, Final, Protocol from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, TypeAdapter from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.core_helpers import json_contains, require_attributes from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.config_sync_pubsub import publish_model_rows_written @@ -104,6 +105,14 @@ def _http_error(status_code: int, message: str) -> HTTPException: return HTTPException(status_code=status_code, detail=detail) +class _AppendableAccessGroups(Protocol): + def append(self, value: str, /) -> None: ... + + +class _RemovableAccessGroups(Protocol): + def remove(self, value: str, /) -> None: ... + + class _DeploymentRow(Protocol): @property def model_id(self) -> str: ... @@ -376,7 +385,7 @@ async def _missing_models_after_read_through( return tuple(still_missing) -def add_access_group_to_deployment(model_info: dict[str, Any], access_group: str) -> tuple[dict[str, Any], bool]: +def add_access_group_to_deployment(model_info: dict[str, object], access_group: str) -> tuple[dict[str, object], bool]: """ Add an access group to a deployment's model_info. @@ -385,16 +394,16 @@ def add_access_group_to_deployment(model_info: dict[str, Any], access_group: str access_group: The access group name to add Returns: - Tuple[Dict[str, Any], bool]: (updated_model_info, was_modified) + tuple[dict[str, object], bool]: (updated_model_info, was_modified) """ access_groups: Final = model_info.get("access_groups", []) # Check if access group already exists - if access_group in access_groups: + if json_contains(access_groups, access_group): return model_info, False # Add the access group - access_groups.append(access_group) + require_attributes(access_groups, _AppendableAccessGroups, "append").append(access_group) model_info["access_groups"] = access_groups return model_info, True @@ -729,7 +738,9 @@ async def _find_deployment_or_400(model_id: str, prisma_client: PrismaClient) -> return deployment.model_info -def remove_access_group_from_deployment(model_info: dict[str, Any], access_group: str) -> tuple[dict[str, Any], bool]: +def remove_access_group_from_deployment( + model_info: dict[str, object], access_group: str +) -> tuple[dict[str, object], bool]: """ Remove an access group from a deployment's model_info. @@ -738,16 +749,16 @@ def remove_access_group_from_deployment(model_info: dict[str, Any], access_group access_group: The access group name to remove Returns: - Tuple[Dict[str, Any], bool]: (updated_model_info, was_modified) + tuple[dict[str, object], bool]: (updated_model_info, was_modified) """ access_groups: Final = model_info.get("access_groups", []) # Check if access group exists - if access_group not in access_groups: + if not json_contains(access_groups, access_group): return model_info, False # Remove the access group - access_groups.remove(access_group) + require_attributes(access_groups, _RemovableAccessGroups, "remove").remove(access_group) model_info["access_groups"] = access_groups return model_info, True diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 0b4f266f838..6d4dc01a8c1 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -553,6 +553,21 @@ def _effective_complexity_router_config( ).effective +def _encrypted_litellm_params( + dumped: Mapping[str, object], + incoming_params: GenericLiteLLMParams | None, + existing_params: GenericLiteLLMParams | None, +) -> Mapping[str, object]: + return { + name: ( + _effective_complexity_router_config(incoming_params, existing_params) + if name == "complexity_router_config" + else encrypt_value_helper(value) + ) + for name, value in dumped.items() + } + + def _dump_without_none(model: BaseModel) -> Mapping[str, object]: return model.model_dump(exclude_none=True) @@ -1511,14 +1526,11 @@ def update_db_model( # update litellm params if updated_patch.litellm_params: # Encrypt any sensitive values - encrypted_params: Final = { - k: ( - _effective_complexity_router_config(updated_patch.litellm_params, db_model.litellm_params) - if k == "complexity_router_config" - else encrypt_value_helper(v) - ) - for k, v in updated_patch.litellm_params.model_dump(exclude_none=True, exclude_unset=True).items() - } + encrypted_params: Final = _encrypted_litellm_params( + updated_patch.litellm_params.model_dump(exclude_none=True, exclude_unset=True), + updated_patch.litellm_params, + db_model.litellm_params, + ) merged_litellm_params.update(encrypted_params) @@ -3471,14 +3483,9 @@ async def update_model( ### ENCRYPT PARAMS ### encrypted_params: Final = MappingProxyType( - { - k: ( - _effective_complexity_router_config(model_params.litellm_params, deployment.litellm_params) - if k == "complexity_router_config" - else encrypt_value_helper(value=v) - ) - for k, v in _new_litellm_params_dict.items() - } + _encrypted_litellm_params( + _new_litellm_params_dict, model_params.litellm_params, deployment.litellm_params + ) ) ### MERGE WITH EXISTING DATA ### diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index a185c0dda35..853f29ecb62 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -206,7 +206,19 @@ class _ObjectPermissionTxClient(Protocol): class _BudgetTxClient(Protocol): - async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> "PrismaBudgetTable | None": ... + async def update( + self, *, where: Mapping[str, object], data: Mapping[str, object] + ) -> "PrismaBudgetTable | None": ... + + +class _OrganizationTxClient(Protocol): + async def update( + self, + *, + where: Mapping[str, object], + data: Mapping[str, object], + include: Mapping[str, object] | None = None, + ) -> "PrismaOrganizationTable | None": ... class _TransactionTables(Protocol): @@ -217,7 +229,7 @@ class _TransactionTables(Protocol): def litellm_budgettable(self) -> "_BudgetTxClient": ... @property - def litellm_organizationtable(self) -> "_OrganizationTableClient": ... + def litellm_organizationtable(self) -> "_OrganizationTxClient": ... class _TransactionManager(Protocol): @@ -228,6 +240,7 @@ class _TransactionManager(Protocol): exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: "TracebackType | None", + /, ) -> bool | None: ... diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 1d35d34cbcf..24675406567 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -164,7 +164,9 @@ def _resolve_team_callbacks(team_metadata: object) -> TeamCallbackMetadata: for entry in logging_entries if isinstance(logging_entries, list) else (): if not isinstance(entry, dict): continue - callback = get_validated_callback_metadata(item=entry, source="team-level read") + callback = get_validated_callback_metadata( + item=_DECODED_JSON.validate_python(entry), source="team-level read" + ) if callback is None: continue resolved = convert_key_logging_metadata_to_callback(data=callback, team_callback_settings_obj=resolved) diff --git a/litellm/proxy/mcp_server/db.py b/litellm/proxy/mcp_server/db.py index 27f8b90a586..f29acc3b380 100644 --- a/litellm/proxy/mcp_server/db.py +++ b/litellm/proxy/mcp_server/db.py @@ -5,6 +5,7 @@ import json from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from types import TracebackType from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypedDict, cast from fastapi import HTTPException @@ -68,8 +69,11 @@ if TYPE_CHECKING: class _UserEnvVarsTransactionClient(Protocol): - litellm_mcpuserenvvars: "TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]" - litellm_mcpservertable: "TableActions[prisma_db_models.LiteLLM_MCPServerTable]" + @property + def litellm_mcpuserenvvars(self) -> "TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]": ... + + @property + def litellm_mcpservertable(self) -> "TableActions[prisma_db_models.LiteLLM_MCPServerTable]": ... async def execute_raw(self, query: str, *args: object) -> int: ... @@ -77,7 +81,13 @@ class _UserEnvVarsTransactionClient(Protocol): class _UserEnvVarsTransaction(Protocol): async def __aenter__(self) -> _UserEnvVarsTransactionClient: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... @dataclass(frozen=True, slots=True) @@ -139,6 +149,10 @@ def _blank_to_none(value: object) -> str | None: return value.strip() or None +def _submitted_or(submitted: Mapping[str, object], field: str, fallback: str | None) -> object: + return submitted.get(field, fallback) + + # Token-exchange settings with dedicated columns that also exist on # ``MCPCredentials`` as a legacy shape (rows and REST callers that predate the # columns). Every write lifts blob values into the columns and strips them from @@ -1379,8 +1393,10 @@ async def update_mcp_server( "credentials": safe_dumps( _bind_submitted_oauth_client( safe_submitted, - _blank_to_none(cleared_auth_fields.get("issuer", prepared_data.get("issuer", old_issuer))), - _blank_to_none(prepared_data.get("url", existing.url if existing else None)), + _blank_to_none( + cleared_auth_fields.get("issuer", _submitted_or(prepared_data, "issuer", old_issuer)) + ), + _blank_to_none(_submitted_or(prepared_data, "url", existing.url if existing else None)), ) ) } diff --git a/litellm/proxy/mcp_server/discoverable_endpoints.py b/litellm/proxy/mcp_server/discoverable_endpoints.py index ecc40b6e98f..f36609aa8b3 100644 --- a/litellm/proxy/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/mcp_server/discoverable_endpoints.py @@ -6,7 +6,17 @@ import time from collections.abc import AsyncIterator, Callable, Mapping from contextlib import asynccontextmanager from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Final, Literal, Optional +from typing import ( + TYPE_CHECKING, + Final, + Literal, + Optional, + Protocol, + SupportsIndex, + SupportsInt, + overload, + runtime_checkable, +) from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import httpx @@ -15,6 +25,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp from mcp.client.auth.utils import get_client_metadata_scopes, union_scopes from mcp.shared.auth import OAuthMetadata from pydantic import ConfigDict, Field, SecretStr, TypeAdapter, ValidationError +from typing_extensions import Buffer from litellm._logging import verbose_logger from litellm.caching.in_memory_cache import InMemoryCache @@ -673,10 +684,24 @@ def _validate_token_response( ) +@runtime_checkable +class _SupportsTrunc(Protocol): + def __trunc__(self) -> int: ... + + +class _OAuthTokenResponseFields(Protocol): + @overload + def get(self, key: Literal["access_token", "refresh_token"], /) -> str | None: ... + @overload + def get(self, key: Literal["expires_in"], /) -> object: ... + @overload + def get(self, key: Literal["scope"], default: str | None, /) -> object: ... + + async def _store_per_user_token_server_side( server: MCPServer, user_id: str, - token_response: dict[str, Any], + token_response: _OAuthTokenResponseFields, identity_binding_proof: str | None = None, cimd_client_id: str | None = None, requested_scope: str | None = None, @@ -699,7 +724,11 @@ async def _store_per_user_token_server_side( raw_expires: Final = token_response.get("expires_in") try: - expires_in: int | None = int(raw_expires) if raw_expires is not None else None + expires_in: int | None = ( + int(raw_expires) + if isinstance(raw_expires, (str, Buffer, SupportsInt, SupportsIndex, _SupportsTrunc)) + else None + ) except (TypeError, ValueError): expires_in = None diff --git a/litellm/proxy/mcp_server/mcp_server_manager.py b/litellm/proxy/mcp_server/mcp_server_manager.py index cc02fc43ef3..3d55f63c73d 100644 --- a/litellm/proxy/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/mcp_server/mcp_server_manager.py @@ -29,7 +29,7 @@ from dataclasses import dataclass, replace from functools import lru_cache from itertools import chain, groupby from types import EllipsisType, MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypedDict, cast +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias, TypedDict, cast from urllib.parse import ParseResult, urlparse import anyio @@ -73,6 +73,7 @@ from litellm.constants import ( MCP_UPSTREAM_SESSION_SHUTDOWN_TIMEOUT_SECONDS, ) from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException +from litellm.litellm_core_utils.core_helpers import as_object_list, decoded_json_object from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.mcp_client.client import ( @@ -469,14 +470,6 @@ class MCPServerConfig(TypedDict, total=False): rpm: ReadOnly[int | None] -class _ProtectedResourceMetadataPayload(TypedDict, total=False): - """The RFC 9728 protected-resource metadata document fields this gateway reads.""" - - authorization_servers: Sequence[object] - scopes_supported: Sequence[str] - scopes: Sequence[str] - - class _AuthorizationServerMetadataPayload(TypedDict, total=False): """The RFC 8414 / OpenID Discovery authorization-server metadata fields this gateway reads.""" @@ -1910,6 +1903,10 @@ def _listing_task_was_cancelled(error: asyncio.CancelledError) -> bool: return error.args != (SESSION_CLEANUP_TIMEOUT_MESSAGE,) +class _InternalNetworkSettings(Protocol): + def get(self, key: Literal["mcp_internal_ip_ranges"], /) -> list[str] | None: ... + + class MCPServerManager: _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$") @@ -5369,7 +5366,7 @@ class MCPServerManager: try: response: Final = await self._fetch_oauth_discovery_url(resource_metadata_url, server_url) response.raise_for_status() - data: Final[_ProtectedResourceMetadataPayload] = response.json() + decoded: Final = _UNTYPED_VALUE.validate_python(response.json()) except SSRFError as exc: verbose_logger.warning( "MCP OAuth discovery: refusing to fetch resource metadata from %s " @@ -5387,11 +5384,13 @@ class MCPServerManager: ) return [], None - raw_servers: Final = data.get("authorization_servers") - if isinstance(raw_servers, list): - authorization_servers = [entry for entry in raw_servers if isinstance(entry, str) and entry.strip() != ""] - else: - authorization_servers = [] + data: Final = decoded_json_object(decoded) + raw_servers: Final = as_object_list(data.get("authorization_servers")) + authorization_servers: Final = ( + [entry for entry in raw_servers if isinstance(entry, str) and entry.strip() != ""] + if raw_servers is not None + else [] + ) scopes: Final = self._extract_scopes(data.get("scopes_supported") or data.get("scopes")) @@ -5631,13 +5630,14 @@ class MCPServerManager: "aws_session_name": credentials_dict.get("aws_session_name"), } - def _extract_scopes(self, scopes_value: str | Sequence[object] | None) -> list[str] | None: + def _extract_scopes(self, scopes_value: object) -> list[str] | None: if isinstance(scopes_value, str): scopes = [s.strip() for s in scopes_value.split() if s.strip()] return scopes or None - if isinstance(scopes_value, list): - scopes = [s for s in scopes_value if isinstance(s, str) and s.strip()] - return scopes or None + scope_entries: Final = as_object_list(scopes_value) + if scope_entries is not None: + listed_scopes: Final = [s for s in scope_entries if isinstance(s, str) and s.strip()] + return listed_scopes or None return None async def _fetch_tools_with_timeout( @@ -7354,7 +7354,7 @@ class MCPServerManager: servers.append(server) return servers - def _get_general_settings(self) -> dict[str, Any]: + def _get_general_settings(self) -> _InternalNetworkSettings: """Get general_settings, importing lazily to avoid circular imports.""" try: from litellm.proxy.proxy_server import ( diff --git a/litellm/proxy/mcp_server/operations.py b/litellm/proxy/mcp_server/operations.py index 8422d516d8c..afa3a373f3c 100644 --- a/litellm/proxy/mcp_server/operations.py +++ b/litellm/proxy/mcp_server/operations.py @@ -2080,7 +2080,7 @@ async def _list_tools_before_first_call( async def execute_mcp_tool( name: str, - arguments: dict[str, object], + arguments: Mapping[str, object], allowed_mcp_servers: list[MCPServer], start_time: datetime, user_api_key_auth: UserAPIKeyAuth | None = None, diff --git a/litellm/proxy/mcp_server/rest_endpoints.py b/litellm/proxy/mcp_server/rest_endpoints.py index 815c1d6270c..165fc23d754 100644 --- a/litellm/proxy/mcp_server/rest_endpoints.py +++ b/litellm/proxy/mcp_server/rest_endpoints.py @@ -15,7 +15,7 @@ import httpx2 from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import TypeAdapter, ValidationError from starlette.datastructures import Headers -from typing_extensions import ReadOnly +from typing_extensions import Never, ReadOnly from litellm._logging import verbose_logger from litellm.constants import MCP_CLIENT_TIMEOUT, MCP_TOOL_LISTING_TIMEOUT @@ -118,6 +118,8 @@ _MCP_GUARDRAIL_REJECTIONS: Final = ( _CLIENT_FORWARDED_TOKEN_AUTH_TYPES: Final = frozenset((MCPAuth.true_passthrough, MCPAuth.oauth_delegate)) +_NO_TOOL_ARGUMENTS: Final[Mapping[str, Never]] = MappingProxyType({}) + def _connection_error_message(exc: BaseException, url: str | None, timeout_seconds: float) -> str: reference: Final = uuid4().hex @@ -356,7 +358,7 @@ if MCP_AVAILABLE: status_code=403, detail={"error": "forbidden", "message": f"{tool_name} requires mcp_tool_search_enabled on the key"}, ) - tool_arguments: Final = data.get("arguments") or {} + tool_arguments: Final = data.get("arguments") or _NO_TOOL_ARGUMENTS if tool_name == AGENT_SEARCH_TOOL_NAME: return await handle_agent_search( query=str(tool_arguments.get("query", "")), diff --git a/litellm/proxy/mcp_server/tool_search.py b/litellm/proxy/mcp_server/tool_search.py index 96bcf8fbacf..e65fcf61f49 100644 --- a/litellm/proxy/mcp_server/tool_search.py +++ b/litellm/proxy/mcp_server/tool_search.py @@ -10,13 +10,13 @@ from dataclasses import dataclass from datetime import datetime from itertools import chain, islice from types import EllipsisType, MappingProxyType -from typing import TYPE_CHECKING, Any, Final, TypedDict +from typing import TYPE_CHECKING, Final, Protocol, SupportsIndex, SupportsInt, TypedDict, runtime_checkable import anyio from anyio import to_process from anyio.lowlevel import RunVar from pydantic import JsonValue, SecretStr, ValidationError -from typing_extensions import ReadOnly, Required, assert_never +from typing_extensions import Buffer, ReadOnly, Required, assert_never import litellm from litellm.llms.litellm_proxy.skills.skill_search import DEFAULT_SKILL_SEARCH_TOP_K @@ -131,7 +131,14 @@ async def _tool_argument_validation_error( return "Tool argument validation worker failed" -def coerce_top_k(value: Any, default: int = 5) -> int: +@runtime_checkable +class _SupportsTrunc(Protocol): + def __trunc__(self) -> int: ... + + +def coerce_top_k(value: object, default: int = 5) -> int: + if not isinstance(value, (str, Buffer, SupportsInt, SupportsIndex, _SupportsTrunc)): + return default try: return int(value) except (TypeError, ValueError): @@ -699,7 +706,7 @@ async def handle_mcp_proxy_tool( async def handle_mcp_tool_call( tool_name: str, - arguments: dict[str, Any], + arguments: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth, client_ip: str | None = None, mcp_servers: list[str] | None = None, diff --git a/litellm/proxy/middleware/admission_control_middleware.py b/litellm/proxy/middleware/admission_control_middleware.py index 83503d0c3d4..1b1af7fff95 100644 --- a/litellm/proxy/middleware/admission_control_middleware.py +++ b/litellm/proxy/middleware/admission_control_middleware.py @@ -3,7 +3,7 @@ import os from collections.abc import Callable, Mapping from dataclasses import dataclass from functools import lru_cache -from typing import Annotated, Final, Protocol, TypeAlias, runtime_checkable +from typing import Annotated, Final, Literal, Protocol, TypeAlias, runtime_checkable from pydantic import Field, TypeAdapter, ValidationError from starlette.responses import JSONResponse @@ -133,6 +133,12 @@ class AdmissionControlState: return self._metrics +class _RoutedScope(Protocol): + def __getitem__(self, key: Literal["path"], /) -> str: ... + + def get(self, key: Literal["root_path"], default: str, /) -> str: ... + + class _AdmissionLease: def __init__(self, state: AdmissionControlState) -> None: self.state: Final = state @@ -207,7 +213,7 @@ class AdmissionControlMiddleware: state.record_release() -def _get_route_path(scope: Scope) -> str: +def _get_route_path(scope: _RoutedScope) -> str: """Strip the ASGI root_path (SERVER_ROOT_PATH) the same way Starlette does before route matching.""" path: Final[str] = scope["path"] root_path: Final[str] = scope.get("root_path", "") diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index 95e5c9d1293..14b99b0e25d 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -19,7 +19,7 @@ import re import threading from collections.abc import Callable, Sequence from enum import Enum -from typing import Final, Protocol, runtime_checkable +from typing import Final, Literal, Protocol, runtime_checkable from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -196,6 +196,26 @@ def _extract_model_id(headers: Sequence[tuple[bytes, bytes]]) -> str | None: ) +class _RequestLineFields(Protocol): + def get(self, key: Literal["path", "method"], default: str, /) -> str: ... + + +class _ResponseStartFields(Protocol): + def __getitem__(self, key: Literal["status"], /) -> int: ... + + def get( + self, key: Literal["headers"], default: Sequence[tuple[bytes, bytes]], / + ) -> Sequence[tuple[bytes, bytes]]: ... + + +def _request_line_fields(scope: _RequestLineFields) -> _RequestLineFields: + return scope + + +def _response_start_fields(message: _ResponseStartFields) -> _ResponseStartFields: + return message + + class BillableRequestMetricsMiddleware: """ Pure ASGI middleware that classifies each request once and fans the result @@ -263,7 +283,10 @@ class BillableRequestMetricsMiddleware: await self.app(scope, receive, send) return - classification: Final = classify_billable_request(scope.get("path", ""), scope.get("method", "POST")) + request_line: Final = _request_line_fields(scope) + classification: Final = classify_billable_request( + request_line.get("path", ""), request_line.get("method", "POST") + ) if classification is None: await self.app(scope, receive, send) return @@ -275,8 +298,9 @@ class BillableRequestMetricsMiddleware: async def send_wrapper(message: Message) -> None: nonlocal status_code, model_id if message["type"] == "http.response.start": - status_code = message["status"] - model_id = _extract_model_id(message.get("headers", [])) + response_start: Final = _response_start_fields(message) + status_code = response_start["status"] + model_id = _extract_model_id(response_start.get("headers", [])) await send(message) await self.app(scope, receive, send_wrapper) diff --git a/litellm/proxy/middleware/per_request_root_path_middleware.py b/litellm/proxy/middleware/per_request_root_path_middleware.py index 3afa268b182..fdc374a58d7 100644 --- a/litellm/proxy/middleware/per_request_root_path_middleware.py +++ b/litellm/proxy/middleware/per_request_root_path_middleware.py @@ -14,7 +14,7 @@ Opt-in: with ``SERVER_ROOT_PATHS`` unset the middleware is not added at all. import os from collections.abc import Sequence from contextvars import ContextVar -from typing import Final +from typing import Final, Literal, Protocol from starlette.types import ASGIApp, Receive, Scope, Send @@ -89,6 +89,14 @@ def get_server_root_paths() -> tuple[str, ...]: return normalize_root_paths(configured.split(",")) +class _RootPathFields(Protocol): + def get(self, key: Literal["path", "root_path"], default: str, /) -> str: ... + + +def _root_path_fields(scope: _RootPathFields) -> _RootPathFields: + return scope + + class PerRequestRootPathMiddleware: """Sets ``scope["root_path"]`` to the configured prefix matching the request path on a whole-segment boundary. ``scope["path"]`` is left @@ -104,7 +112,7 @@ class PerRequestRootPathMiddleware: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] in ("http", "websocket"): - path: Final[str] = scope.get("path", "") + path: Final[str] = _root_path_fields(scope).get("path", "") for prefix in self.root_paths: if path == prefix or path.startswith(prefix + "/"): scope["root_path"] = prefix # rebind-ok: ASGI middleware contract; Router and base_url read it @@ -113,7 +121,7 @@ class PerRequestRootPathMiddleware: # existing value when nothing matched — i.e. FastAPI's scalar # SERVER_ROOT_PATH) so code that emits URLs off the request path # picks the same prefix the router will resolve the request under. - token: Final = _request_root_path_var.set(str(scope.get("root_path", ""))) + token: Final = _request_root_path_var.set(str(_root_path_fields(scope).get("root_path", ""))) try: await self.app(scope, receive, send) finally: diff --git a/litellm/proxy/middleware/request_size_limit_middleware.py b/litellm/proxy/middleware/request_size_limit_middleware.py index 329a3c38d91..06649d72153 100644 --- a/litellm/proxy/middleware/request_size_limit_middleware.py +++ b/litellm/proxy/middleware/request_size_limit_middleware.py @@ -1,7 +1,7 @@ import json -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping from types import MappingProxyType -from typing import Final +from typing import Final, Literal, Protocol from fastapi import HTTPException from starlette._utils import get_route_path @@ -14,6 +14,18 @@ MaxRequestSizeGetter = Callable[[], int | float | None] RequestSizeLimitEnabledGetter = Callable[[], bool] +class _RequestHeadersFields(Protocol): + def get(self, key: Literal["headers"], /) -> Iterable[tuple[bytes, bytes]] | None: ... + + +class _RequestBodyFields(Protocol): + def get(self, key: Literal["body"], default: bytes, /) -> bytes: ... + + +def _request_body_fields(message: _RequestBodyFields) -> _RequestBodyFields: + return message + + class RequestEntityTooLarge(HTTPException): def __init__(self, max_request_size_mb: float) -> None: super().__init__(status_code=413, detail=f"Request size is too large. Max size is {max_request_size_mb} MB") @@ -100,7 +112,7 @@ class RequestSizeLimitMiddleware: if message["type"] != "http.request": return message - received_body_bytes += len(message.get("body", b"")) + received_body_bytes += len(_request_body_fields(message).get("body", b"")) if received_body_bytes > max_request_size_bytes: raise RequestEntityTooLarge(max_request_size_mb) return message @@ -128,7 +140,7 @@ def _mb_to_bytes(max_request_size_mb: float | None) -> int | None: return int(max_request_size_mb * 1024 * 1024) -def _get_content_length(scope: Scope) -> int | None: +def _get_content_length(scope: _RequestHeadersFields) -> int | None: headers: Final = dict(scope.get("headers") or []) raw_content_length: Final = headers.get(b"content-length") if raw_content_length is None: diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index e5e68a930c9..a81fced9021 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -25,7 +25,7 @@ from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_FILE_LIST_LIMIT from litellm.litellm_core_utils.batch_utils import batch_cost_is_final from litellm.litellm_core_utils.cloud_storage_security import REQUEST_S3_BUCKET_PARAMS -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none from litellm.llms.base_llm.managed_resources.utils import is_base64_encoded_unified_id from litellm.proxy._types import ProxyException from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler @@ -1567,7 +1567,7 @@ async def resolve_output_file_ids_to_unified(response, prisma_client) -> None: if not prisma_client: return for attr in ("output_file_id", "error_file_id"): - raw_id = getattr(response, attr, None) + raw_id = attribute_or_none(response, attr) if not raw_id or is_base64_encoded_unified_file_id(raw_id): continue try: diff --git a/litellm/proxy/openai_files_endpoints/file_content_streaming_handler.py b/litellm/proxy/openai_files_endpoints/file_content_streaming_handler.py index 3b809688d6c..fb67cc93e37 100644 --- a/litellm/proxy/openai_files_endpoints/file_content_streaming_handler.py +++ b/litellm/proxy/openai_files_endpoints/file_content_streaming_handler.py @@ -1,5 +1,5 @@ from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast from fastapi.responses import StreamingResponse @@ -12,6 +12,14 @@ if TYPE_CHECKING: from litellm.proxy.utils import ProxyLogging +class _CallIdFields(Protocol): + def get(self, key: Literal["litellm_call_id"], default: str, /) -> str: ... + + +def _call_id_fields(data: _CallIdFields) -> _CallIdFields: + return data + + class FileContentStreamingHandler: @staticmethod def resolve_streaming_request_params( @@ -77,7 +85,7 @@ class FileContentStreamingHandler: async for chunk in stream_iterator: yield chunk await proxy_logging_obj.update_request_status( - litellm_call_id=data.get("litellm_call_id", ""), status="success" + litellm_call_id=_call_id_fields(data).get("litellm_call_id", ""), status="success" ) except Exception as e: await proxy_logging_obj.post_call_failure_hook( diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 8b8d9ec81be..df51740b553 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -11,7 +11,7 @@ import itertools import traceback from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass -from typing import Any, BinaryIO, Final, NoReturn, TypedDict, cast, get_args +from typing import Any, BinaryIO, Final, NoReturn, Protocol, TypedDict, cast, get_args import httpx from fastapi import ( @@ -35,7 +35,7 @@ from litellm.constants import ANTHROPIC_DEFAULT_FILE_LIST_LIMIT from litellm.litellm_core_utils.cloud_storage_security import ( is_managed_cloud_storage_uri, ) -from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket +from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket, has_attributes from litellm.llms.base_llm.files.litellm_db_storage_backend import LITELLM_DB_STORAGE_BACKEND_NAME from litellm.llms.base_llm.files.transformation import BaseFileEndpoints, FileListModelScope from litellm.llms.base_llm.managed_resources.isolation import build_list_page @@ -185,6 +185,11 @@ _LISTED_FILES_ADAPTER: Final = TypeAdapter(list[OpenAIFileObject]) _REQUEST_BODY_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +class _CarriesPrismaClient(Protocol): + @property + def prisma_client(self) -> PrismaClient: ... + + class UploadedFileInfo(TypedDict): filename: ReadOnly[str | None] content_type: ReadOnly[str | None] @@ -1067,8 +1072,11 @@ async def get_file_content( ) # Check if file is stored in a storage backend (check DB) - if hasattr(managed_files_obj, "prisma_client") and getattr(managed_files_obj, "prisma_client", None): - prisma_client: Final[PrismaClient] = getattr(managed_files_obj, "prisma_client") + if ( + has_attributes(managed_files_obj, _CarriesPrismaClient, "prisma_client") + and managed_files_obj.prisma_client + ): + prisma_client: Final = managed_files_obj.prisma_client db_file: Final = await ManagedFileRepository(prisma_client).table.find_first( where={"unified_file_id": file_id} ) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index a2e2e36d458..f1a9e282bc9 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -2510,21 +2510,23 @@ async def _relay_router_model( user_api_key_dict: UserAPIKeyAuth, ) -> Response: try: - result: Final = await llm_router.allm_passthrough_route( - model=model, - method=request.method, - endpoint=endpoint, - request_query_params=request.query_params, - request_headers=safe_get_request_headers(request), - stream=is_streaming_request, - content=None, - data=None, - files=None, - json=(request_body if is_json_content_type(request.headers.get("content-type", "")) else None), - params=None, - headers=None, - cookies=None, - litellm_metadata=get_passthrough_router_request_metadata(user_api_key_dict, request), + result: Final = _DECODED_JSON.validate_python( + await llm_router.allm_passthrough_route( + model=model, + method=request.method, + endpoint=endpoint, + request_query_params=request.query_params, + request_headers=safe_get_request_headers(request), + stream=is_streaming_request, + content=None, + data=None, + files=None, + json=(request_body if is_json_content_type(request.headers.get("content-type", "")) else None), + params=None, + headers=None, + cookies=None, + litellm_metadata=get_passthrough_router_request_metadata(user_api_key_dict, request), + ) ) except httpx.HTTPStatusError as upstream_error: return await _relay_upstream_response(upstream_error.response) diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py index c1fc6479421..dafbf6f74b6 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py @@ -11,6 +11,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import ANTHROPIC_BATCHES_ROUTE from litellm.litellm_core_utils.core_helpers import ( + json_contains, native_messages_reported_cost, override_stamped_response_cost, response_cost_in_hidden_params, @@ -58,6 +59,7 @@ else: EndpointType = Any _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) +_DECODED_JSON: Final = TypeAdapter(object) def _event_payload(event: str) -> Mapping[str, object] | None: @@ -609,10 +611,10 @@ class AnthropicPassthroughLoggingHandler: from litellm.types.utils import Choices, SpecialEnums try: - _json_response: Final = httpx_response.json() + _json_response: Final = _DECODED_JSON.validate_python(httpx_response.json()) # Only handle successful batch job creation (POST requests with 201 status) - if httpx_response.status_code == 200 and "id" in _json_response: + if httpx_response.status_code == 200 and json_contains(_json_response, "id"): # Transform Anthropic response to LiteLLM batch format anthropic_batches_config: Final = AnthropicBatchesConfig() litellm_batch_response: Final = anthropic_batches_config.transform_retrieve_batch_response( @@ -626,7 +628,7 @@ class AnthropicPassthroughLoggingHandler: litellm_batch_response.status = "validating" # Extract batch ID from the response - batch_id: Final = _json_response.get("id", "") + batch_id: Final = litellm_batch_response.id # Get model from request body (batch response doesn't include model) request_body = request_body or {} diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 42a7c458ef2..00e2aeb9e6e 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -6,7 +6,16 @@ import posixpath import re import traceback from base64 import b64encode -from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable, Mapping, MutableMapping, Sequence +from collections.abc import ( + AsyncGenerator, + AsyncIterator, + Callable, + Coroutine, + Iterable, + Mapping, + MutableMapping, + Sequence, +) from dataclasses import dataclass from datetime import datetime from functools import reduce @@ -278,6 +287,11 @@ async def set_env_variables_in_header(custom_headers: dict | None) -> dict | Non return headers +class _AdapterCompletionRouter(Protocol): + @property + def aadapter_completion(self) -> Callable[..., Coroutine[object, object, object]]: ... + + async def chat_completion_pass_through_endpoint( fastapi_response: Response, request: Request, @@ -368,7 +382,8 @@ async def chat_completion_pass_through_endpoint( and data["model"] is not None and llm_router.registry().resolve(data["model"], kind="recognized") ): - llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) + adapter_router = require_attributes(llm_router, _AdapterCompletionRouter, "aadapter_completion") + llm_response = asyncio.create_task(adapter_router.aadapter_completion(**data)) elif ( llm_router is not None and data["model"] not in router_model_names @@ -377,11 +392,13 @@ async def chat_completion_pass_through_endpoint( or len(llm_router.registry().patterns().patterns) > 0 ) ): # check for wildcard routes or default deployment before checking deployment_names - llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) + adapter_router = require_attributes(llm_router, _AdapterCompletionRouter, "aadapter_completion") + llm_response = asyncio.create_task(adapter_router.aadapter_completion(**data)) elif llm_router is not None and data["model"] in llm_router.registry().names( kind="deployment_names" ): # model in router deployments, calling a specific deployment on the router (lowest priority) - llm_response = asyncio.create_task(llm_router.aadapter_completion(**data, specific_deployment=True)) + adapter_router = require_attributes(llm_router, _AdapterCompletionRouter, "aadapter_completion") + llm_response = asyncio.create_task(adapter_router.aadapter_completion(**data, specific_deployment=True)) elif user_model is not None: # `litellm --model ` llm_response = asyncio.create_task(litellm.aadapter_completion(**data)) else: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8f5db97e7a3..b01dcfd2f03 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -394,6 +394,7 @@ from litellm.litellm_core_utils.core_helpers import ( get_litellm_metadata_from_kwargs, get_parent_otel_span_from_kwargs, iter_items, + json_contains, normalize_drop_params, require_attributes, ) @@ -2532,6 +2533,7 @@ class _InvitationAcceptTxManager(Protocol): exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, + /, ) -> bool | None: ... @@ -13806,7 +13808,7 @@ async def moderations( llm_router=llm_router, user_model=user_model, ) - response: Final = await llm_call + response: Final = _UNTYPED_VALUE.validate_python(await llm_call) ### ALERTING ### asyncio.create_task(proxy_logging_obj.update_request_status(litellm_call_id=litellm_call_id, status="success")) @@ -13856,14 +13858,20 @@ async def moderations( ) +class _SpeechBytes(Protocol): + def aiter_bytes(self, chunk_size: int | None = None) -> Awaitable[AsyncIterator[bytes]]: ... + + async def _audio_speech_chunk_generator( - _response: HttpxBinaryResponseContent, + _response: object, ) -> AsyncGenerator[bytes, None]: # chunk_size has a big impact on latency, it can't be too small or too large # too small: latency is high # too large: latency is low, but memory usage is high # 8192 is a good compromise - _generator: Final = await _response.aiter_bytes(chunk_size=AUDIO_SPEECH_CHUNK_SIZE) + _generator: Final = await require_attributes(_response, _SpeechBytes, "aiter_bytes").aiter_bytes( + chunk_size=AUDIO_SPEECH_CHUNK_SIZE + ) async for chunk in _generator: yield chunk @@ -13924,7 +13932,7 @@ async def audio_speech( llm_router=llm_router, user_model=user_model, ) - response: Final = await llm_call + response: Final = _UNTYPED_VALUE.validate_python(await llm_call) ### ALERTING ### asyncio.create_task(proxy_logging_obj.update_request_status(litellm_call_id=litellm_call_id, status="success")) @@ -14088,7 +14096,7 @@ async def audio_transcriptions( llm_router=llm_router, user_model=user_model, ) - response: Final = await llm_call + response: Final = _UNTYPED_VALUE.validate_python(await llm_call) except Exception as e: raise e finally: @@ -14665,7 +14673,7 @@ async def get_assistants( # for now use custom_llm_provider=="openai" -> this will change as LiteLLM adds more providers for acreate_batch if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - response: Final = await llm_router.aget_assistants(**data) + response: Final = _UNTYPED_VALUE.validate_python(await llm_router.aget_assistants(**data)) ### ALERTING ### asyncio.create_task( @@ -14741,7 +14749,7 @@ async def create_assistant( # for now use custom_llm_provider=="openai" -> this will change as LiteLLM adds more providers for acreate_batch if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - response: Final = await llm_router.acreate_assistants(**data) + response: Final = _UNTYPED_VALUE.validate_python(await llm_router.acreate_assistants(**data)) ### ALERTING ### asyncio.create_task( @@ -14816,7 +14824,9 @@ async def delete_assistant( # for now use custom_llm_provider=="openai" -> this will change as LiteLLM adds more providers for acreate_batch if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - response: Final = await llm_router.adelete_assistant(assistant_id=assistant_id, **data) + response: Final = _UNTYPED_VALUE.validate_python( + await llm_router.adelete_assistant(assistant_id=assistant_id, **data) + ) ### ALERTING ### asyncio.create_task( @@ -14891,7 +14901,7 @@ async def create_threads( # for now use custom_llm_provider=="openai" -> this will change as LiteLLM adds more providers for acreate_batch if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - response: Final = await llm_router.acreate_thread(**data) + response: Final = _UNTYPED_VALUE.validate_python(await llm_router.acreate_thread(**data)) ### ALERTING ### asyncio.create_task( @@ -14964,7 +14974,7 @@ async def get_thread( # for now use custom_llm_provider=="openai" -> this will change as LiteLLM adds more providers for acreate_batch if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - response: Final = await llm_router.aget_thread(thread_id=thread_id, **data) + response: Final = _UNTYPED_VALUE.validate_python(await llm_router.aget_thread(thread_id=thread_id, **data)) ### ALERTING ### asyncio.create_task( @@ -15041,7 +15051,7 @@ async def add_messages( # for now use custom_llm_provider=="openai" -> this will change as LiteLLM adds more providers for acreate_batch if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - response: Final = await llm_router.a_add_message(thread_id=thread_id, **data) + response: Final = _UNTYPED_VALUE.validate_python(await llm_router.a_add_message(thread_id=thread_id, **data)) ### ALERTING ### asyncio.create_task( @@ -15114,7 +15124,7 @@ async def get_messages( # for now use custom_llm_provider=="openai" -> this will change as LiteLLM adds more providers for acreate_batch if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - response: Final = await llm_router.aget_messages(thread_id=thread_id, **data) + response: Final = _UNTYPED_VALUE.validate_python(await llm_router.aget_messages(thread_id=thread_id, **data)) ### ALERTING ### asyncio.create_task( @@ -15194,7 +15204,9 @@ async def run_thread( if "stream" in data and data["stream"] is True: # use generate_responses to stream responses async def produce_run_stream() -> StreamingResponse | JSONResponse: - run_stream: Final = await router.arun_thread(thread_id=thread_id, **data) + run_stream: Final = _UNTYPED_VALUE.validate_python( + await router.arun_thread(thread_id=thread_id, **data) + ) return await create_response( generator=async_assistants_data_generator( user_api_key_dict=user_api_key_dict, @@ -15222,7 +15234,7 @@ async def run_thread( on_late_failure=audit_late_failure, ) - response: Final = await router.arun_thread(thread_id=thread_id, **data) + response: Final = _UNTYPED_VALUE.validate_python(await router.arun_thread(thread_id=thread_id, **data)) ### ALERTING ### asyncio.create_task( @@ -16175,11 +16187,11 @@ async def _count_request_tokens( tools: Final = request.tools system: Final = request.system - deployment: dict[str, Any] | None = None if routed is None else dict(routed) + deployment: dict[str, object] | None = None if routed is None else dict(routed) litellm_model_name = None model_info: Final = None if routed is None else routed.get("model_info", {}) if deployment is not None: - litellm_model_name = deployment.get("litellm_params", {}).get("model") + litellm_model_name = _with_get(deployment.get("litellm_params", _EMPTY_MAPPING)).get("model") deployment = { # rebind-ok: merge the named credential into a copy **deployment, "litellm_params": _with_chat_timeout( @@ -16187,8 +16199,8 @@ async def _count_request_tokens( ), } # remove the custom_llm_provider_prefix in the litellm_model_name - if "/" in litellm_model_name: - litellm_model_name = litellm_model_name.split("/", 1)[1] + if json_contains(litellm_model_name, "/"): + litellm_model_name = _split_once(litellm_model_name, "/")[1] model_to_use: str = ( litellm_model_name if isinstance(litellm_model_name, str) and litellm_model_name else request.model @@ -17071,6 +17083,14 @@ class _SupportsLower(Protocol): def lower(self) -> object: ... +class _SupportsSplit(Protocol): + def split(self, sep: str, maxsplit: int, /) -> Sequence[object]: ... + + +def _split_once(value: object, sep: str) -> Sequence[object]: + return require_attributes(value, _SupportsSplit, "split").split(sep, 1) + + def _lowered(value: object) -> object: return require_attributes(value, _SupportsLower, "lower").lower() @@ -17239,18 +17259,19 @@ async def _apply_search_filter_to_models( def _is_byok_outside_caller_teams(model_info_dict: object) -> bool: return _byok_row_outside_caller_teams(model_info_dict, allowed_team_ids) - def _model_matches_search(m: dict[str, Any]) -> bool: + def _model_matches_search(m: Mapping[str, object]) -> bool: # Team BYOK models persist an internal `model_name` # (e.g. `model_name_{team_id}_{uuid}`) and expose the user-facing # name via `model_info.team_public_model_name`. Match both so the # name shown in the UI is searchable. - if search_lower in (m.get("model_name") or "").lower(): + if json_contains(_lowered(m.get("model_name") or ""), search_lower): return True - team_public_model_name: Final = (m.get("model_info") or {}).get("team_public_model_name") or "" - if search_lower in team_public_model_name.lower(): + team_public_model_name: Final = ( + _with_get(m.get("model_info") or _EMPTY_MAPPING).get("team_public_model_name") or "" + ) + if json_contains(_lowered(team_public_model_name), search_lower): return True - litellm_params: Final[Mapping[str, object]] = m.get("litellm_params") or MappingProxyType({}) - litellm_model: Final = litellm_params.get("model") + litellm_model: Final = _with_get(m.get("litellm_params") or _EMPTY_MAPPING).get("model") return isinstance(litellm_model, str) and search_lower in litellm_model.lower() # Filter models in router by search term, dropping BYOK rows that @@ -19481,7 +19502,7 @@ async def async_queue_request( return StreamingResponse( async_data_generator( user_api_key_dict=user_api_key_dict, - response=await router.schedule_acompletion(**data), + response=_UNTYPED_VALUE.validate_python(await router.schedule_acompletion(**data)), request_data=data, request=request, ), @@ -19500,7 +19521,7 @@ async def async_queue_request( on_late_failure=audit_late_failure, ) - response: Final = await router.schedule_acompletion(**data) + response: Final = _UNTYPED_VALUE.validate_python(await router.schedule_acompletion(**data)) fastapi_response.headers.update({"x-litellm-priority": str(queued_priority)}) return response except Exception as e: @@ -20147,7 +20168,7 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request): ) if user_obj and hasattr(user_obj, "__dict__"): - user_obj.__dict__.pop("password", None) + vars(user_obj).pop("password", None) # The password just changed via an invitation/reset link; any UI session # minted under the old password may be in hostile hands. Revoke them all — diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index 47050f01644..38041ed1862 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import ORJSONResponse, StreamingResponse from pydantic import TypeAdapter from starlette.datastructures import UploadFile +from typing_extensions import TypedDict import litellm from litellm._logging import verbose_proxy_logger @@ -91,6 +92,10 @@ _EMPTY_TEAM_PROVIDER_CREDENTIALS: Final[Mapping[str, object]] = MappingProxyType _TEAM_CREDENTIAL_PROVIDER: Final = "openai" +class _ForwardedRequestFields(TypedDict, total=False, extra_items=object): + pass + + def _as_string_keyed_mapping(value: object) -> Mapping[str, object] | None: if isinstance(value, Mapping): return value @@ -904,7 +909,7 @@ async def rag_ingest( vertex_target_error: Final = _vertex_target_error(base) if vertex_target_checked else None if vertex_target_error is not None: raise HTTPException(status_code=400, detail={"error": vertex_target_error}) - request_data: Final[dict[str, Any]] = await add_litellm_data_to_request( + request_data: Final[dict[str, object]] = await add_litellm_data_to_request( data={}, request=request, general_settings=general_settings, @@ -955,7 +960,7 @@ async def rag_ingest( file_url=file_url, file_id=ingest_file_id, router=llm_router, - **request_data, + **_ForwardedRequestFields(**request_data), ) if ingest_response.get("status") == "failed": raise HTTPException( diff --git a/litellm/proxy/rerank_endpoints/endpoints.py b/litellm/proxy/rerank_endpoints/endpoints.py index 4905751a85d..b80220b1aac 100644 --- a/litellm/proxy/rerank_endpoints/endpoints.py +++ b/litellm/proxy/rerank_endpoints/endpoints.py @@ -6,6 +6,7 @@ from typing import Final import orjson from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.responses import ORJSONResponse +from pydantic import TypeAdapter from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -23,6 +24,7 @@ from litellm.proxy.common_utils.openai_error_payload import ( from litellm.types.utils import CallTypes router: Final = APIRouter() +_DECODED_JSON: Final = TypeAdapter(object) @router.post( @@ -85,7 +87,7 @@ async def rerank( llm_router=llm_router, user_model=user_model, ) - response: Final = await llm_call + response: Final = _DECODED_JSON.validate_python(await llm_call) ### ALERTING ### asyncio.create_task(proxy_logging_obj.update_request_status(litellm_call_id=litellm_call_id, status="success")) diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index 11a0b947abe..369d167f623 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -2686,7 +2686,7 @@ async def _responses_websocket_session( llm_router=llm_router, user_model=user_model, ) - outcome: Final = await llm_call + outcome: Final = _DECODED_JSON.validate_python(await llm_call) except asyncio.CancelledError: if refused_turns.first is not None: _ = bill_socket_failure(refused_turns.first) diff --git a/litellm/proxy/response_polling/polling_handler.py b/litellm/proxy/response_polling/polling_handler.py index 0a13e3a9e99..11be7a736ed 100644 --- a/litellm/proxy/response_polling/polling_handler.py +++ b/litellm/proxy/response_polling/polling_handler.py @@ -21,6 +21,13 @@ if TYPE_CHECKING: _RESPONSE_POLLING_TARGET: Final = "response_polling" _JSON_OBJECT: Final = TypeAdapter(dict[str, object]) _JSON_LIST: Final = TypeAdapter(list[object]) +_CACHED_VALUE: Final = TypeAdapter(object) + + +def _json_document(value: object) -> str | bytes | bytearray: + if isinstance(value, (str, bytes, bytearray)): + return value + raise TypeError(f"the JSON object must be str, bytes or bytearray, not {value.__class__.__name__}") class ResponsePollingHandler: @@ -156,13 +163,13 @@ class ResponsePollingHandler: cache_key: Final = self.get_cache_key(polling_id) # Get current state - cached_state: Final = await self.redis_cache.async_get_cache(cache_key) + cached_state: Final = _CACHED_VALUE.validate_python(await self.redis_cache.async_get_cache(cache_key)) if not cached_state: verbose_proxy_logger.warning("No cached state found for polling_id: %s", polling_id) return # Parse existing ResponsesAPIResponse from cache - state: Final = _JSON_OBJECT.validate_python(json.loads(cached_state)) + state: Final = _JSON_OBJECT.validate_python(json.loads(_json_document(cached_state))) # Update status (using OpenAI native status values) if status: @@ -239,10 +246,10 @@ class ResponsePollingHandler: return None cache_key: Final = self.get_cache_key(polling_id) - cached_state: Final = await self.redis_cache.async_get_cache(cache_key) + cached_state: Final = _CACHED_VALUE.validate_python(await self.redis_cache.async_get_cache(cache_key)) if cached_state: - return _JSON_OBJECT.validate_python(json.loads(cached_state)) + return _JSON_OBJECT.validate_python(json.loads(_json_document(cached_state))) return None diff --git a/litellm/proxy/search_endpoints/search_tool_management.py b/litellm/proxy/search_endpoints/search_tool_management.py index a2c4066a14f..dee8c8d5c60 100644 --- a/litellm/proxy/search_endpoints/search_tool_management.py +++ b/litellm/proxy/search_endpoints/search_tool_management.py @@ -7,6 +7,7 @@ from datetime import datetime from typing import Any, Final, TypeAlias from fastapi import APIRouter, Depends, HTTPException +from typing_extensions import ReadOnly, Required, TypedDict from litellm._logging import verbose_proxy_logger from litellm.constants import UI_SESSION_TOKEN_TEAM_ID @@ -68,7 +69,18 @@ async def _refresh_router_search_tools() -> None: verbose_proxy_logger.exception("Search tool router refresh failed after a management write: %s", e) -def _with_loaded_tools_where_undecryptable(db_search_tools: Sequence[dict[str, object]]) -> list[dict[str, Any]]: +class _ListedSearchTool(TypedDict, total=False): + search_tool_id: ReadOnly[str | None] + search_tool_name: ReadOnly[Required[str]] + litellm_params: ReadOnly[Required[Mapping[str, object]]] + search_tool_info: ReadOnly[dict[object, object] | None] + created_at: ReadOnly[str | None] + updated_at: ReadOnly[str | None] + + +def _with_loaded_tools_where_undecryptable( + db_search_tools: Sequence[_ListedSearchTool], +) -> Sequence[_ListedSearchTool]: from litellm.proxy.proxy_server import llm_router kept_search_tools: Final = keep_loaded_search_tools_that_do_not_decrypt( diff --git a/litellm/proxy/search_endpoints/search_tool_registry.py b/litellm/proxy/search_endpoints/search_tool_registry.py index c8cd1100e08..39510076d22 100644 --- a/litellm/proxy/search_endpoints/search_tool_registry.py +++ b/litellm/proxy/search_endpoints/search_tool_registry.py @@ -5,7 +5,7 @@ Search Tool Registry for managing search tool configurations. import os from collections.abc import Iterator, Mapping, Sequence from datetime import datetime, timezone -from typing import Final, Protocol +from typing import Final, Protocol, TypeVar from pydantic import TypeAdapter, ValidationError @@ -172,9 +172,13 @@ def _did_not_decrypt(search_tool: Mapping[str, object]) -> bool: ) +_DbSearchToolT: Final = TypeVar("_DbSearchToolT", bound=Mapping[str, object]) +_LoadedSearchToolT: Final = TypeVar("_LoadedSearchToolT", bound=Mapping[str, object]) + + def keep_loaded_search_tools_that_do_not_decrypt( - db_search_tools: Sequence[Mapping[str, object]], loaded_search_tools: Sequence[Mapping[str, object]] -) -> Sequence[Mapping[str, object]]: + db_search_tools: Sequence[_DbSearchToolT], loaded_search_tools: Sequence[_LoadedSearchToolT] +) -> Sequence[_DbSearchToolT | _LoadedSearchToolT]: """Replace each DB search tool whose params do not decrypt with the current key by its loaded version.""" loaded_by_id: Final = {tool.get("search_tool_id"): tool for tool in loaded_search_tools} kept: Final = tuple( diff --git a/litellm/proxy/spend_tracking/key_metadata_recovery.py b/litellm/proxy/spend_tracking/key_metadata_recovery.py index b3b5e41328d..f48f853ba2a 100644 --- a/litellm/proxy/spend_tracking/key_metadata_recovery.py +++ b/litellm/proxy/spend_tracking/key_metadata_recovery.py @@ -1,10 +1,11 @@ import asyncio from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Set as AbstractSet +from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from datetime import datetime, timedelta from types import MappingProxyType -from typing import Final, Literal, TypeVar +from typing import Final, Literal, Protocol, TypeVar from pydantic import ConfigDict, TypeAdapter from typing_extensions import ReadOnly, TypedDict @@ -168,6 +169,16 @@ async def _db_or_empty( return None +class _SpendLogTransaction(Protocol): + async def execute_raw(self, query: str, /) -> int: ... + + async def query_raw(self, query: str, /, *args: object) -> object: ... + + +def _spend_log_transaction(prisma_client: PrismaClient) -> AbstractAsyncContextManager[_SpendLogTransaction]: + return prisma_client.db.tx(timeout=_SPEND_LOG_TRANSACTION_TIMEOUT) + + async def _rows_within_the_statement_timeout( prisma_client: PrismaClient, sql: str, @@ -177,7 +188,7 @@ async def _rows_within_the_statement_timeout( ) -> Sequence[Mapping[str, object]]: async with ( db_span("recover_key_metadata", table), - prisma_client.db.tx(timeout=_SPEND_LOG_TRANSACTION_TIMEOUT) as transaction, + _spend_log_transaction(prisma_client) as transaction, ): await transaction.execute_raw(_SPEND_LOG_STATEMENT_TIMEOUT_SQL) for setting in planner_settings: diff --git a/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py b/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py index e4a98619bfd..86e8ef31cf4 100644 --- a/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py +++ b/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py @@ -18,7 +18,7 @@ from collections.abc import Awaitable, Callable, Iterator, Mapping from dataclasses import dataclass from datetime import date, datetime, time, timedelta, timezone from itertools import chain, groupby -from types import MappingProxyType +from types import MappingProxyType, TracebackType from typing import TYPE_CHECKING, Final, Protocol from pydantic import TypeAdapter @@ -71,13 +71,20 @@ class _SentinelBatchTable(Protocol): class _SentinelBatch(Protocol): - litellm_dailyteamspend: _SentinelBatchTable + @property + def litellm_dailyteamspend(self) -> _SentinelBatchTable: ... class _SentinelBatchManager(Protocol): async def __aenter__(self) -> _SentinelBatch: ... - async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> bool | None: ... class _SupportsGet(Protocol): diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 5b961f1320b..2de589564ac 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -3,7 +3,7 @@ import asyncio import json import os from collections import Counter -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import ItemsView, Mapping, MutableMapping, Sequence from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -1306,6 +1306,16 @@ async def _load_environment_variables_param( return param_value +def _submitted_sso_values(submitted: Mapping[str, object]) -> ItemsView[str, object]: + return submitted.items() + + +def _environment_value(value: object) -> str: + if isinstance(value, str): + return value + raise TypeError(f"str expected, not {type(value).__name__}") + + def _decrypted_sso_fields(environment_variables: Mapping[str, object]) -> Mapping[str, str | None]: return MappingProxyType( { @@ -1398,11 +1408,11 @@ async def update_sso_settings( field_name: submitted[field_name] if field_name in submitted else fallback_values.get(field_name) for field_name in SSOConfig.model_fields } - for field_name, value in submitted.items(): + for field_name, value in _submitted_sso_values(submitted): if field_name in SSO_FIELD_ENV_VARS: env_var_name = SSO_FIELD_ENV_VARS[field_name] if value: - os.environ[env_var_name] = value + os.environ[env_var_name] = _environment_value(value) else: # Clear environment variable if value is null/empty os.environ.pop(env_var_name, None) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index eac2700551f..874fcbc8d09 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -169,6 +169,7 @@ from litellm.litellm_core_utils.core_helpers import ( attribute_or_none, coerce_token_limit, get_or_create_metadata_bucket, + has_attributes, independent_snapshot, is_expected_client_error, require_attributes, @@ -2373,10 +2374,12 @@ class ProxyLogging: modified_args = self._extract_modified_arguments_from_content(new_content, request_obj) if modified_args is not None: # Return the masked/redacted arguments for the MCP call to use - return MCPPreCallResponseObject( - should_proceed=True, - error_message=None, - modified_arguments=modified_args, + return MCPPreCallResponseObject.model_validate( + { + "should_proceed": True, + "error_message": None, + "modified_arguments": modified_args, + } ) else: # Could not parse modified arguments, allow original call but warn @@ -2395,7 +2398,7 @@ class ProxyLogging: return None - def _extract_modified_arguments_from_content(self, masked_content: str, request_obj) -> dict | None: + def _extract_modified_arguments_from_content(self, masked_content: str, request_obj) -> object: """ Extract modified/masked arguments from the guardrail response content. """ @@ -2416,7 +2419,7 @@ class ProxyLogging: # Try to parse as JSON first try: - modified_args = json.loads(args_text) + modified_args = _DECODED_JSON.validate_python(json.loads(args_text)) verbose_proxy_logger.debug("Successfully parsed JSON args: %s", modified_args) return modified_args except json.JSONDecodeError as e: @@ -5643,8 +5646,13 @@ _LOCK_TEAMS_BY_ID_SQL: Final = ( ) -def _user_unique_conflict_message(metadata: object, user_id: object) -> str: - match metadata: +class _UniqueConflictMetadata(Protocol): + @property + def meta(self) -> object: ... + + +def _user_unique_conflict_message(error: _UniqueConflictMetadata, user_id: object) -> str: + match error.meta: case {"target": ["user_id"]}: return f"User with id {user_id} already exists" case {"target": ["sso_user_id"]}: @@ -5658,7 +5666,7 @@ def _raise_user_unique_conflict(error: "UniqueViolationError", user_id: object, raise error raise HTTPException( status_code=409, - detail={"error": _user_unique_conflict_message(error.meta, user_id)}, + detail={"error": _user_unique_conflict_message(error, user_id)}, ) from error @@ -8338,6 +8346,10 @@ class PrismaClient: ### HELPER FUNCTIONS ### +class _JsonDumpableRow(Protocol): + def model_dump_json(self) -> object: ... + + async def cache_user_row(user_id: str, cache: DualCache, db: PrismaClient) -> None: """ Check if a user_id exists in cache, @@ -8349,8 +8361,8 @@ async def cache_user_row(user_id: str, cache: DualCache, db: PrismaClient) -> No user_row: Final = await db.get_data(user_id=user_id) if user_row is not None: print_verbose(f"User Row: {user_row}, type = {type(user_row)}") - if hasattr(user_row, "model_dump_json") and callable(getattr(user_row, "model_dump_json")): - cache_value: Final[str] = user_row.model_dump_json() + if has_attributes(user_row, _JsonDumpableRow, "model_dump_json") and callable(user_row.model_dump_json): + cache_value: Final = user_row.model_dump_json() cache.set_cache(key=cache_key, value=cache_value, ttl=600) # store for 10 minutes diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index 4b9547300db..62af5663ea9 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -10,6 +10,7 @@ All /vector_store management endpoints import copy import json +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final from fastapi import APIRouter, Depends, HTTPException @@ -54,8 +55,16 @@ def _vector_store_table(prisma_client: "PrismaClient") -> "TableActions[_VectorS return ManagedVectorStoresRepository(prisma_client).table +class _VectorStoreRowFields(TypedDict, total=False, extra_items=object): + pass + + +def _dumped_row(row: "_VectorStoreRow") -> Mapping[str, object]: + return row.model_dump() + + def _row_to_vector_store(row: "_VectorStoreRow") -> LiteLLM_ManagedVectorStore: - return LiteLLM_ManagedVectorStore(**row.model_dump()) + return LiteLLM_ManagedVectorStore(**_VectorStoreRowFields(**_dumped_row(row))) class _ConfigOwnedDetail(TypedDict): diff --git a/litellm/proxy/vector_store_endpoints/utils.py b/litellm/proxy/vector_store_endpoints/utils.py index 9004d1951b8..4044e50f8c1 100644 --- a/litellm/proxy/vector_store_endpoints/utils.py +++ b/litellm/proxy/vector_store_endpoints/utils.py @@ -2,10 +2,11 @@ import json import re from collections.abc import Iterable, Mapping from types import MappingProxyType -from typing import Final, Literal +from typing import Final, Literal, Protocol from fastapi import HTTPException, Request from pydantic import ConfigDict, TypeAdapter +from typing_extensions import TypedDict import litellm from litellm._logging import verbose_proxy_logger @@ -27,6 +28,18 @@ from litellm.utils import ProviderConfigManager _JSON_OBJECT: Final = TypeAdapter(dict[str, object], config=ConfigDict(hide_input_in_errors=True)) +class _ManagedVectorStoreFields(TypedDict, total=False, extra_items=object): + pass + + +class _DumpableRow(Protocol): + def model_dump(self) -> Mapping[str, object]: ... + + +def _managed_vector_store_from_row(row: _DumpableRow) -> LiteLLM_ManagedVectorStore: + return LiteLLM_ManagedVectorStore(**_ManagedVectorStoreFields(**row.model_dump())) + + def normalize_litellm_params( vector_store: LiteLLM_ManagedVectorStore, ) -> LiteLLM_ManagedVectorStore: @@ -316,7 +329,7 @@ async def get_litellm_managed_vector_store( ) if not rows: return None - return normalize_litellm_params(LiteLLM_ManagedVectorStore(**rows[0].model_dump())) + return normalize_litellm_params(_managed_vector_store_from_row(rows[0])) except Exception as e: verbose_proxy_logger.warning( "Failed to resolve vector store id=%s from shared cache: %s", diff --git a/litellm/proxy/video_endpoints/utils.py b/litellm/proxy/video_endpoints/utils.py index 24fd443b68a..8cca272b0b3 100644 --- a/litellm/proxy/video_endpoints/utils.py +++ b/litellm/proxy/video_endpoints/utils.py @@ -1,8 +1,9 @@ -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, Protocol import orjson from pydantic import BaseModel, ConfigDict, TypeAdapter +from litellm.litellm_core_utils.core_helpers import has_attributes from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.auth_utils import video_access_group from litellm.proxy.openai_files_endpoints.common_utils import authorize_model_for_key @@ -63,7 +64,11 @@ def get_custom_provider_from_data(data: dict[str, Any]) -> str | None: return None -def encode_character_id_in_response(response: Any, custom_llm_provider: str, model_id: str | None) -> object: +class _CarriesId(Protocol): + id: object + + +def encode_character_id_in_response(response: object, custom_llm_provider: str, model_id: str | None) -> object: if isinstance(response, dict) and response.get("id"): response["id"] = encode_character_id_with_provider( character_id=response["id"], @@ -72,7 +77,9 @@ def encode_character_id_in_response(response: Any, custom_llm_provider: str, mod ) return response - character_id: Final = getattr(response, "id", None) + if not has_attributes(response, _CarriesId, "id"): + return response + character_id: Final = response.id if isinstance(character_id, str) and character_id: response.id = encode_character_id_with_provider( character_id=character_id, diff --git a/litellm/rag/ingestion/base_ingestion.py b/litellm/rag/ingestion/base_ingestion.py index 8b2cf6e0a4e..0d75fe4e531 100644 --- a/litellm/rag/ingestion/base_ingestion.py +++ b/litellm/rag/ingestion/base_ingestion.py @@ -14,12 +14,13 @@ from __future__ import annotations import base64 from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, Protocol, cast import litellm from litellm._logging import verbose_logger from litellm._uuid import uuid4 from litellm.constants import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE +from litellm.litellm_core_utils.core_helpers import has_attributes, require_attributes from litellm.litellm_core_utils.url_utils import async_safe_get from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, @@ -31,6 +32,8 @@ from litellm.rag.text_splitters import RecursiveCharacterTextSplitter from litellm.types.rag import RAGIngestOptions, RAGIngestResponse if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Iterable + from litellm import Router @@ -38,6 +41,21 @@ class RAGIngestBadRequestError(ValueError): status_code: Final = 400 +class _OcrRouter(Protocol): + @property + def aocr(self) -> Callable[..., Awaitable[object]]: ... + + +class _OcrPages(Protocol): + @property + def pages(self) -> Iterable[object]: ... + + +class _OcrPage(Protocol): + @property + def markdown(self) -> str: ... + + class BaseRAGIngestion(ABC): """ Base class for RAG ingestion. @@ -168,7 +186,8 @@ class BaseRAGIngestion(ABC): # Use router if available if self.router is not None: - ocr_response = await self.router.aocr( + ocr_router: Final = require_attributes(self.router, _OcrRouter, "aocr") + ocr_response = await ocr_router.aocr( model=ocr_model, document={"type": doc_type, url_key: data_url}, ) @@ -179,8 +198,10 @@ class BaseRAGIngestion(ABC): ) # Extract text from pages - if hasattr(ocr_response, "pages") and ocr_response.pages: - return "\n\n".join(page.markdown for page in ocr_response.pages if hasattr(page, "markdown")) + if has_attributes(ocr_response, _OcrPages, "pages") and ocr_response.pages: + return "\n\n".join( + page.markdown for page in ocr_response.pages if has_attributes(page, _OcrPage, "markdown") + ) return None diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index 85d87554023..9bd8148b286 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -21,7 +21,7 @@ from litellm.constants import ( request_timeout, ) from litellm.exceptions import BadRequestError -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, keyword_arguments from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.llms.base_llm.realtime.http_transformation import BaseRealtimeHTTPConfig from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig @@ -394,7 +394,7 @@ async def acreate_realtime_client_secret( async def acreate_realtime_translation_client_secret( model: str | None = None, session: Mapping[str, Any] | None = None, - expires_after: Mapping[str, Any] | None = None, + expires_after: Mapping[str, object] | None = None, timeout: float | None = None, **kwargs: object, # kwargs-ok: public API preserves provider-specific request options ) -> httpx.Response: @@ -409,7 +409,11 @@ async def acreate_realtime_translation_client_secret( req: Final = RealtimeClientSecretRequest( model=requested_model_name, session=session_config, - expires_after=RealtimeExpiresAfter(**expires_after) if expires_after else None, + expires_after=RealtimeExpiresAfter( + **_ExpiresAfterFields(**keyword_arguments(expires_after, RealtimeExpiresAfter)) + ) + if expires_after + else None, ) litellm_logging_obj: Final = kwargs.get("litellm_logging_obj") if not isinstance(litellm_logging_obj, LiteLLMLogging): @@ -488,10 +492,18 @@ def _deployment_base_model(kwargs: Mapping[str, object], custom_llm_provider: st return None if base_model is None else base_model.removeprefix(f"{custom_llm_provider}/") +class _ExpiresAfterFields(TypedDict, total=False, extra_items=object): + pass + + +class _TranscriptionSessionFields(TypedDict, total=False, extra_items=object): + pass + + @wrapper_client async def acreate_realtime_transcription_session( model: str | None = None, - transcription_session: dict[str, Any] | None = None, + transcription_session: Mapping[str, object] | None = None, timeout: float | None = None, **kwargs, ): @@ -506,7 +518,9 @@ async def acreate_realtime_transcription_session( """ req: Final = RealtimeTranscriptionSessionRequest( model=model, - **(transcription_session or {}), + **_TranscriptionSessionFields( + **keyword_arguments(transcription_session or {}, RealtimeTranscriptionSessionRequest) + ), ) model_name = req.resolved_model() or "gpt-realtime-whisper" litellm_logging_obj: Final[LiteLLMLogging] = kwargs.get("litellm_logging_obj") @@ -626,7 +640,7 @@ async def arealtime_translation_calls( openai_ephemeral_key: str, sdp_body: bytes, model: str | None = None, - session: Mapping[str, Any] | None = None, + session: Mapping[str, object] | None = None, timeout: float | None = None, **kwargs: object, # kwargs-ok: public API preserves provider-specific request options ) -> httpx.Response: diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index fbef73a6114..3c3b87c9b8c 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -34,6 +34,16 @@ class _PrismaWriterClientView(Protocol): def writer_db(self) -> object: ... +class _LedgerWriterClientView(Protocol): + @property + def writer_db(self) -> LedgerDatabase: ... + + +class _RawQueryWriterClientView(Protocol): + @property + def writer_db(self) -> LedgerTransaction: ... + + class PrismaTableRepository(Generic[RowT_co]): """Base for repositories that expose a single Prisma table.""" @@ -143,7 +153,7 @@ class SpendLogsRepository(PrismaTableRepository["prisma_models.LiteLLM_SpendLogs ) if not ledger: return legacy_count - database: Final[LedgerDatabase] = self.prisma_client.writer_db + database: Final = require_attributes(self._connected_client(), _LedgerWriterClientView, "writer_db").writer_db async with database.tx(timeout=timedelta(seconds=60)) as transaction: accepted: Final = await self._insert_ledger(transaction, ledger, write_spend_logs=write_spend_logs) return legacy_count + accepted @@ -211,7 +221,7 @@ class SpendLogsRepository(PrismaTableRepository["prisma_models.LiteLLM_SpendLogs return frozenset(str(row["request_id"]) for row in rows) async def has_delivery(self, delivery_id: str) -> bool: - database: Final[LedgerTransaction] = self.prisma_client.writer_db + database: Final = require_attributes(self._connected_client(), _RawQueryWriterClientView, "writer_db").writer_db rows: Final = await database.query_raw( 'SELECT 1 FROM "LiteLLM_SpendLogDelivery" WHERE delivery_id = $1', delivery_id ) diff --git a/litellm/rerank_api/main.py b/litellm/rerank_api/main.py index e344ce46be7..8c2a0a36212 100644 --- a/litellm/rerank_api/main.py +++ b/litellm/rerank_api/main.py @@ -188,14 +188,16 @@ def rerank( model_response: Final = RerankResponse() - rerank_litellm_params: Final = { - "litellm_call_id": litellm_call_id, - "proxy_server_request": proxy_server_request, - "model_info": model_info, - "preset_cache_key": None, - "stream_response": {}, - **optional_params.model_dump(exclude_unset=True), - } + rerank_litellm_params: Final = dict[str, object]( + { + "litellm_call_id": litellm_call_id, + "proxy_server_request": proxy_server_request, + "model_info": model_info, + "preset_cache_key": None, + "stream_response": {}, + **optional_params.model_dump(exclude_unset=True), + } + ) litellm_logging_obj.update_from_kwargs( kwargs=kwargs, diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index c3166b26a74..b2c98282da8 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -21,6 +21,7 @@ from litellm.litellm_core_utils.allowed_tools_tool_choice import ( parse_allowed_tools_tool_choice, tool_calls_the_choice_excludes, ) +from litellm.litellm_core_utils.core_helpers import attribute_or_none from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy.mcp_server.catalog import catalog_operation, global_manager from litellm.proxy.mcp_server.utils import ( @@ -1178,7 +1179,7 @@ class LiteLLM_Proxy_MCP_Handler: @staticmethod def should_raise_mcp_guardrail_block(error: Exception) -> bool: - return getattr(error, "mcp_block_behavior", None) == "raise" + return attribute_or_none(error, "mcp_block_behavior") == "raise" @staticmethod def create_follow_up_messages_for_chat( diff --git a/litellm/router.py b/litellm/router.py index 98b016f590f..2bf4db967fd 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -234,11 +234,29 @@ def _class_descriptor(cls: type, name: str) -> bool: if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Awaitable from typing import ParamSpec, overload + import httpx + from openai.pagination import AsyncCursorPage + from openai.types.beta.assistant import Assistant + from openai.types.beta.assistant_deleted import AssistantDeleted + from openai.types.beta.threads.message import Message as OpenAIMessage + from openai.types.file_deleted import FileDeleted + + from litellm.files.types import FileContentStreamingResult from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper - from litellm.types.llms.openai import AllMessageValues, OpenAIFileObject - from litellm.types.utils import EmbeddingResponse, LiteLLMBatch, ModelResponse + from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator + from litellm.types.decisions import DecisionsResponse, OpenAIDecisionResponse + from litellm.types.llms.openai import ( + AllMessageValues, + HttpxBinaryResponseContent, + OpenAIFileObject, + ResponsesAPIResponse, + Thread, + ) + from litellm.types.utils import EmbeddingResponse, LiteLLMBatch, LiteLLMFineTuningJob, ModelResponse + from litellm.types.vector_stores import VectorStoreCreateResponse _P: Final = ParamSpec("_P") @@ -281,6 +299,28 @@ if TYPE_CHECKING: async def acreate_file(self, model: str, **kwargs: object) -> OpenAIFileObject: ... # pyright: ignore[reportIncompatibleMethodOverride] # see acompletion async def acreate_batch(self, model: str, **kwargs: object) -> LiteLLMBatch: ... # pyright: ignore[reportIncompatibleMethodOverride] # see acompletion async def aretrieve_batch(self, model: str | None = None, **kwargs: object) -> LiteLLMBatch: ... # pyright: ignore[reportIncompatibleMethodOverride] # see acompletion + + aget_assistants: Callable[..., Awaitable[AsyncCursorPage[Assistant]]] + acreate_assistants: Callable[..., Awaitable[Assistant]] + adelete_assistant: Callable[..., Awaitable[AssistantDeleted]] + acreate_thread: Callable[..., Awaitable[Thread]] + aget_thread: Callable[..., Awaitable[Thread]] + a_add_message: Callable[..., Awaitable[OpenAIMessage]] + aget_messages: Callable[..., Awaitable[AsyncCursorPage[OpenAIMessage]]] + arun_thread: Callable[..., Awaitable[object]] + adecisions: Callable[..., Awaitable[OpenAIDecisionResponse | DecisionsResponse]] + acreate_fine_tuning_job: Callable[..., Awaitable[LiteLLMFineTuningJob]] + aretrieve_fine_tuning_job: Callable[..., Awaitable[LiteLLMFineTuningJob]] + acancel_fine_tuning_job: Callable[..., Awaitable[LiteLLMFineTuningJob]] + alist_fine_tuning_jobs: Callable[..., Awaitable[object]] + alist_batches: Callable[..., Awaitable[Mapping[str, object]]] + acancel_batch: Callable[..., Awaitable[LiteLLMBatch]] + aget_responses: Callable[..., Awaitable[ResponsesAPIResponse | BaseResponsesAPIStreamingIterator]] + afile_content: Callable[..., Awaitable[HttpxBinaryResponseContent | FileContentStreamingResult]] + afile_delete: Callable[..., Awaitable[FileDeleted]] + avector_store_create: Callable[..., Awaitable[VectorStoreCreateResponse]] + allm_passthrough_route: Callable[..., Awaitable[httpx.Response | AsyncGenerator[bytes, bytes]]] + def __getattr__(self, name: str) -> Any: ... # the endpoints served by name else: diff --git a/litellm/router_api.py b/litellm/router_api.py index a75397ea43d..1b0f970a537 100644 --- a/litellm/router_api.py +++ b/litellm/router_api.py @@ -60,6 +60,8 @@ if TYPE_CHECKING: from litellm.messages.dispatch import MessagesResult from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator from litellm.router import Router + from litellm.router_rust.surface.model_list import HostedModelList + from litellm.router_rust.surface.registry import UntypedEntry from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter from litellm.router_strategy.auto_router.auto_router import AutoRouter from litellm.router_strategy.budget_limiter import RouterBudgetLimiting @@ -102,14 +104,25 @@ if TYPE_CHECKING: # Re-exported for litellm.types, which may not import router_utils. AttemptedFallbackTargets: TypeAlias = _AttemptedFallbackTargets + ModelGroupAliases: TypeAlias = dict[str, str | RouterModelGroupAliasItem] + RoutingGroups: TypeAlias = dict[str, RoutingGroup] + AccessGroups: TypeAlias = dict[str, list[str]] + AutoRouters: TypeAlias = dict[str, list[TaggedPreRoutingStrategy[AutoRouter]]] + ComplexityRouters: TypeAlias = dict[str, list[TaggedPreRoutingStrategy[ComplexityRouter]]] + QualityRouters: TypeAlias = dict[str, list[TaggedPreRoutingStrategy[QualityRouter]]] + AdaptiveRouters: TypeAlias = dict[str, list[TaggedPreRoutingStrategy[AdaptiveRouter]]] + SwitchyardRouters: TypeAlias = dict[str, list[TaggedPreRoutingStrategy[SwitchyardRouter]]] + RoutingPlugins: TypeAlias = list[RoutingPlugin] + ModelNameSet: TypeAlias = set[str] + # What registry().plugins(kind) returns, one member per kind. RegistryPlugins: TypeAlias = ( - dict[str, list[TaggedPreRoutingStrategy[AutoRouter]]] - | dict[str, list[TaggedPreRoutingStrategy[ComplexityRouter]]] - | dict[str, list[TaggedPreRoutingStrategy[QualityRouter]]] - | dict[str, list[TaggedPreRoutingStrategy[AdaptiveRouter]]] - | dict[str, list[TaggedPreRoutingStrategy[SwitchyardRouter]]] - | list[RoutingPlugin] + AutoRouters + | ComplexityRouters + | QualityRouters + | AdaptiveRouters + | SwitchyardRouters + | RoutingPlugins | RouterBudgetLimiting | DualCache | None @@ -351,6 +364,220 @@ def _json_default_stable_id(obj: object) -> str: return stable_id(obj) +class _RegistryHost(Protocol): + """The host registry members ``RouterRegistry`` reads, typed as the view hands them on.""" + + @property + def _registry_version(self) -> int: ... + + @property + def _core_model_list(self) -> "HostedModelList": ... + + @property + def _core_model_group_alias(self) -> "ModelGroupAliases": ... + + @property + def _core_routing_groups(self) -> "RoutingGroups": ... + + @property + def _core_model_names(self) -> AbstractSet[str]: ... + + @property + def _core_deployment_names(self) -> Sequence[str]: ... + + @property + def _core_team_public_model_names(self) -> frozenset[str]: ... + + @property + def _core_model_name_to_deployment_indices(self) -> Mapping[str, Sequence[int]]: ... + + @property + def _core_team_model_to_deployment_indices(self) -> Mapping[tuple[str, str], Sequence[int]]: ... + + @property + def _core_pattern_router(self) -> "PatternMatchRouter": ... + + @property + def _core_team_pattern_routers(self) -> "Mapping[str, PatternMatchRouter]": ... + + @property + def _core_cached_deployment_model_info(self) -> "_lru_cache_wrapper[ModelMapInfo | None]": ... + + @property + def _core_auto_routers(self) -> "AutoRouters": ... + + @property + def _core_complexity_routers(self) -> "ComplexityRouters": ... + + @property + def _core_quality_routers(self) -> "QualityRouters": ... + + @property + def _core_adaptive_routers(self) -> "AdaptiveRouters": ... + + @property + def _core_switchyard_routers(self) -> "SwitchyardRouters": ... + + @property + def _core_routing_plugins(self) -> "RoutingPlugins": ... + + @property + def _core_prompt_caching_pin_cache(self) -> "DualCache": ... + + def _core_get_deployment(self, model_id: str) -> "Deployment | None": ... + + def _core_has_model_id(self, candidate_id: str) -> bool: ... + + def _core_get_model_info(self, id: str) -> "UntypedEntry | None": ... + + def _core_get_deployment_model_info(self, model_id: str, model_name: str) -> "ModelMapInfo | None": ... + + def _core_get_router_model_info( + self, + deployment: "Mapping[str, object] | Deployment | None", + received_model_name: str, + id: str | None = None, + ) -> "ModelMapInfo": ... + + def _core_get_discovered_model_info(self, model_id: str | None) -> Mapping[str, int]: ... + + def _core_get_model_list( + self, model_name: str | None = None, team_id: str | None = None + ) -> Sequence[Mapping[str, object]] | None: ... + + def _core_config_deployments(self) -> Iterator[Mapping[str, object]]: ... + + def _core_get_model_list_from_model_alias( + self, model_name: str | None = None + ) -> Sequence[Mapping[str, object]]: ... + + def _core_get_model_list_from_routing_groups( + self, model_name: str | None = None + ) -> Sequence[Mapping[str, object]]: ... + + def _core_get_all_deployments( + self, model_name: str, model_alias: str | None = None, team_id: str | None = None + ) -> Sequence[Mapping[str, object]]: ... + + def _core_deployments_named(self, model_name: str) -> tuple[Mapping[str, object], ...] | None: ... + + def _core_get_model_list_of_routed_group( + self, model_group: str, team_id: str | None = None + ) -> Sequence[Mapping[str, object]]: ... + + def _core_wildcard_route_deployments(self, model: str, team_id: str | None) -> Sequence[Mapping[str, object]]: ... + + def _core_get_routable_deployments( + self, model: str, request_kwargs: Mapping[str, object], call_type: str | None = None + ) -> tuple[Mapping[str, object], ...]: ... + + def _core_get_deployment_by_model_group_name(self, model_group_name: str) -> "Deployment | None": ... + + def _core_deployments_for_request( + self, model: str, request_kwargs: Mapping[str, object] + ) -> Sequence[Mapping[str, object]]: ... + + def _core_should_include_deployment( + self, model_name: str, model: Mapping[str, object], team_id: str | None = None + ) -> bool: ... + + def _core_get_model_names(self, team_id: str | None = None) -> Sequence[str]: ... + + def _core_get_model_ids( + self, model_name: str | None = None, exclude_team_models: bool = False + ) -> Sequence[str]: ... + + def team_public_model_names_of(self, team_id: object) -> frozenset[str]: ... + + def _core_resolved_litellm_models( + self, model_name: str, team_id: str | None = None, *, use_base_model: bool = False + ) -> tuple[str, ...]: ... + + def _core_wildcard_routed_models(self, model: str, team_id: str | None) -> frozenset[str]: ... + + def _core_get_candidate_model_ids_for_route(self, model: str, team_id: str | None = None) -> frozenset[str]: ... + + def _core_resolve_model_name_from_model_id( + self, model_id: str | None, custom_llm_provider: str | None = None + ) -> str | None: ... + + def _core_map_team_model( + self, team_model_name: str | None, team_id: str, custom_llm_provider: str | None = None + ) -> str | None: ... + + def _core_routing_group_has_alternatives(self, model_group: str | None) -> bool: ... + + def _core_routable_model_group(self, model_name: str) -> str: ... + + def _core_get_model_from_alias(self, model: str) -> str | None: ... + + def _core_resolve_served_model_name(self, model_name: str, served_model: str) -> str: ... + + def _core_is_recognized_model(self, model: str) -> bool: ... + + def _core_get_model_group(self, id: str) -> Sequence[Mapping[str, object]] | None: ... + + def _core_is_model_access_group_for_wildcard_route(self, model_access_group: str) -> bool: ... + + def _core_team_model_has_alternatives(self, deployment_id: str) -> bool: ... + + def _core_get_routable_upstream_model(self, model_name: str, team_id: str | None = None) -> str | None: ... + + def _core_get_routable_upstream_provider(self, model_name: str, team_id: str | None = None) -> str | None: ... + + def _core_get_search_tool_support(self, model_name: str, team_id: str | None = None) -> tuple[bool | None, ...]: ... + + def _core_get_model_group_info( + self, model_group: str, *, include_hidden: bool = False, team_id: str | None = None + ) -> "ModelGroupInfo | None": ... + + def _core_cached_model_group_info(self, model_group: str) -> "ModelGroupInfo | None": ... + + def _core_get_configured_display_name(self, model_name: str) -> str | None: ... + + def _core_get_configured_mode(self, model_name: str) -> str | None: ... + + def _core_get_configured_service_tiers(self, model_name: str, team_id: str | None = None) -> tuple[object, ...]: ... + + def _core_get_pinned_reasoning_efforts(self, model_name: str, team_id: str | None = None) -> tuple[object, ...]: ... + + def _core_get_configured_max_input_tokens( + self, model_name: str, team_id: str | None = None + ) -> tuple[int | None, ...]: ... + + def _core_get_routable_deployment_model_info( + self, model_name: str, team_id: str | None = None + ) -> "tuple[ModelMapInfo | None, ...]": ... + + def _core_get_configured_token_limits(self, model_name: str) -> tuple[int | None, int | None]: ... + + def _core_get_model_listing_info(self, model_name: str) -> "DeploymentModelListingInfo | None": ... + + def get_routing_group(self, model_name: str) -> "RoutingGroup | None": ... + + def _get_client( + self, deployment: Mapping[str, object], kwargs: Mapping[str, object], client_type: str | None = None + ) -> object: ... + + def _core_get_model_access_groups( + self, model_name: str | None = None, model_access_group: str | None = None, team_id: str | None = None + ) -> "AccessGroups": ... + + def _core_get_deployment_credentials(self, model_id: str) -> Mapping[str, object] | None: ... + + def _core_get_deployment_credentials_with_provider( + self, model_id: str, team_id: str | None = None + ) -> Mapping[str, object] | None: ... + + def _core_get_credential_deployment(self, model_id: str, team_id: str | None = None) -> "Deployment | None": ... + + def _core_get_router_deployment_budget_limiter(self) -> "RouterBudgetLimiting | None": ... + + def _core_decryptable_reasoning( + self, model_group: str, request_input: Sequence[object], targets: "Sequence[Deployment]" + ) -> tuple[object, ...]: ... + + class RouterRegistry: """ Read-only queries over the configured deployments (``registry()``). @@ -361,7 +588,7 @@ class RouterRegistry: __slots__ = ("_router",) - def __init__(self, router: "Router") -> None: + def __init__(self, router: _RegistryHost) -> None: self._router = router @property @@ -407,12 +634,8 @@ class RouterRegistry: id: str | None = None, ) -> "ModelMapInfo": """Model info used for routing decisions (was ``get_router_model_info``).""" - r: Final = self._router - # The core's overloads split (deployment, id) by which one is None; this forwards either form unchanged. - return r._core_get_router_model_info( # pyright: ignore[reportCallIssue] # see above - deployment=deployment, # pyright: ignore[reportArgumentType] # see above - received_model_name=received_model_name, - id=id, # pyright: ignore[reportArgumentType] # see above + return self._router._core_get_router_model_info( + deployment=deployment, received_model_name=received_model_name, id=id ) def discovered_model_info(self, id: str | None) -> Mapping[str, int]: @@ -1316,12 +1539,52 @@ class RouterSettings: return self._owner("_core_deployment_call_timeout")._core_deployment_call_timeout(litellm_params) +class _HealthHost(Protocol): + """The router members ``RouterHealth`` reads, typed as the view hands them on.""" + + @property + def _core_cache(self) -> "DualCache": ... + + @property + def _core_cooldown_cache(self) -> "CooldownCache": ... + + @property + def _core_health_state_cache(self) -> "DeploymentHealthCache": ... + + @property + def _core_deployment_latency_map(self) -> "UntypedDict": ... + + @property + def _core_ctx(self) -> "RoutingContext": ... + + @property + def _core_cooldown_time(self) -> float | None: ... + + def _core_get_fully_blocked_model_names(self) -> "ModelNameSet": ... + + async def _core_async_get_fully_unhealthy_model_names(self) -> "ModelNameSet": ... + + async def _core_get_model_group_usage(self, model_group: str) -> tuple[int | None, int | None]: ... + + async def _core_get_remaining_model_group_usage(self, model_group: str) -> Mapping[str, int]: ... + + def _core_team_ptu_ceiling(self, team_id: str, model_group: str) -> "PTUTeamCeiling | None": ... + + def _core_split_request_priority( + self, model: str, kwargs: Mapping[str, object] + ) -> tuple[int | None, Mapping[str, object]]: ... + + def priority_header_update( + self, model: str, kwargs: Mapping[str, object], header: str | None + ) -> Mapping[str, int | str]: ... + + class RouterHealth: """Cooldowns, blocked deployments, health checks and usage (``health()``).""" __slots__ = ("_router",) - def __init__(self, router: "Router") -> None: + def __init__(self, router: _HealthHost) -> None: self._router = router @property diff --git a/litellm/router_rust/invoke/streaming.py b/litellm/router_rust/invoke/streaming.py index 9286e9aa06c..b1de96fd91b 100644 --- a/litellm/router_rust/invoke/streaming.py +++ b/litellm/router_rust/invoke/streaming.py @@ -42,6 +42,7 @@ from collections.abc import ( Mapping, Sequence, ) +from collections.abc import Set as AbstractSet from typing import ( TYPE_CHECKING, Final, @@ -629,8 +630,8 @@ def continuation_routing(core: "RustCore", request: Mapping[str, object]) -> Map registry: Final = core.registry() excluded: Final = [ deployment.model_info.id - for record in registry.all() - if (deployment := registry.deployment(str(_record_id(record)))) is not None + for record_id in _record_ids(registry.all()) + if (deployment := registry.deployment(str(record_id))) is not None and not deployment_can_continue(deployment.model_dump(exclude_none=True)) ] return {"body": {CONTINUATION_EXCLUDED_IDS: excluded}} @@ -641,13 +642,21 @@ def _record_id(record: Mapping[str, object]) -> object: return model_info.get("id") if isinstance(model_info, Mapping) else None # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # a plain record +def _record_ids(records: Iterable[Mapping[str, object]]) -> Iterator[object]: + return (_record_id(record) for record in records) + + +def _record_model_names(records: Iterable[Mapping[str, object]]) -> AbstractSet[object]: + return {record.get("model_name") for record in records} + + def _names_deployment_that_cannot_continue(core: "RustCore", target: object) -> bool: from litellm.router_utils.fallback_event_handlers import fallback_target_name from litellm.router_utils.pre_call_checks.continuation_prefill_check import deployment_can_continue name: Final = fallback_target_name(target) registry: Final = core.registry() - model_names: Final = {record.get("model_name") for record in registry.all()} + model_names: Final = _record_model_names(registry.all()) if name is None or name in model_names or not registry.has_deployment(name): return False deployment: Final = registry.deployment(name) diff --git a/litellm/router_rust/surface/legacy_members.py b/litellm/router_rust/surface/legacy_members.py index 046501f38f3..640fdf739c0 100644 --- a/litellm/router_rust/surface/legacy_members.py +++ b/litellm/router_rust/surface/legacy_members.py @@ -289,8 +289,8 @@ class RustLegacyMembers(RustRegistryMembers, RustOptionalChecks): validate_routing_strategy(routing_strategy) # pyright: ignore[reportArgumentType] # the strategy as given # `_reset_custom_routing_strategy`. self._custom_strategy = None - _ = self.__dict__.pop("get_available_deployment", None) - _ = self.__dict__.pop("_core_async_get_available_deployment", None) + self.__dict__.pop("get_available_deployment", None) + self.__dict__.pop("_core_async_get_available_deployment", None) self._sel.init_default(routing_strategy, routing_strategy_args, configured=False) def set_optional_pre_call_checks(self, optional_pre_call_checks: Iterable[object] | None) -> None: @@ -301,7 +301,7 @@ class RustLegacyMembers(RustRegistryMembers, RustOptionalChecks): """``initialize_assistants_endpoint`` binds the endpoints; this core answers them from ``__getattr__``, so what is left to do is to drop an assigned replacement.""" for name in _ASSISTANTS_ENDPOINTS: - _ = self.__dict__.pop(name, None) + self.__dict__.pop(name, None) def initialize_router_endpoints(self) -> None: """``initialize_router_endpoints``, as ``initialize_assistants_endpoint`` above.""" @@ -310,7 +310,7 @@ class RustLegacyMembers(RustRegistryMembers, RustOptionalChecks): ) for name in (*_ROUTER_ENDPOINTS, *_generated_endpoints): - _ = self.__dict__.pop(name, None) + self.__dict__.pop(name, None) # -- pre-routing strategies -------------------------------------------------------------------- diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index c3b038b5f26..094c61e0550 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -103,7 +103,7 @@ class QualityRouter(CustomLogger): self._tier_to_models_cache = self._build_tier_index() return self._tier_to_models_cache - def _get_routing_preferences(self, deployment: object) -> dict[str, Any] | None: + def _get_routing_preferences(self, deployment: object) -> dict[str, object] | None: """ Extract litellm_routing_preferences from a deployment, handling both dict-shaped and Pydantic-object-shaped deployments. diff --git a/litellm/router_utils/cooldown_callbacks.py b/litellm/router_utils/cooldown_callbacks.py index 9e90b72ba1b..09bbeb92836 100644 --- a/litellm/router_utils/cooldown_callbacks.py +++ b/litellm/router_utils/cooldown_callbacks.py @@ -42,8 +42,7 @@ async def router_cooldown_event_callback( temp_litellm_params = dict(temp_litellm_params) _model_name: Final = _deployment.model_name or "" _api_base: Final = litellm.get_api_base(model=_model_name, optional_params=temp_litellm_params) or "" - model_info: Final = _deployment["model_info"] - model_id: Final = model_info.id + model_id: Final = _deployment.model_info.id litellm_model_name: Final = temp_litellm_params.get("model") or "" llm_provider = "" @@ -68,7 +67,7 @@ async def router_cooldown_event_callback( prometheusLogger.increment_deployment_cooled_down( litellm_model_name=_model_name, - model_id=model_id, + model_id=str(model_id), api_base=_api_base, api_provider=llm_provider, exception_status=str(exception_status), diff --git a/litellm/router_utils/cooldown_handlers.py b/litellm/router_utils/cooldown_handlers.py index 22bec9e1ead..9e3e0189fd0 100644 --- a/litellm/router_utils/cooldown_handlers.py +++ b/litellm/router_utils/cooldown_handlers.py @@ -149,7 +149,7 @@ _EXCEPTION_POLICY_FIELDS: Final[tuple[tuple[type, str], ...]] = ( ) -def _first_present(*sources: Mapping[str, Any] | None, key: str) -> int | float | None: +def _first_present(*sources: Mapping[str, object] | None, key: str) -> int | float | None: """Return *key* from the first source mapping where it's set, so callers can support a setting living in more than one deployment config location. Sources are checked in order from most to least specific to that setting. Only a number diff --git a/litellm/secret_managers/secret_manager_handler.py b/litellm/secret_managers/secret_manager_handler.py index f1f38c384cc..afed7532d74 100644 --- a/litellm/secret_managers/secret_manager_handler.py +++ b/litellm/secret_managers/secret_manager_handler.py @@ -7,22 +7,13 @@ Handles retrieving secrets from different secret management systems. import base64 import os from collections.abc import Mapping -from typing import Any, Final, Generic, Protocol, TypeVar - -from typing_extensions import ReadOnly, TypedDict +from typing import Final, Protocol import litellm from litellm._logging import print_verbose +from litellm.litellm_core_utils.core_helpers import require_attributes from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem -_ClientT = TypeVar("_ClientT") - - -class _SecretManagerClientView(TypedDict, Generic[_ClientT]): - """Typed read of the untyped secret manager handle configured for this key manager.""" - - client: ReadOnly[_ClientT] - class _AzureKeyVaultSecret(Protocol): @property @@ -74,7 +65,7 @@ def _is_base64(s: str) -> bool: def get_secret_from_manager( - client: Any, + client: object, key_manager: str, secret_name: str, key_management_settings: KeyManagementSettings | None = None, @@ -96,16 +87,14 @@ def get_secret_from_manager( Exception: For other errors during secret retrieval """ secret = None - raw_view: Final[_SecretManagerClientView[object]] = {"client": client} - client_object: Final = raw_view["client"] + client_object: Final = client if ( key_manager == KeyManagementSystem.AZURE_KEY_VAULT.value or type(client_object).__module__ + "." + type(client_object).__name__ == "azure.keyvault.secrets._client.SecretClient" ): # support Azure Secret Client - from azure.keyvault.secrets import SecretClient - azure_view: Final[_SecretManagerClientView[_AzureKeyVaultClient]] = {"client": client} - azure_client: Final = azure_view["client"] + azure_client: Final = require_attributes(client, _AzureKeyVaultClient, "get_secret") secret = azure_client.get_secret(secret_name).value elif ( @@ -122,8 +111,7 @@ def get_secret_from_manager( raise ValueError( "Google KMS requires the encrypted secret to be encoded in base64" ) # fix for this vulnerability https://huntr.com/bounties/ae623c2f-b64b-4245-9ed4-f13a0a5824ce - google_kms_view: Final[_SecretManagerClientView[_GoogleKmsClient]] = {"client": client} - google_kms_client: Final = google_kms_view["client"] + google_kms_client: Final = require_attributes(client, _GoogleKmsClient, "decrypt") google_kms_response: Final = google_kms_client.decrypt( request={ "name": litellm._google_kms_resource_name, @@ -143,8 +131,7 @@ def get_secret_from_manager( ciphertext_blob: Final = base64.b64decode(encrypted_value) # Perform the decryption - aws_kms_view: Final[_SecretManagerClientView[_AwsKmsClient]] = {"client": client} - aws_kms_client: Final = aws_kms_view["client"] + aws_kms_client: Final = require_attributes(client, _AwsKmsClient, "decrypt") aws_kms_response: Final = aws_kms_client.decrypt(CiphertextBlob=ciphertext_blob) # Extract and decode the plaintext @@ -171,8 +158,9 @@ def get_secret_from_manager( elif key_manager == KeyManagementSystem.GOOGLE_SECRET_MANAGER.value: try: - google_secret_manager_view: Final[_SecretManagerClientView[_GoogleSecretManagerClient]] = {"client": client} - google_secret_manager_client: Final = google_secret_manager_view["client"] + google_secret_manager_client: Final = require_attributes( + client, _GoogleSecretManagerClient, "get_secret_from_google_secret_manager" + ) secret = google_secret_manager_client.get_secret_from_google_secret_manager(secret_name) print_verbose(f"secret from google secret manager: [set={secret is not None}]") if secret is None: @@ -183,8 +171,7 @@ def get_secret_from_manager( elif key_manager == KeyManagementSystem.HASHICORP_VAULT.value: try: - hashicorp_view: Final[_SecretManagerClientView[_SyncSecretReader]] = {"client": client} - hashicorp_client: Final = hashicorp_view["client"] + hashicorp_client: Final = require_attributes(client, _SyncSecretReader, "sync_read_secret") secret = hashicorp_client.sync_read_secret(secret_name=secret_name) if secret is None: raise ValueError(f"No secret found in Hashicorp Secret Manager for {secret_name}") @@ -194,8 +181,7 @@ def get_secret_from_manager( elif key_manager == KeyManagementSystem.CYBERARK.value: try: - cyberark_view: Final[_SecretManagerClientView[_SyncSecretReader]] = {"client": client} - cyberark_client: Final = cyberark_view["client"] + cyberark_client: Final = require_attributes(client, _SyncSecretReader, "sync_read_secret") secret = cyberark_client.sync_read_secret(secret_name=secret_name) if secret is None: raise ValueError(f"No secret found in CyberArk Secret Manager for {secret_name}") @@ -224,8 +210,7 @@ def get_secret_from_manager( secret = os.getenv(secret_name) else: # assume the default is infisicial client - infisical_view: Final[_SecretManagerClientView[_InfisicalClient]] = {"client": client} - infisical_client: Final = infisical_view["client"] + infisical_client: Final = require_attributes(client, _InfisicalClient, "get_secret") secret = infisical_client.get_secret(secret_name).secret_value return secret diff --git a/litellm/text_completions/streaming_iterator.py b/litellm/text_completions/streaming_iterator.py index daf65b77a85..517a499ec8c 100644 --- a/litellm/text_completions/streaming_iterator.py +++ b/litellm/text_completions/streaming_iterator.py @@ -1,11 +1,15 @@ -from typing import Final +from typing import Final, Protocol from litellm.constants import VLLM_CHOICE_FIELDS -from litellm.litellm_core_utils.core_helpers import decoded_json_object +from litellm.litellm_core_utils.core_helpers import decoded_json_object, json_contains, require_attributes, subscript from litellm.litellm_core_utils.stream_usage_chunks import is_injected_stream_usage_artifact from litellm.types.utils import Choices, ModelResponse, ModelResponseStream, TextChoices, TextCompletionResponse +class _SupportsGet(Protocol): + def get(self, key: str, /) -> object: ... + + class TextCompletionStreamWrapper: def __init__( self, @@ -43,16 +47,22 @@ class TextCompletionStreamWrapper: raise Exception if chunk["choices"]: - chat_choice: Final = chunk["choices"][0] + chat_choice: Final = subscript(subscript(chunk, "choices"), 0) choice_fields: Final = decoded_json_object( - {field: chat_choice[field] for field in VLLM_CHOICE_FIELDS if field in chat_choice} + { + field: subscript(chat_choice, field) + for field in VLLM_CHOICE_FIELDS + if json_contains(chat_choice, field) + } ) text_choices: Final = TextChoices(index=0, **choice_fields) - delta: Final = chat_choice["delta"] - text_choices["text"] = delta["content"] - text_choices["reasoning_content"] = delta.get("reasoning_content") - text_choices["index"] = chat_choice["index"] - text_choices["finish_reason"] = chat_choice["finish_reason"] + delta: Final = subscript(chat_choice, "delta") + text_choices["text"] = subscript(delta, "content") + text_choices["reasoning_content"] = require_attributes(delta, _SupportsGet, "get").get( + "reasoning_content" + ) + text_choices["index"] = subscript(chat_choice, "index") + text_choices["finish_reason"] = subscript(chat_choice, "finish_reason") response["choices"] = [text_choices] else: response["choices"] = [] diff --git a/litellm/utils.py b/litellm/utils.py index fb974b24283..7012741148c 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -95,7 +95,9 @@ from litellm.litellm_core_utils.core_helpers import ( bind_budget_reservation_to_callbacks, has_attributes, iter_items, + json_contains, normalize_drop_params, + require_attributes, unbind_budget_reservation_from_callbacks, ) from litellm.litellm_core_utils.fallback_generalizations import ( @@ -581,6 +583,7 @@ additional_details: Final[dict[str, str] | None] = {} local_cache: Final[dict[str, str] | None] = {} last_fetched_at: Final = None last_fetched_at_keys: Final = None +_OPAQUE_VALUE: Final = TypeAdapter(object) ######## Model Response ######################### # All liteLLM Model responses will be in this format, Follows the OpenAI Format @@ -765,14 +768,20 @@ def _is_same_registered_custom_logger(existing: object, callback_class: CustomLo ) -def get_request_guardrails(kwargs: dict[str, Any]) -> list[str]: +class _SupportsGet(Protocol): + def get(self, key: str, /) -> object: ... + + +def _metadata_field(metadata: object, key: str) -> object: + return require_attributes(metadata, _SupportsGet, "get").get(key) if metadata else None + + +def get_request_guardrails(kwargs: Mapping[str, object]) -> object: """ Get the request guardrails from the kwargs """ - metadata: Final = kwargs.get("metadata") or {} - requester_metadata: Final = metadata.get("requester_metadata") or {} - applied_guardrails: Final = requester_metadata.get("guardrails") or [] - return applied_guardrails + requester_metadata: Final = _metadata_field(kwargs.get("metadata"), "requester_metadata") + return _metadata_field(requester_metadata, "guardrails") or [] def get_applied_guardrails(kwargs: dict[str, object]) -> list[str]: @@ -787,7 +796,7 @@ def get_applied_guardrails(kwargs: dict[str, object]) -> list[str]: for callback in litellm.callbacks: if callback is not None and isinstance(callback, CustomGuardrail): if callback.guardrail_name is not None: - if callback.default_on is True or callback.guardrail_name in request_guardrails: + if callback.default_on is True or json_contains(request_guardrails, callback.guardrail_name): applied_guardrails.append(callback.guardrail_name) return applied_guardrails @@ -1431,7 +1440,7 @@ def function_setup( level="info", ) if "logger_fn" in kwargs: - user_logger_fn = kwargs["logger_fn"] + user_logger_fn = _OPAQUE_VALUE.validate_python(kwargs["logger_fn"]) # INIT LOGGER - for user-specified integrations model: Final = args[0] if len(args) > 0 else kwargs.get("model", None) call_type: Final = original_function @@ -1515,9 +1524,9 @@ def function_setup( elif call_type == CallTypes.embedding.value or call_type == CallTypes.aembedding.value: messages = args[1] if len(args) > 1 else kwargs.get("input", None) elif call_type == CallTypes.image_generation.value or call_type == CallTypes.aimage_generation.value: - messages = args[0] if len(args) > 0 else kwargs["prompt"] + messages = _OPAQUE_VALUE.validate_python(args[0] if len(args) > 0 else kwargs["prompt"]) elif call_type == CallTypes.moderation.value or call_type == CallTypes.amoderation.value: - messages = args[1] if len(args) > 1 else kwargs["input"] + messages = _OPAQUE_VALUE.validate_python(args[1] if len(args) > 1 else kwargs["input"]) elif call_type == CallTypes.atext_completion.value or call_type == CallTypes.text_completion.value: messages = args[0] if len(args) > 0 else kwargs.get("prompt") elif call_type == CallTypes.rerank.value or call_type == CallTypes.arerank.value: @@ -1544,10 +1553,10 @@ def function_setup( kwargs["metadata"] = {"file_checksum": file_checksum} messages = file_checksum elif call_type == CallTypes.aspeech.value or call_type == CallTypes.speech.value: - messages = kwargs.get("input", "speech") + messages = _OPAQUE_VALUE.validate_python(kwargs.get("input", "speech")) elif call_type == CallTypes.aresponses.value or call_type == CallTypes.responses.value: # Handle both 'input' (standard Responses API) and 'messages' (Cursor chat format) - messages = ( + messages = _OPAQUE_VALUE.validate_python( args[0] if len(args) > 0 else kwargs.get("input") or kwargs.get("messages", "default-message-value") ) elif ( @@ -1727,9 +1736,9 @@ def _schedule_async_success_logging( async def _client_async_logging_helper( logging_obj: LiteLLMLoggingObject, - result, - start_time, - end_time, + result: object, + start_time: datetime.datetime, + end_time: datetime.datetime, is_completion_with_fallbacks: bool, ): if ( @@ -1748,7 +1757,7 @@ async def _client_async_logging_helper( ) -def _get_wrapper_num_retries(kwargs: dict[str, Any], exception: Exception) -> tuple[int | None, dict[str, Any]]: +def _get_wrapper_num_retries(kwargs: dict[str, object], exception: Exception) -> tuple[object, dict[str, object]]: """ Get the number of retries from the kwargs and the retry policy. Used for the wrapper functions. @@ -1773,6 +1782,17 @@ def _get_wrapper_num_retries(kwargs: dict[str, Any], exception: Exception) -> tu return num_retries, kwargs +@runtime_checkable +class _ModelFallbackLookup(Protocol): + def __getitem__(self, model: str | None, /) -> object: ... + + +def _context_window_fallback_model(fallbacks: object, model: str | None) -> object: + if isinstance(fallbacks, _ModelFallbackLookup): + return fallbacks[model] + raise TypeError(f"'{type(fallbacks).__name__}' object is not subscriptable") + + def _get_wrapper_timeout(kwargs: dict[str, object]) -> object: from litellm.litellm_core_utils.litellm_logging import Logging @@ -2661,7 +2681,7 @@ def client(original_function): retry_call_type: Final = _get_call_type_from_public_name(original_function.__name__) num_retries, kwargs = _get_wrapper_num_retries(kwargs=kwargs, exception=e) if retry_call_type == CallTypes.acompletion.value: - context_window_fallback_dict: Final = kwargs.get("context_window_fallback_dict", {}) + context_window_fallback_dict: Final = kwargs.get("context_window_fallback_dict") is_acompletion_litellm_router_call: Final = _is_litellm_router_call(kwargs) @@ -2689,13 +2709,13 @@ def client(original_function): elif ( isinstance(e, litellm.exceptions.ContextWindowExceededError) and context_window_fallback_dict - and model in context_window_fallback_dict + and json_contains(context_window_fallback_dict, model) and not is_acompletion_litellm_router_call ): if len(args) > 0: - args[0] = context_window_fallback_dict[model] + args[0] = _context_window_fallback_model(context_window_fallback_dict, model) else: - kwargs["model"] = context_window_fallback_dict[model] + kwargs["model"] = _context_window_fallback_model(context_window_fallback_dict, model) result = await original_function(*args, **kwargs) return result elif retry_call_type == CallTypes.aresponses.value: @@ -4469,7 +4489,7 @@ def get_optional_params_transcription( passed_params.pop("custom_llm_provider") passed_params.pop("drop_params") normalized_drop_params: Final = normalize_drop_params(drop_params) - special_params: Final[Mapping[str, object]] = passed_params.pop("kwargs") + special_params: Final = as_str_mapping(_OPAQUE_VALUE.validate_python(passed_params.pop("kwargs"))) or {} from litellm.litellm_core_utils.audio_utils.transcription_streaming import ( transcription_stream_flag, transcription_stream_unset, @@ -4494,9 +4514,10 @@ def get_optional_params_transcription( message="`stream` must be a boolean. To drop it from the call, set `litellm.drop_params = True`.", ) + passed_entries: Final[Mapping[str, object]] = MappingProxyType(passed_params) non_default_params: Final = { k: v - for k, v in passed_params.items() + for k, v in passed_entries.items() if k in default_params and v != default_params[k] and not (k == "stream" and (stream_unset or malformed_stream)) } optional_params = {} @@ -4735,7 +4756,7 @@ def get_optional_params_embeddings( # retrieve all parameters passed to the function passed_params: Final = locals() passed_params.pop("custom_llm_provider", None) - special_params: Final = passed_params.pop("kwargs") + special_params: Final = as_str_mapping(_OPAQUE_VALUE.validate_python(passed_params.pop("kwargs"))) or {} passed_params.pop("drop_params", None) drop_params = normalize_drop_params(drop_params) @@ -5196,7 +5217,7 @@ class PreProcessNonDefaultParams: @staticmethod def base_pre_process_non_default_params( passed_params: dict, - special_params: dict, + special_params: Mapping[str, object], custom_llm_provider: str, additional_drop_params: list[str] | None, default_param_values: dict, @@ -5241,7 +5262,7 @@ class PreProcessNonDefaultParams: @staticmethod def embedding_pre_process_non_default_params( passed_params: dict, - special_params: dict, + special_params: Mapping[str, object], custom_llm_provider: str, additional_drop_params: list[str] | None, model: str, @@ -5262,7 +5283,7 @@ class PreProcessNonDefaultParams: def pre_process_non_default_params( passed_params: dict, - special_params: dict, + special_params: Mapping[str, object], custom_llm_provider: str, additional_drop_params: list[str] | None, model: str, @@ -7585,7 +7606,8 @@ def get_model_info_helper( token_count_tokenizer=_model_info.get("token_count_tokenizer", None), vertex_ai_audio_api=_model_info.get("vertex_ai_audio_api", None), ) - for cost_key, cost_value in _model_info.items(): + cost_entries: Final[Mapping[str, object]] = MappingProxyType(_model_info) + for cost_key, cost_value in cost_entries.items(): if cost_key not in returned_model_info and _ABOVE_THRESHOLD_COST_KEY.search(cost_key) is not None: returned_model_info[cost_key] = cost_value return returned_model_info @@ -7805,7 +7827,7 @@ def function_to_dict(input_function) -> dict: # may represent a set of acceptable values # translating as enum for function calling try: - param_enum = str(list(literal_eval(param_type))) + param_enum = str(list(iter_items(_OPAQUE_VALUE.validate_python(literal_eval(param_type))))) param_type = "string" except Exception: pass @@ -10774,7 +10796,7 @@ class ProviderConfigManager: @staticmethod def get_provider_passthrough_config( model: str, - provider: LlmProviders, + provider: LlmProviders | str, ) -> BasePassthroughConfig | None: if LlmProviders.BEDROCK == provider: from litellm.llms.bedrock.passthrough.transformation import ( diff --git a/litellm/vector_stores/vector_store_registry.py b/litellm/vector_stores/vector_store_registry.py index b83c9ef48b1..2adaf17ac44 100644 --- a/litellm/vector_stores/vector_store_registry.py +++ b/litellm/vector_stores/vector_store_registry.py @@ -1,6 +1,6 @@ # litellm/proxy/vector_stores/vector_store_registry.py import json -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from datetime import datetime, timezone from itertools import chain from typing import ( @@ -12,6 +12,7 @@ from typing import ( ) from pydantic import ConfigDict, TypeAdapter, ValidationError +from typing_extensions import TypedDict from litellm._logging import verbose_logger from litellm.litellm_core_utils.core_helpers import remove_items_at_indices @@ -39,6 +40,14 @@ _TOOL: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(strict=True)) _STORE_ID_ITEMS: Final = TypeAdapter(list[object] | tuple[object, ...], config=ConfigDict(strict=True)) +class _ManagedVectorStoreRowFields(TypedDict, total=False, extra_items=object): + pass + + +def _row_fields(row: Iterable[tuple[str, object]]) -> Mapping[str, object]: + return dict(row) + + def _named_store_ids(store_ids: object) -> tuple[str, ...]: if isinstance(store_ids, str): return (store_ids,) @@ -538,8 +547,10 @@ class VectorStoreRegistry: order={"created_at": "desc"}, ) for vector_store in _vector_stores_from_db: - _dict_vector_store = dict(vector_store) - _litellm_managed_vector_store = LiteLLM_ManagedVectorStore(**_dict_vector_store) + _dict_vector_store = _row_fields(vector_store) + _litellm_managed_vector_store = LiteLLM_ManagedVectorStore( + **_ManagedVectorStoreRowFields(**_dict_vector_store) + ) vector_stores_from_db.append(_litellm_managed_vector_store) return vector_stores_from_db diff --git a/litellm/videos/main.py b/litellm/videos/main.py index 66eb10e30db..f2681b03cb2 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -6,10 +6,13 @@ from functools import partial from typing import Final, Literal, overload from httpx._types import FileContent +from pydantic import TypeAdapter +from typing_extensions import TypedDict import litellm from litellm.constants import DEFAULT_VIDEO_ENDPOINT_MODEL from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT +from litellm.litellm_core_utils.core_helpers import iter_items, keyword_arguments from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.videos.transformation import BaseVideoConfig @@ -26,6 +29,16 @@ from litellm.types.videos.utils import decode_video_id_with_provider from litellm.utils import ProviderConfigManager, client from litellm.videos.utils import VideoGenerationRequestUtils +_DECODED_JSON: Final = TypeAdapter(object) + + +class _VideoObjectFields(TypedDict, total=False, extra_items=object): + pass + + +class _CharacterObjectFields(TypedDict, total=False, extra_items=object): + pass + def _unsupported_video_call( call_name: str, custom_llm_provider: str, model: str | None = None @@ -204,9 +217,11 @@ def video_generation( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) - response: Final = VideoObject(**mock_response) + response: Final = VideoObject.model_validate( + _VideoObjectFields(**keyword_arguments(mock_response, VideoObject)) + ) return response # get llm provider logic @@ -593,9 +608,11 @@ def video_remix( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) - response: Final = VideoObject(**mock_response) + response: Final = VideoObject.model_validate( + _VideoObjectFields(**keyword_arguments(mock_response, VideoObject)) + ) return response # Try to decode provider from video_id if not explicitly provided @@ -808,8 +825,11 @@ def video_list( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) - return [VideoObject(**item) for item in mock_response] + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) + return [ + VideoObject.model_validate(_VideoObjectFields(**keyword_arguments(item, VideoObject))) + for item in iter_items(mock_response) + ] # Ensure custom_llm_provider is not None - default to openai if not provided if custom_llm_provider is None: @@ -1026,9 +1046,11 @@ def video_status( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) - response: Final = VideoObject(**mock_response) + response: Final = VideoObject.model_validate( + _VideoObjectFields(**keyword_arguments(mock_response, VideoObject)) + ) return response # Try to decode provider from video_id if not explicitly provided @@ -1173,8 +1195,10 @@ def video_create_character( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) - return CharacterObject(**mock_response) + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) + return CharacterObject.model_validate( + _CharacterObjectFields(**keyword_arguments(mock_response, CharacterObject)) + ) if custom_llm_provider is None: custom_llm_provider = "openai" @@ -1298,8 +1322,10 @@ def video_get_character( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) - return CharacterObject(**mock_response) + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) + return CharacterObject.model_validate( + _CharacterObjectFields(**keyword_arguments(mock_response, CharacterObject)) + ) if custom_llm_provider is None: custom_llm_provider = "openai" @@ -1430,8 +1456,8 @@ def video_edit( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) - return VideoObject(**mock_response) + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) + return VideoObject.model_validate(_VideoObjectFields(**keyword_arguments(mock_response, VideoObject))) if custom_llm_provider is None: decoded: Final = decode_video_id_with_provider(video_id) @@ -1564,8 +1590,8 @@ def video_extension( mock_response = kwargs.get("mock_response", None) if mock_response is not None: if isinstance(mock_response, str): - mock_response = json.loads(mock_response) - return VideoObject(**mock_response) + mock_response = _DECODED_JSON.validate_python(json.loads(mock_response)) + return VideoObject.model_validate(_VideoObjectFields(**keyword_arguments(mock_response, VideoObject))) if custom_llm_provider is None: decoded: Final = decode_video_id_with_provider(video_id) diff --git a/tests/unit/litellm_core_utils/test_streaming_handler.py b/tests/unit/litellm_core_utils/test_streaming_handler.py index beb1b58ec27..793c40bcf83 100644 --- a/tests/unit/litellm_core_utils/test_streaming_handler.py +++ b/tests/unit/litellm_core_utils/test_streaming_handler.py @@ -9935,3 +9935,134 @@ def test_chunk_creator_usage_only_chunk_with_dict_usage_keeps_details( assert usage.prompt_tokens_details.cached_tokens == 80 assert usage.completion_tokens_details is not None assert usage.completion_tokens_details.reasoning_tokens == 5 + + +def _stream_of(chunks: Sequence[object], sync_mode: bool) -> Iterator[object] | AsyncIterator[object]: + return iter(chunks) if sync_mode else _as_async(iter(chunks)) + + +def _streamed_text_and_finish_reasons(received: Sequence[ModelResponseStream]) -> tuple[str, list[str]]: + return ( + "".join(chunk.choices[0].delta.content or "" for chunk in received if chunk.choices), + [chunk.choices[0].finish_reason for chunk in received if chunk.choices and chunk.choices[0].finish_reason], + ) + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_predibase_byte_stream_yields_token_text_then_the_reported_finish_reason( + sync_mode: bool, logging_obj: Logging +) -> None: + lines: Final = ( + b'data: {"token": {"id": 1, "text": "Hel"}}', + b'data: {"token": {"id": 2, "text": "lo"}, "details": {"finish_reason": "length"}}', + ) + wrapper: Final = CustomStreamWrapper( + completion_stream=_stream_of(lines, sync_mode), + model="llama-3-1-8b-instruct", + custom_llm_provider="predibase", + logging_obj=logging_obj, + ) + + received: Final = await _collect(wrapper, sync_mode) + + assert _streamed_text_and_finish_reasons(received) == ("Hello", ["length"]) + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_predibase_error_event_fails_the_stream_with_the_upstream_message( + sync_mode: bool, logging_obj: Logging +) -> None: + lines: Final = ( + b'data: {"token": {"id": 1, "text": "Hel"}}', + b'data: {"error": "Request failed during generation: CUDA out of memory"}', + ) + wrapper: Final = CustomStreamWrapper( + completion_stream=_stream_of(lines, sync_mode), + model="llama-3-1-8b-instruct", + custom_llm_provider="predibase", + logging_obj=logging_obj, + ) + + with pytest.raises(MidStreamFallbackError) as raised: + await _collect(wrapper, sync_mode) + + assert "CUDA out of memory" in str(raised.value) + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_nlp_cloud_stream_drops_the_done_marker_and_finishes_with_stop( + sync_mode: bool, logging_obj: Logging +) -> None: + lines: Final = ('{"generated_text": "Hello"}', '{"generated_text": " world[DONE]"}') + wrapper: Final = CustomStreamWrapper( + completion_stream=_stream_of(lines, sync_mode), + model="finetuned-llama-3-70b", + custom_llm_provider="nlp_cloud", + logging_obj=logging_obj, + ) + + received: Final = await _collect(wrapper, sync_mode) + + assert _streamed_text_and_finish_reasons(received) == ("Hello world", ["stop"]) + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_aleph_alpha_stream_yields_the_completion_text_and_stops(sync_mode: bool, logging_obj: Logging) -> None: + lines: Final = (b'{"completions": [{"completion": " Paris", "finish_reason": "maximum_tokens"}]}',) + wrapper: Final = CustomStreamWrapper( + completion_stream=_stream_of(lines, sync_mode), + model="luminous-base", + custom_llm_provider="aleph_alpha", + logging_obj=logging_obj, + ) + + received: Final = await _collect(wrapper, sync_mode) + + assert _streamed_text_and_finish_reasons(received) == (" Paris", ["stop"]) + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_generic_tool_use_streams_as_a_function_call_when_the_caller_passed_functions( + sync_mode: bool, logging_obj: Logging +) -> None: + logging_obj.update_environment_variables( + model="bedrock/invoke-endpoint", + user=None, + optional_params={"functions": [{"name": "get_weather", "parameters": {"type": "object", "properties": {}}}]}, + litellm_params={"litellm_call_id": "functions-stream"}, + ) + tool_use: Final = { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + "index": 0, + } + chunks: Final = [ + { + "text": "", + "tool_use": tool_use, + "is_finished": True, + "finish_reason": "tool_calls", + "usage": None, + "index": 0, + } + ] + wrapper: Final = CustomStreamWrapper( + completion_stream=ModelResponseListIterator(model_responses=chunks), + model="bedrock/invoke-endpoint", + custom_llm_provider="bedrock", + logging_obj=logging_obj, + ) + + received: Final = await _collect(wrapper, sync_mode) + + deltas: Final = [chunk.choices[0].delta for chunk in received if chunk.choices] + assert [ + (delta.function_call.name, delta.function_call.arguments) for delta in deltas if delta.function_call is not None + ] == [("get_weather", '{"city": "Paris"}')] + assert [delta.tool_calls for delta in deltas if delta.tool_calls] == [] diff --git a/tests/unit/llms/azure_ai/agents/test_handler.py b/tests/unit/llms/azure_ai/agents/test_handler.py index 14b6094747f..04bc8e2bf97 100644 --- a/tests/unit/llms/azure_ai/agents/test_handler.py +++ b/tests/unit/llms/azure_ai/agents/test_handler.py @@ -1,8 +1,10 @@ import datetime import json +from collections.abc import Mapping from typing import Final import httpx +import openai import pytest import respx @@ -22,7 +24,12 @@ def _httpx_only_transport(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") -def _assistants_api(thread_id: str, answer: str, project_path: str = "/api/projects/litellm-test") -> respx.MockRouter: +def _assistants_api( + thread_id: str, + answer: str, + project_path: str = "/api/projects/litellm-test", + run: Mapping[str, object] | None = None, +) -> respx.MockRouter: thread_path: Final = f"{project_path}/threads/{thread_id}" router: Final = respx.mock(assert_all_called=False) router.post(path=f"{project_path}/threads").respond( @@ -42,7 +49,10 @@ def _assistants_api(thread_id: str, answer: str, project_path: str = "/api/proje }, ) router.get(path=f"{thread_path}/runs/run_1").respond( - 200, json={"id": "run_1", "object": "thread.run", "thread_id": thread_id, "status": "completed"} + 200, + json={"id": "run_1", "object": "thread.run", "thread_id": thread_id, "status": "completed"} + if run is None + else run, ) router.get(path=f"{thread_path}/messages").respond( 200, @@ -172,6 +182,50 @@ async def test_handler_acompletion_with_thread_id_reuses_that_thread() -> None: assert response._hidden_params["thread_id"] == "thread_existing" +async def _complete(sync_mode: bool, request: Mapping[str, object]) -> object: + if sync_mode: + return litellm.completion(**request) + return await litellm.acompletion(**request) + + +@pytest.mark.parametrize("status", ["failed", "cancelled", "expired"]) +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_a_run_that_ends_without_completing_raises_its_last_error_and_reads_no_messages( + sync_mode: bool, status: str +) -> None: + router: Final = _assistants_api( + "thread_new", + "never read", + run={ + "id": "run_1", + "object": "thread.run", + "thread_id": "thread_new", + "status": status, + "last_error": {"code": "server_error", "message": "Sorry, something went wrong."}, + }, + ) + request: Final = { + "model": f"azure_ai/agents/{AGENT_ID}", + "messages": [{"role": "user", "content": "hi"}], + "api_base": API_BASE, + "api_key": "azure-ad-token", + "stream": False, + } + + with router: + with pytest.raises(openai.APIError) as raised: + await _complete(sync_mode, request) + recorded: Final = _recorded(router) + + assert raised.value.status_code == 500 + assert f"Run {status}: Sorry, something went wrong." in raised.value.message + assert [(method, url.split("?")[0]) for method, url, _, _ in recorded][-1] == ( + "GET", + "/api/projects/litellm-test/threads/thread_new/runs/run_1", + ) + + def _sse(event: str, data: dict[str, object] | str) -> str: return f"event: {event}\ndata: {data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)}\n\n" diff --git a/tests/unit/llms/azure_ai/vector_stores/test_azure_ai_vector_stores_transformation.py b/tests/unit/llms/azure_ai/vector_stores/test_azure_ai_vector_stores_transformation.py index 7a0df7d96f0..5f86c5adc58 100644 --- a/tests/unit/llms/azure_ai/vector_stores/test_azure_ai_vector_stores_transformation.py +++ b/tests/unit/llms/azure_ai/vector_stores/test_azure_ai_vector_stores_transformation.py @@ -1,4 +1,5 @@ import json +from typing import Final from unittest.mock import MagicMock import httpx @@ -180,3 +181,38 @@ def test_transform_requires_embedding_model(): with pytest.raises(ValueError, match="litellm_embedding_model is required"): AzureAIVectorStoreConfig().transform_search_vector_store_request(**transform_kwargs) + + +class _SearchLogging: + def __init__(self, query: str) -> None: + self.model_call_details = {"input": query} + + +def test_search_response_keeps_document_fields_as_attributes_without_search_internals() -> None: + body: Final = { + "value": [ + { + "@search.score": 0.8, + "id": "doc-1", + "content": "Run litellm --config config.yaml", + "contentVector": [0.5, -0.25], + "title": "Proxy setup", + "tags": ["proxy", "setup"], + } + ] + } + + response: Final = AzureAIVectorStoreConfig().transform_search_vector_store_response( + httpx.Response(200, json=body), _SearchLogging("how do I start the proxy") + ) + + assert response["search_query"] == "how do I start the proxy" + assert response["data"] == [ + { + "score": 0.8, + "content": [{"text": "Run litellm --config config.yaml", "type": "text"}], + "file_id": "doc-1", + "filename": "Document doc-1", + "attributes": {"title": "Proxy setup", "tags": ["proxy", "setup"], "document_id": "doc-1"}, + } + ] diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py index 5b3cb2e0c92..fa4780053c2 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py @@ -1,8 +1,10 @@ +from collections.abc import Mapping from typing import Final from unittest.mock import Mock import httpx import pytest +from pydantic import BaseModel, ConfigDict from litellm.llms.bedrock.chat.invoke_transformations.amazon_twelvelabs_pegasus_transformation import ( AmazonTwelveLabsPegasusConfig, @@ -112,3 +114,47 @@ def test_transform_response_accepts_null_finish_reason(finish_reason: str | None assert result.choices[0].message.content == "A cat jumps." assert result.choices[0].finish_reason == expected + + +class _VideoSummary(BaseModel): + model_config = ConfigDict(frozen=True) + + title: str + duration_seconds: int + + +def _request_with_response_format(response_format: object) -> Mapping[str, object]: + config: Final = AmazonTwelveLabsPegasusConfig() + optional_params: Final = config.map_openai_params( + non_default_params={"response_format": response_format}, + optional_params={}, + model="twelvelabs.pegasus-1-2-v1:0", + drop_params=False, + ) + return config.transform_request( + model="twelvelabs.pegasus-1-2-v1:0", + messages=_make_messages(), + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + +def test_pydantic_response_format_is_sent_as_the_json_schema_of_its_fields() -> None: + response_format: Final = _request_with_response_format(_VideoSummary)["responseFormat"] + assert isinstance(response_format, Mapping) + json_schema: Final = response_format["jsonSchema"] + assert isinstance(json_schema, Mapping) + + assert set(json_schema["properties"]) == {"title", "duration_seconds"} + + +def test_null_response_format_sends_no_response_format() -> None: + request: Final = _request_with_response_format(None) + + assert "responseFormat" not in request + + +def test_response_format_that_is_neither_a_schema_nor_a_model_raises_type_error() -> None: + with pytest.raises(TypeError): + _request_with_response_format("json") diff --git a/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py b/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py index 87c57023c17..e69fb34038f 100644 --- a/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py +++ b/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py @@ -6,6 +6,7 @@ import httpx import pytest import respx +from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.vector_stores.transformation import BedrockVectorStoreConfig @@ -375,3 +376,37 @@ async def test_bedrock_search_with_credentials_managed_registry( assert search_response["data"][0]["score"] == 0.9 assert search_response["data"][0]["content"][0]["text"] == "Mock search result" + + +class _SearchLogging: + def __init__(self, query: str) -> None: + self.model_call_details: Final = {"query": query} + + +def test_search_response_keeps_each_text_result_with_its_score_and_metadata() -> None: + metadata: Final = {"x-amz-bedrock-kb-source-uri": "s3://docs/setup.md", "x-amz-bedrock-kb-chunk-id": "chunk-1"} + body: Final = { + "retrievalResults": [ + {"content": {"text": "Run litellm --config config.yaml"}, "metadata": metadata, "score": 0.42}, + {"content": {"type": "IMAGE"}, "score": 0.9}, + ] + } + + response: Final = BedrockVectorStoreConfig().transform_search_vector_store_response( + httpx.Response(200, json=body), _SearchLogging("how do I start the proxy") + ) + + assert response["search_query"] == "how do I start the proxy" + assert [(result["score"], result["content"][0]["text"], result["attributes"]) for result in response["data"]] == [ + (0.42, "Run litellm --config config.yaml", metadata) + ] + + +@pytest.mark.parametrize("body", [[], [{"retrievalResults": []}], "retrievalResults"]) +def test_search_response_that_is_not_a_json_object_raises_a_bedrock_error(body: object) -> None: + with pytest.raises(BedrockError) as raised: + BedrockVectorStoreConfig().transform_search_vector_store_response( + httpx.Response(200, json=body), _SearchLogging("query") + ) + + assert raised.value.status_code == 200 diff --git a/tests/unit/llms/cohere/test_cohere_common_utils.py b/tests/unit/llms/cohere/test_cohere_common_utils.py index 1d17824a181..0fde387b5ea 100644 --- a/tests/unit/llms/cohere/test_cohere_common_utils.py +++ b/tests/unit/llms/cohere/test_cohere_common_utils.py @@ -2,7 +2,7 @@ from typing import Final import pytest -from litellm.llms.cohere.common_utils import ModelResponseIterator +from litellm.llms.cohere.common_utils import CohereV2ModelResponseIterator, ModelResponseIterator def test_stream_parses_object_chunks_and_rejects_a_chunk_that_is_not_an_object() -> None: @@ -16,3 +16,21 @@ def test_stream_parses_object_chunks_and_rejects_a_chunk_that_is_not_an_object() assert (first["text"], first["index"], first["is_finished"]) == ("Hi", 1, False) with pytest.raises(AttributeError, match=r"^'list' object has no attribute 'get'$"): next(stream) + + +def test_v2_stream_parses_object_chunks_and_rejects_a_chunk_that_is_not_an_object() -> None: + stream: Final = CohereV2ModelResponseIterator( + streaming_response=iter( + [ + '{"type": "content-delta", "index": 0, "delta": {"message": {"content": {"text": "Hi"}}}}', + "[1, 2]", + ] + ), + sync_stream=True, + ) + + first: Final = next(stream) + + assert (first["text"], first["index"], first["is_finished"]) == ("Hi", 0, False) + with pytest.raises(RuntimeError): + next(stream) diff --git a/tests/unit/llms/huggingface/embedding/test_huggingface_embedding_transformation.py b/tests/unit/llms/huggingface/embedding/test_huggingface_embedding_transformation.py new file mode 100644 index 00000000000..37016d69b53 --- /dev/null +++ b/tests/unit/llms/huggingface/embedding/test_huggingface_embedding_transformation.py @@ -0,0 +1,106 @@ +from datetime import datetime, timezone +from typing import Final + +import httpx +import pytest + +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.llms.huggingface.embedding.transformation import HuggingFaceEmbeddingConfig +from litellm.types.utils import ModelResponse + + +def _transform( + body: object, *, task: str | None = None, optional_params: dict[str, object] | None = None +) -> ModelResponse: + return HuggingFaceEmbeddingConfig().transform_response( + model="tgi-endpoint", + raw_response=httpx.Response(200, json=body), + model_response=ModelResponse(), + logging_obj=Logging( + model="tgi-endpoint", + messages=[{"role": "user", "content": "Capital of France?"}], + stream=False, + call_type="completion", + start_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + litellm_call_id="huggingface-transform", + function_id="huggingface-transform", + ), + request_data={"inputs": "Capital of France?"}, + messages=[{"role": "user", "content": "Capital of France?"}], + optional_params={} if optional_params is None else optional_params, + litellm_params={} if task is None else {"task": task}, + encoding=None, + ) + + +def test_tgi_response_strips_chat_tokens_and_sums_the_token_logprobs() -> None: + response: Final = _transform( + [ + { + "generated_text": "Paris", + "details": { + "finish_reason": "length", + "tokens": [{"logprob": -0.5}, {"logprob": None}, {"logprob": -0.25}], + }, + } + ] + ) + + assert response.choices[0].message.content == "Paris" + assert response.choices[0].finish_reason == "length" + assert response.choices[0].message._logprob == -0.75 + + +def test_tgi_response_with_best_of_adds_one_choice_per_extra_sequence() -> None: + response: Final = _transform( + [ + { + "generated_text": "Paris", + "details": { + "finish_reason": "length", + "tokens": [{"logprob": -0.5}], + "best_of_sequences": [ + { + "generated_text": "Lyon", + "finish_reason": "length", + "tokens": [{"logprob": -1.0}, {"logprob": None}, {"logprob": -0.5}], + }, + {"generated_text": "", "finish_reason": "stop", "tokens": []}, + ], + }, + } + ], + optional_params={"best_of": 2}, + ) + + assert [(choice.index, choice.message.content, choice.finish_reason) for choice in response.choices] == [ + (0, "Paris", "length"), + (1, "Lyon", "length"), + (2, None, "stop"), + ] + assert response.choices[1].message.logprobs == -1.5 + + +def test_text_generation_task_reads_the_first_generated_text() -> None: + response: Final = _transform([{"generated_text": "<|assistant|>Paris"}], task="text-generation") + + assert response.choices[0].message.content == "Paris" + + +@pytest.mark.parametrize( + ("body", "error"), + [ + pytest.param([{"generated_text": 42}], TypeError, id="generated-text-without-a-length"), + pytest.param([{"generated_text": ["Paris"]}], AttributeError, id="generated-text-that-is-not-a-string"), + pytest.param( + [{"generated_text": "Paris", "details": {"finish_reason": "length", "tokens": [{"logprob": "-0.5"}]}}], + TypeError, + id="logprob-that-is-not-a-number", + ), + ], +) +def test_tgi_response_with_malformed_fields_raises_the_python_error_for_that_field( + body: object, error: type[Exception] +) -> None: + with pytest.raises(error): + _transform(body) diff --git a/tests/unit/llms/nlp_cloud/chat/test_nlp_cloud_chat_transformation.py b/tests/unit/llms/nlp_cloud/chat/test_nlp_cloud_chat_transformation.py index 35f8d933cee..3cf2518921a 100644 --- a/tests/unit/llms/nlp_cloud/chat/test_nlp_cloud_chat_transformation.py +++ b/tests/unit/llms/nlp_cloud/chat/test_nlp_cloud_chat_transformation.py @@ -1,6 +1,13 @@ +from datetime import datetime, timezone +from typing import Final + +import httpx import pytest +from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.nlp_cloud.chat.transformation import NLPCloudConfig +from litellm.llms.nlp_cloud.common_utils import NLPCloudError +from litellm.types.utils import ModelResponse @pytest.mark.parametrize( @@ -45,3 +52,48 @@ def test_init_called_again_with_none_keeps_the_value_stored_earlier() -> None: ScopedNLPCloudConfig(top_k=None, top_p=0.5) assert ScopedNLPCloudConfig.get_config() == {"top_k": 40, "top_p": 0.5} + + +def _transform(body: object) -> ModelResponse: + return NLPCloudConfig().transform_response( + model="finetuned-llama-3-70b", + raw_response=httpx.Response(200, json=body), + model_response=ModelResponse(), + logging_obj=Logging( + model="finetuned-llama-3-70b", + messages=[{"role": "user", "content": "Hi"}], + stream=False, + call_type="completion", + start_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + litellm_call_id="nlp-cloud-transform", + function_id="nlp-cloud-transform", + ), + request_data={"text": "Hi"}, + messages=[{"role": "user", "content": "Hi"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + +def test_transform_response_returns_the_generated_text_and_reported_token_counts() -> None: + response: Final = _transform({"generated_text": "Hello there", "nb_input_tokens": 3, "nb_generated_tokens": 2}) + + assert response.choices[0].message.content == "Hello there" + assert (response.usage.prompt_tokens, response.usage.completion_tokens, response.usage.total_tokens) == (3, 2, 5) + + +@pytest.mark.parametrize( + "body", + [ + pytest.param({"nb_input_tokens": 3, "nb_generated_tokens": 0}, id="missing-generated-text"), + pytest.param( + {"generated_text": 42, "nb_input_tokens": 3, "nb_generated_tokens": 1}, id="generated-text-without-a-length" + ), + ], +) +def test_transform_response_without_usable_generated_text_raises_an_nlp_cloud_error(body: dict[str, object]) -> None: + with pytest.raises(NLPCloudError) as raised: + _transform(body) + + assert raised.value.status_code == 200 diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index 36df47deaab..a51acd6dd44 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -5,8 +5,10 @@ import logging import os import sys import traceback +from datetime import datetime, timezone from typing import Final, cast +import httpx import pytest import respx from pydantic import BaseModel @@ -28,6 +30,7 @@ from unittest.mock import ANY, MagicMock, patch import litellm from litellm._logging import verbose_logger +from litellm.litellm_core_utils.litellm_logging import Logging from litellm.types.utils import ( ChatCompletionDeltaToolCall, Choices, @@ -1444,3 +1447,59 @@ def test_reasoning_effort_with_a_matching_think_value_does_not_warn( assert result["think"] is think assert _warnings_naming(caplog, "reasoning_effort") == () + + +def _transform_json_format_function_call(content: object) -> ModelResponse: + return OllamaChatConfig().transform_response( + model="llama3.2", + raw_response=httpx.Response( + 200, + json={ + "model": "llama3.2", + "message": {"role": "assistant", "content": content}, + "done": True, + "prompt_eval_count": 12, + "eval_count": 8, + }, + ), + model_response=ModelResponse(), + logging_obj=Logging( + model="llama3.2", + messages=[{"role": "user", "content": "Weather in Paris?"}], + stream=False, + call_type="completion", + start_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + litellm_call_id="ollama-json-function-call", + function_id="ollama-json-function-call", + ), + request_data={"format": "json"}, + messages=[{"role": "user", "content": "Weather in Paris?"}], + optional_params={}, + litellm_params={"function_name": "lookup_weather"}, + encoding=None, + ) + + +@pytest.mark.parametrize( + ("content", "name", "arguments"), + [ + pytest.param( + '{"name": "get_weather", "arguments": {"city": "Paris"}}', "get_weather", {"city": "Paris"}, id="named-call" + ), + pytest.param('{"city": "Paris"}', "lookup_weather", {"city": "Paris"}, id="bare-arguments"), + ], +) +def test_json_format_response_for_a_function_call_becomes_a_tool_call( + content: str, name: str, arguments: dict[str, object] +) -> None: + response: Final = _transform_json_format_function_call(content) + + choice: Final = response.choices[0] + tool_call: Final = choice.message.tool_calls[0] + assert (choice.finish_reason, choice.message.content) == ("tool_calls", None) + assert (tool_call.function.name, json.loads(tool_call.function.arguments)) == (name, arguments) + + +def test_json_format_function_call_response_without_text_content_raises_type_error() -> None: + with pytest.raises(TypeError): + _transform_json_format_function_call(None) diff --git a/tests/unit/realtime_api/test_main.py b/tests/unit/realtime_api/test_main.py index ad3036560a3..4e5b6d665e2 100644 --- a/tests/unit/realtime_api/test_main.py +++ b/tests/unit/realtime_api/test_main.py @@ -5,7 +5,7 @@ import time from collections.abc import Awaitable, Callable, Mapping from datetime import datetime, timezone from pathlib import Path -from types import TracebackType +from types import MappingProxyType, TracebackType from typing import ClassVar, Final, Literal, Protocol from unittest.mock import AsyncMock, MagicMock, patch @@ -1135,6 +1135,93 @@ async def test_realtime_session_requests_forward_an_explicit_null_upstream( assert [key for key, value in session_fields.items() if value is None] == [] +def _create_translation_client_secret(expires_after: object, gateway: _GatewayThatRecordsTheBody) -> Awaitable[object]: + return realtime_main.acreate_realtime_translation_client_secret.__wrapped__( + model="gpt-realtime-translate", + expires_after=expires_after, + custom_llm_provider="openai", + api_base="https://realtime-gateway.example/v1", + api_key="fake-key", + litellm_logging_obj=_realtime_http_logging("translation_client_secrets"), + client=AsyncHTTPHandler(transport=httpx.MockTransport(gateway)), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "expires_after", + [ + pytest.param({"anchor": "created_at", "seconds": 600}, id="dict"), + pytest.param(MappingProxyType({"anchor": "created_at", "seconds": 600}), id="read-only-mapping"), + ], +) +async def test_translation_client_secret_forwards_a_mapping_expires_after(expires_after: Mapping[str, object]) -> None: + gateway: Final = _GatewayThatRecordsTheBody() + + await _create_translation_client_secret(expires_after, gateway) + + assert gateway.body["expires_after"] == {"anchor": "created_at", "seconds": 600} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "expires_after", + [ + pytest.param([("anchor", "created_at"), ("seconds", 600)], id="list-of-pairs"), + pytest.param("seconds=600", id="string"), + ], +) +async def test_translation_client_secret_rejects_an_expires_after_that_is_not_a_mapping(expires_after: object) -> None: + gateway: Final = _GatewayThatRecordsTheBody() + + with pytest.raises(TypeError, match=r"realtime\.RealtimeExpiresAfter\(\) argument after \*\* must be a mapping"): + await _create_translation_client_secret(expires_after, gateway) + + assert gateway.body is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "transcription_session", + [ + pytest.param([("input_audio_format", "pcm16")], id="list-of-pairs"), + pytest.param("input_audio_format=pcm16", id="string"), + ], +) +async def test_transcription_session_rejects_a_session_that_is_not_a_mapping(transcription_session: object) -> None: + gateway: Final = _GatewayThatRecordsTheBody() + + with pytest.raises( + TypeError, match=r"realtime\.RealtimeTranscriptionSessionRequest\(\) argument after \*\* must be a mapping" + ): + await realtime_main.acreate_realtime_transcription_session.__wrapped__( + model="openai/gpt-transcribe", + transcription_session=transcription_session, + api_base="https://realtime-gateway.example/v1", + api_key="fake-key", + litellm_logging_obj=FakeLogging(), + client=AsyncHTTPHandler(transport=httpx.MockTransport(gateway)), + ) + + assert gateway.body is None + + +@pytest.mark.asyncio +async def test_transcription_session_accepts_a_read_only_mapping() -> None: + gateway: Final = _GatewayThatRecordsTheBody() + + await realtime_main.acreate_realtime_transcription_session.__wrapped__( + model="openai/gpt-transcribe", + transcription_session=MappingProxyType({"input_audio_format": "pcm16"}), + api_base="https://realtime-gateway.example/v1", + api_key="fake-key", + litellm_logging_obj=FakeLogging(), + client=AsyncHTTPHandler(transport=httpx.MockTransport(gateway)), + ) + + assert gateway.body["input_audio_format"] == "pcm16" + + @pytest.mark.asyncio async def test_transcription_client_secret_sends_the_deployment_model_as_the_session_model() -> None: gateway: Final = _GatewayThatRecordsTheSession() diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 577d4b4b11d..97df9cdbfbf 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -20,7 +20,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest import respx -from openai import APITimeoutError, AsyncOpenAI, OpenAI +from openai import APIError, APITimeoutError, AsyncOpenAI, OpenAI from openai._legacy_response import LegacyAPIResponse from openai.types.chat.chat_completion import ChatCompletion from pydantic import BaseModel, ConfigDict, TypeAdapter @@ -12016,3 +12016,57 @@ def test_supplied_openai_client_base_url_decides_data_residency(monkeypatch: pyt ) assert captured["logging_obj"].litellm_params.get("data_residency") is None + + +_ALEPH_ALPHA_COMPLETE_URL: Final = "https://api.aleph-alpha.com/complete" + + +def _aleph_alpha_completion() -> object: + return litellm.completion( + model="luminous-base", + custom_llm_provider="aleph_alpha", + messages=[{"role": "user", "content": "Capital of France?"}], + api_key="aleph-test-key", + ) + + +def test_aleph_alpha_completion_returns_each_upstream_completion_as_a_choice(respx_mock: respx.MockRouter) -> None: + respx_mock.post(_ALEPH_ALPHA_COMPLETE_URL).respond( + 200, + json={ + "model_version": "2022-04", + "completions": [ + {"completion": " Paris", "finish_reason": "maximum_tokens"}, + {"completion": " Lyon", "finish_reason": "end_of_text"}, + ], + }, + ) + + response: Final = _aleph_alpha_completion() + + assert [choice.message.content for choice in response.choices] == [" Paris", " Lyon"] + + +def test_aleph_alpha_error_in_a_successful_body_fails_the_call_with_the_upstream_message( + respx_mock: respx.MockRouter, +) -> None: + respx_mock.post(_ALEPH_ALPHA_COMPLETE_URL).respond( + 200, json={"error": "Prompt rejected by the content filter", "code": "PROMPT_REJECTED"} + ) + + with pytest.raises(APIError) as raised: + _aleph_alpha_completion() + + assert "Prompt rejected by the content filter" in raised.value.message + + +@pytest.mark.parametrize("completion", [42, None]) +def test_aleph_alpha_completion_that_is_not_text_fails_the_call( + respx_mock: respx.MockRouter, completion: object +) -> None: + respx_mock.post(_ALEPH_ALPHA_COMPLETE_URL).respond( + 200, json={"completions": [{"completion": completion, "finish_reason": "end_of_text"}]} + ) + + with pytest.raises(APIError): + _aleph_alpha_completion() diff --git a/tests/unit/videos/test_main.py b/tests/unit/videos/test_main.py index d43141bdea3..26a6c0687d0 100644 --- a/tests/unit/videos/test_main.py +++ b/tests/unit/videos/test_main.py @@ -30,6 +30,7 @@ helper runs for real against genuinely-encoded ids, so the provider assertions reflect production. """ +from collections.abc import Callable, Mapping from contextlib import ExitStack from dataclasses import dataclass from typing import Any, Dict, Final @@ -38,6 +39,7 @@ from unittest.mock import MagicMock, patch from litellm.types.videos.main import VideoCreateOptionalRequestParams import pytest +from pydantic import BaseModel import litellm @@ -486,3 +488,80 @@ def test_video_generation__custom_provider_without_a_video_config_is_a_400(monke with pytest.raises(litellm.BadRequestError, match="video generation is not supported for image-only"): videos_main.video_generation(model="image-only/seedance-1", prompt="a cat") handler.video_generation_handler.assert_not_called() + + +_VIDEO_JSON: Final = '{"id": "video_1", "object": "video", "status": "completed", "model": "sora-2"}' +_CHARACTER_JSON: Final = '{"id": "char_1", "object": "character", "created_at": 1767225600, "name": "hero"}' + + +def _fields(value: object) -> object: + if isinstance(value, list): + return [_fields(item) for item in value] + return value.model_dump() if isinstance(value, BaseModel) else value + + +@pytest.mark.parametrize( + ("operation", "arguments", "mock_response", "expected"), + [ + pytest.param( + videos_main.video_generation, + {"prompt": "a cat surfing", "model": "sora-2"}, + _VIDEO_JSON, + VideoObject(id="video_1", object="video", status="completed", model="sora-2"), + id="generation", + ), + pytest.param( + videos_main.video_remix, + {"video_id": "video_1", "prompt": "make it night"}, + _VIDEO_JSON, + VideoObject(id="video_1", object="video", status="completed", model="sora-2"), + id="remix", + ), + pytest.param( + videos_main.video_status, + {"video_id": "video_1"}, + _VIDEO_JSON, + VideoObject(id="video_1", object="video", status="completed", model="sora-2"), + id="status", + ), + pytest.param( + videos_main.video_edit, + {"video_id": "video_1", "prompt": "add rain"}, + _VIDEO_JSON, + VideoObject(id="video_1", object="video", status="completed", model="sora-2"), + id="edit", + ), + pytest.param( + videos_main.video_extension, + {"video_id": "video_1", "prompt": "keep going", "seconds": "4"}, + _VIDEO_JSON, + VideoObject(id="video_1", object="video", status="completed", model="sora-2"), + id="extension", + ), + pytest.param( + videos_main.video_list, + {}, + f"[{_VIDEO_JSON}]", + [VideoObject(id="video_1", object="video", status="completed", model="sora-2")], + id="list", + ), + pytest.param( + videos_main.video_create_character, + {"name": "hero", "video": b"\x00\x00\x00\x18ftypmp42"}, + _CHARACTER_JSON, + CharacterObject(id="char_1", object="character", created_at=1767225600, name="hero"), + id="create-character", + ), + pytest.param( + videos_main.video_get_character, + {"character_id": "char_1"}, + _CHARACTER_JSON, + CharacterObject(id="char_1", object="character", created_at=1767225600, name="hero"), + id="get-character", + ), + ], +) +def test_a_json_string_mock_response_is_parsed_into_the_typed_result( + operation: Callable[..., object], arguments: Mapping[str, object], mock_response: str, expected: object +) -> None: + assert _fields(operation(**arguments, mock_response=mock_response)) == _fields(expected)