mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
chore: merge main into Bedrock pricing fix
This commit is contained in:
commit
9c5339588e
108 changed files with 11720 additions and 1248 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -410,6 +410,7 @@ jobs:
|
|||
tests/unit/embeddings
|
||||
tests/unit/endpoints
|
||||
tests/unit/files
|
||||
tests/unit/fine_tuning
|
||||
tests/unit/images
|
||||
tests/unit/interactions
|
||||
tests/unit/messages
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import base64
|
|||
import json
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -479,6 +480,13 @@ def _with_managed_background_marker(request: Mapping[str, object]) -> dict[str,
|
|||
|
||||
|
||||
_MANAGED_FILES_TARGET: Final = "managed_files"
|
||||
_PROVIDER_FILE_ID_KWARGS: Final = MappingProxyType(
|
||||
{
|
||||
CallTypes.acreate_batch: ("input_file_id",),
|
||||
CallTypes.acreate_fine_tuning_job: ("training_file", "validation_file"),
|
||||
}
|
||||
)
|
||||
_PROVIDER_FILE_ID_KEYS: Final = tuple(dict.fromkeys(chain.from_iterable(_PROVIDER_FILE_ID_KWARGS.values())))
|
||||
_PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS: Final = (0.5, 1.0, 2.0)
|
||||
|
||||
|
||||
|
|
@ -1562,6 +1570,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
if isinstance(file_id, str) and file_id
|
||||
)
|
||||
await self.check_file_ids_access(fine_tuning_file_ids, user_api_key_dict)
|
||||
if fine_tuning_file_ids:
|
||||
data["model_file_id_mapping"] = await self.get_model_file_id_mapping(
|
||||
fine_tuning_file_ids, user_api_key_dict.parent_otel_span
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
|
@ -1576,24 +1588,27 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
if request_kwargs is None:
|
||||
return healthy_deployments
|
||||
|
||||
input_file_id = cast(Optional[str], request_kwargs.get("input_file_id"))
|
||||
model_file_id_mapping = cast(
|
||||
Optional[Dict[str, Dict[str, str]]],
|
||||
request_kwargs.get("model_file_id_mapping"),
|
||||
request: Final = cast(Mapping[str, object], request_kwargs)
|
||||
model_file_id_mapping: Final = cast(Mapping[str, Mapping[str, str]], request.get("model_file_id_mapping") or {})
|
||||
requested_file_ids: Final = (request.get(key) for key in _PROVIDER_FILE_ID_KEYS)
|
||||
model_ids_per_file: Final = tuple(
|
||||
frozenset(model_file_id_mapping[file_id])
|
||||
for file_id in requested_file_ids
|
||||
if isinstance(file_id, str) and file_id in model_file_id_mapping
|
||||
)
|
||||
allowed_model_ids: Final[frozenset[str]] = (
|
||||
model_ids_per_file[0].intersection(*model_ids_per_file[1:]) if model_ids_per_file else frozenset()
|
||||
)
|
||||
allowed_model_ids = []
|
||||
if input_file_id and model_file_id_mapping:
|
||||
model_id_dict = model_file_id_mapping.get(input_file_id, {})
|
||||
allowed_model_ids = list(model_id_dict.keys())
|
||||
|
||||
if len(allowed_model_ids) == 0:
|
||||
return healthy_deployments
|
||||
|
||||
return [
|
||||
holders: Final[list[dict[str, object]]] = [
|
||||
deployment
|
||||
for deployment in healthy_deployments
|
||||
if deployment.get("model_info", {}).get("id") in allowed_model_ids
|
||||
]
|
||||
return holders if holders or "input_file_id" in request else healthy_deployments
|
||||
|
||||
async def async_pre_call_deployment_hook(
|
||||
self, kwargs: Dict[str, Any], call_type: Optional[CallTypes]
|
||||
|
|
@ -1601,25 +1616,21 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
"""
|
||||
Allow modifying the request just before it's sent to the deployment.
|
||||
"""
|
||||
accessor_key: Optional[str] = None
|
||||
if call_type and call_type == CallTypes.acreate_batch:
|
||||
accessor_key = "input_file_id"
|
||||
elif call_type and call_type == CallTypes.acreate_fine_tuning_job:
|
||||
accessor_key = "training_file"
|
||||
else:
|
||||
accessor_keys: Final = _PROVIDER_FILE_ID_KWARGS.get(call_type) if call_type else None
|
||||
if accessor_keys is None:
|
||||
return kwargs
|
||||
|
||||
if accessor_key:
|
||||
model_file_id_mapping = cast(Optional[Dict[str, Dict[str, str]]], kwargs.get("model_file_id_mapping"))
|
||||
# model_info may be at top-level or nested under litellm_metadata
|
||||
# (batch/file operations use litellm_metadata)
|
||||
model_id = cast(Optional[str], kwargs.get("model_info", {}).get("id", None))
|
||||
if model_id is None:
|
||||
model_id = cast(
|
||||
Optional[str],
|
||||
kwargs.get("litellm_metadata", {}).get("model_info", {}).get("id", None),
|
||||
)
|
||||
for accessor_key in accessor_keys:
|
||||
input_file_id = cast(Optional[str], kwargs.get(accessor_key))
|
||||
model_file_id_mapping = cast(Optional[Dict[str, Dict[str, str]]], kwargs.get("model_file_id_mapping"))
|
||||
# model_info may be at top-level or nested under litellm_metadata
|
||||
# (batch/file operations use litellm_metadata)
|
||||
model_id = cast(Optional[str], kwargs.get("model_info", {}).get("id", None))
|
||||
if model_id is None:
|
||||
model_id = cast(
|
||||
Optional[str],
|
||||
kwargs.get("litellm_metadata", {}).get("model_info", {}).get("id", None),
|
||||
)
|
||||
mapped_file_id: Optional[str] = None
|
||||
if input_file_id and model_file_id_mapping and model_id:
|
||||
mapped_file_id = model_file_id_mapping.get(input_file_id, {}).get(model_id, None)
|
||||
|
|
@ -1813,7 +1824,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
),
|
||||
)
|
||||
|
||||
async def get_model_file_id_mapping(self, file_ids: List[str], litellm_parent_otel_span: Span) -> dict:
|
||||
async def get_model_file_id_mapping(self, file_ids: Sequence[str], litellm_parent_otel_span: Span) -> dict:
|
||||
"""
|
||||
Get model-specific file IDs for a list of proxy file IDs.
|
||||
Returns a dictionary mapping litellm_proxy/ file_id -> model_id -> model_file_id
|
||||
|
|
|
|||
|
|
@ -252,6 +252,13 @@ MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS: Final = get_env_int("MCP_PER_USER_TOKE
|
|||
# MCP timeout defaults (seconds). Override via env vars for slow/custom MCP servers.
|
||||
MCP_CLIENT_TIMEOUT: Final = get_env_float("LITELLM_MCP_CLIENT_TIMEOUT", 60.0)
|
||||
MCP_TOOL_LISTING_TIMEOUT: Final = get_env_float("LITELLM_MCP_TOOL_LISTING_TIMEOUT", 30.0)
|
||||
MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES: Final = get_env_int("LITELLM_MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES", 3)
|
||||
MCP_UPSTREAM_RATE_LIMIT_BASE_DELAY_SECONDS: Final = get_env_float(
|
||||
"LITELLM_MCP_UPSTREAM_RATE_LIMIT_BASE_DELAY_SECONDS", 0.5
|
||||
)
|
||||
MCP_UPSTREAM_RATE_LIMIT_MAX_DELAY_SECONDS: Final = get_env_float(
|
||||
"LITELLM_MCP_UPSTREAM_RATE_LIMIT_MAX_DELAY_SECONDS", 5.0
|
||||
)
|
||||
MCP_METADATA_TIMEOUT: Final = get_env_float("LITELLM_MCP_METADATA_TIMEOUT", 10.0)
|
||||
MCP_HEALTH_CHECK_TIMEOUT: Final = get_env_float("LITELLM_MCP_HEALTH_CHECK_TIMEOUT", 10.0)
|
||||
AGENT_HEALTH_CHECK_TIMEOUT_SECONDS: Final = get_env_float("LITELLM_AGENT_HEALTH_CHECK_TIMEOUT", 5.0)
|
||||
|
|
|
|||
|
|
@ -1135,6 +1135,8 @@ def get_usage_object(
|
|||
|
||||
if isinstance(completion_response, (DecisionsResponse, OpenAIDecisionResponse)):
|
||||
return None if completion_response.usage is None else _decisions_usage(completion_response.usage)
|
||||
if isinstance(completion_response, HttpxBinaryResponseContent):
|
||||
return completion_response.usage
|
||||
if usage_obj is None:
|
||||
return None
|
||||
if isinstance(usage_obj, Usage):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Final
|
|||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from litellm._internal_context import internal_sub_call
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm import LiteLLMLoggingObj
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
|
|
@ -111,9 +113,10 @@ class SpeechToCompletionBridgeHandler:
|
|||
voice=voice,
|
||||
)
|
||||
|
||||
result: Final = completion(
|
||||
**request_data,
|
||||
)
|
||||
with internal_sub_call():
|
||||
result: Final = completion(
|
||||
**request_data,
|
||||
)
|
||||
|
||||
requested_response_format: Final = optional_params.get("response_format")
|
||||
if isinstance(result, ModelResponse):
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ from typing import TYPE_CHECKING, Final, cast
|
|||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
|
||||
from litellm.litellm_core_utils.hidden_params import get_hidden_params, served_from_cache, set_hidden_param
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm import Logging as LiteLLMLoggingObj
|
||||
from litellm.types.llms.openai import ChatCompletionUserMessage, HttpxBinaryResponseContent
|
||||
from litellm.types.utils import ModelResponse
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
|
||||
def _completion_response_cost(model_response: "ModelResponse") -> float | None:
|
||||
|
|
@ -20,6 +21,13 @@ def _completion_response_cost(model_response: "ModelResponse") -> float | None:
|
|||
return response_cost if isinstance(response_cost, float) else None
|
||||
|
||||
|
||||
def _completion_usage(model_response: "ModelResponse") -> "Usage | None":
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
usage: Final = getattr(model_response, "usage", None)
|
||||
return usage if isinstance(usage, Usage) else None
|
||||
|
||||
|
||||
GEMINI_TTS_CHAT_AUDIO_FORMAT: Final = "pcm16"
|
||||
GEMINI_TTS_RAW_RESPONSE_FORMAT: Final = "pcm"
|
||||
GEMINI_TTS_SUPPORTED_RESPONSE_FORMATS: Final = frozenset({"wav", GEMINI_TTS_RAW_RESPONSE_FORMAT})
|
||||
|
|
@ -179,4 +187,9 @@ class SpeechToCompletionBridgeTransformationHandler:
|
|||
)
|
||||
binary_response: Final = HttpxBinaryResponseContent(response)
|
||||
binary_response.set_response_cost(_completion_response_cost(model_response))
|
||||
binary_response.usage = _completion_usage(model_response)
|
||||
cached_hidden_params: Final = get_hidden_params(model_response) if served_from_cache(model_response) else None
|
||||
if cached_hidden_params is not None:
|
||||
set_hidden_param(binary_response, "cache_hit", True)
|
||||
set_hidden_param(binary_response, "cache_key", cached_hidden_params.get("cache_key"))
|
||||
return binary_response
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ def create_fine_tuning_job(
|
|||
try:
|
||||
_is_async: Final = kwargs.pop("acreate_fine_tuning_job", False) is True
|
||||
optional_params: Final = GenericLiteLLMParams(**kwargs)
|
||||
provider_model: Final = model.removeprefix(f"{custom_llm_provider}/")
|
||||
|
||||
# handle hyperparameters
|
||||
hyperparameters = hyperparameters or {} # original hyperparameters
|
||||
|
|
@ -218,7 +219,7 @@ def create_fine_tuning_job(
|
|||
)
|
||||
|
||||
create_fine_tuning_job_data_dict = _build_fine_tuning_job_data(
|
||||
model,
|
||||
provider_model,
|
||||
training_file,
|
||||
_oai_hyperparameters,
|
||||
suffix,
|
||||
|
|
@ -263,7 +264,7 @@ def create_fine_tuning_job(
|
|||
extra_body = _prepare_azure_extra_body(extra_body, kwargs, azure_specific_hyperparams)
|
||||
|
||||
create_fine_tuning_job_data_dict = _build_fine_tuning_job_data(
|
||||
model,
|
||||
provider_model,
|
||||
training_file,
|
||||
_oai_hyperparameters,
|
||||
suffix,
|
||||
|
|
@ -298,7 +299,7 @@ def create_fine_tuning_job(
|
|||
response = vertex_fine_tuning_apis_instance.create_fine_tuning_job(
|
||||
_is_async=_is_async,
|
||||
create_fine_tuning_job_data=_build_fine_tuning_job_data(
|
||||
model,
|
||||
provider_model,
|
||||
training_file,
|
||||
_oai_hyperparameters,
|
||||
suffix,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Transforms between Anthropic/OpenAI tool_use format and LiteLLM search format.
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -539,7 +539,8 @@ class WebSearchTransformation:
|
|||
result: SearchResponse from litellm.asearch()
|
||||
|
||||
Returns:
|
||||
Formatted text with Title, URL, Snippet for each result
|
||||
Formatted text with Title, URL, Snippet for each result, or the response
|
||||
without its provider_metadata (the caller's billing envelope) when there are no results
|
||||
"""
|
||||
# Convert SearchResponse to string
|
||||
if hasattr(result, "results") and result.results:
|
||||
|
|
@ -548,6 +549,9 @@ class WebSearchTransformation:
|
|||
[f"Title: {r.title}\nURL: {r.url}\nSnippet: {r.snippet}" for r in result.results]
|
||||
)
|
||||
else:
|
||||
search_result_text = str(result)
|
||||
fields: Final[Mapping[str, object]] = dict(result)
|
||||
search_result_text = " ".join(
|
||||
f"{key}={value!r}" for key, value in fields.items() if key != "provider_metadata"
|
||||
)
|
||||
|
||||
return search_result_text
|
||||
|
|
|
|||
|
|
@ -1088,6 +1088,25 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu
|
|||
)
|
||||
|
||||
|
||||
def _modality_token_cost(
|
||||
model_info: ModelInfo,
|
||||
modality_cost_key: str,
|
||||
tokens: int,
|
||||
prompt_base_cost: float,
|
||||
service_tier: str | None,
|
||||
) -> float:
|
||||
"""
|
||||
Audio, image and video prompt tokens bill at their modality rate when the model has one.
|
||||
Without one they are ordinary prompt tokens, so they bill at `prompt_base_cost`, which
|
||||
`_get_token_base_cost` has already resolved for the service tier and long-context tier.
|
||||
"""
|
||||
tiered_key: Final = get_service_tier_cost_key(modality_cost_key, service_tier)
|
||||
cost_key: Final = tiered_key if model_info.get(tiered_key) is not None else modality_cost_key
|
||||
if model_info.get(cost_key) is None:
|
||||
return float(tokens) * prompt_base_cost
|
||||
return calculate_cost_component(model_info, cost_key, tokens)
|
||||
|
||||
|
||||
def _calculate_input_cost(
|
||||
prompt_tokens_details: PromptTokensDetailsResult,
|
||||
model_info: ModelInfo,
|
||||
|
|
@ -1118,28 +1137,37 @@ def _calculate_input_cost(
|
|||
if prompt_tokens_details["audio_tokens"] and not (
|
||||
prompt_tokens_details["audio_length_seconds"] and model_info.get("input_cost_per_audio_per_second") is not None
|
||||
):
|
||||
audio_cost_key: Final = get_service_tier_cost_key("input_cost_per_audio_token", service_tier)
|
||||
prompt_cost += calculate_cost_component(model_info, audio_cost_key, prompt_tokens_details["audio_tokens"])
|
||||
prompt_cost += _modality_token_cost(
|
||||
model_info,
|
||||
"input_cost_per_audio_token",
|
||||
prompt_tokens_details["audio_tokens"],
|
||||
prompt_base_cost,
|
||||
service_tier,
|
||||
)
|
||||
|
||||
### IMAGE TOKEN COST
|
||||
if prompt_tokens_details["image_tokens"] and not (
|
||||
prompt_tokens_details["image_count"] and model_info.get("input_cost_per_image") is not None
|
||||
):
|
||||
# For image token costs:
|
||||
# First check if input_cost_per_image_token is available. If not, default to generic input_cost_per_token.
|
||||
image_token_cost_key = "input_cost_per_image_token"
|
||||
if model_info.get(image_token_cost_key) is None:
|
||||
image_token_cost_key = "input_cost_per_token"
|
||||
prompt_cost += calculate_cost_component(model_info, image_token_cost_key, prompt_tokens_details["image_tokens"])
|
||||
prompt_cost += _modality_token_cost(
|
||||
model_info,
|
||||
"input_cost_per_image_token",
|
||||
prompt_tokens_details["image_tokens"],
|
||||
prompt_base_cost,
|
||||
service_tier,
|
||||
)
|
||||
|
||||
### VIDEO TOKEN COST
|
||||
if prompt_tokens_details["video_tokens"] and not (
|
||||
prompt_tokens_details["video_length_seconds"] and model_info.get("input_cost_per_video_per_second") is not None
|
||||
):
|
||||
video_token_cost_key = "input_cost_per_video_token"
|
||||
if model_info.get(video_token_cost_key) is None:
|
||||
video_token_cost_key = "input_cost_per_token"
|
||||
prompt_cost += calculate_cost_component(model_info, video_token_cost_key, prompt_tokens_details["video_tokens"])
|
||||
prompt_cost += _modality_token_cost(
|
||||
model_info,
|
||||
"input_cost_per_video_token",
|
||||
prompt_tokens_details["video_tokens"],
|
||||
prompt_base_cost,
|
||||
service_tier,
|
||||
)
|
||||
|
||||
### CACHE WRITING COST - Now uses tiered pricing
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ tell "user asked for this" from "nobody set it". This resolver answers that:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
from collections.abc import Mapping
|
||||
from typing import Final, Literal, Protocol
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS
|
||||
|
||||
_TIMEOUTS_BY_SERVICE_TIER: Final[TypeAdapter[Mapping[str, float] | None]] = TypeAdapter(Mapping[str, float] | None)
|
||||
|
||||
|
||||
def get_configured_request_timeout() -> float | None:
|
||||
"""Return the explicitly-configured ``litellm.request_timeout``, else ``None``."""
|
||||
|
|
@ -27,3 +32,19 @@ def get_configured_request_timeout() -> float | None:
|
|||
if timeout != float(DEFAULT_REQUEST_TIMEOUT_SECONDS):
|
||||
return timeout
|
||||
return None
|
||||
|
||||
|
||||
class _DeploymentParams(Protocol):
|
||||
def get(self, key: str, /) -> object: ...
|
||||
|
||||
|
||||
def get_service_tier_timeout(
|
||||
service_tier: object,
|
||||
litellm_params: _DeploymentParams,
|
||||
key: Literal["timeout_by_service_tier", "stream_timeout_by_service_tier"],
|
||||
) -> float | None:
|
||||
"""Return the deployment's ``key`` timeout for the request's ``service_tier``, else ``None``."""
|
||||
if not isinstance(service_tier, str):
|
||||
return None
|
||||
timeouts: Final = _TIMEOUTS_BY_SERVICE_TIER.validate_python(litellm_params.get(key))
|
||||
return None if timeouts is None else timeouts.get(service_tier)
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ from ..common_utils import (
|
|||
refusal_provider_specific_fields,
|
||||
requires_native_compaction_beta,
|
||||
strip_advisor_blocks_from_messages,
|
||||
tools_marked_for_a_tool_config_point,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -2408,6 +2409,19 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
# Remove internal LiteLLM parameters that should not be sent to Anthropic API
|
||||
optional_params.pop("is_vertex_request", None)
|
||||
optional_params.pop("client_metadata", None)
|
||||
request_params: Final = cast( # cast-ok: optional_params is the untyped dict of the shared transform signature
|
||||
dict[str, object], optional_params
|
||||
)
|
||||
marked_tools: Final = tools_marked_for_a_tool_config_point(
|
||||
request_params.get("tools"), request_params.pop("cache_control_injection_points", None)
|
||||
)
|
||||
if marked_tools is not None:
|
||||
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
|
||||
|
||||
request_litellm_params: Final = cast( # cast-ok: litellm_params is the untyped dict of the shared signature
|
||||
Mapping[str, object], litellm_params
|
||||
)
|
||||
AnthropicCacheControlHook.record_gateway_injection(request_litellm_params, 1)
|
||||
|
||||
# ``top_k`` is a provider-specific kwarg that bypasses
|
||||
# ``map_openai_params``; gate it here, the single boundary shared by
|
||||
|
|
@ -2427,6 +2441,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
"model": model,
|
||||
"messages": anthropic_messages,
|
||||
**optional_params,
|
||||
**({} if marked_tools is None else {"tools": list(marked_tools)}),
|
||||
}
|
||||
|
||||
self._apply_output_config(data=data, model=model, optional_params=optional_params)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from litellm.types.llms.anthropic import (
|
|||
ANTHROPIC_OAUTH_TOKEN_PREFIX,
|
||||
ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX,
|
||||
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
|
||||
ANTHROPIC_TOOL_SEARCH_TOOL_TYPES,
|
||||
AllAnthropicToolsValues,
|
||||
AnthropicMcpServerTool,
|
||||
AnthropicMessagesToolChoice,
|
||||
|
|
@ -111,7 +112,7 @@ _DATED_RELEASE_SUFFIX_RE: Final = re.compile(r"-\d{8}$")
|
|||
_DOTTED_VERSION_RE: Final = re.compile(r"(\d)\.(\d)")
|
||||
_CLAUDE_CODE_BILLING_HEADER_PREFIX: Final = "x-anthropic-billing-header:"
|
||||
OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[object, object])
|
||||
_CLAUDE_CODE_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
|
||||
OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
|
||||
_DECODED_JSON: Final = TypeAdapter(object)
|
||||
|
||||
|
||||
|
|
@ -160,6 +161,40 @@ def supports_anthropic_cache_control(model: str, custom_llm_provider: str | None
|
|||
)
|
||||
|
||||
|
||||
def _tool_config_point_control(points: object) -> Mapping[object, object] | None:
|
||||
point_fields: Final = tuple(validated_object_mapping(point) for point in validated_object_list(points) or ())
|
||||
tool_config_point: Final = next(
|
||||
(fields for fields in point_fields if fields is not None and fields.get("location") == "tool_config"), None
|
||||
)
|
||||
if tool_config_point is None:
|
||||
return None
|
||||
control: Final = tool_config_point.get("control")
|
||||
return {"type": "ephemeral"} if control is None else validated_object_mapping(control)
|
||||
|
||||
|
||||
def tools_marked_for_a_tool_config_point(tools: object, points: object) -> tuple[object, ...] | None:
|
||||
"""Put the ``tool_config`` point's breakpoint on the last tool Anthropic caches.
|
||||
|
||||
Anthropic caches tools as one prefix ending at the marked tool, so the last one carries
|
||||
the mark, skipping tool-search tools whose transform drops cache markers. A client that
|
||||
already marked a tool keeps its own breakpoint. Returns ``None`` when nothing is marked.
|
||||
"""
|
||||
control: Final = _tool_config_point_control(points)
|
||||
tool_list: Final = validated_object_list(tools) or ()
|
||||
tool_fields: Final = tuple(validated_object_mapping(tool) for tool in tool_list)
|
||||
if control is None or any(fields is not None and "cache_control" in fields for fields in tool_fields):
|
||||
return None
|
||||
cacheable: Final = tuple(
|
||||
(position, fields)
|
||||
for position, fields in enumerate(tool_fields)
|
||||
if fields is not None and fields.get("type") not in ANTHROPIC_TOOL_SEARCH_TOOL_TYPES
|
||||
)
|
||||
if not cacheable:
|
||||
return None
|
||||
position, fields = cacheable[-1]
|
||||
return (*tool_list[:position], {**fields, "cache_control": control}, *tool_list[position + 1 :])
|
||||
|
||||
|
||||
def is_claude_code_user_agent(user_agent: str) -> bool:
|
||||
"""Claude Code sends its API calls through the Anthropic SDK as `claude-cli/<version>` and its own
|
||||
fetches, such as gateway model discovery, as `claude-code/<version>`"""
|
||||
|
|
@ -173,9 +208,9 @@ def validated_object_mapping(value: object) -> dict[object, object] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _validated_claude_code_list(value: object) -> list[object] | None:
|
||||
def validated_object_list(value: object) -> Sequence[object] | None:
|
||||
try:
|
||||
return _CLAUDE_CODE_OBJECT_LIST_ADAPTER.validate_python(value)
|
||||
return OBJECT_LIST_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
|
@ -202,7 +237,7 @@ def _claude_code_billing_fields(text: str) -> tuple[tuple[str, str], ...] | None
|
|||
def _claude_code_billing_texts(system: object) -> tuple[str, ...] | None:
|
||||
if isinstance(system, str):
|
||||
return (system,)
|
||||
blocks: Final = _validated_claude_code_list(system)
|
||||
blocks: Final = validated_object_list(system)
|
||||
if blocks is None:
|
||||
return None
|
||||
block_mappings: Final = tuple(validated_object_mapping(block) for block in blocks)
|
||||
|
|
@ -1963,8 +1998,8 @@ def tool_call_is_rebuilt_as_server_tool_use(
|
|||
return (
|
||||
find_anthropic_server_tool_result(
|
||||
tool_call_id,
|
||||
_validated_claude_code_list(fields.get("web_search_results")),
|
||||
_validated_claude_code_list(fields.get("tool_results")),
|
||||
validated_object_list(fields.get("web_search_results")),
|
||||
validated_object_list(fields.get("tool_results")),
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ from pydantic import TypeAdapter
|
|||
|
||||
import litellm
|
||||
from litellm.anthropic_beta_headers_manager import aload_beta_headers_config
|
||||
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
|
||||
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.anthropic.common_utils import (
|
||||
strip_provider_specific_fields_from_anthropic_messages,
|
||||
tools_marked_for_a_tool_config_point,
|
||||
)
|
||||
from litellm.llms.base_llm.anthropic_messages.transformation import (
|
||||
BaseAnthropicMessagesConfig,
|
||||
|
|
@ -701,7 +703,13 @@ def anthropic_messages_handler(
|
|||
f"custom_llm_provider is required for Anthropic messages, passed in model={model}, custom_llm_provider={custom_llm_provider}"
|
||||
)
|
||||
|
||||
local_vars.update(kwargs)
|
||||
request_kwargs: Final = cast(dict[str, object], kwargs) # cast-ok: **kwargs of the handler is untyped
|
||||
marked_tools: Final = tools_marked_for_a_tool_config_point(
|
||||
local_vars.get("tools"), request_kwargs.pop("cache_control_injection_points", None)
|
||||
)
|
||||
if marked_tools is not None:
|
||||
AnthropicCacheControlHook.record_gateway_injection(request_kwargs, 1)
|
||||
local_vars.update({**request_kwargs, **({"tools": list(marked_tools)} if marked_tools is not None else {})})
|
||||
anthropic_messages_optional_request_params: Final = (
|
||||
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
|
||||
params=local_vars,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.secret_managers.main import get_secret_str
|
|||
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
|
||||
_JSON_VALUES: Final = TypeAdapter(Iterable[object], config=ConfigDict(hide_input_in_errors=True))
|
||||
_STRS: Final = TypeAdapter(Iterable[str], config=ConfigDict(hide_input_in_errors=True))
|
||||
_RESULT_EXTRAS: Final = (("highlights", "highlights"), ("highlightScores", "highlight_scores"), ("score", "score"))
|
||||
|
||||
|
||||
class _ExaAISearchRequestRequired(TypedDict):
|
||||
|
|
@ -173,6 +174,9 @@ class ExaAISearchConfig(BaseSearchConfig):
|
|||
- results[].summary → SearchResult.snippet (fallback when "text" and "highlights" are absent)
|
||||
- results[].publishedDate → SearchResult.date
|
||||
- No last_updated field in Exa AI response (set to None)
|
||||
- results[].highlights / highlightScores / score → SearchResult.highlights / highlight_scores / score,
|
||||
each set only when Exa returned it
|
||||
- every top-level key except results (requestId, costDollars, ...) → SearchResponse.provider_metadata
|
||||
|
||||
Args:
|
||||
raw_response: Raw httpx response from Exa AI API
|
||||
|
|
@ -197,11 +201,15 @@ class ExaAISearchConfig(BaseSearchConfig):
|
|||
or "",
|
||||
"date": result.get("publishedDate"), # ISO 8601 datetime string
|
||||
"last_updated": None, # Exa AI doesn't provide last_updated in response
|
||||
**{field: result[key] for key, field in _RESULT_EXTRAS if key in result},
|
||||
}
|
||||
)
|
||||
results.append(search_result)
|
||||
|
||||
return SearchResponse(
|
||||
results=results,
|
||||
object="search",
|
||||
return SearchResponse.model_validate(
|
||||
{
|
||||
"results": results,
|
||||
"object": "search",
|
||||
"provider_metadata": {key: value for key, value in response_json.items() if key != "results"},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8500,14 +8500,8 @@ async def aspeech(*args, **kwargs) -> HttpxBinaryResponseContent:
|
|||
api_base=api_base_hint,
|
||||
)
|
||||
|
||||
# Await normally
|
||||
init_response: Final = await loop.run_in_executor(None, func_with_context)
|
||||
if asyncio.iscoroutine(init_response):
|
||||
response = await init_response
|
||||
else:
|
||||
# Call the synchronous function using run_in_executor
|
||||
response = await loop.run_in_executor(None, func_with_context)
|
||||
return response
|
||||
return await init_response if asyncio.iscoroutine(init_response) else init_response
|
||||
except Exception as e:
|
||||
custom_llm_provider = custom_llm_provider or "openai"
|
||||
raise exception_type(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ from typing import Final
|
|||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.litellm_core_utils.request_timeout_resolver import get_configured_request_timeout
|
||||
from litellm.litellm_core_utils.request_timeout_resolver import (
|
||||
get_configured_request_timeout,
|
||||
get_service_tier_timeout,
|
||||
)
|
||||
|
||||
DEFAULT_PASS_THROUGH_REQUEST_TIMEOUT_SECONDS: Final = 600.0
|
||||
|
||||
|
|
@ -50,21 +53,27 @@ def resolve_llm_passthrough_timeout(
|
|||
Anthropic /v1/messages).
|
||||
|
||||
Non-streaming precedence: kwargs timeout/request_timeout -> litellm_params
|
||||
timeout/request_timeout -> router_timeout -> litellm.request_timeout (litellm_settings.request_timeout,
|
||||
timeout_by_service_tier[kwargs service_tier] -> litellm_params timeout/request_timeout -> router_timeout -> litellm.request_timeout (litellm_settings.request_timeout,
|
||||
when explicitly set) -> general_settings.pass_through_request_timeout -> 600s default.
|
||||
|
||||
Streaming (``kwargs["stream"]`` truthy) resolves ``stream_timeout`` at every level before
|
||||
any generic timeout, matching ``Router._get_stream_timeout`` on the completion route:
|
||||
kwargs stream_timeout -> litellm_params stream_timeout -> router_stream_timeout, then the
|
||||
non-streaming chain above.
|
||||
kwargs stream_timeout -> litellm_params stream_timeout_by_service_tier[kwargs service_tier] ->
|
||||
litellm_params stream_timeout -> router_stream_timeout, then the non-streaming chain above.
|
||||
|
||||
Only the first set value is validated as seconds, so a value in a lower-precedence
|
||||
field never fails the call.
|
||||
"""
|
||||
request: Final = kwargs if kwargs is not None else _NO_PARAMS
|
||||
deployment: Final = litellm_params if litellm_params is not None else _NO_PARAMS
|
||||
service_tier: Final = request.get("service_tier")
|
||||
stream_candidates: Final = (
|
||||
(request.get("stream_timeout"), deployment.get("stream_timeout"), router_stream_timeout)
|
||||
(
|
||||
request.get("stream_timeout"),
|
||||
get_service_tier_timeout(service_tier, deployment, "stream_timeout_by_service_tier"),
|
||||
deployment.get("stream_timeout"),
|
||||
router_stream_timeout,
|
||||
)
|
||||
if request.get("stream")
|
||||
else ()
|
||||
)
|
||||
|
|
@ -72,6 +81,7 @@ def resolve_llm_passthrough_timeout(
|
|||
*stream_candidates,
|
||||
request.get("timeout"),
|
||||
request.get("request_timeout"),
|
||||
get_service_tier_timeout(service_tier, deployment, "timeout_by_service_tier"),
|
||||
deployment.get("timeout"),
|
||||
deployment.get("request_timeout"),
|
||||
router_timeout,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import functools
|
||||
import re
|
||||
import secrets
|
||||
|
|
@ -5,8 +6,9 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import SecretStr, TypeAdapter
|
||||
|
|
@ -41,11 +43,15 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
|
|||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
|
||||
is_session_bearer_shaped,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.utils import asgi_scope_fields
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
asgi_scope_fields,
|
||||
strip_known_server_prefix,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
UI_TEAM_ID,
|
||||
LiteLLM_AccessGroupTable,
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
ProxyException,
|
||||
SpecialHeaders,
|
||||
|
|
@ -466,6 +472,24 @@ class MCPServerAccess:
|
|||
scope: Literal["unscoped", "scoped", "unresolved"] = "unscoped"
|
||||
|
||||
|
||||
def _mcp_server_access_after_error(
|
||||
error: Exception,
|
||||
key_object_permission: LiteLLM_ObjectPermissionTable | None,
|
||||
*,
|
||||
require_readable_team_policy: bool,
|
||||
) -> MCPServerAccess:
|
||||
if require_readable_team_policy:
|
||||
raise error
|
||||
if isinstance(error, UnloadableEntitlementError):
|
||||
verbose_logger.warning("Denying MCP access, entitlement unreadable: %s", error)
|
||||
else:
|
||||
verbose_logger.warning("Failed to get allowed MCP servers: %s", error)
|
||||
return MCPServerAccess(
|
||||
server_ids=(),
|
||||
scope="scoped" if getattr(key_object_permission, "mcp_servers", None) is not None else "unresolved",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DcrBridgeTarget:
|
||||
"""The single DCR-bridge server a request targets, paired with the exact name the caller
|
||||
|
|
@ -1804,11 +1828,28 @@ class MCPRequestHandler:
|
|||
)
|
||||
return list(access.server_ids)
|
||||
|
||||
@staticmethod
|
||||
async def _apply_agent_caller_ceiling_to_multi_source_server_access(
|
||||
access: MCPServerAccess,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
) -> MCPServerAccess:
|
||||
if user_api_key_auth is None or not MCPRequestHandler.is_personal_key_inheriting_team_grants(user_api_key_auth):
|
||||
return access
|
||||
caller_capped, caller_restricts = await MCPRequestHandler.apply_agent_caller_ceiling(
|
||||
access.server_ids,
|
||||
user_api_key_auth,
|
||||
)
|
||||
return MCPServerAccess(
|
||||
server_ids=caller_capped,
|
||||
scope="scoped" if caller_restricts else access.scope,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_mcp_server_access(
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
*,
|
||||
keyless_source: bool = False,
|
||||
require_readable_team_policy: bool = False,
|
||||
) -> MCPServerAccess:
|
||||
"""
|
||||
Get list of allowed MCP servers for the given user/key based on permissions.
|
||||
|
|
@ -1844,9 +1885,11 @@ class MCPRequestHandler:
|
|||
# matters: the no_mcp_servers opt-out below reads the caller's own object_permission, so above
|
||||
# this branch a user's own opt-out would wrongly zero their TEAMS' grants too (each source is
|
||||
# independent; an opt-out silences only its own source, inside the recursive call).
|
||||
if is_mcp_admitted_user_subject(user_api_key_auth) and user_api_key_auth is not None:
|
||||
return MCPServerAccess(
|
||||
server_ids=tuple(await MCPRequestHandler.resolve_admitted_subject_servers(user_api_key_auth)),
|
||||
multi_source_access: Final = await MCPRequestHandler._multi_source_server_access(user_api_key_auth)
|
||||
if multi_source_access is not None:
|
||||
return await MCPRequestHandler._apply_agent_caller_ceiling_to_multi_source_server_access(
|
||||
multi_source_access,
|
||||
user_api_key_auth,
|
||||
)
|
||||
|
||||
# Get allowed servers from key and team
|
||||
|
|
@ -1861,7 +1904,10 @@ class MCPRequestHandler:
|
|||
):
|
||||
return MCPServerAccess(server_ids=(), scope="scoped")
|
||||
|
||||
allowed_mcp_servers_for_team = await MCPRequestHandler._get_allowed_mcp_servers_for_team(user_api_key_auth)
|
||||
allowed_mcp_servers_for_team = await MCPRequestHandler._get_allowed_mcp_servers_for_policy(
|
||||
user_api_key_auth,
|
||||
require_readable_policy=require_readable_team_policy,
|
||||
)
|
||||
|
||||
key_access_group_grants = await MCPRequestHandler._get_key_access_group_mcp_server_extras(user_api_key_auth)
|
||||
|
||||
|
|
@ -1993,15 +2039,10 @@ class MCPRequestHandler:
|
|||
),
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, UnloadableEntitlementError):
|
||||
# A ceiling we KNOW exists and cannot read. Denying is the only answer that does not
|
||||
# widen this caller past what an operator configured, for both caller shapes.
|
||||
verbose_logger.warning("Denying MCP access, entitlement unreadable: %s", e)
|
||||
else:
|
||||
verbose_logger.warning("Failed to get allowed MCP servers: %s", e)
|
||||
return MCPServerAccess(
|
||||
server_ids=(),
|
||||
scope="scoped" if getattr(key_object_permission, "mcp_servers", None) is not None else "unresolved",
|
||||
return _mcp_server_access_after_error(
|
||||
e,
|
||||
key_object_permission,
|
||||
require_readable_team_policy=require_readable_team_policy,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -2144,8 +2185,6 @@ class MCPRequestHandler:
|
|||
# whole union to deny-all over one momentarily-unreadable row.
|
||||
verbose_logger.warning("MCP admitted-subject source team %r unresolvable, skipping: %s", team_id, e)
|
||||
return None
|
||||
if team_obj is None:
|
||||
return None
|
||||
member_user_ids: Final = {getattr(m, "user_id", None) for m in (team_obj.members_with_roles or [])} - {None}
|
||||
if auth.user_id not in member_user_ids:
|
||||
return None
|
||||
|
|
@ -2240,35 +2279,156 @@ class MCPRequestHandler:
|
|||
return list(reachable)
|
||||
|
||||
@staticmethod
|
||||
async def billing_auth_for_tool_call(auth: UserAPIKeyAuth, tool_name: str) -> UserAPIKeyAuth:
|
||||
async def billing_auth_for_tool_call(
|
||||
auth: UserAPIKeyAuth,
|
||||
tool_name: str,
|
||||
*,
|
||||
mcp_servers: Sequence[str] | None = None,
|
||||
scoped_server: MCPServer | None = None,
|
||||
native_tool_name: str | None = None,
|
||||
allowed_mcp_servers: Sequence[MCPServer] | None = None,
|
||||
) -> UserAPIKeyAuth:
|
||||
"""The auth object a tool call's SPEND should be recorded against.
|
||||
|
||||
``auth`` unchanged for any non-admitted caller (key/JWT billing byte-identical). For an admitted
|
||||
subject whose call is reached through a team's grant, a copy carrying that team's ``team_id`` and
|
||||
owning ``org_id`` so the team's budget accumulates and the right org is charged. Falls back to
|
||||
user-level attribution (rather than guessing a team) when the tool name does not resolve to a
|
||||
server, reusing the manager's own tool-name lookup."""
|
||||
if not is_mcp_admitted_user_subject(auth):
|
||||
``auth`` unchanged for callers without inherited team grants. For an admitted subject or personal
|
||||
key whose call is reached through a team's grant, a copy carrying that team's ``team_id`` and
|
||||
owning ``org_id`` so the team's budget accumulates and the right org is charged."""
|
||||
from litellm.proxy.auth.auth_checks import personal_key_inherits_team_grants
|
||||
|
||||
if not is_mcp_admitted_user_subject(auth) and not personal_key_inherits_team_grants(auth):
|
||||
return auth
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy._experimental.mcp_server.operations import (
|
||||
_get_allowed_mcp_servers,
|
||||
_resolve_display_name_to_original,
|
||||
resolve_scoped_mcp_server,
|
||||
)
|
||||
|
||||
server: Final = global_mcp_server_manager.get_mcp_server_from_tool_name(tool_name)
|
||||
servers_for_name_resolution: Final = (
|
||||
allowed_mcp_servers
|
||||
if allowed_mcp_servers is not None
|
||||
else await _get_allowed_mcp_servers(auth, mcp_servers)
|
||||
)
|
||||
resolved_tool_name: Final = _resolve_display_name_to_original(tool_name, servers_for_name_resolution)
|
||||
server: Final = (
|
||||
scoped_server
|
||||
or (await resolve_scoped_mcp_server(auth, mcp_servers) if mcp_servers is not None else None)
|
||||
or global_mcp_server_manager.get_mcp_server_from_tool_name(resolved_tool_name)
|
||||
or global_mcp_server_manager.server_owning_tool_name_prefix(resolved_tool_name)
|
||||
)
|
||||
if server is None:
|
||||
return auth
|
||||
source: Final = await MCPRequestHandler.attributing_source_for_server(auth, server.server_id)
|
||||
source: Final = await (
|
||||
MCPRequestHandler.attributing_source_for_server(auth, server.server_id)
|
||||
if is_mcp_admitted_user_subject(auth)
|
||||
else MCPRequestHandler.personal_key_attributing_source(
|
||||
auth,
|
||||
server.server_id,
|
||||
native_tool_name
|
||||
if native_tool_name is not None
|
||||
else strip_known_server_prefix(resolved_tool_name, server),
|
||||
)
|
||||
)
|
||||
if source is None or not source.team_id:
|
||||
return auth
|
||||
billed: Final = auth.model_copy()
|
||||
billed.team_id = source.team_id
|
||||
billed.org_id = source.org_id
|
||||
return billed
|
||||
if is_mcp_admitted_user_subject(auth):
|
||||
return auth.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
"team_id": source.team_id,
|
||||
"org_id": source.org_id,
|
||||
"team_metadata": source.team_metadata,
|
||||
}
|
||||
)
|
||||
)
|
||||
source_auth_update: Final = MappingProxyType(
|
||||
{
|
||||
"team_id": source.team_id,
|
||||
"org_id": source.org_id,
|
||||
"team_metadata": source.team_metadata,
|
||||
}
|
||||
)
|
||||
try:
|
||||
team_auth_context: Final = await MCPRequestHandler._personal_key_team_auth_context(auth, source)
|
||||
except Exception as e: # noqa: BLE001 # keep the selected team's attribution on follow-up read failures
|
||||
verbose_logger.warning("MCP billing context lookup failed for team %r: %s", source.team_id, e)
|
||||
return auth.model_copy(update=source_auth_update)
|
||||
if team_auth_context is None:
|
||||
return auth.model_copy(update=source_auth_update)
|
||||
team_object, team_membership = team_auth_context
|
||||
from litellm.proxy.auth.team_grants import team_grant_auth_update, team_grant_billing_auth_update
|
||||
|
||||
try:
|
||||
billed_update: Final = await team_grant_billing_auth_update(auth, team_object, team_membership)
|
||||
except Exception as e: # noqa: BLE001 # retain team controls when organization attribution fails
|
||||
verbose_logger.warning("MCP billing controls lookup failed for team %r: %s", team_object.team_id, e)
|
||||
return auth.model_copy(
|
||||
update=team_grant_auth_update(
|
||||
team_object,
|
||||
team_membership,
|
||||
auth.user_id,
|
||||
key_org_id=auth.org_id,
|
||||
)
|
||||
)
|
||||
return auth.model_copy(update=billed_update)
|
||||
except Exception as e: # noqa: BLE001 # attribution must never fail an authorized call
|
||||
verbose_logger.warning("MCP billing attribution failed for %r, billing the user: %s", tool_name, e)
|
||||
return auth
|
||||
|
||||
@staticmethod
|
||||
async def guardrail_auth_for_call(
|
||||
guardrail_auth: UserAPIKeyAuth | None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
server: MCPServer | None,
|
||||
tool_name: str,
|
||||
allowed_mcp_servers: Sequence[MCPServer] | None = None,
|
||||
) -> UserAPIKeyAuth | None:
|
||||
if guardrail_auth is not None or user_api_key_auth is None or server is None:
|
||||
return guardrail_auth if guardrail_auth is not None else user_api_key_auth
|
||||
if not MCPRequestHandler.is_personal_key_inheriting_team_grants(user_api_key_auth):
|
||||
return user_api_key_auth
|
||||
return await MCPRequestHandler.billing_auth_for_tool_call(
|
||||
user_api_key_auth,
|
||||
tool_name,
|
||||
scoped_server=server,
|
||||
native_tool_name=tool_name,
|
||||
allowed_mcp_servers=allowed_mcp_servers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _personal_key_team_auth_context(
|
||||
auth: UserAPIKeyAuth,
|
||||
source: UserAPIKeyAuth,
|
||||
) -> tuple[LiteLLM_TeamTable, LiteLLM_TeamMembership | None] | None:
|
||||
from litellm.proxy.auth.auth_checks import get_team_membership, get_team_object
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
if prisma_client is None or source.team_id is None:
|
||||
return None
|
||||
team: Final = await get_team_object(
|
||||
team_id=source.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=auth.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=auth.requires_fresh_policy,
|
||||
)
|
||||
membership: Final = (
|
||||
await get_team_membership(
|
||||
user_id=auth.user_id,
|
||||
team_id=source.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=auth.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=auth.requires_fresh_policy,
|
||||
)
|
||||
if auth.user_id is not None
|
||||
else None
|
||||
)
|
||||
return team, membership
|
||||
|
||||
@staticmethod
|
||||
async def attributing_source_for_server(
|
||||
auth: UserAPIKeyAuth,
|
||||
|
|
@ -2328,6 +2488,201 @@ class MCPRequestHandler:
|
|||
allowed.update(tools)
|
||||
return await MCPRequestHandler._narrow_tools_to_toolset(sorted(allowed), server_id, auth)
|
||||
|
||||
@staticmethod
|
||||
async def _multi_source_server_access(user_api_key_auth: UserAPIKeyAuth | None) -> MCPServerAccess | None:
|
||||
"""Server access for the two caller shapes pinned to no single team, each resolved per grant
|
||||
source: a keyless admitted subject (its own grants plus its teams') and, under
|
||||
``personal_key_model_access_from_teams``, a personal key (its owner's teams). None for every
|
||||
other caller, which the single-source rules then resolve."""
|
||||
from litellm.proxy.auth.auth_checks import personal_key_inherits_team_grants
|
||||
|
||||
if user_api_key_auth is None:
|
||||
return None
|
||||
if is_mcp_admitted_user_subject(user_api_key_auth):
|
||||
return MCPServerAccess(
|
||||
server_ids=tuple(await MCPRequestHandler.resolve_admitted_subject_servers(user_api_key_auth)),
|
||||
)
|
||||
if personal_key_inherits_team_grants(user_api_key_auth):
|
||||
return await MCPRequestHandler.resolve_personal_key_team_servers(user_api_key_auth)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def is_personal_key_inheriting_team_grants(auth: UserAPIKeyAuth) -> bool:
|
||||
from litellm.proxy.auth.auth_checks import personal_key_inherits_team_grants
|
||||
|
||||
return personal_key_inherits_team_grants(auth)
|
||||
|
||||
@staticmethod
|
||||
async def personal_key_team_sources(auth: UserAPIKeyAuth) -> tuple[UserAPIKeyAuth, ...]:
|
||||
"""One source per current team of a personal key's owner, each resolving as the same key pinned
|
||||
to that team, so key/team inheritance and intersection hold exactly as for a team key. The
|
||||
pinned ``team_id`` keeps a source out of this branch. A team of another organization grants
|
||||
nothing to a key issued under its own org, so neither org ceiling is dropped. No team, or an owner that cannot be
|
||||
loaded, yields no source: the key reaches nothing through its owner."""
|
||||
from litellm.proxy.auth.auth_checks import personal_key_owner_teams
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
teams: Final = await personal_key_owner_teams(
|
||||
valid_token=auth,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=bool(auth.requires_fresh_policy),
|
||||
)
|
||||
return tuple(
|
||||
auth.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
"team_id": team.team_id,
|
||||
"org_id": auth.org_id or team.organization_id,
|
||||
"team_metadata": TypeAdapter(Mapping[str, object]).validate_python(
|
||||
team.model_dump().get("metadata") or {}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
for team in teams
|
||||
if MCPRequestHandler._personal_key_team_permission_is_readable(team)
|
||||
and (auth.org_id is None or team.organization_id in (None, auth.org_id))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _personal_key_team_permission_is_readable(team: LiteLLM_TeamTable) -> bool:
|
||||
if team.object_permission_id is None or team.object_permission is not None:
|
||||
return True
|
||||
verbose_logger.warning(
|
||||
"Personal key owner team %s has an unreadable object_permission, granting nothing",
|
||||
team.team_id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def personal_key_team_server_grants(
|
||||
auth: UserAPIKeyAuth,
|
||||
) -> tuple[tuple[UserAPIKeyAuth, MCPServerAccess], ...]:
|
||||
sources: Final = await MCPRequestHandler.personal_key_team_sources(auth)
|
||||
|
||||
async def resolve_source(source: UserAPIKeyAuth) -> tuple[UserAPIKeyAuth, MCPServerAccess]:
|
||||
try:
|
||||
return source, await MCPRequestHandler.get_mcp_server_access(
|
||||
source,
|
||||
require_readable_team_policy=True,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # an unreadable team grant must not widen inherited access
|
||||
verbose_logger.warning("MCP access lookup failed for owner team %r: %s", source.team_id, e)
|
||||
return source, MCPServerAccess(server_ids=(), scope="scoped")
|
||||
|
||||
return tuple(await asyncio.gather(*(resolve_source(source) for source in sources)))
|
||||
|
||||
@staticmethod
|
||||
async def personal_key_attributing_source(
|
||||
auth: UserAPIKeyAuth,
|
||||
server_id: str,
|
||||
tool_name: str,
|
||||
grants: tuple[tuple[UserAPIKeyAuth, MCPServerAccess], ...] | None = None,
|
||||
) -> UserAPIKeyAuth | None:
|
||||
source_grants: Final = grants or await MCPRequestHandler.personal_key_team_server_grants(auth)
|
||||
|
||||
async def _source_if_tool_allowed(source: UserAPIKeyAuth, access: MCPServerAccess) -> UserAPIKeyAuth | None:
|
||||
if server_id not in access.server_ids:
|
||||
return None
|
||||
try:
|
||||
allowed_tools: Final = await MCPRequestHandler._single_credential_tools(server_id, source)
|
||||
except Exception as e: # noqa: BLE001 # attribution metadata must not fail an authorized call
|
||||
verbose_logger.warning("MCP tool attribution failed for team %r: %s", source.team_id, e)
|
||||
return None
|
||||
return source if allowed_tools is None or tool_name in allowed_tools else None
|
||||
|
||||
eligible_sources: Final = tuple(
|
||||
source
|
||||
for source in await asyncio.gather(
|
||||
*(_source_if_tool_allowed(source, access) for source, access in source_grants)
|
||||
)
|
||||
if source is not None
|
||||
)
|
||||
return min(eligible_sources, key=lambda source: source.team_id or "") if eligible_sources else None
|
||||
|
||||
@staticmethod
|
||||
async def personal_key_team_rpm_limits(
|
||||
auth: UserAPIKeyAuth, server: MCPServer, tool_name: str
|
||||
) -> Mapping[str, Mapping[str, int]] | None:
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy.auth.auth_checks import get_team_object
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
try:
|
||||
source: Final = await MCPRequestHandler.personal_key_attributing_source(auth, server.server_id, tool_name)
|
||||
if source is None or source.team_id is None or prisma_client is None:
|
||||
return None
|
||||
team: Final = await get_team_object(
|
||||
team_id=source.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=auth.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=bool(auth.requires_fresh_policy),
|
||||
)
|
||||
team_metadata: Final[Mapping[str, object]] = TypeAdapter(Mapping[str, object]).validate_python(
|
||||
team.model_dump().get("metadata") or {}
|
||||
)
|
||||
team_limit: Final = team_metadata.get("mcp_rpm_limit")
|
||||
if not isinstance(team_limit, dict) or not team_limit:
|
||||
return None
|
||||
validated_limit: Final = TypeAdapter(dict[str, int]).validate_python(team_limit)
|
||||
applicable: Final = {
|
||||
name: rpm
|
||||
for name, rpm in validated_limit.items()
|
||||
if server.server_id in global_mcp_server_manager.expand_permission_list([name])
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 # throttling metadata must not fail an allowed request
|
||||
verbose_logger.warning("Failed to resolve per-team MCP rpm limits for personal key: %s", e)
|
||||
return None
|
||||
else:
|
||||
return {source.team_id: applicable} if applicable else None
|
||||
|
||||
@staticmethod
|
||||
async def resolve_personal_key_team_servers(auth: UserAPIKeyAuth) -> MCPServerAccess:
|
||||
"""Union of what the owner's teams grant the personal key, each answered by the canonical
|
||||
resolver. Unscoped only when some team leaves the key unscoped, as a team key of it would be."""
|
||||
grants: Final = await MCPRequestHandler.personal_key_team_server_grants(auth)
|
||||
server_ids: Final = tuple(dict.fromkeys(chain.from_iterable(access.server_ids for _source, access in grants)))
|
||||
unscoped: Final = any(access.scope == "unscoped" for _source, access in grants)
|
||||
return MCPServerAccess(server_ids=server_ids, scope="unscoped" if unscoped else "scoped")
|
||||
|
||||
@staticmethod
|
||||
async def resolve_personal_key_team_tools(server_id: str, auth: UserAPIKeyAuth) -> tuple[str, ...] | None:
|
||||
"""Effective tool allowlist on ``server_id`` for a personal key: the union over the owner's teams
|
||||
that grant the server, where a granting team without a tool rule allows every tool. A server no
|
||||
team grants is reachable only through an open channel (allow_all_keys, the owner's BYOM, the
|
||||
admin-view role) and
|
||||
then carries the key's OWN tool rules, never a team's; otherwise nothing is allowed. Each team
|
||||
resolves through the raising ``_single_credential_tools`` so one team's fault reaches the
|
||||
caller's fail-closed handler instead of becoming an allow-all ``None`` in the union."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
grants: Final = await MCPRequestHandler.personal_key_team_server_grants(auth)
|
||||
granting: Final = tuple(source for source, access in grants if server_id in access.server_ids)
|
||||
if not granting:
|
||||
reachable: Final = server_id in await global_mcp_server_manager.operator_open_server_ids(
|
||||
auth
|
||||
) or await MCPRequestHandler.admin_view_unscoped(auth)
|
||||
if not reachable:
|
||||
return ()
|
||||
own_tools: Final = await MCPRequestHandler._single_credential_tools(server_id, auth)
|
||||
return None if own_tools is None else tuple(own_tools)
|
||||
tool_lists: Final = await asyncio.gather(
|
||||
*(MCPRequestHandler._single_credential_tools(server_id, source) for source in granting)
|
||||
)
|
||||
if any(tools is None for tools in tool_lists):
|
||||
return None
|
||||
return tuple(sorted(frozenset(chain.from_iterable(tools for tools in tool_lists if tools is not None))))
|
||||
|
||||
@staticmethod
|
||||
def _get_key_object_permission(
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
|
|
@ -2693,6 +3048,8 @@ class MCPRequestHandler:
|
|||
|
||||
return await managed_agent_tools(server_id, user_api_key_auth)
|
||||
|
||||
from litellm.proxy.auth.auth_checks import personal_key_inherits_team_grants
|
||||
|
||||
try:
|
||||
# FIRST statement, mirroring get_allowed_mcp_servers: a keyless admitted subject resolves per
|
||||
# source and shares nothing with the single-credential prelude below. Ordering is the invariant:
|
||||
|
|
@ -2700,97 +3057,11 @@ class MCPRequestHandler:
|
|||
if is_mcp_admitted_user_subject(user_api_key_auth):
|
||||
return await MCPRequestHandler.resolve_admitted_subject_tools(server_id, user_api_key_auth)
|
||||
|
||||
# Get key and team object permissions (already loaded in main auth flow)
|
||||
key_obj_perm: Final = MCPRequestHandler._get_key_object_permission(user_api_key_auth)
|
||||
team_obj_perm: Final = await MCPRequestHandler._get_team_object_permission(user_api_key_auth)
|
||||
if personal_key_inherits_team_grants(user_api_key_auth):
|
||||
return _as_list(await MCPRequestHandler.resolve_personal_key_team_tools(server_id, user_api_key_auth))
|
||||
|
||||
# Extract tool permissions for this server. Dict keys may be
|
||||
# server_ids OR names/aliases; normalize to server_id-keyed form
|
||||
# before lookup so a name-based key does not silently drop its
|
||||
# tool restrictions when server_id is the resolved uuid.
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
key_direct_tools: Final = (
|
||||
global_mcp_server_manager.expand_tool_permissions(key_obj_perm.mcp_tool_permissions).get(server_id)
|
||||
if key_obj_perm
|
||||
else None
|
||||
)
|
||||
|
||||
# Tools granted through the key's toolsets restrict this server exactly
|
||||
# as direct tool permissions do; union with any direct grants so the
|
||||
# tool-level check sees the key's full effective tool scope
|
||||
key_toolset_ids: Final = (key_obj_perm.mcp_toolsets or []) if key_obj_perm else []
|
||||
key_toolset_tools: Final = (
|
||||
(
|
||||
await global_mcp_server_manager.resolve_toolset_tool_permissions(
|
||||
toolset_ids=key_toolset_ids,
|
||||
requires_fresh_policy=user_api_key_auth.requires_fresh_policy,
|
||||
)
|
||||
).get(server_id)
|
||||
if key_toolset_ids
|
||||
else None
|
||||
)
|
||||
|
||||
key_group_tools: Final = await MCPRequestHandler._key_access_group_tools_for_server(
|
||||
user_api_key_auth, server_id
|
||||
)
|
||||
key_direct_servers: Final = await MCPRequestHandler._key_direct_servers(
|
||||
key_obj_perm, requires_fresh_policy=bool(user_api_key_auth.requires_fresh_policy)
|
||||
)
|
||||
key_tools: Final = _as_list(
|
||||
MCPRequestHandler._key_tool_scope(
|
||||
key_direct_tools, key_toolset_tools, key_group_tools, server_id in key_direct_servers
|
||||
)
|
||||
)
|
||||
team_direct_tools: Final = (
|
||||
global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id)
|
||||
if team_obj_perm
|
||||
else None
|
||||
)
|
||||
|
||||
# Tools granted through the team's toolsets restrict this server exactly
|
||||
# as the team's direct tool permissions do, mirroring the key path above
|
||||
team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(
|
||||
team_obj_perm, server_id, requires_fresh_policy=user_api_key_auth.requires_fresh_policy
|
||||
)
|
||||
team_group_tools: Final = await MCPRequestHandler._team_access_group_tools_for_server(
|
||||
user_api_key_auth, server_id
|
||||
)
|
||||
team_tools: Final = MCPRequestHandler._union_tool_grants(
|
||||
team_direct_tools, team_toolset_tools, team_group_tools
|
||||
)
|
||||
|
||||
# Apply same inheritance logic as get_allowed_mcp_servers. ``is not None`` so an explicit
|
||||
# empty allowlist stays a deny instead of reading as "no restriction".
|
||||
if team_tools is not None:
|
||||
if key_tools is not None:
|
||||
# Both have restrictions → intersection
|
||||
allowed_tools = list(set(team_tools) & set(key_tools))
|
||||
else:
|
||||
# Only team has restrictions → inherit from team
|
||||
allowed_tools = team_tools
|
||||
else:
|
||||
# No team restrictions → use key restrictions
|
||||
allowed_tools = cast(list[str], key_tools)
|
||||
|
||||
allowed_tools = _as_list(
|
||||
await MCPRequestHandler._apply_end_user_tool_ceiling(allowed_tools, server_id, user_api_key_auth)
|
||||
)
|
||||
|
||||
allowed_tools = _as_list(
|
||||
await MCPRequestHandler._apply_user_tool_ceiling(
|
||||
allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
|
||||
)
|
||||
)
|
||||
|
||||
allowed_tools = _as_list(
|
||||
await MCPRequestHandler.apply_agent_caller_tool_ceiling(allowed_tools, server_id, user_api_key_auth)
|
||||
)
|
||||
|
||||
return await MCPRequestHandler._apply_agent_and_org_tool_ceilings(
|
||||
allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
|
||||
return await MCPRequestHandler._single_credential_tools(
|
||||
server_id, user_api_key_auth, keyless_source=keyless_source
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -2806,10 +3077,108 @@ class MCPRequestHandler:
|
|||
# keyless_source AND the marker are needed: each source resolves through an UNMARKED auth, so
|
||||
# without keyless_source a fault under a source returns None and wins the union as allow-all.
|
||||
deny_all: Final = (
|
||||
unreadable_entitlement or keyless_source or is_mcp_admitted_user_subject(user_api_key_auth)
|
||||
unreadable_entitlement
|
||||
or keyless_source
|
||||
or is_mcp_admitted_user_subject(user_api_key_auth)
|
||||
or personal_key_inherits_team_grants(user_api_key_auth)
|
||||
)
|
||||
return [] if deny_all else None
|
||||
|
||||
@staticmethod
|
||||
async def _single_credential_tools(
|
||||
server_id: str,
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
*,
|
||||
keyless_source: bool = False,
|
||||
) -> list[str] | None: # mutable-ok: resolver returns a list
|
||||
"""Tool allowlist on ``server_id`` for one credential pinned to at most one team: the key's own
|
||||
and its team's tool rules under the end-user, user, agent-caller, agent and org ceilings."""
|
||||
# Get key and team object permissions (already loaded in main auth flow)
|
||||
key_obj_perm: Final = MCPRequestHandler._get_key_object_permission(user_api_key_auth)
|
||||
team_obj_perm: Final = await MCPRequestHandler._get_team_object_permission(user_api_key_auth)
|
||||
|
||||
# Extract tool permissions for this server. Dict keys may be
|
||||
# server_ids OR names/aliases; normalize to server_id-keyed form
|
||||
# before lookup so a name-based key does not silently drop its
|
||||
# tool restrictions when server_id is the resolved uuid.
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
key_direct_tools: Final = (
|
||||
global_mcp_server_manager.expand_tool_permissions(key_obj_perm.mcp_tool_permissions).get(server_id)
|
||||
if key_obj_perm
|
||||
else None
|
||||
)
|
||||
|
||||
# Tools granted through the key's toolsets restrict this server exactly
|
||||
# as direct tool permissions do; union with any direct grants so the
|
||||
# tool-level check sees the key's full effective tool scope
|
||||
key_toolset_ids: Final = (key_obj_perm.mcp_toolsets or []) if key_obj_perm else []
|
||||
key_toolset_tools: Final = (
|
||||
(
|
||||
await global_mcp_server_manager.resolve_toolset_tool_permissions(
|
||||
toolset_ids=key_toolset_ids,
|
||||
requires_fresh_policy=user_api_key_auth.requires_fresh_policy,
|
||||
)
|
||||
).get(server_id)
|
||||
if key_toolset_ids
|
||||
else None
|
||||
)
|
||||
|
||||
key_group_tools: Final = await MCPRequestHandler._key_access_group_tools_for_server(
|
||||
user_api_key_auth, server_id
|
||||
)
|
||||
key_direct_servers: Final = await MCPRequestHandler._key_direct_servers(
|
||||
key_obj_perm, requires_fresh_policy=bool(user_api_key_auth.requires_fresh_policy)
|
||||
)
|
||||
key_tools: Final = _as_list(
|
||||
MCPRequestHandler._key_tool_scope(
|
||||
key_direct_tools, key_toolset_tools, key_group_tools, server_id in key_direct_servers
|
||||
)
|
||||
)
|
||||
team_direct_tools: Final = (
|
||||
global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id)
|
||||
if team_obj_perm
|
||||
else None
|
||||
)
|
||||
|
||||
# Tools granted through the team's toolsets restrict this server exactly
|
||||
# as the team's direct tool permissions do, mirroring the key path above
|
||||
team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(
|
||||
team_obj_perm, server_id, requires_fresh_policy=user_api_key_auth.requires_fresh_policy
|
||||
)
|
||||
team_group_tools: Final = await MCPRequestHandler._team_access_group_tools_for_server(
|
||||
user_api_key_auth, server_id
|
||||
)
|
||||
team_tools: Final = MCPRequestHandler._union_tool_grants(
|
||||
team_direct_tools, team_toolset_tools, team_group_tools
|
||||
)
|
||||
|
||||
# Apply same inheritance logic as get_allowed_mcp_servers. ``is not None`` so an explicit
|
||||
# empty allowlist stays a deny instead of reading as "no restriction".
|
||||
initial_allowed_tools: Final = (
|
||||
list(set(team_tools) & set(key_tools))
|
||||
if team_tools is not None and key_tools is not None
|
||||
else team_tools
|
||||
if team_tools is not None
|
||||
else key_tools
|
||||
)
|
||||
end_user_allowed_tools: Final = _as_list(
|
||||
await MCPRequestHandler._apply_end_user_tool_ceiling(initial_allowed_tools, server_id, user_api_key_auth)
|
||||
)
|
||||
user_allowed_tools: Final = _as_list(
|
||||
await MCPRequestHandler._apply_user_tool_ceiling(
|
||||
end_user_allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
|
||||
)
|
||||
)
|
||||
caller_allowed_tools: Final = _as_list(
|
||||
await MCPRequestHandler.apply_agent_caller_tool_ceiling(user_allowed_tools, server_id, user_api_key_auth)
|
||||
)
|
||||
return await MCPRequestHandler._apply_agent_and_org_tool_ceilings(
|
||||
caller_allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _apply_agent_and_org_tool_ceilings(
|
||||
allowed_tools: list[str] | None,
|
||||
|
|
@ -3069,6 +3438,8 @@ class MCPRequestHandler:
|
|||
@staticmethod
|
||||
async def _get_allowed_mcp_servers_for_team(
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
*,
|
||||
require_readable_policy: bool = False,
|
||||
) -> list[str]:
|
||||
"""Get allowed MCP servers a caller inherits from the team it is pinned to.
|
||||
|
||||
|
|
@ -3080,7 +3451,24 @@ class MCPRequestHandler:
|
|||
team_ids: Final = await MCPRequestHandler._team_ids_for_mcp_grant(user_api_key_auth)
|
||||
if not team_ids:
|
||||
return []
|
||||
return await MCPRequestHandler._allowed_mcp_servers_for_single_team(team_ids[0], user_api_key_auth)
|
||||
return await MCPRequestHandler._allowed_mcp_servers_for_single_team(
|
||||
team_ids[0],
|
||||
user_api_key_auth,
|
||||
require_readable_policy=require_readable_policy,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _get_allowed_mcp_servers_for_policy(
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
*,
|
||||
require_readable_policy: bool,
|
||||
) -> list[str]:
|
||||
if require_readable_policy:
|
||||
return await MCPRequestHandler._get_allowed_mcp_servers_for_team(
|
||||
user_api_key_auth,
|
||||
require_readable_policy=True,
|
||||
)
|
||||
return await MCPRequestHandler._get_allowed_mcp_servers_for_team(user_api_key_auth)
|
||||
|
||||
@staticmethod
|
||||
async def _team_ids_for_mcp_grant(user_api_key_auth: UserAPIKeyAuth | None) -> list[str]:
|
||||
|
|
@ -3134,6 +3522,7 @@ class MCPRequestHandler:
|
|||
team_access_group_servers: list[str],
|
||||
*,
|
||||
requires_fresh_policy: bool = False,
|
||||
require_readable_policy: bool = False,
|
||||
) -> AbstractSet[str]:
|
||||
"""The raw MCP-server set a team grants (before any org ceiling): its object_permission (direct
|
||||
``mcp_servers``, the ``all_proxy_servers`` sentinel → the full registry, legacy access groups,
|
||||
|
|
@ -3150,10 +3539,11 @@ class MCPRequestHandler:
|
|||
return set(global_mcp_server_manager.get_registry().keys())
|
||||
legacy_access_group_servers: Final = await MCPRequestHandler.get_mcp_servers_from_access_groups(
|
||||
object_permissions.mcp_access_groups or [],
|
||||
requires_fresh_policy=requires_fresh_policy,
|
||||
requires_fresh_policy=requires_fresh_policy or require_readable_policy,
|
||||
)
|
||||
toolset_grants: Final = await MCPRequestHandler._toolset_tool_permissions(
|
||||
object_permissions, requires_fresh_policy=requires_fresh_policy
|
||||
object_permissions,
|
||||
requires_fresh_policy=requires_fresh_policy or require_readable_policy,
|
||||
)
|
||||
servers: Final = (
|
||||
set(global_mcp_server_manager.expand_permission_list(object_permissions.mcp_servers or []))
|
||||
|
|
@ -3168,6 +3558,8 @@ class MCPRequestHandler:
|
|||
async def _allowed_mcp_servers_for_single_team(
|
||||
team_id: str,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
*,
|
||||
require_readable_policy: bool = False,
|
||||
) -> list[str]:
|
||||
"""Allowed MCP servers granted by ONE team (its raw grant, then capped by the team's own org
|
||||
for a keyless admitted subject).
|
||||
|
|
@ -3216,17 +3608,19 @@ class MCPRequestHandler:
|
|||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=bool(user_api_key_auth and user_api_key_auth.requires_fresh_policy),
|
||||
strict_errors=require_readable_policy,
|
||||
)
|
||||
|
||||
servers: Final = await MCPRequestHandler._team_granted_servers(
|
||||
team_obj,
|
||||
team_access_group_servers,
|
||||
requires_fresh_policy=bool(user_api_key_auth and user_api_key_auth.requires_fresh_policy),
|
||||
require_readable_policy=require_readable_policy,
|
||||
)
|
||||
return list(servers)
|
||||
except Exception as e:
|
||||
if isinstance(e, UnloadableEntitlementError) or (
|
||||
user_api_key_auth is not None and user_api_key_auth.requires_fresh_policy
|
||||
require_readable_policy or (user_api_key_auth is not None and user_api_key_auth.requires_fresh_policy)
|
||||
):
|
||||
raise
|
||||
verbose_logger.warning("Failed to get allowed MCP servers for team: %s", e)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from itertools import chain
|
|||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Generic, ParamSpec, TypeAlias, TypeVar, cast
|
||||
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import CacheableResult
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
|
||||
|
||||
|
|
@ -1055,12 +1056,20 @@ async def aggregate_gateway_tools(
|
|||
)
|
||||
|
||||
|
||||
class ToolListingFailedOutright(MCPError):
|
||||
pass
|
||||
|
||||
|
||||
async def list_gateway_tools(
|
||||
context: OperationContext, params: PaginatedRequestParams, *, log_list_tools_to_spendlogs: bool = True
|
||||
) -> ListToolsResult:
|
||||
from mcp.types import ListToolsResult
|
||||
from mcp.types import INTERNAL_ERROR, ListToolsResult
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import SERVER_OUTCOMES_META_KEY, outcome_wire_value
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
|
||||
SERVER_OUTCOMES_META_KEY,
|
||||
listing_failed_outright,
|
||||
outcome_wire_value,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.operations import _list_mcp_tools
|
||||
|
||||
caller, auth, servers, server_headers, oauth_headers, headers, client_ip = context.legacy_auth()
|
||||
|
|
@ -1078,16 +1087,21 @@ async def list_gateway_tools(
|
|||
record_listing=True,
|
||||
list_tools_log_source="mcp_protocol",
|
||||
)
|
||||
wire_outcomes: Final = (
|
||||
{key: outcome_wire_value(outcome) for key, outcome in listing.outcomes.items()} if listing.outcomes else None
|
||||
)
|
||||
if params.cursor is None and listing_failed_outright(listing.outcomes):
|
||||
raise ToolListingFailedOutright(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Every MCP server in scope failed to list tools",
|
||||
data={SERVER_OUTCOMES_META_KEY: wire_outcomes},
|
||||
)
|
||||
return ListToolsResult(
|
||||
tools=listing.tools,
|
||||
next_cursor=listing.next_cursor,
|
||||
ttl_ms=listing.ttl_ms,
|
||||
cache_scope="private",
|
||||
_meta={
|
||||
SERVER_OUTCOMES_META_KEY: {key: outcome_wire_value(outcome) for key, outcome in listing.outcomes.items()}
|
||||
}
|
||||
if listing.outcomes
|
||||
else None,
|
||||
_meta={SERVER_OUTCOMES_META_KEY: wire_outcomes} if wire_outcomes else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -138,5 +138,6 @@ class AuthorizedToolCall:
|
|||
host_progress_callback: ProgressCallback | None
|
||||
guardrail_context: Mapping[str, object] | None
|
||||
logging_data: Mapping[str, object]
|
||||
guardrail_auth: UserAPIKeyAuth | None = None
|
||||
input_responses: InputResponses | None = None
|
||||
request_state: str | None = None
|
||||
|
|
|
|||
|
|
@ -10,17 +10,27 @@ becomes an outcome, never a second failure.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import email.utils
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from typing import Final, Literal, NamedTuple, NoReturn, TypeAlias
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final, Literal, NamedTuple, NoReturn, TypeAlias, TypeVar
|
||||
from urllib.request import parse_http_list, parse_keqv_list
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp.types import Tool as MCPTool
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import (
|
||||
MCP_UPSTREAM_RATE_LIMIT_BASE_DELAY_SECONDS,
|
||||
MCP_UPSTREAM_RATE_LIMIT_MAX_DELAY_SECONDS,
|
||||
MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import (
|
||||
MCPServerListError,
|
||||
MCPUpstreamAuthError,
|
||||
|
|
@ -176,6 +186,113 @@ def classify_list_exception(exc: BaseException) -> ServerListFault:
|
|||
return ServerListFault(tag="internal")
|
||||
|
||||
|
||||
class UpstreamRateLimit(NamedTuple):
|
||||
retry_after_seconds: float | None
|
||||
|
||||
|
||||
def _parse_retry_after(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped: Final = value.strip()
|
||||
if re.fullmatch(r"\d+", stripped):
|
||||
return float(stripped)
|
||||
try:
|
||||
parsed: Final = email.utils.parsedate_to_datetime(stripped)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
aware: Final = parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
|
||||
return max(0.0, (aware - datetime.now(timezone.utc)).total_seconds())
|
||||
|
||||
|
||||
def upstream_rate_limit(exc: BaseException) -> UpstreamRateLimit | None:
|
||||
"""The upstream 429 signal for the tools/list retry loop: an auth challenge wins over rate
|
||||
limiting so a 401/403 anywhere in the tree keeps its own channel, then the first 429 response
|
||||
in deliberate order carries its Retry-After hint. Anything else is not retryable here."""
|
||||
if upstream_auth_challenge(exc) is not None:
|
||||
return None
|
||||
for response in _iter_upstream_responses(exc):
|
||||
if response.status_code == 429:
|
||||
return UpstreamRateLimit(_parse_retry_after(response.headers.get("retry-after")))
|
||||
return None
|
||||
|
||||
|
||||
def rate_limit_retry_delay(
|
||||
attempt: int,
|
||||
retry_after_seconds: float | None,
|
||||
*,
|
||||
base_delay: float,
|
||||
max_delay: float,
|
||||
jitter: float,
|
||||
) -> float:
|
||||
if retry_after_seconds is not None:
|
||||
return min(retry_after_seconds, max_delay)
|
||||
return min(max_delay, base_delay * 2**attempt * (1 + jitter))
|
||||
|
||||
|
||||
T: Final = TypeVar("T")
|
||||
|
||||
|
||||
async def retry_upstream_rate_limit(
|
||||
fetch: Callable[[], Awaitable[T]],
|
||||
server_name: str,
|
||||
*,
|
||||
max_retries: int = MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES,
|
||||
base_delay: float = MCP_UPSTREAM_RATE_LIMIT_BASE_DELAY_SECONDS,
|
||||
max_delay: float = MCP_UPSTREAM_RATE_LIMIT_MAX_DELAY_SECONDS,
|
||||
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
|
||||
jitter: Callable[[], float] = random.random,
|
||||
deadline_seconds: float | None = None,
|
||||
clock: Callable[[], float] = anyio.current_time,
|
||||
) -> T:
|
||||
attempts: Final = max(0, max_retries)
|
||||
deadline: Final = None if deadline_seconds is None else clock() + deadline_seconds
|
||||
last_rate_limit: BaseException | None = None # rebind-ok: each 429 replaces the cause a retry deadline re-raises
|
||||
for attempt in range(attempts + 1):
|
||||
try:
|
||||
if attempt > 0 and deadline is not None:
|
||||
try:
|
||||
with anyio.fail_after(max(0.0, deadline - clock())):
|
||||
return await fetch()
|
||||
except TimeoutError as timeout:
|
||||
if last_rate_limit is None:
|
||||
raise
|
||||
raise last_rate_limit from timeout
|
||||
return await fetch()
|
||||
except Exception as exc:
|
||||
limit = upstream_rate_limit(exc)
|
||||
if limit is not None:
|
||||
last_rate_limit = exc
|
||||
if limit is None or attempt == attempts:
|
||||
raise
|
||||
delay = rate_limit_retry_delay(
|
||||
attempt,
|
||||
limit.retry_after_seconds,
|
||||
base_delay=base_delay,
|
||||
max_delay=max_delay,
|
||||
jitter=jitter(),
|
||||
)
|
||||
if deadline is not None and clock() + delay >= deadline:
|
||||
raise
|
||||
verbose_logger.warning(
|
||||
"MCP server %s answered 429 to tools/list; retry %d/%d in %.2fs",
|
||||
server_name,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
delay,
|
||||
)
|
||||
await sleep(delay)
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def listing_failed_outright(outcomes: Mapping[str, ServerOutcome]) -> bool:
|
||||
"""True only when every server in scope contributed a non-auth fault: an auth outcome means a
|
||||
caller can fix the listing by authenticating, so the aggregate stays a 200 with outcomes."""
|
||||
return bool(outcomes) and all(
|
||||
isinstance(outcome, ServerListFault) and outcome.tag not in ("auth_required", "forbidden")
|
||||
for outcome in outcomes.values()
|
||||
)
|
||||
|
||||
|
||||
def outcome_wire_value(outcome: ServerOutcome) -> dict[str, object]:
|
||||
"""The client-visible form of one outcome, for the tools/list result ``_meta`` and the REST
|
||||
response: category plus status code only, never upstream prose or URLs."""
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ from litellm.proxy._experimental.mcp_server.exceptions import (
|
|||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
|
||||
ServerListFault,
|
||||
raise_classified_list_failure,
|
||||
retry_upstream_rate_limit,
|
||||
upstream_auth_challenge,
|
||||
upstream_insufficient_scope,
|
||||
)
|
||||
|
|
@ -3579,7 +3580,7 @@ class MCPServerManager:
|
|||
*,
|
||||
allow_all_server_ids: list[str] | None = None,
|
||||
submitted_server_ids: list[str] | None = None,
|
||||
) -> set:
|
||||
) -> frozenset[str]:
|
||||
"""Servers reachable through OPEN channels rather than a grant: operator-opened
|
||||
``allow_all_keys`` servers, plus the caller's own active BYOM submissions when the caller
|
||||
carries no explicit ``mcp_servers`` scope.
|
||||
|
|
@ -3600,10 +3601,10 @@ class MCPServerManager:
|
|||
if user_api_key_auth is not None and (
|
||||
user_api_key_auth.mcp_toolset_id is not None or user_api_key_auth.mcp_explicit_grants_only
|
||||
):
|
||||
return set()
|
||||
if allow_all_server_ids is None:
|
||||
allow_all_server_ids = self.get_allow_all_keys_server_ids()
|
||||
open_ids: Final = set(allow_all_server_ids)
|
||||
return frozenset()
|
||||
open_ids: Final = frozenset(
|
||||
allow_all_server_ids if allow_all_server_ids is not None else self.get_allow_all_keys_server_ids()
|
||||
)
|
||||
key_object_permission: Final = user_api_key_auth.object_permission if user_api_key_auth else None
|
||||
# "Explicitly scoped, so do not widen with BYOM" is a rule about a CREDENTIAL that carries
|
||||
# its own mcp_servers list. It does not describe a keyless admitted subject: its
|
||||
|
|
@ -3615,11 +3616,14 @@ class MCPServerManager:
|
|||
and key_object_permission is not None
|
||||
and (key_object_permission.mcp_servers is not None)
|
||||
)
|
||||
if not has_explicit_object_permission:
|
||||
if submitted_server_ids is None:
|
||||
submitted_server_ids = await self._get_active_submitted_mcp_server_ids_for_user(user_api_key_auth)
|
||||
open_ids.update(submitted_server_ids)
|
||||
return open_ids
|
||||
if has_explicit_object_permission:
|
||||
return open_ids
|
||||
submitted_ids: Final = (
|
||||
submitted_server_ids
|
||||
if submitted_server_ids is not None
|
||||
else await self._get_active_submitted_mcp_server_ids_for_user(user_api_key_auth)
|
||||
)
|
||||
return open_ids | frozenset(submitted_ids)
|
||||
|
||||
@staticmethod
|
||||
def _admitted_session_resource_scope(user_api_key_auth: UserAPIKeyAuth | None) -> frozenset[str] | None:
|
||||
|
|
@ -4531,7 +4535,11 @@ class MCPServerManager:
|
|||
)
|
||||
else:
|
||||
page: Final = (
|
||||
await self._fetch_tools_page_with_timeout(client, server, params)
|
||||
await retry_upstream_rate_limit(
|
||||
lambda: self._fetch_tools_page_with_timeout(client, server, params),
|
||||
server.name,
|
||||
deadline_seconds=MCP_TOOL_LISTING_TIMEOUT,
|
||||
)
|
||||
if params is not None
|
||||
else ListToolsResult(tools=await self._fetch_tools_with_timeout(client, server.name))
|
||||
)
|
||||
|
|
@ -5600,7 +5608,11 @@ class MCPServerManager:
|
|||
"""
|
||||
try:
|
||||
with anyio.fail_after(MCP_TOOL_LISTING_TIMEOUT):
|
||||
tools: Final = await client.list_tools(raise_on_error=True)
|
||||
tools: Final = await retry_upstream_rate_limit(
|
||||
lambda: client.list_tools(raise_on_error=True),
|
||||
server_name,
|
||||
deadline_seconds=MCP_TOOL_LISTING_TIMEOUT,
|
||||
)
|
||||
verbose_logger.debug("Tools from %s: %s", server_name, tools)
|
||||
return tools
|
||||
except TimeoutError as e:
|
||||
|
|
@ -6042,6 +6054,7 @@ class MCPServerManager:
|
|||
guardrail_context: Mapping[str, object] | None = None,
|
||||
tool: MCPTool | None = None,
|
||||
incoming_bearer_token: SecretStr | None | EllipsisType = ...,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run pre-call checks and guardrail hooks for an MCP tool call.
|
||||
|
|
@ -6098,6 +6111,27 @@ class MCPServerManager:
|
|||
if proxy_logging_obj is None:
|
||||
return hook_result
|
||||
|
||||
base_guardrail_auth: Final = await MCPRequestHandler.guardrail_auth_for_call(
|
||||
guardrail_auth=guardrail_auth,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
server=server,
|
||||
tool_name=name,
|
||||
)
|
||||
checked_auth: Final = (
|
||||
base_guardrail_auth.model_copy(
|
||||
update={
|
||||
"mcp_source_team_rpm_limits": await MCPRequestHandler.personal_key_team_rpm_limits(
|
||||
user_api_key_auth, server, name
|
||||
)
|
||||
}
|
||||
)
|
||||
if base_guardrail_auth is not None
|
||||
and user_api_key_auth is not None
|
||||
and MCPRequestHandler.is_personal_key_inheriting_team_grants(user_api_key_auth)
|
||||
and base_guardrail_auth.mcp_source_team_rpm_limits is None
|
||||
else base_guardrail_auth
|
||||
)
|
||||
|
||||
caller_bearer: Final = (
|
||||
MCPRequestHandler.get_incoming_bearer_token(raw_headers or {})
|
||||
if incoming_bearer_token is ...
|
||||
|
|
@ -6110,13 +6144,11 @@ class MCPServerManager:
|
|||
"arguments": arguments,
|
||||
"server_name": server_name,
|
||||
"mcp_rate_limit_server_name": server.alias or server.server_name or server.name,
|
||||
"user_api_key_auth": user_api_key_auth,
|
||||
"user_api_key_user_id": (getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None),
|
||||
"user_api_key_team_id": (getattr(user_api_key_auth, "team_id", None) if user_api_key_auth else None),
|
||||
"user_api_key_end_user_id": (
|
||||
getattr(user_api_key_auth, "end_user_id", None) if user_api_key_auth else None
|
||||
),
|
||||
"user_api_key_hash": (getattr(user_api_key_auth, "api_key_hash", None) if user_api_key_auth else None),
|
||||
"user_api_key_auth": checked_auth,
|
||||
"user_api_key_user_id": (getattr(checked_auth, "user_id", None) if checked_auth else None),
|
||||
"user_api_key_team_id": (getattr(checked_auth, "team_id", None) if checked_auth else None),
|
||||
"user_api_key_end_user_id": (getattr(checked_auth, "end_user_id", None) if checked_auth else None),
|
||||
"user_api_key_hash": (getattr(checked_auth, "api_key_hash", None) if checked_auth else None),
|
||||
"incoming_bearer_token": caller_bearer.get_secret_value() if caller_bearer is not None else None,
|
||||
"headers": logging_safe_mcp_headers(raw_headers),
|
||||
"tool_description": tool.description if tool is not None else None,
|
||||
|
|
@ -6137,11 +6169,11 @@ class MCPServerManager:
|
|||
try:
|
||||
# Use standard pre_call_hook
|
||||
modified_data: Final = await proxy_logging_obj.pre_call_hook(
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
user_api_key_dict=checked_auth,
|
||||
data=synthetic_llm_data,
|
||||
call_type=CallTypes.call_mcp_tool.value,
|
||||
)
|
||||
await proxy_logging_obj.enforce_mcp_server_rate_limits(user_api_key_auth, server)
|
||||
await proxy_logging_obj.enforce_mcp_server_rate_limits(checked_auth, server)
|
||||
if modified_data:
|
||||
# Convert response back to MCP format and apply modifications
|
||||
modified_kwargs: Final = proxy_logging_obj.convert_mcp_hook_response_to_kwargs(
|
||||
|
|
@ -6902,6 +6934,7 @@ class MCPServerManager:
|
|||
input_responses: InputResponses | None = None,
|
||||
request_state: str | None = None,
|
||||
*,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
catalog_auth_header: str | None | EllipsisType = ...,
|
||||
listed_tool: MCPTool | None | EllipsisType = ...,
|
||||
incoming_bearer_token: SecretStr | None | EllipsisType = ...,
|
||||
|
|
@ -6929,6 +6962,12 @@ class MCPServerManager:
|
|||
"""
|
||||
start_time: Final = datetime.datetime.now()
|
||||
mcp_server: Final = self._resolve_mcp_server_for_tool_call(server_name, name)
|
||||
effective_guardrail_auth: Final = await MCPRequestHandler.guardrail_auth_for_call(
|
||||
guardrail_auth=guardrail_auth,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
server=mcp_server,
|
||||
tool_name=name,
|
||||
)
|
||||
client_auth_header: Final = _catalog_auth_header(mcp_auth_header, catalog_auth_header)
|
||||
|
||||
# Resolved before any hook runs so a missing BYOK credential (401) never
|
||||
|
|
@ -6960,6 +6999,7 @@ class MCPServerManager:
|
|||
guardrail_context=guardrail_context,
|
||||
tool=self.get_listed_tool(mcp_server, name, listed_caller) if listed_tool is ... else listed_tool,
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
guardrail_auth=effective_guardrail_auth,
|
||||
)
|
||||
if "arguments" in hook_result:
|
||||
arguments = hook_result["arguments"]
|
||||
|
|
@ -6971,7 +7011,7 @@ class MCPServerManager:
|
|||
name=name,
|
||||
arguments=arguments,
|
||||
server_name_from_prefix=server_name,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
user_api_key_auth=effective_guardrail_auth,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
start_time=start_time,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import time
|
|||
import traceback
|
||||
import types
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from functools import partial, reduce
|
||||
from types import EllipsisType
|
||||
from typing import Any, Final, NoReturn, TypeAlias, overload
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ from mcp.types import (
|
|||
ToolsCapability,
|
||||
)
|
||||
from mcp.types import Tool as MCPTool
|
||||
from pydantic import AnyUrl, ConfigDict, Field, SecretStr, TypeAdapter
|
||||
from pydantic import AnyUrl, ConfigDict, Field, JsonValue, SecretStr, TypeAdapter
|
||||
from typing_extensions import ReadOnly, TypedDict, assert_never
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -317,6 +317,7 @@ async def _build_virtual_call_logging_obj(
|
|||
name: str,
|
||||
arguments: dict[str, object],
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
logging_auth: UserAPIKeyAuth | None = None,
|
||||
raw_headers: Mapping[str, str] | None = None,
|
||||
client_ip: str | None = None,
|
||||
) -> LiteLLMLoggingObj | None:
|
||||
|
|
@ -336,7 +337,7 @@ async def _build_virtual_call_logging_obj(
|
|||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
_, virtual_logging_obj = await ProxyBaseLLMRequestProcessing(
|
||||
pre_call_result: Final = await ProxyBaseLLMRequestProcessing(
|
||||
data={"name": name, "arguments": arguments}
|
||||
).common_processing_pre_call_logic(
|
||||
request=request,
|
||||
|
|
@ -345,10 +346,67 @@ async def _build_virtual_call_logging_obj(
|
|||
route_type=CallTypes.call_mcp_tool.value,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
general_settings=general_settings,
|
||||
logging_auth=logging_auth,
|
||||
)
|
||||
_, logging_candidate = TypeAdapter(tuple[dict[str, object], object]).validate_python(pre_call_result)
|
||||
if not isinstance(logging_candidate, LiteLLMLoggingObj):
|
||||
verbose_logger.warning("MCP request processing did not return a logging object")
|
||||
return None
|
||||
virtual_logging_obj: Final = logging_candidate
|
||||
return virtual_logging_obj
|
||||
|
||||
|
||||
async def _log_virtual_mcp_tool_failure(
|
||||
*,
|
||||
logging_obj: LiteLLMLoggingObj | None,
|
||||
exc: Exception,
|
||||
call_start: datetime,
|
||||
name: str,
|
||||
arguments: dict[str, object] | None,
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
raw_headers: Mapping[str, str] | None,
|
||||
client_ip: str | None,
|
||||
) -> None:
|
||||
try:
|
||||
failure_logging_obj: Final = (
|
||||
logging_obj
|
||||
if logging_obj is not None
|
||||
else await _build_virtual_call_logging_obj(
|
||||
name=name,
|
||||
arguments=arguments or {},
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
)
|
||||
if failure_logging_obj is None:
|
||||
return
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj as request_logging_obj
|
||||
|
||||
failure_end: Final = datetime.now() # noqa: DTZ005 # matches the logging pipeline start time
|
||||
failure_traceback: Final = traceback.format_exc(limit=MAXIMUM_TRACEBACK_LINES_TO_LOG)
|
||||
failure_logging_obj.failure_handler(exc, failure_traceback, call_start, failure_end)
|
||||
await failure_logging_obj.async_failure_handler(exc, failure_traceback, call_start, failure_end)
|
||||
if isinstance(exc, MCPUpstreamAuthError):
|
||||
return
|
||||
failure_hook: Final = TypeAdapter(Callable[..., Awaitable[HTTPException | None]]).validate_python(
|
||||
request_logging_obj.post_call_failure_hook
|
||||
)
|
||||
await failure_hook(
|
||||
request_data={
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
"litellm_logging_obj": failure_logging_obj,
|
||||
},
|
||||
original_exception=exc,
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
route="/mcp/call_tool",
|
||||
traceback_str=failure_traceback,
|
||||
)
|
||||
except Exception: # noqa: BLE001 # failure logging must not mask the tool call error
|
||||
verbose_logger.exception("Error logging failed MCP proxy tool call")
|
||||
|
||||
|
||||
async def _dispatch_virtual_mcp_tool(
|
||||
name: str,
|
||||
arguments: dict[str, object] | None,
|
||||
|
|
@ -393,11 +451,51 @@ async def _dispatch_virtual_mcp_tool(
|
|||
if mcp_proxy_mode and name in MCP_PROXY_TOOL_NAMES:
|
||||
assert user_api_key_auth is not None
|
||||
proxy_call_start: Final = datetime.now() # noqa: DTZ005 # logging pipeline uses naive datetimes
|
||||
proxy_arguments: Final = arguments or {}
|
||||
proxy_arguments_json: Final = TypeAdapter(dict[str, JsonValue]).validate_python(proxy_arguments)
|
||||
proxy_tool_id: Final = proxy_arguments.get("tool_id")
|
||||
try:
|
||||
proxy_target_tool: Final = (
|
||||
await find_mcp_proxy_tool(
|
||||
tool_id=proxy_tool_id,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
if name == MCP_PROXY_CALL_TOOL_NAME and isinstance(proxy_tool_id, str)
|
||||
else None
|
||||
)
|
||||
proxy_guardrail_auth: Final = (
|
||||
await MCPRequestHandler.billing_auth_for_tool_call(
|
||||
user_api_key_auth,
|
||||
tool_name=proxy_target_tool.name,
|
||||
mcp_servers=mcp_servers,
|
||||
)
|
||||
if proxy_target_tool is not None
|
||||
else user_api_key_auth
|
||||
)
|
||||
except Exception as exc:
|
||||
await _log_virtual_mcp_tool_failure(
|
||||
logging_obj=None,
|
||||
exc=exc,
|
||||
call_start=proxy_call_start,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
raise
|
||||
proxy_logging_obj: Final = (
|
||||
await _build_virtual_call_logging_obj(
|
||||
name=name,
|
||||
arguments=arguments or {},
|
||||
arguments=proxy_arguments,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
logging_auth=proxy_guardrail_auth,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
|
@ -407,7 +505,7 @@ async def _dispatch_virtual_mcp_tool(
|
|||
try:
|
||||
proxy_result: Final = await handle_mcp_proxy_tool(
|
||||
name=name,
|
||||
arguments=arguments or {},
|
||||
arguments=proxy_arguments_json,
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
client_ip=client_ip,
|
||||
mcp_servers=mcp_servers,
|
||||
|
|
@ -417,30 +515,21 @@ async def _dispatch_virtual_mcp_tool(
|
|||
raw_headers=raw_headers,
|
||||
litellm_logging_obj=proxy_logging_obj,
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
guardrail_auth=proxy_guardrail_auth,
|
||||
resolved_tool=proxy_target_tool if name == MCP_PROXY_CALL_TOOL_NAME else ...,
|
||||
)
|
||||
except Exception as exc:
|
||||
if proxy_logging_obj is not None:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj as request_logging_obj
|
||||
|
||||
failure_end: Final = datetime.now() # noqa: DTZ005 # matches the logging pipeline start time
|
||||
failure_traceback: Final = traceback.format_exc(limit=MAXIMUM_TRACEBACK_LINES_TO_LOG)
|
||||
try:
|
||||
proxy_logging_obj.failure_handler(exc, failure_traceback, proxy_call_start, failure_end)
|
||||
await proxy_logging_obj.async_failure_handler(exc, failure_traceback, proxy_call_start, failure_end)
|
||||
if not isinstance(exc, MCPUpstreamAuthError):
|
||||
await request_logging_obj.post_call_failure_hook(
|
||||
request_data={
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
"litellm_logging_obj": proxy_logging_obj,
|
||||
},
|
||||
original_exception=exc,
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
route="/mcp/call_tool",
|
||||
traceback_str=failure_traceback,
|
||||
)
|
||||
except Exception: # noqa: BLE001 # a failing failure hook must not mask the tool call's own error
|
||||
verbose_logger.exception("Error logging failed MCP proxy tool call")
|
||||
await _log_virtual_mcp_tool_failure(
|
||||
logging_obj=proxy_logging_obj,
|
||||
exc=exc,
|
||||
call_start=proxy_call_start,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
user_api_key_auth=proxy_guardrail_auth,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
raise
|
||||
if proxy_logging_obj is not None:
|
||||
return await _fire_mcp_tool_call_logging(
|
||||
|
|
@ -448,8 +537,8 @@ async def _dispatch_virtual_mcp_tool(
|
|||
result=proxy_result,
|
||||
start_time=proxy_call_start,
|
||||
end_time=datetime.now(), # noqa: DTZ005 # matches the logging pipeline start time
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
request_data=types.MappingProxyType({"name": name, "arguments": arguments}),
|
||||
user_api_key_auth=proxy_guardrail_auth,
|
||||
request_data=types.MappingProxyType({"name": name, "arguments": proxy_arguments}),
|
||||
)
|
||||
return proxy_result
|
||||
|
||||
|
|
@ -498,16 +587,22 @@ async def _dispatch_virtual_mcp_tool(
|
|||
top_k=coerce_top_k(args.get("top_k", DEFAULT_SKILL_SEARCH_TOP_K), default=DEFAULT_SKILL_SEARCH_TOP_K),
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
)
|
||||
tool_request: Final = TypeAdapter(CallToolRequestParams).validate_python(
|
||||
{"name": args.get("tool_name", ""), "arguments": args.get("arguments") or {}}
|
||||
)
|
||||
tool_guardrail_auth: Final = await MCPRequestHandler.billing_auth_for_tool_call(
|
||||
user_api_key_auth,
|
||||
tool_name=tool_request.name,
|
||||
mcp_servers=mcp_servers,
|
||||
)
|
||||
virtual_logging_obj: Final = await _build_virtual_call_logging_obj(
|
||||
name=name,
|
||||
arguments=args,
|
||||
name=tool_request.name,
|
||||
arguments=tool_request.arguments or {},
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
logging_auth=tool_guardrail_auth,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
tool_request: Final = CallToolRequestParams.model_validate(
|
||||
types.MappingProxyType({"name": args.get("tool_name", ""), "arguments": args.get("arguments") or {}})
|
||||
)
|
||||
return await handle_mcp_tool_call(
|
||||
tool_name=tool_request.name,
|
||||
arguments=tool_request.arguments or {},
|
||||
|
|
@ -520,6 +615,7 @@ async def _dispatch_virtual_mcp_tool(
|
|||
raw_headers=raw_headers,
|
||||
litellm_logging_obj=virtual_logging_obj,
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
guardrail_auth=tool_guardrail_auth,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1775,7 +1871,7 @@ async def _list_mcp_resource_templates(
|
|||
|
||||
def _resolve_display_name_to_original(
|
||||
name: str,
|
||||
allowed_mcp_servers: list[MCPServer],
|
||||
allowed_mcp_servers: Sequence[MCPServer],
|
||||
) -> str:
|
||||
"""Translate a display-name override back to the original prefixed tool name.
|
||||
|
||||
|
|
@ -1792,6 +1888,15 @@ def _resolve_display_name_to_original(
|
|||
return name
|
||||
|
||||
|
||||
def resolve_native_tool_name_for_server(
|
||||
name: str,
|
||||
server: MCPServer,
|
||||
allowed_mcp_servers: Sequence[MCPServer],
|
||||
) -> str:
|
||||
display_name_resolved: Final = _resolve_display_name_to_original(name, allowed_mcp_servers)
|
||||
return strip_known_server_prefix(display_name_resolved, server)
|
||||
|
||||
|
||||
async def _get_byok_credential(
|
||||
mcp_server: MCPServer,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
|
|
@ -1988,6 +2093,7 @@ async def execute_mcp_tool(
|
|||
raw_headers: dict[str, str] | None = None,
|
||||
host_progress_callback: ProgressCallback | None = None,
|
||||
guardrail_context: Mapping[str, object] | None = None,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
client_ip: str | None = None,
|
||||
wire_compat: WireCompat = WireCompat.LEGACY,
|
||||
input_responses: InputResponses | None = None,
|
||||
|
|
@ -2012,6 +2118,7 @@ async def execute_mcp_tool(
|
|||
start_time=start_time,
|
||||
host_progress_callback=host_progress_callback,
|
||||
guardrail_context=guardrail_context,
|
||||
guardrail_auth=guardrail_auth,
|
||||
logging_data=types.MappingProxyType(kwargs),
|
||||
input_responses=input_responses,
|
||||
request_state=request_state,
|
||||
|
|
@ -2031,6 +2138,7 @@ async def _execute_mcp_tool(
|
|||
raw_headers: dict[str, str] | None = None,
|
||||
host_progress_callback: ProgressCallback | None = None,
|
||||
guardrail_context: Mapping[str, object] | None = None,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
client_ip: str | None = None,
|
||||
wire_compat: WireCompat = WireCompat.LEGACY,
|
||||
input_responses: InputResponses | None = None,
|
||||
|
|
@ -2138,7 +2246,7 @@ async def _execute_mcp_tool(
|
|||
break
|
||||
if mcp_server is not None:
|
||||
server_name = mcp_server.name
|
||||
original_tool_name = strip_known_server_prefix(name, mcp_server)
|
||||
original_tool_name = resolve_native_tool_name_for_server(name, mcp_server, allowed_mcp_servers)
|
||||
|
||||
if requested_server is not None:
|
||||
if mcp_server is not None and mcp_server.server_id != requested_server.server_id:
|
||||
|
|
@ -2156,7 +2264,7 @@ async def _execute_mcp_tool(
|
|||
if mcp_server is None:
|
||||
mcp_server = requested_server
|
||||
server_name = requested_server.name
|
||||
original_tool_name = strip_known_server_prefix(name, requested_server)
|
||||
original_tool_name = resolve_native_tool_name_for_server(name, requested_server, allowed_mcp_servers)
|
||||
|
||||
# Only enforce server-level permissions when we can resolve a server
|
||||
if server_name:
|
||||
|
|
@ -2187,6 +2295,14 @@ async def _execute_mcp_tool(
|
|||
name
|
||||
)
|
||||
|
||||
effective_guardrail_auth: Final = await MCPRequestHandler.guardrail_auth_for_call(
|
||||
guardrail_auth=guardrail_auth,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
server=mcp_server,
|
||||
tool_name=original_tool_name,
|
||||
allowed_mcp_servers=allowed_mcp_servers,
|
||||
)
|
||||
|
||||
client_auth_header: Final = mcp_auth_header
|
||||
if mcp_server:
|
||||
standard_logging_mcp_tool_call["mcp_server_cost_info"] = (mcp_server.mcp_info or {}).get("mcp_server_cost_info")
|
||||
|
|
@ -2274,6 +2390,7 @@ async def _execute_mcp_tool(
|
|||
),
|
||||
),
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
guardrail_auth=effective_guardrail_auth,
|
||||
)
|
||||
# `pre_call_tool_check` may return guardrail-modified
|
||||
# arguments; honor them on the local path too.
|
||||
|
|
@ -2322,6 +2439,7 @@ async def _execute_mcp_tool(
|
|||
name=original_tool_name,
|
||||
arguments=arguments,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
guardrail_auth=effective_guardrail_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
catalog_auth_header=client_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
|
|
@ -2331,7 +2449,11 @@ async def _execute_mcp_tool(
|
|||
litellm_logging_obj=litellm_logging_obj,
|
||||
guardrail_context=guardrail_context,
|
||||
host_progress_callback=_guarded_progress_callback(
|
||||
host_progress_callback, litellm_logging_obj, user_api_key_auth, kwargs
|
||||
callback=host_progress_callback,
|
||||
logging_obj=litellm_logging_obj,
|
||||
original_auth=user_api_key_auth,
|
||||
guardrail_auth=effective_guardrail_auth,
|
||||
request_data=kwargs,
|
||||
),
|
||||
wire_compat=wire_compat,
|
||||
input_responses=input_responses,
|
||||
|
|
@ -2410,6 +2532,7 @@ async def _execute_mcp_tool(
|
|||
),
|
||||
),
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
guardrail_auth=effective_guardrail_auth,
|
||||
)
|
||||
if "arguments" in hook_result:
|
||||
arguments = hook_result["arguments"] # pyright: ignore[reportAny] # hook returns untyped args
|
||||
|
|
@ -2422,7 +2545,8 @@ async def _execute_mcp_tool(
|
|||
return await _run_post_mcp_call_guardrails(
|
||||
result=converted,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
original_auth=user_api_key_auth,
|
||||
guardrail_auth=effective_guardrail_auth,
|
||||
request_data=kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -2430,7 +2554,8 @@ async def _execute_mcp_tool(
|
|||
def _guarded_progress_callback(
|
||||
callback: ProgressCallback | None,
|
||||
logging_obj: LiteLLMLoggingObj | None,
|
||||
caller: UserAPIKeyAuth | None,
|
||||
original_auth: UserAPIKeyAuth | None,
|
||||
guardrail_auth: UserAPIKeyAuth | None,
|
||||
request_data: Mapping[str, object],
|
||||
) -> ProgressCallback | None:
|
||||
if callback is None:
|
||||
|
|
@ -2442,7 +2567,11 @@ def _guarded_progress_callback(
|
|||
return
|
||||
try:
|
||||
checked: Final = await _run_post_mcp_call_guardrails(
|
||||
CallToolResult(content=[TextContent(type="text", text=message)]), logging_obj, caller, request_data
|
||||
CallToolResult(content=[TextContent(type="text", text=message)]),
|
||||
litellm_logging_obj=logging_obj,
|
||||
original_auth=original_auth,
|
||||
guardrail_auth=guardrail_auth,
|
||||
request_data=request_data,
|
||||
)
|
||||
filtered: Final = TextContent.model_validate(checked.content[0]).text
|
||||
except Exception: # noqa: BLE001 # any guardrail failure must withhold unverified progress text
|
||||
|
|
@ -2457,7 +2586,8 @@ def _guarded_progress_callback(
|
|||
async def _run_post_mcp_call_guardrails(
|
||||
result: CallToolResult,
|
||||
litellm_logging_obj: LiteLLMLoggingObj | None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
original_auth: UserAPIKeyAuth | None,
|
||||
guardrail_auth: UserAPIKeyAuth | None,
|
||||
request_data: Mapping[str, object],
|
||||
) -> CallToolResult:
|
||||
"""Run ``post_mcp_call`` guardrails over an executed tool result.
|
||||
|
|
@ -2472,15 +2602,139 @@ async def _run_post_mcp_call_guardrails(
|
|||
|
||||
if proxy_logging_obj is None:
|
||||
return result
|
||||
post_auth: Final = guardrail_auth if guardrail_auth is not None else original_auth
|
||||
if post_auth is original_auth:
|
||||
request_data_for_hook: Final = (
|
||||
litellm_logging_obj.model_call_details if litellm_logging_obj is not None else dict(request_data)
|
||||
)
|
||||
return await proxy_logging_obj.post_mcp_call_hook(
|
||||
response=result,
|
||||
request_data=request_data_for_hook,
|
||||
user_api_key_dict=post_auth,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import add_guardrails_from_auth_metadata
|
||||
|
||||
base_request_data: Final[Mapping[str, object]] = (
|
||||
litellm_logging_obj.model_call_details if litellm_logging_obj is not None else request_data
|
||||
)
|
||||
request_metadata: Final[object | None] = base_request_data.get("metadata")
|
||||
metadata: Final[Mapping[str, object]] = (
|
||||
TypeAdapter(Mapping[str, object]).validate_python(request_metadata)
|
||||
if isinstance(request_metadata, Mapping)
|
||||
else {}
|
||||
)
|
||||
post_auth_metadata: Final[Mapping[str, object]] = (
|
||||
TypeAdapter(Mapping[str, object]).validate_python(post_auth.metadata)
|
||||
if post_auth is not None and post_auth.metadata is not None
|
||||
else {}
|
||||
)
|
||||
post_auth_team_metadata: Final[Mapping[str, object]] = (
|
||||
TypeAdapter(Mapping[str, object]).validate_python(post_auth.team_metadata)
|
||||
if post_auth is not None and post_auth.team_metadata is not None
|
||||
else {}
|
||||
)
|
||||
post_auth_project_metadata: Final[Mapping[str, object]] = (
|
||||
TypeAdapter(Mapping[str, object]).validate_python(post_auth.project_metadata)
|
||||
if post_auth is not None and post_auth.project_metadata is not None
|
||||
else {}
|
||||
)
|
||||
auth_guardrail_data: Final[dict[str, object]] = {"metadata": {}} # mutable-ok: resolver writes metadata
|
||||
if post_auth is not None:
|
||||
auth_for_guardrail_resolution: Final = post_auth.model_copy(
|
||||
update={
|
||||
"metadata": _guardrail_metadata_for_legacy_resolver(post_auth_metadata),
|
||||
"team_metadata": _guardrail_metadata_for_legacy_resolver(post_auth_team_metadata),
|
||||
"project_metadata": _guardrail_metadata_for_legacy_resolver(post_auth_project_metadata),
|
||||
}
|
||||
)
|
||||
add_guardrails_from_auth_metadata(
|
||||
user_api_key_dict=auth_for_guardrail_resolution,
|
||||
data=auth_guardrail_data,
|
||||
metadata_variable_name="metadata",
|
||||
)
|
||||
auth_metadata: Final = auth_guardrail_data.get("metadata")
|
||||
resolved_auth_metadata: Final[Mapping[str, object]] = (
|
||||
TypeAdapter(Mapping[str, object]).validate_python(auth_metadata) if isinstance(auth_metadata, Mapping) else {}
|
||||
)
|
||||
request_guardrails: Final = _guardrails_from_metadata(metadata)
|
||||
request_guardrail_names: Final = frozenset[str]().union(
|
||||
*(_guardrail_names_from_entry(entry) for entry in request_guardrails)
|
||||
)
|
||||
auth_guardrails: Final = _deduplicate_auth_guardrails(
|
||||
(
|
||||
*_auth_guardrails_from_metadata(post_auth_metadata),
|
||||
*_auth_guardrails_from_metadata(post_auth_team_metadata),
|
||||
*_auth_guardrails_from_metadata(post_auth_project_metadata),
|
||||
*_auth_guardrails_from_metadata(resolved_auth_metadata),
|
||||
),
|
||||
request_guardrail_names,
|
||||
)
|
||||
merged_guardrails: Final = (*request_guardrails, *auth_guardrails)
|
||||
guardrail_request_data: Final = {
|
||||
**base_request_data,
|
||||
"metadata": {
|
||||
**metadata,
|
||||
**resolved_auth_metadata,
|
||||
"guardrails": list(merged_guardrails),
|
||||
},
|
||||
}
|
||||
return await proxy_logging_obj.post_mcp_call_hook(
|
||||
response=result,
|
||||
request_data=(
|
||||
litellm_logging_obj.model_call_details if litellm_logging_obj is not None else dict(request_data)
|
||||
),
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
request_data=guardrail_request_data,
|
||||
user_api_key_dict=post_auth,
|
||||
)
|
||||
|
||||
|
||||
def _guardrails_from_metadata(metadata: Mapping[str, object] | None) -> tuple[object, ...]:
|
||||
if metadata is None:
|
||||
return ()
|
||||
guardrails_value: Final = metadata.get("guardrails")
|
||||
if not isinstance(guardrails_value, list):
|
||||
return ()
|
||||
return TypeAdapter(tuple[object, ...]).validate_python(guardrails_value)
|
||||
|
||||
|
||||
def _guardrail_metadata_for_legacy_resolver(metadata: Mapping[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
**metadata,
|
||||
"guardrails": [entry for entry in _guardrails_from_metadata(metadata) if isinstance(entry, str)],
|
||||
}
|
||||
|
||||
|
||||
def _auth_guardrails_from_metadata(metadata: Mapping[str, object]) -> tuple[object, ...]:
|
||||
return tuple(
|
||||
TypeAdapter(Mapping[object, object]).validate_python(guardrail) if isinstance(guardrail, Mapping) else guardrail
|
||||
for guardrail in _guardrails_from_metadata(metadata)
|
||||
if isinstance(guardrail, (str, Mapping))
|
||||
)
|
||||
|
||||
|
||||
def _guardrail_names_from_entry(entry: object) -> frozenset[str]:
|
||||
if isinstance(entry, str):
|
||||
return frozenset({entry})
|
||||
if not isinstance(entry, Mapping):
|
||||
return frozenset()
|
||||
typed_entry: Final[Mapping[object, object]] = TypeAdapter(Mapping[object, object]).validate_python(entry)
|
||||
return frozenset(name for name in typed_entry if isinstance(name, str))
|
||||
|
||||
|
||||
def _deduplicate_auth_guardrails(
|
||||
guardrails: tuple[object, ...],
|
||||
request_guardrail_names: frozenset[str],
|
||||
) -> tuple[object, ...]:
|
||||
def keep_distinct(
|
||||
accumulated: tuple[tuple[object, ...], frozenset[str]],
|
||||
entry: object,
|
||||
) -> tuple[tuple[object, ...], frozenset[str]]:
|
||||
kept_entries, kept_names = accumulated
|
||||
entry_names: Final = _guardrail_names_from_entry(entry)
|
||||
if entry_names & (request_guardrail_names | kept_names):
|
||||
return accumulated
|
||||
return (*kept_entries, entry), kept_names | entry_names
|
||||
|
||||
return reduce(keep_distinct, guardrails, ((), frozenset()))[0]
|
||||
|
||||
|
||||
def suppress_completed_success_logging(logging_obj: LiteLLMLoggingObj) -> None:
|
||||
"""An interim ``InputRequiredResult`` is not a completed call, so the ``@client`` wrapper
|
||||
on ``call_mcp_tool`` must not run the success handlers for it when the coroutine returns."""
|
||||
|
|
@ -2607,6 +2861,14 @@ def _single_scoped_server(
|
|||
return allowed_mcp_servers[0]
|
||||
|
||||
|
||||
async def resolve_scoped_mcp_server(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
mcp_servers: Sequence[str],
|
||||
) -> MCPServer | None:
|
||||
allowed_mcp_servers: Final = await _get_allowed_mcp_servers(user_api_key_auth, mcp_servers)
|
||||
return _single_scoped_server(mcp_servers, allowed_mcp_servers)
|
||||
|
||||
|
||||
@client
|
||||
@catalog_operation(lambda: global_mcp_server_manager)
|
||||
async def call_mcp_tool(
|
||||
|
|
@ -2623,6 +2885,7 @@ async def call_mcp_tool(
|
|||
input_responses: InputResponses | None = None,
|
||||
request_state: str | None = None,
|
||||
incoming_bearer_token: SecretStr | None | EllipsisType = ...,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
**kwargs: Any,
|
||||
) -> CallToolResult | InputRequiredResult:
|
||||
"""
|
||||
|
|
@ -2686,6 +2949,7 @@ async def call_mcp_tool(
|
|||
request_state=request_state,
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
requested_server_id=scoped_server.server_id if scoped_server is not None else None,
|
||||
guardrail_auth=guardrail_auth,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -2874,6 +3138,7 @@ async def _handle_managed_mcp_tool(
|
|||
litellm_logging_obj: LiteLLMLoggingObj | None = None,
|
||||
host_progress_callback: ProgressCallback | None = None,
|
||||
guardrail_context: Mapping[str, object] | None = None,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
client_ip: str | None = None,
|
||||
wire_compat: WireCompat = WireCompat.LEGACY,
|
||||
input_responses: InputResponses | None = None,
|
||||
|
|
@ -2903,6 +3168,7 @@ async def _handle_managed_mcp_tool(
|
|||
host_progress_callback=host_progress_callback,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
guardrail_context=guardrail_context,
|
||||
guardrail_auth=guardrail_auth,
|
||||
wire_compat=wire_compat,
|
||||
input_responses=input_responses,
|
||||
request_state=request_state,
|
||||
|
|
@ -3041,6 +3307,19 @@ async def _execute_handle_list_tools(
|
|||
return ListToolsResult(tools=[])
|
||||
|
||||
|
||||
async def _discover_tools_listing(
|
||||
context: OperationContext, host_progress_callback: ProgressCallback | None
|
||||
) -> ListToolsResult:
|
||||
from litellm.proxy._experimental.mcp_server.catalog import ToolListingFailedOutright
|
||||
|
||||
try:
|
||||
return await _execute_handle_list_tools(
|
||||
context, PaginatedRequestParams(), host_progress_callback, log_list_tools_to_spendlogs=False
|
||||
)
|
||||
except ToolListingFailedOutright:
|
||||
return ListToolsResult(tools=[])
|
||||
|
||||
|
||||
async def _execute_mcp_server_tool_call(
|
||||
context: OperationContext, params: CallToolRequestParams, host_progress_callback: ProgressCallback | None = None
|
||||
) -> CallToolResult | InputRequiredResult:
|
||||
|
|
@ -3099,7 +3378,16 @@ async def _execute_mcp_server_tool_call(
|
|||
raw_headers=raw_headers,
|
||||
client_ip=_client_ip,
|
||||
)
|
||||
if user_api_key_auth is not None:
|
||||
guardrail_auth: Final = (
|
||||
await MCPRequestHandler.billing_auth_for_tool_call(
|
||||
user_api_key_auth,
|
||||
tool_name=params.name,
|
||||
mcp_servers=mcp_servers,
|
||||
)
|
||||
if user_api_key_auth is not None
|
||||
else None
|
||||
)
|
||||
if guardrail_auth is not None:
|
||||
data = await add_litellm_data_to_request(
|
||||
data=body_data,
|
||||
request=request,
|
||||
|
|
@ -3111,9 +3399,7 @@ async def _execute_mcp_server_tool_call(
|
|||
# half (an already-over-budget team stops granting) lives in the source gate.
|
||||
# Authorization is unaffected: it ran before this, and the union is resolved
|
||||
# from the untouched auth object passed to call_mcp_tool below.
|
||||
user_api_key_dict=await MCPRequestHandler.billing_auth_for_tool_call(
|
||||
user_api_key_auth, tool_name=params.name
|
||||
),
|
||||
user_api_key_dict=guardrail_auth,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
else:
|
||||
|
|
@ -3132,6 +3418,7 @@ async def _execute_mcp_server_tool_call(
|
|||
input_responses=params.input_responses,
|
||||
request_state=params.request_state,
|
||||
incoming_bearer_token=context.incoming_bearer_token,
|
||||
guardrail_auth=guardrail_auth,
|
||||
**data, # for logging
|
||||
)
|
||||
except MCPMissingUserEnvVarsError as e:
|
||||
|
|
@ -3517,14 +3804,7 @@ class GatewayOperations:
|
|||
)
|
||||
memo_token: Final = _mcp_server_admission_memo.set({})
|
||||
tasks: Final = (
|
||||
asyncio.create_task(
|
||||
_execute_handle_list_tools(
|
||||
context,
|
||||
PaginatedRequestParams(),
|
||||
self._host_progress_callback,
|
||||
log_list_tools_to_spendlogs=False,
|
||||
)
|
||||
),
|
||||
asyncio.create_task(_discover_tools_listing(context, self._host_progress_callback)),
|
||||
*(asyncio.create_task(self.execute(listing, context)) for listing in listings),
|
||||
)
|
||||
try:
|
||||
|
|
@ -3593,6 +3873,7 @@ class GatewayOperations:
|
|||
client_ip=_client_ip,
|
||||
host_progress_callback=operation.host_progress_callback,
|
||||
guardrail_context=operation.guardrail_context,
|
||||
guardrail_auth=operation.guardrail_auth,
|
||||
wire_compat=context.wire_compat,
|
||||
input_responses=operation.input_responses,
|
||||
request_state=operation.request_state,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
|
@ -12,7 +13,7 @@ import anyio
|
|||
import httpx
|
||||
import httpx2
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import ValidationError
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from starlette.datastructures import Headers
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ from litellm.proxy._experimental.mcp_server.ui_session_utils import (
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
MCPMissingUserEnvVarsError,
|
||||
add_server_prefix_to_name,
|
||||
get_server_prefix,
|
||||
merge_mcp_headers,
|
||||
)
|
||||
|
|
@ -62,7 +64,7 @@ from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitErro
|
|||
from litellm.responses.mcp.request_context import MCPRequestContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.types import CallToolResult
|
||||
from mcp.types import CallToolResult, InputRequiredResult
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
|
||||
|
|
@ -226,6 +228,7 @@ if MCP_AVAILABLE:
|
|||
from mcp.types import Tool as MCPTool
|
||||
|
||||
from litellm.experimental_mcp_client.client import MCPClient, as_mcp_read_timeout
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.litellm_proxy.skills.skill_search import (
|
||||
DEFAULT_SKILL_SEARCH_TOP_K,
|
||||
)
|
||||
|
|
@ -249,6 +252,7 @@ if MCP_AVAILABLE:
|
|||
filter_tools_by_allowed_tools,
|
||||
filter_tools_by_key_team_permissions,
|
||||
fire_mcp_tool_call_failure_logging,
|
||||
resolve_native_tool_name_for_server,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.server import ( # noqa: F401 # legacy module exports
|
||||
_apply_toolset_scope, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
|
|
@ -1215,6 +1219,102 @@ if MCP_AVAILABLE:
|
|||
"message": f"An unexpected error occurred: {e}",
|
||||
}
|
||||
|
||||
async def _execute_rest_mcp_tool(
|
||||
request: Request,
|
||||
tool_name: str,
|
||||
tool_arguments: Mapping[str, object],
|
||||
allowed_mcp_servers: Sequence[MCPServer],
|
||||
canonical_server_id: str,
|
||||
target_server: MCPServer | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: Mapping[str, object],
|
||||
logging_obj: "LiteLLMLoggingObj | None",
|
||||
guardrail_auth: UserAPIKeyAuth,
|
||||
) -> "tuple[CallToolResult | InputRequiredResult, datetime]":
|
||||
(
|
||||
mcp_auth_header,
|
||||
mcp_server_auth_headers,
|
||||
raw_headers_from_request,
|
||||
caller_oauth2_headers_from_request,
|
||||
) = _extract_mcp_headers_from_request(request, user_api_key_dict)
|
||||
typed_data: Final = TypeAdapter(dict[str, object]).validate_python(data)
|
||||
request_metadata: Final = TypeAdapter(dict[str, object]).validate_python(typed_data.get("metadata") or {})
|
||||
user_api_key_auth_from_metadata: Final = request_metadata.get("user_api_key_auth")
|
||||
request_auth: Final = (
|
||||
{"user_api_key_auth": TypeAdapter(UserAPIKeyAuth).validate_python(user_api_key_auth_from_metadata)}
|
||||
if user_api_key_auth_from_metadata is not None
|
||||
else {}
|
||||
)
|
||||
request_headers: Final = {
|
||||
**({"mcp_auth_header": mcp_auth_header} if mcp_auth_header else {}),
|
||||
**({"mcp_server_auth_headers": mcp_server_auth_headers} if mcp_server_auth_headers else {}),
|
||||
"raw_headers": raw_headers_from_request,
|
||||
}
|
||||
execution_data: Final = MappingProxyType({**typed_data, **request_auth, **request_headers})
|
||||
_stamp_mcp_tool_metadata(logging_obj, canonical_server_id, tool_name)
|
||||
user_oauth_extra_headers: Final = (
|
||||
await _get_user_oauth_extra_headers(target_server, user_api_key_dict) if target_server is not None else None
|
||||
)
|
||||
caller_oauth2_headers: Final = (
|
||||
caller_oauth2_headers_from_request
|
||||
if target_server is not None and target_server.auth_type in _CLIENT_FORWARDED_TOKEN_AUTH_TYPES
|
||||
else None
|
||||
)
|
||||
tool_start_time: Final = datetime.now()
|
||||
executed: Final = await execute_mcp_tool(
|
||||
name=tool_name,
|
||||
arguments=TypeAdapter(dict[str, object]).validate_python(tool_arguments),
|
||||
allowed_mcp_servers=list(allowed_mcp_servers),
|
||||
start_time=tool_start_time,
|
||||
user_api_key_auth=TypeAdapter(UserAPIKeyAuth | None).validate_python(
|
||||
execution_data.get("user_api_key_auth")
|
||||
),
|
||||
mcp_auth_header=TypeAdapter(str | None).validate_python(execution_data.get("mcp_auth_header")),
|
||||
mcp_server_auth_headers=TypeAdapter(dict[str, dict[str, str]] | None).validate_python(
|
||||
execution_data.get("mcp_server_auth_headers")
|
||||
),
|
||||
oauth2_headers=user_oauth_extra_headers or caller_oauth2_headers,
|
||||
raw_headers=TypeAdapter(dict[str, str] | None).validate_python(execution_data.get("raw_headers")),
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
litellm_logging_obj=logging_obj,
|
||||
guardrail_context=MCPRequestContext.resolve_guardrail_context(execution_data),
|
||||
requested_server_id=canonical_server_id,
|
||||
incoming_bearer_token=MCPRequestHandler.get_guardrail_bearer_token(request.headers, user_api_key_dict),
|
||||
guardrail_auth=guardrail_auth,
|
||||
)
|
||||
return executed, tool_start_time
|
||||
|
||||
async def _rest_tool_call_server_and_auth(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
allowed_mcp_servers: Sequence[MCPServer],
|
||||
canonical_server_id: str,
|
||||
tool_name: str,
|
||||
) -> tuple[MCPServer | None, UserAPIKeyAuth]:
|
||||
|
||||
target_server: Final = next(
|
||||
(server for server in allowed_mcp_servers if server.server_id == canonical_server_id),
|
||||
None,
|
||||
)
|
||||
if target_server is None:
|
||||
return None, user_api_key_auth
|
||||
native_tool_name: Final = resolve_native_tool_name_for_server(
|
||||
tool_name,
|
||||
target_server,
|
||||
allowed_mcp_servers,
|
||||
)
|
||||
billing_tool_name: Final = add_server_prefix_to_name(
|
||||
native_tool_name,
|
||||
get_server_prefix(target_server),
|
||||
)
|
||||
guardrail_auth: Final = await MCPRequestHandler.billing_auth_for_tool_call(
|
||||
user_api_key_auth,
|
||||
tool_name=billing_tool_name,
|
||||
scoped_server=target_server,
|
||||
native_tool_name=native_tool_name,
|
||||
allowed_mcp_servers=allowed_mcp_servers,
|
||||
)
|
||||
return target_server, guardrail_auth
|
||||
|
||||
@router.post("/tools/call", dependencies=[Depends(user_api_key_auth)])
|
||||
@catalog_operation(global_manager)
|
||||
async def call_tool_rest_api(
|
||||
|
|
@ -1279,9 +1379,48 @@ if MCP_AVAILABLE:
|
|||
_request_start_time: Final = datetime.now() # noqa: DTZ005 # naive to match the tool start time below
|
||||
try:
|
||||
(
|
||||
data,
|
||||
logging_obj,
|
||||
) = await proxy_base_llm_response_processor.common_processing_pre_call_logic(
|
||||
allowed_mcp_servers,
|
||||
canonical_server_id,
|
||||
) = await _resolve_allowed_mcp_servers_with_ip_filter(request, user_api_key_dict, server_id)
|
||||
except Exception as access_error:
|
||||
try:
|
||||
failure_pre_call_result: Final = (
|
||||
await proxy_base_llm_response_processor.common_processing_pre_call_logic(
|
||||
request=request,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=proxy_config,
|
||||
route_type=CallTypes.call_mcp_tool.value,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
general_settings=general_settings,
|
||||
skip_guardrails=True,
|
||||
logging_auth=user_api_key_dict,
|
||||
)
|
||||
)
|
||||
failure_data, failure_logging_candidate = TypeAdapter(
|
||||
tuple[dict[str, object], object]
|
||||
).validate_python(failure_pre_call_result)
|
||||
if isinstance(failure_logging_candidate, LiteLLMLoggingObj):
|
||||
_stamp_mcp_tool_metadata(failure_logging_candidate, server_id, tool_name)
|
||||
await _safe_fire_mcp_tool_call_failure_logging(
|
||||
failure_logging_candidate,
|
||||
access_error,
|
||||
_request_start_time,
|
||||
user_api_key_dict,
|
||||
failure_data,
|
||||
)
|
||||
else:
|
||||
verbose_logger.warning("MCP request processing did not return a logging object")
|
||||
except Exception as logging_error: # noqa: BLE001 # logging failures must not mask the access error
|
||||
verbose_logger.warning("MCP tool call access-failure logging failed: %s", logging_error)
|
||||
raise
|
||||
target_server, guardrail_auth = await _rest_tool_call_server_and_auth(
|
||||
user_api_key_dict,
|
||||
allowed_mcp_servers,
|
||||
canonical_server_id,
|
||||
tool_name,
|
||||
)
|
||||
try:
|
||||
pre_call_result: Final = await proxy_base_llm_response_processor.common_processing_pre_call_logic(
|
||||
request=request,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=proxy_config,
|
||||
|
|
@ -1289,81 +1428,53 @@ if MCP_AVAILABLE:
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
general_settings=general_settings,
|
||||
skip_guardrails=True,
|
||||
logging_auth=guardrail_auth,
|
||||
)
|
||||
|
||||
# Extract MCP auth headers from request and add to data dict
|
||||
(
|
||||
mcp_auth_header,
|
||||
mcp_server_auth_headers,
|
||||
raw_headers_from_request,
|
||||
caller_oauth2_headers_from_request,
|
||||
) = _extract_mcp_headers_from_request(request, user_api_key_dict)
|
||||
if mcp_auth_header:
|
||||
data["mcp_auth_header"] = mcp_auth_header
|
||||
if mcp_server_auth_headers:
|
||||
data["mcp_server_auth_headers"] = mcp_server_auth_headers
|
||||
data["raw_headers"] = raw_headers_from_request
|
||||
|
||||
# Extract user_api_key_auth from metadata and add to top level
|
||||
# call_mcp_tool expects user_api_key_auth as a top-level parameter
|
||||
if "metadata" in data and "user_api_key_auth" in data["metadata"]:
|
||||
data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"]
|
||||
|
||||
_stamp_mcp_tool_metadata(logging_obj, server_id, tool_name)
|
||||
|
||||
# Resolve allowed MCP servers with IP filtering
|
||||
(
|
||||
allowed_mcp_servers,
|
||||
canonical_server_id,
|
||||
) = await _resolve_allowed_mcp_servers_with_ip_filter(request, user_api_key_dict, server_id)
|
||||
|
||||
# Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
|
||||
user_oauth_extra_headers: dict[str, str] | None = None
|
||||
target_server: Final = next(
|
||||
(s for s in allowed_mcp_servers if s.server_id == canonical_server_id),
|
||||
None,
|
||||
)
|
||||
if target_server is not None:
|
||||
user_oauth_extra_headers = await _get_user_oauth_extra_headers(target_server, user_api_key_dict)
|
||||
caller_oauth2_headers: Final = (
|
||||
caller_oauth2_headers_from_request
|
||||
if target_server is not None and target_server.auth_type in _CLIENT_FORWARDED_TOKEN_AUTH_TYPES
|
||||
else None
|
||||
data, logging_candidate = TypeAdapter(tuple[dict[str, object], object]).validate_python(pre_call_result)
|
||||
logging_obj: Final[LiteLLMLoggingObj | None] = (
|
||||
logging_candidate if isinstance(logging_candidate, LiteLLMLoggingObj) else None
|
||||
)
|
||||
if logging_obj is None:
|
||||
verbose_logger.warning("MCP request processing did not return a logging object")
|
||||
|
||||
# Call execute_mcp_tool directly (permission checks already done)
|
||||
_tool_start_time: Final = datetime.now()
|
||||
executed: Final = await execute_mcp_tool(
|
||||
name=tool_name,
|
||||
arguments=tool_arguments,
|
||||
executed, _tool_start_time = await _execute_rest_mcp_tool(
|
||||
request=request,
|
||||
tool_name=tool_name,
|
||||
tool_arguments=tool_arguments,
|
||||
allowed_mcp_servers=allowed_mcp_servers,
|
||||
start_time=_tool_start_time,
|
||||
user_api_key_auth=data.get("user_api_key_auth"),
|
||||
mcp_auth_header=data.get("mcp_auth_header"),
|
||||
mcp_server_auth_headers=data.get("mcp_server_auth_headers"),
|
||||
oauth2_headers=user_oauth_extra_headers or caller_oauth2_headers,
|
||||
raw_headers=data.get("raw_headers"),
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
litellm_logging_obj=data.get("litellm_logging_obj"),
|
||||
guardrail_context=MCPRequestContext.resolve_guardrail_context(data),
|
||||
requested_server_id=canonical_server_id,
|
||||
incoming_bearer_token=MCPRequestHandler.get_guardrail_bearer_token(
|
||||
request.headers, user_api_key_dict
|
||||
),
|
||||
canonical_server_id=canonical_server_id,
|
||||
target_server=target_server,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
logging_obj=logging_obj,
|
||||
guardrail_auth=guardrail_auth,
|
||||
)
|
||||
result: Final = complete_call_tool_result(executed, WireCompat.LEGACY)
|
||||
except Exception as e:
|
||||
request_data: Final = proxy_base_llm_response_processor.data
|
||||
await _safe_fire_mcp_tool_call_failure_logging(
|
||||
request_data.get("litellm_logging_obj"), e, _request_start_time, user_api_key_dict, request_data
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
failure: Final = sys.exc_info()[1]
|
||||
if isinstance(failure, Exception):
|
||||
request_data: Final[Mapping[str, object]] = TypeAdapter(Mapping[str, object]).validate_python(
|
||||
proxy_base_llm_response_processor.data
|
||||
)
|
||||
request_logging_obj_candidate: Final = request_data.get("litellm_logging_obj")
|
||||
request_logging_obj: Final[LiteLLMLoggingObj | None] = (
|
||||
request_logging_obj_candidate
|
||||
if isinstance(request_logging_obj_candidate, LiteLLMLoggingObj)
|
||||
else None
|
||||
)
|
||||
await _safe_fire_mcp_tool_call_failure_logging(
|
||||
request_logging_obj,
|
||||
failure,
|
||||
_request_start_time,
|
||||
guardrail_auth,
|
||||
request_data,
|
||||
)
|
||||
return await _safe_fire_mcp_tool_call_logging(
|
||||
logging_obj,
|
||||
result,
|
||||
_tool_start_time,
|
||||
datetime.now(),
|
||||
user_api_key_auth=user_api_key_dict,
|
||||
user_api_key_auth=guardrail_auth,
|
||||
request_data=data,
|
||||
)
|
||||
except MCPMissingUserEnvVarsError as e:
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ from mcp.types import (
|
|||
from litellm.constants import MCP_TOOL_LISTING_MAX_PAGES, MCP_TOOL_LISTING_TIMEOUT
|
||||
from litellm.experimental_mcp_client.client import MCPClient
|
||||
from litellm.proxy._experimental.mcp_server import operations
|
||||
from litellm.proxy._experimental.mcp_server.catalog import (
|
||||
ToolListingFailedOutright,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.catalog import (
|
||||
_configuration_identity as configuration_identity, # pyright: ignore[reportPrivateUsage] # reuse catalog-owned configuration identity without treating discovered metadata as configuration
|
||||
)
|
||||
|
|
@ -283,9 +286,12 @@ async def _listing(event: ServerEvent, context: OperationContext) -> str | None:
|
|||
cursor: str | None = None # rebind-ok: advance the signed gateway cursor for each catalog page
|
||||
with anyio.fail_after(MCP_TOOL_LISTING_TIMEOUT):
|
||||
for _ in range(MCP_TOOL_LISTING_MAX_PAGES):
|
||||
result = await gateway.execute(
|
||||
operation.model_copy(update={"params": PaginatedRequestParams(cursor=cursor)}), context
|
||||
)
|
||||
try:
|
||||
result = await gateway.execute(
|
||||
operation.model_copy(update={"params": PaginatedRequestParams(cursor=cursor)}), context
|
||||
)
|
||||
except ToolListingFailedOutright:
|
||||
return digest.hexdigest()
|
||||
items = (
|
||||
result.tools
|
||||
if isinstance(result, ListToolsResult)
|
||||
|
|
|
|||
|
|
@ -600,6 +600,8 @@ async def handle_mcp_proxy_tool(
|
|||
raw_headers: dict[str, str] | None = None, # mutable-ok: preserve request headers
|
||||
litellm_logging_obj: LiteLLMLoggingObj | None = None,
|
||||
incoming_bearer_token: SecretStr | None | EllipsisType = ...,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
resolved_tool: Tool | None | EllipsisType = ...,
|
||||
) -> CallToolResult:
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -646,18 +648,22 @@ async def handle_mcp_proxy_tool(
|
|||
|
||||
tool_id: Final = arguments.get("tool_id")
|
||||
tool: Final = (
|
||||
await find_mcp_proxy_tool(
|
||||
tool_id,
|
||||
user_api_key_auth=user_api_key_dict,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
resolved_tool
|
||||
if resolved_tool is not ...
|
||||
else (
|
||||
await find_mcp_proxy_tool(
|
||||
tool_id,
|
||||
user_api_key_auth=user_api_key_dict,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
if isinstance(tool_id, str)
|
||||
else None
|
||||
)
|
||||
if isinstance(tool_id, str)
|
||||
else None
|
||||
)
|
||||
if tool is None:
|
||||
return _text_tool_result("Unknown or unauthorized tool_id", is_error=True)
|
||||
|
|
@ -687,6 +693,7 @@ async def handle_mcp_proxy_tool(
|
|||
raw_headers=raw_headers,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
guardrail_auth=guardrail_auth,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -704,6 +711,7 @@ async def handle_mcp_tool_call(
|
|||
requested_server_id: str | None = None,
|
||||
guardrail_context: Mapping[str, object] | None = None,
|
||||
incoming_bearer_token: SecretStr | None | EllipsisType = ...,
|
||||
guardrail_auth: UserAPIKeyAuth | None = None,
|
||||
) -> CallToolResult:
|
||||
from litellm.proxy._experimental.mcp_server.operations import (
|
||||
_get_allowed_mcp_servers,
|
||||
|
|
@ -745,6 +753,7 @@ async def handle_mcp_tool_call(
|
|||
litellm_logging_obj=litellm_logging_obj,
|
||||
requested_server_id=requested_server_id,
|
||||
guardrail_context=guardrail_context,
|
||||
guardrail_auth=guardrail_auth,
|
||||
incoming_bearer_token=incoming_bearer_token,
|
||||
)
|
||||
return complete_call_tool_result(result, WireCompat.LEGACY)
|
||||
|
|
|
|||
|
|
@ -731,6 +731,10 @@ async def invoke_agent_a2a(
|
|||
)
|
||||
|
||||
user_api_key_dict.invoked_agent_id = agent.agent_id
|
||||
strict_policy: Final = agent.identity_managed or bool(user_api_key_dict.requires_fresh_policy)
|
||||
user_api_key_auth_for_call: Final = await AgentRequestHandler.personal_key_agent_billing_auth(
|
||||
user_api_key_dict, agent.agent_id, strict=strict_policy
|
||||
)
|
||||
|
||||
_enforce_inbound_trace_id(agent, request)
|
||||
|
||||
|
|
@ -790,7 +794,7 @@ async def invoke_agent_a2a(
|
|||
data, logging_obj = await processor.common_processing_pre_call_logic(
|
||||
request=request,
|
||||
general_settings=general_settings,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
user_api_key_dict=user_api_key_auth_for_call,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
proxy_config=proxy_config,
|
||||
route_type="asend_message",
|
||||
|
|
@ -889,7 +893,7 @@ async def invoke_agent_a2a(
|
|||
|
||||
try:
|
||||
response = await proxy_logging_obj.post_call_success_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
user_api_key_dict=user_api_key_auth_for_call,
|
||||
data=data,
|
||||
response=response,
|
||||
)
|
||||
|
|
@ -924,7 +928,7 @@ async def invoke_agent_a2a(
|
|||
metadata=data.get("metadata", {}),
|
||||
proxy_server_request=data.get("proxy_server_request"),
|
||||
agent_extra_headers=agent_extra_headers,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
user_api_key_dict=user_api_key_auth_for_call,
|
||||
request_data=data,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
served_version=served_version,
|
||||
|
|
@ -990,7 +994,7 @@ async def invoke_agent_a2a(
|
|||
|
||||
response = LiteLLMSendMessageResponse.from_dict(result, request_id=request_id)
|
||||
response = await proxy_logging_obj.post_call_success_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
user_api_key_dict=user_api_key_auth_for_call,
|
||||
data=data,
|
||||
response=response,
|
||||
)
|
||||
|
|
@ -1017,7 +1021,7 @@ async def invoke_agent_a2a(
|
|||
request_id=request_id,
|
||||
extra_headers=agent_extra_headers,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
user_api_key_dict=user_api_key_auth_for_call,
|
||||
request_data=data,
|
||||
served_version=served_version,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Follows the same pattern as MCP permission handling.
|
|||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, TypeAlias
|
||||
|
||||
|
|
@ -45,6 +46,11 @@ class RestrictedAgentAccess:
|
|||
agent_ids: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentPolicyUnavailable:
|
||||
team_id: str
|
||||
|
||||
|
||||
AgentAccess: TypeAlias = UnrestrictedAgentAccess | RestrictedAgentAccess
|
||||
|
||||
|
||||
|
|
@ -83,6 +89,16 @@ def _restricted_ids(access: AgentAccess) -> frozenset[str] | None:
|
|||
return _to_stable_ids(access.agent_ids)
|
||||
|
||||
|
||||
def _union_agent_access(accesses: Sequence[AgentAccess]) -> AgentAccess:
|
||||
if accesses and all(isinstance(access, UnrestrictedAgentAccess) for access in accesses):
|
||||
return UnrestrictedAgentAccess()
|
||||
return RestrictedAgentAccess(
|
||||
frozenset(
|
||||
chain.from_iterable(access.agent_ids for access in accesses if isinstance(access, RestrictedAgentAccess))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _intersect_agent_access(key_access: AgentAccess, team_access: AgentAccess) -> AgentAccess:
|
||||
key_ids: Final = _restricted_ids(key_access)
|
||||
team_ids: Final = _restricted_ids(team_access)
|
||||
|
|
@ -251,7 +267,11 @@ class AgentRequestHandler:
|
|||
)
|
||||
return target.agent_id in explicit
|
||||
|
||||
match await AgentRequestHandler.resolve_agent_access(user_api_key_auth, resolve_ceiling):
|
||||
match await AgentRequestHandler.resolve_agent_access(
|
||||
user_api_key_auth,
|
||||
resolve_ceiling,
|
||||
strict=bool(user_api_key_auth is not None and user_api_key_auth.requires_fresh_policy),
|
||||
):
|
||||
case UnrestrictedAgentAccess():
|
||||
return True
|
||||
case RestrictedAgentAccess(allowed_agent_ids):
|
||||
|
|
@ -370,6 +390,200 @@ class AgentRequestHandler:
|
|||
verbose_logger.warning("Failed to get allowed agents for key: %s", e)
|
||||
return UnrestrictedAgentAccess()
|
||||
|
||||
@staticmethod
|
||||
async def _personal_key_owner_team_agent_access(user_api_key_auth: UserAPIKeyAuth, *, strict: bool) -> AgentAccess:
|
||||
"""Union owner-team access; teams without a rule add nothing once any team declares one."""
|
||||
from litellm.proxy.auth.auth_checks import personal_key_owner_teams_with_status
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
try:
|
||||
owner_teams: Final = await personal_key_owner_teams_with_status(
|
||||
valid_token=user_api_key_auth,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=strict,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # fail closed: an owner whose teams cannot be read grants nothing
|
||||
if strict:
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable") from e
|
||||
verbose_logger.warning("Failed to load owner teams for personal key agent access: %s", e)
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
team_results: Final = await asyncio.gather(
|
||||
*(AgentRequestHandler._owner_team_agent_grants(team, strict=strict) for team in owner_teams.teams)
|
||||
)
|
||||
withheld_results: Final = await asyncio.gather(
|
||||
*(AgentRequestHandler._owner_team_agent_grants(team, strict=strict) for team in owner_teams.withheld)
|
||||
)
|
||||
if any(isinstance(access, AgentPolicyUnavailable) for access in team_results):
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable")
|
||||
team_accesses: Final = tuple(
|
||||
access for access in team_results if not isinstance(access, AgentPolicyUnavailable)
|
||||
)
|
||||
withheld_restrictions: Final = tuple(
|
||||
RestrictedAgentAccess(frozenset())
|
||||
for access in withheld_results
|
||||
if isinstance(access, (AgentPolicyUnavailable, RestrictedAgentAccess))
|
||||
)
|
||||
accesses: Final = (
|
||||
(*team_accesses, *withheld_restrictions, RestrictedAgentAccess(frozenset()))
|
||||
if owner_teams.unreadable
|
||||
else (*team_accesses, *withheld_restrictions)
|
||||
)
|
||||
return _union_agent_access(accesses)
|
||||
|
||||
@staticmethod
|
||||
async def personal_key_agent_billing_auth(
|
||||
auth: UserAPIKeyAuth,
|
||||
agent_id: str,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> UserAPIKeyAuth:
|
||||
try:
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
personal_key_inherits_team_grants,
|
||||
personal_key_owner_teams,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if not personal_key_inherits_team_grants(auth):
|
||||
return auth
|
||||
teams: Final = await personal_key_owner_teams(
|
||||
valid_token=auth,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=strict,
|
||||
)
|
||||
accesses: Final = await asyncio.gather(
|
||||
*(AgentRequestHandler._owner_team_agent_grants(team, strict=strict) for team in teams)
|
||||
)
|
||||
stable_agent_id: Final = global_agent_registry.stable_agent_id(agent_id)
|
||||
|
||||
restricted_sources: Final = tuple(
|
||||
(team, access)
|
||||
for team, access in zip(teams, accesses, strict=True)
|
||||
if isinstance(access, RestrictedAgentAccess) and stable_agent_id in _to_stable_ids(access.agent_ids)
|
||||
)
|
||||
unrestricted_sources: Final = (
|
||||
tuple(zip(teams, accesses, strict=True))
|
||||
if not restricted_sources
|
||||
and teams
|
||||
and all(isinstance(access, UnrestrictedAgentAccess) for access in accesses)
|
||||
else ()
|
||||
)
|
||||
sources: Final = restricted_sources or unrestricted_sources
|
||||
if not sources:
|
||||
verbose_logger.warning("No owner team grants agent %s; billing against the personal key", agent_id)
|
||||
return auth
|
||||
team: Final = min((source[0] for source in sources), key=lambda source: source.team_id)
|
||||
except Exception as e: # noqa: BLE001 # attribution must never fail an authorized call
|
||||
verbose_logger.warning("Failed to attribute agent %s spend to an owner team: %s", agent_id, e)
|
||||
return auth
|
||||
return await AgentRequestHandler._personal_key_agent_billing_auth_for_team(
|
||||
auth,
|
||||
agent_id,
|
||||
team,
|
||||
strict=strict,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _personal_key_agent_billing_auth_for_team(
|
||||
auth: UserAPIKeyAuth,
|
||||
agent_id: str,
|
||||
team: LiteLLM_TeamTable,
|
||||
*,
|
||||
strict: bool,
|
||||
) -> UserAPIKeyAuth:
|
||||
from litellm.proxy.auth.auth_checks import get_team_membership
|
||||
from litellm.proxy.auth.team_grants import team_grant_auth_update, team_grant_billing_auth_update
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
try:
|
||||
membership: Final = (
|
||||
await get_team_membership(
|
||||
user_id=auth.user_id,
|
||||
team_id=team.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=strict,
|
||||
)
|
||||
if auth.user_id and prisma_client is not None
|
||||
else None
|
||||
)
|
||||
team_auth_update: Final = await team_grant_billing_auth_update(auth, team, membership)
|
||||
except Exception as e: # noqa: BLE001 # a chosen team's controls must survive billing lookup failures
|
||||
verbose_logger.warning(
|
||||
"Failed to load billing controls for agent %s from owner team %s: %s",
|
||||
agent_id,
|
||||
team.team_id,
|
||||
e,
|
||||
)
|
||||
return auth.model_copy(update=team_grant_auth_update(team, None, auth.user_id, key_org_id=auth.org_id))
|
||||
return auth.model_copy(update=team_auth_update)
|
||||
|
||||
@staticmethod
|
||||
async def _owner_team_agent_grants(
|
||||
team: LiteLLM_TeamTable,
|
||||
*,
|
||||
strict: bool,
|
||||
) -> AgentAccess | AgentPolicyUnavailable:
|
||||
if team.object_permission_id is not None and team.object_permission is None:
|
||||
if strict:
|
||||
return AgentPolicyUnavailable(team_id=team.team_id)
|
||||
verbose_logger.warning("Owner team %s has an unreadable object_permission, granting nothing", team.team_id)
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
try:
|
||||
return await AgentRequestHandler._team_agent_grants(team, strict=strict)
|
||||
except Exception as e: # noqa: BLE001 # per-team isolation: a team whose grants cannot be read grants nothing
|
||||
if strict:
|
||||
return AgentPolicyUnavailable(team_id=team.team_id)
|
||||
verbose_logger.warning("Failed to get allowed agents for owner team %s: %s", team.team_id, e)
|
||||
return RestrictedAgentAccess(frozenset())
|
||||
|
||||
@staticmethod
|
||||
async def _team_agent_grants(team: LiteLLM_TeamTable, *, strict: bool) -> AgentAccess:
|
||||
"""The agents one loaded team grants: its object_permission agents plus its access groups.
|
||||
Declared-but-empty grants stay restricted; a team with no grant at all does not restrict."""
|
||||
# 1. Get agents from object_permission (native permissions)
|
||||
object_permissions: Final = team.object_permission
|
||||
direct_agents: Final = tuple(object_permissions.agents or () if object_permissions is not None else ())
|
||||
declared_access_groups: Final = tuple(
|
||||
object_permissions.agent_access_groups or () if object_permissions is not None else ()
|
||||
)
|
||||
# 2. Also include agents from team's access_group_ids (unified access groups)
|
||||
team_access_group_ids: Final = tuple(team.access_group_ids or ())
|
||||
|
||||
if not direct_agents and not declared_access_groups and not team_access_group_ids:
|
||||
return UnrestrictedAgentAccess()
|
||||
|
||||
access_group_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_agents_from_access_groups(declared_access_groups, check_db_only=strict)
|
||||
)
|
||||
if declared_access_groups
|
||||
else ()
|
||||
)
|
||||
unified_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_unified_access_group_agents(team_access_group_ids, check_db_only=strict)
|
||||
)
|
||||
if team_access_group_ids
|
||||
else ()
|
||||
)
|
||||
|
||||
return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
|
||||
|
||||
@staticmethod
|
||||
async def _get_allowed_agents_for_team(
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
|
|
@ -389,7 +603,11 @@ class AgentRequestHandler:
|
|||
return UnrestrictedAgentAccess()
|
||||
|
||||
if user_api_key_auth.team_id is None:
|
||||
return UnrestrictedAgentAccess()
|
||||
from litellm.proxy.auth.auth_checks import personal_key_inherits_team_grants
|
||||
|
||||
if not personal_key_inherits_team_grants(user_api_key_auth):
|
||||
return UnrestrictedAgentAccess()
|
||||
return await AgentRequestHandler._personal_key_owner_team_agent_access(user_api_key_auth, strict=strict)
|
||||
|
||||
try:
|
||||
from litellm.proxy.auth.auth_checks import get_team_object
|
||||
|
|
@ -415,38 +633,7 @@ class AgentRequestHandler:
|
|||
if team_obj is None:
|
||||
return RestrictedAgentAccess(frozenset()) if strict else UnrestrictedAgentAccess()
|
||||
|
||||
# 1. Get agents from object_permission (native permissions)
|
||||
object_permissions: Final = team_obj.object_permission
|
||||
direct_agents: Final = tuple(object_permissions.agents or () if object_permissions is not None else ())
|
||||
declared_access_groups: Final = tuple(
|
||||
object_permissions.agent_access_groups or () if object_permissions is not None else ()
|
||||
)
|
||||
# 2. Also include agents from team's access_group_ids (unified access groups)
|
||||
team_access_group_ids: Final = tuple(team_obj.access_group_ids or ())
|
||||
|
||||
if not direct_agents and not declared_access_groups and not team_access_group_ids:
|
||||
return UnrestrictedAgentAccess()
|
||||
|
||||
access_group_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_agents_from_access_groups(
|
||||
declared_access_groups, check_db_only=strict
|
||||
)
|
||||
)
|
||||
if declared_access_groups
|
||||
else ()
|
||||
)
|
||||
unified_agents: Final = (
|
||||
tuple(
|
||||
await AgentRequestHandler._get_unified_access_group_agents(
|
||||
team_access_group_ids, check_db_only=strict
|
||||
)
|
||||
)
|
||||
if team_access_group_ids
|
||||
else ()
|
||||
)
|
||||
|
||||
return RestrictedAgentAccess(frozenset(direct_agents + access_group_agents + unified_agents))
|
||||
return await AgentRequestHandler._team_agent_grants(team_obj, strict=strict)
|
||||
except Exception as e:
|
||||
if strict:
|
||||
raise HTTPException(503, "Agent invocation policy is unavailable") from e
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from dataclasses import dataclass
|
|||
from functools import partial
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, Optional, Protocol, TypeAlias
|
||||
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NoReturn, Optional, Protocol, TypeAlias
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
|
@ -2585,6 +2585,10 @@ def _membership_from_cached_payload(
|
|||
return cached_membership if cached_membership is not None else _TEAM_MEMBERSHIP_CACHE_MISS
|
||||
|
||||
|
||||
def _raise_no_db_connected() -> NoReturn:
|
||||
raise Exception("No db connected")
|
||||
|
||||
|
||||
@log_db_metrics
|
||||
@with_service_target(AUTH_OBJECTS_TARGET)
|
||||
async def _fetch_team_membership_from_db(
|
||||
|
|
@ -2594,9 +2598,13 @@ async def _fetch_team_membership_from_db(
|
|||
user_api_key_cache: UserApiKeyCache,
|
||||
parent_otel_span: Span | None = None,
|
||||
proxy_logging_obj: ProxyLogging | None = None,
|
||||
*,
|
||||
use_writer: bool = False,
|
||||
) -> LiteLLM_TeamMembership | None:
|
||||
_ = parent_otel_span, proxy_logging_obj
|
||||
response: Final = await _dictable_table(TeamMembershipRepository(prisma_client), "team_membership").find_unique(
|
||||
response: Final = await _dictable_table(
|
||||
TeamMembershipRepository(prisma_client, use_writer=use_writer), "team_membership"
|
||||
).find_unique(
|
||||
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
|
|
@ -2650,6 +2658,8 @@ async def get_team_membership(
|
|||
user_api_key_cache: UserApiKeyCache,
|
||||
parent_otel_span: Span | None = None,
|
||||
proxy_logging_obj: ProxyLogging | None = None,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> Optional["LiteLLM_TeamMembership"]:
|
||||
"""
|
||||
Returns team membership object if user is member of team.
|
||||
|
|
@ -2661,6 +2671,19 @@ async def get_team_membership(
|
|||
|
||||
_key: Final = team_membership_reservation_cache_key(user_id=user_id, team_id=team_id)
|
||||
|
||||
if check_db_only:
|
||||
if prisma_client is None:
|
||||
_raise_no_db_connected()
|
||||
return await _fetch_team_membership_from_db(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
use_writer=True,
|
||||
)
|
||||
|
||||
l1_cached: Final[object] = await user_api_key_cache.async_get_cache(key=_key, local_only=True)
|
||||
l1_membership: Final = _membership_from_cached_payload(l1_cached)
|
||||
if not isinstance(l1_membership, _TeamMembershipCacheMiss):
|
||||
|
|
@ -2671,7 +2694,7 @@ async def get_team_membership(
|
|||
return _membership_from_shared_load(await asyncio.shield(inflight))
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("No db connected")
|
||||
_raise_no_db_connected()
|
||||
|
||||
task: Final = asyncio.ensure_future(
|
||||
_load_team_membership_on_cache_miss(
|
||||
|
|
@ -4674,6 +4697,8 @@ async def get_mcp_server_ids_from_access_groups(
|
|||
user_api_key_cache: UserApiKeyCache | None = None,
|
||||
proxy_logging_obj: ProxyLogging | None = None,
|
||||
check_db_only: bool = False,
|
||||
*,
|
||||
strict_errors: bool = False,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Collect MCP server IDs from unified access groups.
|
||||
|
|
@ -4686,6 +4711,7 @@ async def get_mcp_server_ids_from_access_groups(
|
|||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
strict_errors=strict_errors,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -5470,6 +5496,8 @@ async def _personal_key_owner(
|
|||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> LiteLLM_UserTable | None:
|
||||
if valid_token.user_id is None:
|
||||
return None
|
||||
|
|
@ -5481,6 +5509,7 @@ async def _personal_key_owner(
|
|||
user_id_upsert=False,
|
||||
parent_otel_span=valid_token.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # fail closed: an owner that cannot be loaded grants nothing
|
||||
verbose_proxy_logger.warning(
|
||||
|
|
@ -5870,13 +5899,32 @@ def personal_key_team_model_access_applies(valid_token: UserAPIKeyAuth | None) -
|
|||
)
|
||||
|
||||
|
||||
async def _load_personal_key_team(
|
||||
def personal_key_inherits_team_grants(valid_token: UserAPIKeyAuth | None) -> bool:
|
||||
"""MCP server, MCP tool and agent inheritance is for a virtual key only. A keyless auth with no
|
||||
team (one grant source of an admitted subject, a JWT user) resolves as the plain caller it is."""
|
||||
return (
|
||||
personal_key_team_model_access_applies(valid_token)
|
||||
and valid_token is not None
|
||||
and valid_token.api_key is not None
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PersonalKeyTeamLoad:
|
||||
team: LiteLLM_TeamTableCachedObj | None
|
||||
unreadable: bool
|
||||
withheld_team: LiteLLM_TeamTableCachedObj | None
|
||||
|
||||
|
||||
async def _load_personal_key_team_with_status(
|
||||
team_id: str,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> LiteLLM_TeamTableCachedObj | None:
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> _PersonalKeyTeamLoad:
|
||||
try:
|
||||
team_object: Final = await get_team_object(
|
||||
team_id=team_id,
|
||||
|
|
@ -5884,13 +5932,36 @@ async def _load_personal_key_team(
|
|||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=valid_token.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # fail closed: a team that cannot be loaded grants nothing
|
||||
verbose_proxy_logger.warning(
|
||||
"Personal key team model access: team=%s lookup failed, granting nothing: %s", team_id, e
|
||||
)
|
||||
return None
|
||||
return None if team_object.blocked is True else team_object
|
||||
return _PersonalKeyTeamLoad(team=None, unreadable=True, withheld_team=None)
|
||||
if team_object.blocked is True:
|
||||
return _PersonalKeyTeamLoad(team=None, unreadable=False, withheld_team=team_object)
|
||||
return _PersonalKeyTeamLoad(team=team_object, unreadable=False, withheld_team=None)
|
||||
|
||||
|
||||
async def _load_personal_key_team(
|
||||
team_id: str,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> LiteLLM_TeamTableCachedObj | None:
|
||||
loaded: Final = await _load_personal_key_team_with_status(
|
||||
team_id=team_id,
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
return loaded.team
|
||||
|
||||
|
||||
async def _load_personal_key_teams(
|
||||
|
|
@ -5899,6 +5970,8 @@ async def _load_personal_key_teams(
|
|||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> tuple[LiteLLM_TeamTableCachedObj | None, ...]:
|
||||
return tuple(
|
||||
await asyncio.gather(
|
||||
|
|
@ -5909,6 +5982,7 @@ async def _load_personal_key_teams(
|
|||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
for team_id in dict.fromkeys(user_object.teams)
|
||||
)
|
||||
|
|
@ -5988,6 +6062,8 @@ async def _personal_key_listing_teams(
|
|||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache | None,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> tuple[LiteLLM_TeamTableCachedObj | None, ...]:
|
||||
if (
|
||||
user_api_key_dict.user_id is None
|
||||
|
|
@ -6001,6 +6077,7 @@ async def _personal_key_listing_teams(
|
|||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
if owner is None:
|
||||
return ()
|
||||
|
|
@ -6010,9 +6087,177 @@ async def _personal_key_listing_teams(
|
|||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
|
||||
|
||||
async def _personal_key_team_within_budget(
|
||||
team: LiteLLM_TeamTableCachedObj,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> bool:
|
||||
source: Final = valid_token.model_copy(
|
||||
update={"team_id": team.team_id, "org_id": valid_token.org_id or team.organization_id}
|
||||
)
|
||||
try:
|
||||
await team_max_budget_check(
|
||||
team_object=team,
|
||||
valid_token=source,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await _team_multi_budget_check(team_object=team)
|
||||
if check_db_only:
|
||||
team_member_user_id: Final = valid_token.user_id
|
||||
if team_member_user_id is None:
|
||||
return False
|
||||
team_membership: Final = await get_team_membership(
|
||||
user_id=team_member_user_id,
|
||||
team_id=team.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=True,
|
||||
)
|
||||
await _check_team_member_budget(
|
||||
team_object=team,
|
||||
user_object=None,
|
||||
valid_token=source,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
team_membership=team_membership,
|
||||
team_membership_loaded=True,
|
||||
)
|
||||
else:
|
||||
await _check_team_member_budget(
|
||||
team_object=team,
|
||||
user_object=None,
|
||||
valid_token=source,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await organization_max_budget_check(
|
||||
valid_token=source,
|
||||
team_object=team,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except litellm.BudgetExceededError:
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001 # fail closed: a team whose budget cannot be checked grants nothing
|
||||
verbose_proxy_logger.warning("Failed to check budget for personal key team %s, skipping: %s", team.team_id, e)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonalKeyOwnerTeams:
|
||||
teams: tuple[LiteLLM_TeamTableCachedObj, ...]
|
||||
unreadable: bool
|
||||
withheld: tuple[LiteLLM_TeamTableCachedObj, ...]
|
||||
|
||||
|
||||
async def personal_key_owner_teams_with_status(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache | None,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> PersonalKeyOwnerTeams:
|
||||
"""The owner's current, loadable, unblocked, within-budget teams: the grant sources a personal key inherits
|
||||
MCP server, MCP tool and agent access from under ``personal_key_model_access_from_teams``."""
|
||||
if valid_token.user_id is None or prisma_client is None or user_api_key_cache is None or proxy_logging_obj is None:
|
||||
return PersonalKeyOwnerTeams(teams=(), unreadable=False, withheld=())
|
||||
owner: Final = await _personal_key_owner(
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
if owner is None:
|
||||
return PersonalKeyOwnerTeams(teams=(), unreadable=False, withheld=())
|
||||
team_ids: Final = tuple(team_id for team_id in dict.fromkeys(owner.teams) if team_id and team_id != UI_TEAM_ID)
|
||||
team_loads: Final = await asyncio.gather(
|
||||
*(
|
||||
_load_personal_key_team_with_status(
|
||||
team_id=team_id,
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
for team_id in team_ids
|
||||
)
|
||||
)
|
||||
blocked_teams: Final = tuple(
|
||||
load.withheld_team
|
||||
for load in team_loads
|
||||
if load.withheld_team is not None
|
||||
and (valid_token.org_id is None or load.withheld_team.organization_id in (None, valid_token.org_id))
|
||||
)
|
||||
loaded_teams: Final = tuple(load.team for load in team_loads if load.team is not None)
|
||||
eligible: Final = tuple(
|
||||
team
|
||||
for team in loaded_teams
|
||||
if valid_token.org_id is None or team.organization_id in (None, valid_token.org_id)
|
||||
)
|
||||
if not eligible:
|
||||
return PersonalKeyOwnerTeams(
|
||||
teams=(),
|
||||
unreadable=any(load.unreadable for load in team_loads),
|
||||
withheld=blocked_teams,
|
||||
)
|
||||
within_budget: Final = await asyncio.gather(
|
||||
*(
|
||||
_personal_key_team_within_budget(
|
||||
team=team,
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
for team in eligible
|
||||
)
|
||||
)
|
||||
return PersonalKeyOwnerTeams(
|
||||
teams=tuple(team for team, allowed in zip(eligible, within_budget, strict=True) if allowed),
|
||||
unreadable=any(load.unreadable for load in team_loads),
|
||||
withheld=(
|
||||
*blocked_teams,
|
||||
*(team for team, allowed in zip(eligible, within_budget, strict=True) if not allowed),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def personal_key_owner_teams(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache | None,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
*,
|
||||
check_db_only: bool = False,
|
||||
) -> tuple[LiteLLM_TeamTableCachedObj, ...]:
|
||||
return (
|
||||
await personal_key_owner_teams_with_status(
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
)
|
||||
).teams
|
||||
|
||||
|
||||
async def personal_key_team_visible_models(
|
||||
models: Sequence[str],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.proxy._types import (
|
|||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
Member,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
|
|
@ -74,6 +75,91 @@ class TeamGrants(TypedDict, total=False):
|
|||
team_member_rpm_limit: ReadOnly[int | None]
|
||||
|
||||
|
||||
_TEAM_GRANT_AUTH_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"team_alias",
|
||||
"team_tpm_limit",
|
||||
"team_rpm_limit",
|
||||
"team_tpd_limit",
|
||||
"team_max_budget",
|
||||
"team_soft_budget",
|
||||
"team_spend",
|
||||
"team_metadata",
|
||||
"team_member",
|
||||
"team_member_spend",
|
||||
"team_member_tpm_limit",
|
||||
"team_member_rpm_limit",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def team_grant_auth_update(
|
||||
team_object: LiteLLM_TeamTable,
|
||||
team_membership: LiteLLM_TeamMembership | None,
|
||||
user_id: str | None,
|
||||
*,
|
||||
key_org_id: str | None,
|
||||
) -> Mapping[str, object]:
|
||||
grant_values: Final = team_grants(team_object, team_membership, user_id)
|
||||
return MappingProxyType(
|
||||
{
|
||||
"team_id": team_object.team_id,
|
||||
"org_id": key_org_id or team_object.organization_id,
|
||||
**{field: value for field, value in grant_values.items() if field in _TEAM_GRANT_AUTH_FIELDS},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def team_grant_billing_auth_update(
|
||||
auth: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable,
|
||||
team_membership: LiteLLM_TeamMembership | None,
|
||||
) -> Mapping[str, object]:
|
||||
team_auth_update: Final = team_grant_auth_update(
|
||||
team_object,
|
||||
team_membership,
|
||||
auth.user_id,
|
||||
key_org_id=auth.org_id,
|
||||
)
|
||||
if auth.org_id is not None or team_object.organization_id is None:
|
||||
return team_auth_update
|
||||
|
||||
organization_auth_fields: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{
|
||||
"organization_alias": None,
|
||||
"organization_metadata": None,
|
||||
"organization_max_budget": None,
|
||||
"organization_tpm_limit": None,
|
||||
"organization_rpm_limit": None,
|
||||
}
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import get_org_object_for_request
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
return MappingProxyType({**team_auth_update, **organization_auth_fields})
|
||||
org_object: Final = await get_org_object_for_request(
|
||||
org_id=team_object.organization_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=auth.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if org_object is None:
|
||||
return MappingProxyType({**team_auth_update, **organization_auth_fields})
|
||||
budget: Final = org_object.litellm_budget_table
|
||||
return MappingProxyType(
|
||||
{
|
||||
**team_auth_update,
|
||||
"organization_alias": org_object.organization_alias,
|
||||
"organization_metadata": org_object.metadata,
|
||||
"organization_max_budget": budget.max_budget if budget is not None else None,
|
||||
"organization_tpm_limit": budget.tpm_limit if budget is not None else None,
|
||||
"organization_rpm_limit": budget.rpm_limit if budget is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _json_columns(team_object: LiteLLM_TeamTable) -> _TeamJsonColumns:
|
||||
try:
|
||||
return _TeamJsonColumns.model_validate(team_object.model_dump(include=_JSON_COLUMNS))
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ import sys
|
|||
import tempfile
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final, TypeAlias
|
||||
from typing import Final, TypeAlias, TypeVar
|
||||
|
||||
import click
|
||||
import requests
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.proxy.common_utils.codex_model_catalog import (
|
||||
CODEX_BASE_INSTRUCTIONS_PATH,
|
||||
|
|
@ -25,8 +26,15 @@ from litellm.proxy.common_utils.codex_model_catalog import (
|
|||
from litellm.types.llms.base_model import LiteLLMBaseModel
|
||||
|
||||
from .auth import cli_context_obj, context_secret_vault, get_stored_api_key, login
|
||||
from .claude_settings import ClaudeSettingsError, install_statusline_script
|
||||
from .claude_settings import (
|
||||
ENV_KEY,
|
||||
ClaudeSettingsError,
|
||||
claude_settings_path,
|
||||
install_statusline_script,
|
||||
load_json_or_empty,
|
||||
)
|
||||
from .cmd_quoting import quote_for_cmd
|
||||
from .config import claude_subscription_default
|
||||
from .pi import (
|
||||
LITELLM_PROXY_API_KEY_ENV,
|
||||
PI_PROVIDER_NAME,
|
||||
|
|
@ -41,6 +49,8 @@ from .pi import (
|
|||
ANTHROPIC_BASE_URL_ENV: Final = "ANTHROPIC_BASE_URL"
|
||||
ANTHROPIC_AUTH_TOKEN_ENV: Final = "ANTHROPIC_AUTH_TOKEN"
|
||||
ANTHROPIC_API_KEY_ENV: Final = "ANTHROPIC_API_KEY"
|
||||
ANTHROPIC_CUSTOM_HEADERS_ENV: Final = "ANTHROPIC_CUSTOM_HEADERS"
|
||||
LITELLM_KEY_HEADER: Final = "x-litellm-api-key"
|
||||
ENABLE_TOOL_SEARCH_ENV: Final = "ENABLE_TOOL_SEARCH"
|
||||
ENABLE_TOOL_SEARCH_VALUE: Final = "true"
|
||||
ENABLE_GATEWAY_MODEL_DISCOVERY_ENV: Final = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"
|
||||
|
|
@ -73,6 +83,7 @@ _INSTALL_DOCS: Final[dict[str, str]] = {
|
|||
}
|
||||
|
||||
_HIDDEN_AGENTS: Final = frozenset({"pi"})
|
||||
_SUBSCRIPTION_AGENTS: Final = frozenset({"claude"})
|
||||
|
||||
CODEX_PROXY_PROVIDER: Final = "litellm"
|
||||
CODEX_HOME_ENV: Final = "CODEX_HOME"
|
||||
|
|
@ -96,11 +107,24 @@ def agent_profile(command: str) -> tuple[str, frozenset[str]]:
|
|||
return base, frozenset({PROFILE_ANTHROPIC, PROFILE_OPENAI})
|
||||
|
||||
|
||||
def _custom_headers_with_litellm_key(existing: str, api_key: str) -> str:
|
||||
kept: Final = tuple(
|
||||
line for line in existing.splitlines() if line.strip() and not _is_header_line(line, LITELLM_KEY_HEADER)
|
||||
)
|
||||
return "\n".join((*kept, f"{LITELLM_KEY_HEADER}: Bearer {api_key}"))
|
||||
|
||||
|
||||
def _is_header_line(line: str, name: str) -> bool:
|
||||
return line.partition(":")[0].strip().lower() == name
|
||||
|
||||
|
||||
def build_agent_env(
|
||||
base_env: Mapping[str, str],
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
profiles: frozenset[str],
|
||||
*,
|
||||
subscription: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""Return a copy of base_env wired to route the agent through the proxy.
|
||||
|
||||
|
|
@ -115,12 +139,22 @@ def build_agent_env(
|
|||
proxy's /v1/models; likewise left alone when already set.
|
||||
pi ignores both base URL variables and instead resolves $LITELLM_PROXY_API_KEY
|
||||
from its synced models.json provider entry.
|
||||
With subscription=True the Anthropic profile keeps Claude Code on its claude.ai login: the key
|
||||
travels in the x-litellm-api-key header through ANTHROPIC_CUSTOM_HEADERS, and no
|
||||
ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is left in the environment, since either one makes
|
||||
Claude Code stop using the subscription (and its connectors).
|
||||
"""
|
||||
env: Final = dict(base_env)
|
||||
root: Final = base_url.rstrip("/")
|
||||
if PROFILE_ANTHROPIC in profiles:
|
||||
env[ANTHROPIC_BASE_URL_ENV] = root
|
||||
env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key
|
||||
if subscription:
|
||||
env[ANTHROPIC_CUSTOM_HEADERS_ENV] = _custom_headers_with_litellm_key(
|
||||
env.get(ANTHROPIC_CUSTOM_HEADERS_ENV, ""), api_key
|
||||
)
|
||||
env.pop(ANTHROPIC_AUTH_TOKEN_ENV, None)
|
||||
else:
|
||||
env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key
|
||||
env.pop(ANTHROPIC_API_KEY_ENV, None)
|
||||
if ENABLE_TOOL_SEARCH_ENV not in env:
|
||||
env[ENABLE_TOOL_SEARCH_ENV] = ENABLE_TOOL_SEARCH_VALUE
|
||||
|
|
@ -146,6 +180,130 @@ def codex_proxy_provider(base_url: str) -> Mapping[str, str | bool]:
|
|||
)
|
||||
|
||||
|
||||
_SETTINGS_FLAG: Final = "--settings"
|
||||
_SUBSCRIPTION_SETTINGS_ENV: Final = MappingProxyType({ANTHROPIC_AUTH_TOKEN_ENV: "", ANTHROPIC_API_KEY_ENV: ""})
|
||||
_SETTINGS_OVERRIDE_ADAPTER: Final = TypeAdapter(dict[str, JsonValue])
|
||||
|
||||
|
||||
def _settings_flag_spans(args: Sequence[str]) -> tuple[tuple[int, int, str], ...]:
|
||||
"""(start, end, value) of every `--settings X` or `--settings=X` in args, in order."""
|
||||
return tuple(
|
||||
(i, i + 1, arg.partition("=")[2]) if arg != _SETTINGS_FLAG else (i, i + 2, "".join(args[i + 1 : i + 2]))
|
||||
for i, arg in enumerate(args)
|
||||
if arg == _SETTINGS_FLAG or arg.startswith(_SETTINGS_FLAG + "=")
|
||||
)
|
||||
|
||||
|
||||
def _split_settings_flags(args: Sequence[str]) -> tuple[str | None, tuple[str, ...]]:
|
||||
"""The value of the last `--settings` the user passed (the one Claude Code would keep), and args without any."""
|
||||
spans: Final = _settings_flag_spans(args)
|
||||
covered: Final = frozenset(chain.from_iterable(range(start, end) for start, end, _ in spans))
|
||||
rest: Final = tuple(arg for i, arg in enumerate(args) if i not in covered)
|
||||
return (spans[-1][2] if spans else None), rest
|
||||
|
||||
|
||||
def _load_settings_override(value: str) -> Mapping[str, JsonValue]:
|
||||
"""The user's `--settings` value: inline JSON when it starts with `{`, else a settings file that must exist."""
|
||||
if value.lstrip().startswith("{"):
|
||||
try:
|
||||
return _SETTINGS_OVERRIDE_ADAPTER.validate_json(value)
|
||||
except ValidationError as e:
|
||||
raise AgentRunError(f"`{_SETTINGS_FLAG}` is not a JSON object: {value}") from e
|
||||
path: Final = Path(value).expanduser()
|
||||
try:
|
||||
return load_json_or_empty(path) if path.is_file() else _missing_settings_file(path)
|
||||
except ClaudeSettingsError as e:
|
||||
raise AgentRunError(str(e)) from e
|
||||
except OSError as e:
|
||||
raise AgentRunError(f"`{_SETTINGS_FLAG}` must be a settings file or a JSON object, got: {value}") from e
|
||||
|
||||
|
||||
def _missing_settings_file(path: Path) -> Mapping[str, JsonValue]:
|
||||
raise AgentRunError(f"`{_SETTINGS_FLAG}` file not found: {path}")
|
||||
|
||||
|
||||
def _settings_env(settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
|
||||
env: Final = settings.get(ENV_KEY)
|
||||
return env if isinstance(env, dict) else {}
|
||||
|
||||
|
||||
def _settings_json(base: Mapping[str, JsonValue], api_key: str, inherited_headers: str) -> str:
|
||||
base_env: Final = _settings_env(base)
|
||||
own_headers: Final = base_env.get(ANTHROPIC_CUSTOM_HEADERS_ENV)
|
||||
headers: Final = _custom_headers_with_litellm_key(
|
||||
own_headers if isinstance(own_headers, str) else inherited_headers, api_key
|
||||
)
|
||||
overlay: Final = {**base_env, **_SUBSCRIPTION_SETTINGS_ENV, ANTHROPIC_CUSTOM_HEADERS_ENV: headers}
|
||||
return json.dumps({**base, ENV_KEY: overlay}, separators=(",", ":"))
|
||||
|
||||
|
||||
def _inherited_custom_headers(settings_files: Sequence[Mapping[str, JsonValue]], environ: Mapping[str, str]) -> str:
|
||||
"""The ANTHROPIC_CUSTOM_HEADERS Claude Code would apply without the overlay: the first settings file in
|
||||
precedence order that sets it, else the process environment."""
|
||||
from_files: Final = (_settings_env(settings).get(ANTHROPIC_CUSTOM_HEADERS_ENV) for settings in settings_files)
|
||||
return next(
|
||||
(value for value in from_files if isinstance(value, str)), environ.get(ANTHROPIC_CUSTOM_HEADERS_ENV, "")
|
||||
)
|
||||
|
||||
|
||||
def claude_settings_files(
|
||||
read_settings: Callable[[Path], Mapping[str, JsonValue]], environ: Mapping[str, str], project_dir: Path
|
||||
) -> tuple[Mapping[str, JsonValue], ...]:
|
||||
"""The settings files Claude Code merges below `--settings`, highest precedence first: project local, project,
|
||||
then user. A missing file reads as empty, a malformed one is a clean CLI error."""
|
||||
paths: Final = (
|
||||
project_dir / ".claude" / "settings.local.json",
|
||||
project_dir / ".claude" / "settings.json",
|
||||
claude_settings_path(environ),
|
||||
)
|
||||
try:
|
||||
return tuple(read_settings(path) for path in paths)
|
||||
except ClaudeSettingsError as e:
|
||||
raise AgentRunError(str(e)) from e
|
||||
|
||||
|
||||
OVERLAY_FILE_NAME: Final = "litellm-subscription-overlay.json"
|
||||
|
||||
|
||||
def write_settings_overlay(text: str, environ: Mapping[str, str]) -> Path:
|
||||
"""Write the overlay next to Claude Code's own settings.json (so under CLAUDE_CONFIG_DIR when set) and return
|
||||
its path. One file per user, rewritten on every launch, mode 0600 whether or not it already existed. It carries
|
||||
the LiteLLM key, which is why it is a private file and not an inline `--settings` argument that every local
|
||||
user could read from the process list, and why it does not pile up in the temp dir."""
|
||||
path: Final = claude_settings_path(environ).parent / OVERLAY_FILE_NAME
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd: Final = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
if os.name == "posix":
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
return path
|
||||
|
||||
|
||||
def subscription_launch_args(
|
||||
command: Sequence[str],
|
||||
api_key: str,
|
||||
environ: Mapping[str, str],
|
||||
settings_files: Sequence[Mapping[str, JsonValue]],
|
||||
write_overlay: Callable[[str, Mapping[str, str]], Path],
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""The `--settings` that keeps Claude Code on its claude.ai login, plus the user's remaining args.
|
||||
|
||||
Claude Code applies `env` from its settings files over the process environment, so a key that `lite up` or
|
||||
`lite configure claude` left in ANTHROPIC_AUTH_TOKEN would land back in `Authorization`, and an
|
||||
ANTHROPIC_CUSTOM_HEADERS there would drop the x-litellm-api-key header. The overlay blanks both credential
|
||||
keys (read as unset for this session only) and carries the custom headers with the LiteLLM key appended,
|
||||
taking the headers from the user's `--settings`, else the settings files in Claude Code's precedence order,
|
||||
else the process environment. Any `--settings` the user passed is stripped and the last one (inline JSON or a
|
||||
file, the one Claude Code would keep) is merged in, since Claude Code keeps only the last `--settings`.
|
||||
"""
|
||||
inherited: Final = _inherited_custom_headers(settings_files, environ)
|
||||
user_value, rest = _split_settings_flags(tuple(command[1:]))
|
||||
base: Final[Mapping[str, JsonValue]] = _load_settings_override(user_value) if user_value is not None else {}
|
||||
overlay: Final = write_overlay(_settings_json(base, api_key, inherited), environ)
|
||||
return (_SETTINGS_FLAG, str(overlay)), rest
|
||||
|
||||
|
||||
def _codex_proxy_args(base_url: str) -> list[str]:
|
||||
"""Codex `-c` overrides that point it at the proxy.
|
||||
|
||||
|
|
@ -570,6 +728,7 @@ def verify_proxy_key(
|
|||
api_key: str,
|
||||
*,
|
||||
get: Callable[..., requests.Response] = requests.get,
|
||||
key_header: str = "Authorization",
|
||||
) -> None:
|
||||
"""Probe the proxy with the key so bad creds fail here, not inside the agent.
|
||||
|
||||
|
|
@ -578,7 +737,7 @@ def verify_proxy_key(
|
|||
"""
|
||||
url: Final = base_url.rstrip("/") + "/v1/models"
|
||||
try:
|
||||
resp: Final = get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=10)
|
||||
resp: Final = get(url, headers={key_header: f"Bearer {api_key}"}, timeout=10)
|
||||
except requests.RequestException as e:
|
||||
raise AgentRunError(
|
||||
f"Could not reach the LiteLLM proxy at {base_url.rstrip('/')}: {e}. "
|
||||
|
|
@ -591,6 +750,15 @@ def verify_proxy_key(
|
|||
)
|
||||
|
||||
|
||||
def verify_subscription_key(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
*,
|
||||
get: Callable[..., requests.Response] = requests.get,
|
||||
) -> None:
|
||||
verify_proxy_key(base_url, api_key, get=get, key_header=LITELLM_KEY_HEADER)
|
||||
|
||||
|
||||
_WINDOWS_SHIM_SUFFIXES: Final[frozenset[str]] = frozenset({".cmd", ".bat"})
|
||||
_CMD_LINE_BREAKS: Final = ("\r", "\n")
|
||||
|
||||
|
|
@ -686,14 +854,19 @@ def run_agent(
|
|||
command: Sequence[str],
|
||||
*,
|
||||
skip_verify: bool = False,
|
||||
subscription: bool = False,
|
||||
base_env: Mapping[str, str] | None = None,
|
||||
which: Callable[[str], str | None] = shutil.which,
|
||||
verify: Callable[[str, str], None] = verify_proxy_key,
|
||||
verify_subscription: Callable[[str, str], None] = verify_subscription_key,
|
||||
sync_models: Callable[[str, Mapping[str, str], str, str, bool], ModelSyncResult] = agent_model_sync_env,
|
||||
warn: Callable[[str], None] = _warn,
|
||||
launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _hand_off,
|
||||
reattach_terminal: Callable[[], None] | None = None,
|
||||
preparers: Mapping[str, _Preparer] = MappingProxyType(_PREPARERS),
|
||||
read_settings: Callable[[Path], Mapping[str, JsonValue]] = load_json_or_empty,
|
||||
write_overlay: Callable[[str, Mapping[str, str]], Path] = write_settings_overlay,
|
||||
project_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""Validate, wire the environment, and hand off to the agent.
|
||||
|
||||
|
|
@ -713,7 +886,7 @@ def run_agent(
|
|||
raise AgentRunError(f"Could not find `{command[0]}` on your PATH.{hint}")
|
||||
|
||||
if not skip_verify:
|
||||
verify(base_url, api_key)
|
||||
(verify_subscription if subscription else verify)(base_url, api_key)
|
||||
|
||||
env_before_sync: Final = base_env if base_env is not None else os.environ
|
||||
synced: Final = sync_models(binary, env_before_sync, base_url, api_key, skip_verify)
|
||||
|
|
@ -725,15 +898,28 @@ def run_agent(
|
|||
|
||||
env: Final = MappingProxyType(
|
||||
{
|
||||
**build_agent_env(env_before_sync, base_url, api_key, profiles),
|
||||
**build_agent_env(env_before_sync, base_url, api_key, profiles, subscription=subscription),
|
||||
**(synced if isinstance(synced, Mapping) else _NO_EXTRA_ENV),
|
||||
}
|
||||
)
|
||||
synced_args: Final = synced.args if isinstance(synced, ModelSyncArgs) else ()
|
||||
extra_args: Final = (*agent_launch_args(command[0], base_url), *synced_args, *prepared_args)
|
||||
settings_args, forwarded = (
|
||||
subscription_launch_args(
|
||||
command,
|
||||
api_key,
|
||||
env_before_sync,
|
||||
claude_settings_files(
|
||||
read_settings, env_before_sync, project_dir if project_dir is not None else Path.cwd()
|
||||
),
|
||||
write_overlay,
|
||||
)
|
||||
if subscription and os.path.basename(command[0]) in _SUBSCRIPTION_AGENTS
|
||||
else ((), tuple(command[1:]))
|
||||
)
|
||||
if reattach_terminal is not None:
|
||||
reattach_terminal()
|
||||
launcher(binary, [command[0], *extra_args, *command[1:]], env)
|
||||
launcher(binary, [command[0], *extra_args, *settings_args, *forwarded], env)
|
||||
|
||||
|
||||
def _is_interactive() -> bool:
|
||||
|
|
@ -762,16 +948,27 @@ def resolve_api_key(ctx: click.Context) -> str:
|
|||
|
||||
|
||||
_SKIP_VERIFY_HELP: Final = "Skip the pre-launch key check against the proxy."
|
||||
_SUBSCRIPTION_HELP: Final = (
|
||||
"Keep using your claude.ai login (and its connectors). The LiteLLM key is sent in the "
|
||||
"x-litellm-api-key header instead of replacing Claude Code's own credential. "
|
||||
"Make it the default with `lite config set claude_subscription true`."
|
||||
)
|
||||
|
||||
|
||||
def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool) -> None:
|
||||
def _launch(
|
||||
ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool, subscription: bool | None = None
|
||||
) -> None:
|
||||
ctx_obj: Final = cli_context_obj(ctx)
|
||||
base_url: Final = ctx_obj["base_url"]
|
||||
started_interactive: Final = _is_interactive()
|
||||
api_key: Final = resolve_api_key(ctx)
|
||||
|
||||
display_name, _profiles = agent_profile(binary)
|
||||
click.echo(f"litellm: routing {display_name} through proxy at {base_url.rstrip('/')}")
|
||||
use_subscription: Final = (
|
||||
subscription if subscription is not None else binary in _SUBSCRIPTION_AGENTS and claude_subscription_default()
|
||||
)
|
||||
login_note: Final = " (keeping your claude.ai login)" if use_subscription else ""
|
||||
click.echo(f"litellm: routing {display_name} through proxy at {base_url.rstrip('/')}{login_note}")
|
||||
|
||||
try:
|
||||
run_agent(
|
||||
|
|
@ -779,12 +976,22 @@ def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify
|
|||
api_key,
|
||||
[binary, *args],
|
||||
skip_verify=skip_verify,
|
||||
subscription=use_subscription,
|
||||
reattach_terminal=(_restore_controlling_terminal if started_interactive else None),
|
||||
)
|
||||
except AgentRunError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
_Decorated: Final = TypeVar("_Decorated", bound=Callable[..., object] | click.Command)
|
||||
|
||||
|
||||
def _subscription_option(binary: str) -> Callable[[_Decorated], _Decorated]:
|
||||
if binary not in _SUBSCRIPTION_AGENTS:
|
||||
return lambda func: func
|
||||
return click.option("--subscription/--no-subscription", default=None, help=_SUBSCRIPTION_HELP)
|
||||
|
||||
|
||||
def _make_agent_command(binary: str, display_name: str) -> click.Command:
|
||||
@click.command(
|
||||
name=binary,
|
||||
|
|
@ -793,10 +1000,11 @@ def _make_agent_command(binary: str, display_name: str) -> click.Command:
|
|||
hidden=binary in _HIDDEN_AGENTS,
|
||||
)
|
||||
@click.option("--skip-verify", is_flag=True, default=False, help=_SKIP_VERIFY_HELP)
|
||||
@_subscription_option(binary)
|
||||
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
||||
@click.pass_context
|
||||
def _command(ctx: click.Context, skip_verify: bool, args: Sequence[str]) -> None:
|
||||
_launch(ctx, binary, list(args), skip_verify=skip_verify)
|
||||
def _command(ctx: click.Context, skip_verify: bool, args: Sequence[str], subscription: bool | None = None) -> None:
|
||||
_launch(ctx, binary, list(args), skip_verify=skip_verify, subscription=subscription)
|
||||
|
||||
_command.help = (
|
||||
f"Run {display_name} routed through your LiteLLM proxy.\n\n"
|
||||
|
|
@ -813,6 +1021,7 @@ def agent_commands() -> tuple[click.Command, ...]:
|
|||
|
||||
|
||||
__all__ = [
|
||||
"OVERLAY_FILE_NAME",
|
||||
"AgentRunError",
|
||||
"ListedModel",
|
||||
"ModelSyncSkipped",
|
||||
|
|
@ -821,10 +1030,14 @@ __all__ = [
|
|||
"agent_model_sync_env",
|
||||
"agent_profile",
|
||||
"build_agent_env",
|
||||
"claude_settings_files",
|
||||
"opencode_model_sync_env",
|
||||
"opencode_provider_config",
|
||||
"prepare_pi",
|
||||
"resolve_api_key",
|
||||
"run_agent",
|
||||
"subscription_launch_args",
|
||||
"verify_proxy_key",
|
||||
"verify_subscription_key",
|
||||
"write_settings_overlay",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from .claude_settings import (
|
|||
refuse_while_owned,
|
||||
settings_file_owners,
|
||||
)
|
||||
from .config import pkce_default
|
||||
from .pkce_login import (
|
||||
Http,
|
||||
PkceFailure,
|
||||
|
|
@ -884,17 +885,16 @@ def _pkce_login(base_url: str, config_claude: bool, vault: SecretVault) -> None:
|
|||
),
|
||||
)
|
||||
@click.option(
|
||||
"--pkce",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
"--pkce/--no-pkce",
|
||||
default=None,
|
||||
help=(
|
||||
"Sign in with OAuth authorization code + PKCE through your system browser (loopback redirect), "
|
||||
"with a refresh token that renews the key automatically. Requires a proxy that serves "
|
||||
"/.well-known/litellm-cli-auth."
|
||||
"/.well-known/litellm-cli-auth. Make it the default with `lite config set pkce true`."
|
||||
),
|
||||
)
|
||||
@click.pass_context
|
||||
def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
|
||||
def login(ctx: click.Context, config_claude: bool, pkce: bool | None) -> None:
|
||||
"""Login to LiteLLM proxy using SSO authentication"""
|
||||
from litellm.constants import LITELLM_CLI_SOURCE_IDENTIFIER
|
||||
|
||||
|
|
@ -908,7 +908,7 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
|
|||
raise click.ClickException(f"Cannot configure Claude Code, so not logging in: {e}")
|
||||
|
||||
try:
|
||||
if pkce:
|
||||
if pkce if pkce is not None else pkce_default():
|
||||
_pkce_login(base_url, config_claude, context_secret_vault(ctx))
|
||||
return
|
||||
cli_sso_flow: Final = _start_cli_sso_flow(base_url=base_url)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from pydantic import TypeAdapter
|
|||
from litellm.litellm_core_utils.private_json import ensure_private_dir, write_private_json
|
||||
|
||||
HIDDEN_COMMANDS_KEY: Final = "hidden_commands"
|
||||
CLAUDE_SUBSCRIPTION_KEY: Final = "claude_subscription"
|
||||
PKCE_KEY: Final = "pkce"
|
||||
|
||||
_config_adapter: Final[TypeAdapter[Mapping[str, str]]] = TypeAdapter(Mapping[str, str])
|
||||
|
||||
|
|
@ -88,10 +90,46 @@ def _normalize_hidden_commands(value: str) -> str:
|
|||
return ",".join(sorted(names))
|
||||
|
||||
|
||||
_BOOLEAN_WORDS: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"true": "true",
|
||||
"on": "true",
|
||||
"1": "true",
|
||||
"yes": "true",
|
||||
"false": "false",
|
||||
"off": "false",
|
||||
"0": "false",
|
||||
"no": "false",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_boolean(key: str) -> Callable[[str], str]:
|
||||
def normalize(value: str) -> str:
|
||||
normalized: Final = _BOOLEAN_WORDS.get(value.strip().lower())
|
||||
if normalized is None:
|
||||
raise click.UsageError(f"{key} must be true or false, e.g. `lite config set {key} true`")
|
||||
return normalized
|
||||
|
||||
return normalize
|
||||
|
||||
|
||||
def claude_subscription_default() -> bool:
|
||||
"""Whether `lite claude` keeps the claude.ai login unless told otherwise."""
|
||||
return get_config_value(CLAUDE_SUBSCRIPTION_KEY) == "true"
|
||||
|
||||
|
||||
def pkce_default() -> bool:
|
||||
"""Whether `lite login` signs in with browser PKCE unless told otherwise."""
|
||||
return get_config_value(PKCE_KEY) == "true"
|
||||
|
||||
|
||||
_NORMALIZERS: Final[Mapping[str, Callable[[str], str]]] = MappingProxyType(
|
||||
{
|
||||
"base_url": normalize_base_url,
|
||||
HIDDEN_COMMANDS_KEY: _normalize_hidden_commands,
|
||||
CLAUDE_SUBSCRIPTION_KEY: _normalize_boolean(CLAUDE_SUBSCRIPTION_KEY),
|
||||
PKCE_KEY: _normalize_boolean(PKCE_KEY),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from .claude_settings import (
|
|||
merge_claude_settings,
|
||||
write_claude_settings,
|
||||
)
|
||||
from .config import pkce_default
|
||||
|
||||
|
||||
class UpError(ClaudeSettingsError):
|
||||
|
|
@ -131,7 +132,7 @@ def ensure_fresh_login(ctx: click.Context) -> None:
|
|||
if _usable_login(_key_resolved_on_the_way_in(ctx_obj, base_url, vault), vault):
|
||||
return
|
||||
|
||||
pkce: Final = _stored_login_is_pkce(vault)
|
||||
pkce: Final = _stored_login_is_pkce(vault) or pkce_default()
|
||||
login_command: Final = "lite login --pkce" if pkce else "lite login"
|
||||
if not sys.stdin.isatty():
|
||||
raise UpError(f"No fresh LiteLLM login found for this proxy. Run `{login_command}` first.")
|
||||
|
|
|
|||
|
|
@ -253,7 +253,9 @@ from litellm.proxy.anthropic_endpoints.streaming_model_restamp import (
|
|||
AnthropicStreamModelRestamper,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
LiteLLMProxyRequestSetup,
|
||||
add_litellm_data_to_request,
|
||||
get_metadata_variable_name,
|
||||
refresh_proxy_server_request_body_snapshot,
|
||||
reject_url_valued_destination,
|
||||
sanitize_for_log,
|
||||
|
|
@ -2244,6 +2246,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
llm_router: Router | None = None,
|
||||
rate_limited_model: str | None = None,
|
||||
skip_guardrails: bool = False,
|
||||
logging_auth: UserAPIKeyAuth | None = None,
|
||||
) -> tuple[dict, LiteLLMLoggingObj]:
|
||||
start_time: Final = datetime.now() # start before calling guardrail hooks
|
||||
|
||||
|
|
@ -2263,6 +2266,14 @@ class ProxyBaseLLMRequestProcessing:
|
|||
version=version,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
if logging_auth is not None:
|
||||
metadata_variable_name: Final = get_metadata_variable_name(request)
|
||||
request_metadata: Final = self.data.get(metadata_variable_name)
|
||||
if isinstance(request_metadata, dict):
|
||||
self.data[metadata_variable_name] = {
|
||||
**TypeAdapter(dict[str, object]).validate_python(request_metadata),
|
||||
**LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(logging_auth),
|
||||
}
|
||||
if not general_settings.get("expose_fallback_errors_to_caller"):
|
||||
self.data.pop("include_fallback_errors", None)
|
||||
if route_type in {"aresponses", "_aresponses_websocket"}:
|
||||
|
|
@ -2285,8 +2296,6 @@ class ProxyBaseLLMRequestProcessing:
|
|||
|
||||
# Store queue time in metadata after add_litellm_data_to_request to ensure it's preserved
|
||||
if queue_time_seconds is not None:
|
||||
from litellm.proxy.litellm_pre_call_utils import get_metadata_variable_name
|
||||
|
||||
_metadata_variable_name: Final = get_metadata_variable_name(request)
|
||||
if _metadata_variable_name not in self.data:
|
||||
self.data[_metadata_variable_name] = {}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,8 @@ async def create_fine_tuning_job(
|
|||
)
|
||||
|
||||
response = cast(LiteLLMFineTuningJob, await llm_router.acreate_fine_tuning_job(**data))
|
||||
response.training_file = unified_file_id
|
||||
response.training_file = training_file
|
||||
response.validation_file = fine_tuning_request.validation_file
|
||||
set_hidden_param(response, "unified_file_id", unified_file_id)
|
||||
## ELSE, Route based on custom_llm_provider
|
||||
elif fine_tuning_request.custom_llm_provider:
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ else:
|
|||
|
||||
|
||||
_REQUEST_RATE_LIMIT_DATA: Final = TypeAdapter(Mapping[str, object])
|
||||
_MCP_KEY_RPM_LIMITS: Final[TypeAdapter[dict[str, int] | None]] = TypeAdapter(dict[str, int] | None)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -3000,12 +3001,11 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|||
MCP tool calls have no token usage, so only requests_per_unit is set;
|
||||
tokens_per_unit stays None so the TPM reservation path is never engaged.
|
||||
"""
|
||||
from litellm.proxy.auth.auth_utils import get_key_mcp_rpm_limit
|
||||
|
||||
if not mcp_server_name or not user_api_key_dict.api_key:
|
||||
return
|
||||
|
||||
mcp_rpm_limit: Final = get_key_mcp_rpm_limit(user_api_key_dict)
|
||||
metadata: Final = _REQUEST_RATE_LIMIT_DATA.validate_python(user_api_key_dict.metadata or {})
|
||||
mcp_rpm_limit: Final = _MCP_KEY_RPM_LIMITS.validate_python(metadata.get("mcp_rpm_limit"))
|
||||
if not mcp_rpm_limit:
|
||||
return
|
||||
|
||||
|
|
@ -3046,11 +3046,18 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|||
# team's mcp_rpm_limit. Every applicable team is charged rather than one being picked: the
|
||||
# limiter enforces all descriptors, so each team's own ceiling binds on a call made through
|
||||
# its grant, and there is no arbitrary attribution when several teams grant the same server.
|
||||
team_limits: Final[list[tuple[str | None, dict[str, int] | None]]] = []
|
||||
if user_api_key_dict.team_id:
|
||||
team_limits.append((user_api_key_dict.team_id, get_team_mcp_rpm_limit(user_api_key_dict)))
|
||||
for source_team_id, source_limit in (user_api_key_dict.mcp_source_team_rpm_limits or {}).items():
|
||||
team_limits.append((source_team_id, source_limit))
|
||||
source_team_limits: Final = user_api_key_dict.mcp_source_team_rpm_limits or {}
|
||||
primary_team_ids: Final = (user_api_key_dict.team_id,) if user_api_key_dict.team_id else ()
|
||||
team_ids: Final = tuple(dict.fromkeys((*primary_team_ids, *source_team_limits)))
|
||||
team_limits: Final = tuple(
|
||||
(
|
||||
team_id,
|
||||
get_team_mcp_rpm_limit(user_api_key_dict) or source_team_limits.get(team_id)
|
||||
if team_id == user_api_key_dict.team_id
|
||||
else source_team_limits[team_id],
|
||||
)
|
||||
for team_id in team_ids
|
||||
)
|
||||
|
||||
for team_id, mcp_rpm_limit in team_limits:
|
||||
if not team_id or not mcp_rpm_limit:
|
||||
|
|
|
|||
|
|
@ -457,7 +457,6 @@ class _ProxyDBLogger(CustomLogger):
|
|||
update_cache_read_keys=update_cache_read_keys(
|
||||
user_id=user_id,
|
||||
end_user_id=end_user_id,
|
||||
team_id=team_id,
|
||||
tags=tags,
|
||||
response_cost=response_cost,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3718,7 +3718,7 @@ def _add_guardrails_from_policies_in_metadata(
|
|||
|
||||
def add_guardrails_from_auth_metadata(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: dict, # mutable-ok: writes guardrails into the live request dict, same contract as the helpers it wraps
|
||||
data: dict[str, object], # mutable-ok: writes guardrails into the live request dict, same contract as helpers
|
||||
metadata_variable_name: str,
|
||||
) -> None:
|
||||
"""Resolve key, team, and project guardrails, direct and via policies, onto the request metadata."""
|
||||
|
|
@ -3776,7 +3776,7 @@ async def move_guardrails_to_metadata(
|
|||
|
||||
add_guardrails_from_auth_metadata(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
data=data, # pyright: ignore[reportUnknownArgumentType] # request data retains a legacy dynamic shape
|
||||
metadata_variable_name=_metadata_variable_name,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ This is an enterprise feature and requires a premium license.
|
|||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from functools import partial, reduce
|
||||
from itertools import chain, groupby
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypeAlias, overload
|
||||
from typing import TYPE_CHECKING, Final, NamedTuple, NoReturn, Protocol, TypeAlias, overload
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -24,6 +24,7 @@ from fastapi import (
|
|||
Response,
|
||||
)
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from pydantic.fields import FieldInfo
|
||||
from typing_extensions import ReadOnly, TypedDict, assert_never
|
||||
|
||||
import litellm
|
||||
|
|
@ -2271,6 +2272,217 @@ def _handle_multi_valued_attribute_update(path: str, op_type: str, value: object
|
|||
metadata[metadata_key] = dumped
|
||||
|
||||
|
||||
def _enterprise_extension_sub_path(path: str) -> str | None:
|
||||
schema: Final = SCIM_ENTERPRISE_USER_SCHEMA
|
||||
if path.casefold() == schema.casefold():
|
||||
return ""
|
||||
prefix: Final = f"{schema}:"
|
||||
if path.casefold().startswith(prefix.casefold()):
|
||||
return path[len(prefix) :]
|
||||
return None
|
||||
|
||||
|
||||
def _scim_model_field_names(model_fields: Mapping[str, FieldInfo]) -> Mapping[str, str]:
|
||||
return {
|
||||
**{field_name.casefold(): field_name for field_name in model_fields},
|
||||
**{
|
||||
field.alias.casefold(): field_name
|
||||
for field_name, field in model_fields.items()
|
||||
if isinstance(field.alias, str)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _canonicalize_scim_model_data(
|
||||
value: Mapping[str, object],
|
||||
model_fields: Mapping[str, FieldInfo],
|
||||
) -> Mapping[str, object]:
|
||||
field_names: Final = _scim_model_field_names(model_fields)
|
||||
return {field_names.get(key.casefold(), key): field_value for key, field_value in value.items()}
|
||||
|
||||
|
||||
def _canonicalize_enterprise_data(value: Mapping[str, object]) -> Mapping[str, object]:
|
||||
enterprise_data: Final = _canonicalize_scim_model_data(value, SCIMEnterpriseUser.model_fields)
|
||||
manager: Final = enterprise_data.get("manager")
|
||||
if isinstance(manager, str):
|
||||
return {**enterprise_data, "manager": {"value": manager}}
|
||||
if not isinstance(manager, dict):
|
||||
return enterprise_data
|
||||
return {
|
||||
**enterprise_data,
|
||||
"manager": _canonicalize_scim_model_data(
|
||||
_JSON_OBJECT_ADAPTER.validate_python(manager),
|
||||
SCIMUserManager.model_fields,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _InvalidEnterpriseValue:
|
||||
detail: str
|
||||
|
||||
|
||||
def _merge_enterprise_data(
|
||||
existing: Mapping[str, object],
|
||||
incoming: Mapping[str, object],
|
||||
incoming_manager_is_object: bool,
|
||||
) -> Mapping[str, object]:
|
||||
merged: Final[Mapping[str, object]] = {**existing, **incoming}
|
||||
existing_manager: Final = existing.get("manager")
|
||||
incoming_manager: Final = incoming.get("manager")
|
||||
if incoming_manager_is_object and isinstance(existing_manager, dict) and isinstance(incoming_manager, dict):
|
||||
existing_manager_data: Final = _canonicalize_scim_model_data(
|
||||
_JSON_OBJECT_ADAPTER.validate_python(existing_manager),
|
||||
SCIMUserManager.model_fields,
|
||||
)
|
||||
incoming_manager_data: Final = _JSON_OBJECT_ADAPTER.validate_python(incoming_manager)
|
||||
return {**merged, "manager": {**existing_manager_data, **incoming_manager_data}}
|
||||
return merged
|
||||
|
||||
|
||||
def _validate_enterprise_update(
|
||||
value: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None | _InvalidEnterpriseValue:
|
||||
try:
|
||||
enterprise: Final = SCIMEnterpriseUser.model_validate(value)
|
||||
except ValidationError:
|
||||
return _InvalidEnterpriseValue(detail="Invalid value for the enterprise extension")
|
||||
dumped: Final = enterprise.model_dump(by_alias=True, exclude_none=True)
|
||||
return dumped if dumped else None
|
||||
|
||||
|
||||
def _raise_invalid_enterprise_value(error: _InvalidEnterpriseValue) -> NoReturn:
|
||||
raise HTTPException(status_code=400, detail={"error": error.detail})
|
||||
|
||||
|
||||
def _handle_enterprise_update(
|
||||
sub_path: str,
|
||||
op_type: str,
|
||||
value: object,
|
||||
existing_enterprise: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None | _InvalidEnterpriseValue:
|
||||
if op_type == "remove" and not sub_path:
|
||||
return None
|
||||
|
||||
if not sub_path:
|
||||
if not isinstance(value, dict):
|
||||
return _InvalidEnterpriseValue(detail="The enterprise extension value must be an object")
|
||||
incoming_value: Final = _JSON_OBJECT_ADAPTER.validate_python(value)
|
||||
incoming: Final = _canonicalize_enterprise_data(incoming_value)
|
||||
incoming_manager_is_object: Final = isinstance(
|
||||
_canonicalize_scim_model_data(incoming_value, SCIMEnterpriseUser.model_fields).get("manager"),
|
||||
dict,
|
||||
)
|
||||
return _validate_enterprise_update(
|
||||
_merge_enterprise_data(existing_enterprise, incoming, incoming_manager_is_object)
|
||||
)
|
||||
|
||||
path_parts: Final = sub_path.split(".")
|
||||
if len(path_parts) == 1:
|
||||
field_name: Final = _scim_model_field_names(SCIMEnterpriseUser.model_fields).get(sub_path.casefold())
|
||||
if field_name is None:
|
||||
return _validate_enterprise_update(existing_enterprise)
|
||||
if op_type == "remove":
|
||||
return _validate_enterprise_update(
|
||||
{key: attr for key, attr in existing_enterprise.items() if key != field_name}
|
||||
)
|
||||
if value is None:
|
||||
return _InvalidEnterpriseValue(detail=f"The {op_type} operation requires a 'value' member")
|
||||
field_incoming: Final = _canonicalize_enterprise_data({field_name: value})
|
||||
field_incoming_manager_is_object: Final = field_name == "manager" and isinstance(value, dict)
|
||||
return _validate_enterprise_update(
|
||||
_merge_enterprise_data(
|
||||
existing_enterprise,
|
||||
field_incoming,
|
||||
field_incoming_manager_is_object,
|
||||
)
|
||||
)
|
||||
|
||||
if len(path_parts) == 2 and path_parts[0].casefold() == "manager":
|
||||
manager_field: Final = _scim_model_field_names(SCIMUserManager.model_fields).get(path_parts[1].casefold())
|
||||
if manager_field is None:
|
||||
return _validate_enterprise_update(existing_enterprise)
|
||||
existing_manager: Final = existing_enterprise.get("manager")
|
||||
manager_data: Final[Mapping[str, object]] = (
|
||||
_canonicalize_scim_model_data(
|
||||
_JSON_OBJECT_ADAPTER.validate_python(existing_manager),
|
||||
SCIMUserManager.model_fields,
|
||||
)
|
||||
if isinstance(existing_manager, dict)
|
||||
else {}
|
||||
)
|
||||
if op_type == "remove":
|
||||
removed_manager: Final[Mapping[str, object]] = {
|
||||
key: attr for key, attr in manager_data.items() if key != manager_field
|
||||
}
|
||||
updated_enterprise: Final[Mapping[str, object]] = (
|
||||
{**existing_enterprise, "manager": removed_manager}
|
||||
if removed_manager
|
||||
else {key: attr for key, attr in existing_enterprise.items() if key != "manager"}
|
||||
)
|
||||
return _validate_enterprise_update(updated_enterprise)
|
||||
if value is None:
|
||||
return _InvalidEnterpriseValue(detail=f"The {op_type} operation requires a 'value' member")
|
||||
updated_manager: Final[Mapping[str, object]] = {**manager_data, manager_field: value}
|
||||
return _validate_enterprise_update({**existing_enterprise, "manager": updated_manager})
|
||||
|
||||
return _validate_enterprise_update(existing_enterprise)
|
||||
|
||||
|
||||
def _enterprise_update_for_pathless_key(
|
||||
key_and_value: tuple[str, object],
|
||||
*,
|
||||
op_type: str,
|
||||
) -> Iterator[tuple[str, str, object]]:
|
||||
key, value = key_and_value
|
||||
sub_path: Final = _enterprise_extension_sub_path(key)
|
||||
if sub_path is not None:
|
||||
yield sub_path, op_type, value
|
||||
|
||||
|
||||
def _enterprise_updates_for_operation(
|
||||
op: SCIMPatchOperation,
|
||||
) -> Iterator[tuple[str, str, object]]:
|
||||
path: Final = op.path.lower() if isinstance(op.path, str) else ""
|
||||
value: Final[object] = TypeAdapter(object).validate_python(op.value)
|
||||
if not path and isinstance(value, dict):
|
||||
pathless_values: Final[Mapping[str, object]] = _JSON_OBJECT_ADAPTER.validate_python(value)
|
||||
yield from chain.from_iterable(
|
||||
map(partial(_enterprise_update_for_pathless_key, op_type=op.op), pathless_values.items())
|
||||
)
|
||||
return
|
||||
sub_path: Final = _enterprise_extension_sub_path(path)
|
||||
if sub_path is not None:
|
||||
yield sub_path, op.op, value
|
||||
|
||||
|
||||
def _enterprise_updates(operations: Sequence[SCIMPatchOperation]) -> Iterator[tuple[str, str, object]]:
|
||||
yield from chain.from_iterable(map(_enterprise_updates_for_operation, operations))
|
||||
|
||||
|
||||
def _apply_enterprise_update(
|
||||
state: Mapping[str, object] | None | _InvalidEnterpriseValue,
|
||||
update: tuple[str, str, object],
|
||||
) -> Mapping[str, object] | None | _InvalidEnterpriseValue:
|
||||
if isinstance(state, _InvalidEnterpriseValue):
|
||||
return state
|
||||
sub_path, op_type, value = update
|
||||
existing_enterprise: Final[Mapping[str, object]] = {} if state is None else state
|
||||
return _handle_enterprise_update(sub_path, op_type, value, existing_enterprise)
|
||||
|
||||
|
||||
def _apply_enterprise_updates(
|
||||
updates: Sequence[tuple[str, str, object]],
|
||||
stored_enterprise: object,
|
||||
) -> Mapping[str, object] | None | _InvalidEnterpriseValue:
|
||||
initial_enterprise: Final[Mapping[str, object] | None] = (
|
||||
_canonicalize_enterprise_data(_JSON_OBJECT_ADAPTER.validate_python(stored_enterprise))
|
||||
if isinstance(stored_enterprise, dict)
|
||||
else None
|
||||
)
|
||||
return reduce(_apply_enterprise_update, updates, initial_enterprise)
|
||||
|
||||
|
||||
def _handle_generic_metadata(path: str, op_type: str, value: object, metadata: dict[str, object]) -> None:
|
||||
"""Handle generic metadata operations for unknown paths."""
|
||||
if op_type == "remove":
|
||||
|
|
@ -2285,20 +2497,37 @@ def _apply_patch_ops(
|
|||
) -> tuple[dict[str, object], set[str]]:
|
||||
"""Apply patch operations and return update data and final team set."""
|
||||
update_data: Final[dict[str, object]] = {}
|
||||
metadata: Final = existing_user.metadata or {}
|
||||
scim_metadata: Final = metadata.get("scim_metadata", {})
|
||||
stored_metadata: Final[object] = TypeAdapter(object).validate_python(existing_user.model_dump().get("metadata"))
|
||||
metadata: Final = _JSON_OBJECT_ADAPTER.validate_python(stored_metadata) if isinstance(stored_metadata, dict) else {}
|
||||
stored_scim_metadata: Final = metadata.get("scim_metadata")
|
||||
scim_metadata: Final = (
|
||||
_JSON_OBJECT_ADAPTER.validate_python(stored_scim_metadata) if isinstance(stored_scim_metadata, dict) else {}
|
||||
)
|
||||
enterprise_updates: Final = tuple(_enterprise_updates(patch_ops.Operations))
|
||||
enterprise: Final[Mapping[str, object] | None | _InvalidEnterpriseValue] = (
|
||||
_apply_enterprise_updates(
|
||||
enterprise_updates,
|
||||
metadata.get(SCIM_ENTERPRISE_METADATA_KEY),
|
||||
)
|
||||
if enterprise_updates
|
||||
else None
|
||||
)
|
||||
if isinstance(enterprise, _InvalidEnterpriseValue):
|
||||
_raise_invalid_enterprise_value(enterprise)
|
||||
|
||||
teams_set: Final[set[str]] = set(existing_user.teams or [])
|
||||
replace_team_set: set[str] | None = None
|
||||
|
||||
for op in patch_ops.Operations:
|
||||
path = (op.path or "").lower()
|
||||
path = op.path.lower() if isinstance(op.path, str) else ""
|
||||
value = op.value
|
||||
op_type = op.op
|
||||
|
||||
# Handle SCIM operations without path where value contains the fields
|
||||
if not path and isinstance(value, dict):
|
||||
for key, val in value.items():
|
||||
for key, val in _JSON_OBJECT_ADAPTER.validate_python(value).items():
|
||||
if _enterprise_extension_sub_path(key) is not None:
|
||||
continue
|
||||
key_lower = key.lower()
|
||||
if key_lower == "active":
|
||||
_handle_active_update(op_type, val, metadata)
|
||||
|
|
@ -2309,7 +2538,7 @@ def _apply_patch_ops(
|
|||
elif key_lower in SCIM_MULTI_VALUED_ATTRIBUTE_METADATA_KEYS:
|
||||
_handle_multi_valued_attribute_update(key_lower, op_type, val, metadata)
|
||||
elif key_lower == "name" and isinstance(val, dict):
|
||||
for name_key, name_val in val.items():
|
||||
for name_key, name_val in _JSON_OBJECT_ADAPTER.validate_python(val).items():
|
||||
name_key_lower = name_key.lower()
|
||||
if name_key_lower in ("givenname", "familyname"):
|
||||
_handle_name_update(
|
||||
|
|
@ -2334,12 +2563,22 @@ def _apply_patch_ops(
|
|||
new_replace_set = _handle_group_operations(op_type, value, teams_set, op.path)
|
||||
if new_replace_set is not None:
|
||||
replace_team_set = new_replace_set
|
||||
elif _enterprise_extension_sub_path(path) is not None:
|
||||
continue
|
||||
else:
|
||||
_handle_generic_metadata(path, op_type, value, metadata)
|
||||
|
||||
final_team_set: Final = replace_team_set if replace_team_set is not None else teams_set
|
||||
metadata["scim_metadata"] = scim_metadata
|
||||
update_data["metadata"] = metadata
|
||||
final_metadata: Final = (
|
||||
{
|
||||
**{key: metadata_value for key, metadata_value in metadata.items() if key != SCIM_ENTERPRISE_METADATA_KEY},
|
||||
"scim_metadata": scim_metadata,
|
||||
**({SCIM_ENTERPRISE_METADATA_KEY: enterprise} if enterprise is not None else {}),
|
||||
}
|
||||
if enterprise_updates
|
||||
else {**metadata, "scim_metadata": scim_metadata}
|
||||
)
|
||||
update_data["metadata"] = final_metadata
|
||||
return update_data, final_team_set
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5343,6 +5343,52 @@ async def team_member_me(
|
|||
)
|
||||
|
||||
|
||||
async def _set_team_blocked(
|
||||
team_id: str,
|
||||
blocked: bool,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> "prisma_models.LiteLLM_TeamTable":
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("No DB Connected.")
|
||||
|
||||
existing_team: Final = await _team_db(prisma_client).find_unique(where={"team_id": team_id})
|
||||
if existing_team is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Team not found, passed team_id={team_id}"},
|
||||
)
|
||||
|
||||
if not await get_team_access().allows(
|
||||
user_api_key_dict, LiteLLM_TeamTable.model_validate(existing_team.model_dump()), TEAM_OR_ORG_ADMIN
|
||||
):
|
||||
team_access_denied()
|
||||
|
||||
record: Final = await _team_db(prisma_client).update(
|
||||
where={"team_id": team_id},
|
||||
data={"blocked": blocked},
|
||||
include=_TEAM_UPDATE_INCLUDE,
|
||||
)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Team not found, passed team_id={team_id}"},
|
||||
)
|
||||
|
||||
await refresh_cached_team(
|
||||
team_row=record,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
return record.model_copy(update=dict.fromkeys(_TEAM_UPDATE_INCLUDE))
|
||||
|
||||
|
||||
@router.post("/team/block", tags=["team management"], dependencies=[Depends(user_api_key_auth)])
|
||||
@management_endpoint_wrapper
|
||||
async def block_team(
|
||||
|
|
@ -5372,30 +5418,7 @@ async def block_team(
|
|||
|
||||
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("No DB Connected.")
|
||||
|
||||
existing_team: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id})
|
||||
if existing_team is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Team not found, passed team_id={data.team_id}"},
|
||||
)
|
||||
|
||||
# Verify caller has access to manage this team
|
||||
if not await get_team_access().allows(
|
||||
user_api_key_dict, LiteLLM_TeamTable.model_validate(existing_team.model_dump()), TEAM_OR_ORG_ADMIN
|
||||
):
|
||||
team_access_denied()
|
||||
|
||||
record: Final = await _team_db(prisma_client).update(
|
||||
where={"team_id": data.team_id},
|
||||
data={"blocked": True},
|
||||
)
|
||||
|
||||
return record
|
||||
return await _set_team_blocked(team_id=data.team_id, blocked=True, user_api_key_dict=user_api_key_dict)
|
||||
|
||||
|
||||
@router.post("/team/unblock", tags=["team management"], dependencies=[Depends(user_api_key_auth)])
|
||||
|
|
@ -5421,30 +5444,7 @@ async def unblock_team(
|
|||
}'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("No DB Connected.")
|
||||
|
||||
existing_team: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id})
|
||||
if existing_team is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Team not found, passed team_id={data.team_id}"},
|
||||
)
|
||||
|
||||
# Verify caller has access to manage this team
|
||||
if not await get_team_access().allows(
|
||||
user_api_key_dict, LiteLLM_TeamTable.model_validate(existing_team.model_dump()), TEAM_OR_ORG_ADMIN
|
||||
):
|
||||
team_access_denied()
|
||||
|
||||
record: Final = await _team_db(prisma_client).update(
|
||||
where={"team_id": data.team_id},
|
||||
data={"blocked": False},
|
||||
)
|
||||
|
||||
return record
|
||||
return await _set_team_blocked(team_id=data.team_id, blocked=False, user_api_key_dict=user_api_key_dict)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Handles cost tracking and logging for OpenAI passthrough endpoints, specifically
|
|||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
from typing import Final, Literal, TypeAlias
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
|
@ -14,6 +14,7 @@ import httpx
|
|||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.hidden_params import set_hidden_param
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_background_response
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload,
|
||||
|
|
@ -31,6 +32,8 @@ from litellm.proxy.pass_through_endpoints.success_handler import (
|
|||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
SETTLES_BACKGROUND_RESPONSE_KWARG,
|
||||
BackgroundResponseSettlement,
|
||||
EndpointType,
|
||||
PassthroughStandardLoggingPayload,
|
||||
)
|
||||
|
|
@ -56,7 +59,7 @@ _AZURE_OPENAI_HOSTNAMES: Final = ("openai.azure.com", "cognitiveservices.azure.c
|
|||
_AZURE_OPENAI_PATH_MARKERS: Final = ("/openai/", "/v1/")
|
||||
|
||||
|
||||
def _hostname_matches(hostname: str, suffixes: tuple) -> bool:
|
||||
def _hostname_matches(hostname: str, suffixes: tuple[str, ...]) -> bool:
|
||||
"""True if hostname equals one of `suffixes` or is a subdomain of it.
|
||||
|
||||
Uses suffix matching (not a bare substring test) so look-alikes such as
|
||||
|
|
@ -102,6 +105,19 @@ def is_openai_compatible_url(url_route: str | None) -> bool:
|
|||
_is_openai_compatible_url: Final = is_openai_compatible_url
|
||||
|
||||
|
||||
ResponsesCallBilling: TypeAlias = Literal["unbilled", "billed", "background_settlement"]
|
||||
|
||||
|
||||
def _is_finished_background_response(response_body: Mapping[str, object]) -> bool:
|
||||
return is_background_response(response_body) and response_body.get("usage") is not None
|
||||
|
||||
|
||||
def _responses_sub_route(path: str) -> tuple[str, ...] | None:
|
||||
segments: Final = path.rstrip("/").split("/")
|
||||
anchor: Final = max((index for index, segment in enumerate(segments) if segment == "responses"), default=None)
|
||||
return None if anchor is None else tuple(segments[anchor + 1 :])
|
||||
|
||||
|
||||
def _is_remote_high_detail_image(part: object) -> bool:
|
||||
if not isinstance(part, Mapping) or part.get("type") != "image_url":
|
||||
return False
|
||||
|
|
@ -186,6 +202,30 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
"/v1/responses" in parsed_url.path or "/responses" in parsed_url.path
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def openai_responses_call_billing(
|
||||
method: str, url_route: str, response_body: Mapping[str, object]
|
||||
) -> ResponsesCallBilling:
|
||||
if not OpenAIPassthroughLoggingHandler.is_openai_responses_route(url_route):
|
||||
return "unbilled"
|
||||
match (method, _responses_sub_route(urlparse(url_route).path)):
|
||||
case ("POST", ("input_tokens",)) | ("GET", (_, "input_items")) | ("DELETE", (_,)):
|
||||
return "unbilled"
|
||||
case ("GET", (_,)) | ("POST", (_, "cancel")):
|
||||
return "background_settlement" if _is_finished_background_response(response_body) else "unbilled"
|
||||
case _:
|
||||
return "billed"
|
||||
|
||||
@staticmethod
|
||||
def _label_unbilled_responses_call(
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
url_route: str,
|
||||
response_body: Mapping[str, object],
|
||||
) -> None:
|
||||
model: Final = response_body.get("model")
|
||||
if OpenAIPassthroughLoggingHandler.is_openai_responses_route(url_route) and isinstance(model, str):
|
||||
logging_obj.model_call_details["model"] = model # rebind-ok: spend rows read the model from here
|
||||
|
||||
@staticmethod
|
||||
def is_openai_embeddings_route(url_route: str) -> bool:
|
||||
"""Check if the URL route is an OpenAI embeddings endpoint."""
|
||||
|
|
@ -348,9 +388,13 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
is_embeddings: Final = OpenAIPassthroughLoggingHandler.is_openai_embeddings_route(url_route)
|
||||
is_image_generation: Final = OpenAIPassthroughLoggingHandler.is_openai_image_generation_route(url_route)
|
||||
is_image_editing: Final = OpenAIPassthroughLoggingHandler.is_openai_image_editing_route(url_route)
|
||||
is_responses: Final = OpenAIPassthroughLoggingHandler.is_openai_responses_route(url_route)
|
||||
responses_billing: Final = OpenAIPassthroughLoggingHandler.openai_responses_call_billing(
|
||||
httpx_response.request.method, url_route, response_body
|
||||
)
|
||||
is_responses: Final = responses_billing != "unbilled"
|
||||
|
||||
if not (is_chat_completions or is_embeddings or is_image_generation or is_image_editing or is_responses):
|
||||
OpenAIPassthroughLoggingHandler._label_unbilled_responses_call(logging_obj, url_route, response_body)
|
||||
return {
|
||||
"result": None,
|
||||
"kwargs": kwargs,
|
||||
|
|
@ -505,6 +549,9 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
logging_obj.model_call_details["model"] = model
|
||||
logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider
|
||||
logging_obj.model_call_details["response_cost"] = response_cost
|
||||
logging_obj.model_call_details[SETTLES_BACKGROUND_RESPONSE_KWARG] = ( # rebind-ok: spend rows read it here
|
||||
BackgroundResponseSettlement.SETTLES if responses_billing == "background_settlement" else None
|
||||
)
|
||||
|
||||
endpoint_type: Final = (
|
||||
"chat_completions"
|
||||
|
|
@ -625,16 +672,24 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
Handle logging for collected OpenAI streaming chunks with cost tracking.
|
||||
"""
|
||||
try:
|
||||
# Extract model from request body
|
||||
model: Final = request_body.get("model", "gpt-4o")
|
||||
|
||||
is_responses: Final = OpenAIPassthroughLoggingHandler.is_openai_responses_route(url_route)
|
||||
terminal_response: Final = (
|
||||
OpenAIResponsesAPIConfig.parse_terminal_response_from_stream_chunks(all_chunks=all_chunks)
|
||||
if is_responses
|
||||
else None
|
||||
)
|
||||
requested_model: Final[object] = request_body.get("model")
|
||||
model: Final = (
|
||||
requested_model
|
||||
if isinstance(requested_model, str) and requested_model
|
||||
else (terminal_response.model if terminal_response is not None else None) or "gpt-4o"
|
||||
)
|
||||
|
||||
# Build complete response from chunks using our streaming handler
|
||||
handler: Final = OpenAIPassthroughLoggingHandler()
|
||||
handler_instance: Final = handler
|
||||
complete_response: Final = (
|
||||
OpenAIResponsesAPIConfig.parse_terminal_response_from_stream_chunks(all_chunks=all_chunks)
|
||||
terminal_response
|
||||
if is_responses
|
||||
else handler._build_complete_streaming_response(
|
||||
all_chunks=all_chunks,
|
||||
|
|
@ -667,17 +722,27 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
)
|
||||
)
|
||||
|
||||
settles_background_response: Final = (
|
||||
BackgroundResponseSettlement.SETTLES
|
||||
if terminal_response is not None
|
||||
and is_background_response(terminal_response)
|
||||
and terminal_response.usage is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Preserve existing litellm_params to maintain metadata tags
|
||||
existing_litellm_params: Final = litellm_logging_obj.model_call_details.get("litellm_params", {}) or {}
|
||||
litellm_params: Final = existing_litellm_params.copy()
|
||||
|
||||
# Prepare kwargs for logging
|
||||
kwargs: Final = {
|
||||
"response_cost": response_cost,
|
||||
SETTLES_BACKGROUND_RESPONSE_KWARG: settles_background_response,
|
||||
"model": model,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"call_type": litellm_logging_obj.call_type,
|
||||
"messages": litellm_logging_obj.model_call_details.get("messages"),
|
||||
"litellm_params": existing_litellm_params.copy(),
|
||||
"litellm_params": litellm_params,
|
||||
}
|
||||
|
||||
# Extract user information for tracking
|
||||
|
|
@ -689,9 +754,7 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
passthrough_logging_payload=passthrough_logging_payload,
|
||||
)
|
||||
if user:
|
||||
kwargs["litellm_params"].setdefault("proxy_server_request", {}).setdefault("body", {})["user"] = (
|
||||
user
|
||||
)
|
||||
litellm_params.setdefault("proxy_server_request", {}).setdefault("body", {})["user"] = user
|
||||
|
||||
# Attach the payload to kwargs so the success handler adopts it;
|
||||
# its later rebuild runs on a copy whose Responses usage was
|
||||
|
|
@ -712,6 +775,9 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
litellm_logging_obj.model_call_details["model"] = model
|
||||
litellm_logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider
|
||||
litellm_logging_obj.model_call_details["response_cost"] = response_cost
|
||||
litellm_logging_obj.model_call_details[SETTLES_BACKGROUND_RESPONSE_KWARG] = ( # rebind-ok: spend row id
|
||||
settles_background_response
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"OpenAI streaming passthrough cost tracking - Model: {model}, Cost: ${response_cost:.6f}"
|
||||
|
|
|
|||
|
|
@ -4194,7 +4194,6 @@ async def run_spend_counter_pipeline(pending: Sequence[PendingSpendIncrement]) -
|
|||
def update_cache_read_keys(
|
||||
user_id: str | None,
|
||||
end_user_id: str | None,
|
||||
team_id: str | None,
|
||||
tags: Sequence[object] | None,
|
||||
response_cost: float | None,
|
||||
) -> tuple[str, ...]:
|
||||
|
|
@ -4202,9 +4201,8 @@ def update_cache_read_keys(
|
|||
return ()
|
||||
user_keys: tuple[str, ...] = (user_id, GLOBAL_PROXY_SPEND_CACHE_KEY) if user_id is not None else ()
|
||||
end_user_keys: tuple[str, ...] = (end_user_cache_key(end_user_id),) if end_user_id is not None else ()
|
||||
team_keys: tuple[str, ...] = (f"team_id:{team_id}",) if team_id is not None else ()
|
||||
tag_keys: tuple[str, ...] = tuple(tag_cache_key(tag) for tag in tags or () if isinstance(tag, str) and tag)
|
||||
return user_keys + end_user_keys + team_keys + tag_keys
|
||||
return user_keys + end_user_keys + tag_keys
|
||||
|
||||
|
||||
_UPDATE_CACHE_PREFETCH_SLOT: Final = "update_cache_read"
|
||||
|
|
@ -4281,7 +4279,7 @@ async def update_cache(
|
|||
with service_target(AUTH_OBJECTS_TARGET):
|
||||
cached_values: Final = await _read_update_cache_values(
|
||||
keys=update_cache_read_keys(
|
||||
user_id=user_id, end_user_id=end_user_id, team_id=team_id, tags=tags, response_cost=response_cost
|
||||
user_id=user_id, end_user_id=end_user_id, tags=tags, response_cost=response_cost
|
||||
),
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
|
|
@ -4431,35 +4429,28 @@ async def update_cache(
|
|||
)
|
||||
|
||||
### UPDATE TEAM SPEND ###
|
||||
async def _update_team_cache():
|
||||
def _update_team_cache():
|
||||
if team_id is None or response_cost is None:
|
||||
return
|
||||
|
||||
_id: Final = f"team_id:{team_id}"
|
||||
try:
|
||||
cached_team: Final = cached_values.get(_id)
|
||||
if cached_team is None:
|
||||
# do nothing if team not in api key cache
|
||||
return
|
||||
existing_spend_obj: Final[LiteLLM_TeamTableCachedObj | None] = CacheCodec.deserialize(
|
||||
cached_team, LiteLLM_TeamTableCachedObj
|
||||
current_team: Final = CacheCodec.deserialize(
|
||||
user_api_key_cache.get_cache(key=_id, local_only=True), LiteLLM_TeamTableCachedObj
|
||||
)
|
||||
if existing_spend_obj is None:
|
||||
if current_team is None:
|
||||
return
|
||||
verbose_proxy_logger.debug(
|
||||
"_update_team_db: existing spend: %s; response_cost: %s", existing_spend_obj, response_cost
|
||||
"_update_team_db: existing spend: %s; response_cost: %s", current_team, response_cost
|
||||
)
|
||||
|
||||
existing_spend: Final[float] = existing_spend_obj.spend or 0.0
|
||||
# Calculate the new cost by adding the existing cost and response_cost
|
||||
new_spend: Final = existing_spend + response_cost
|
||||
|
||||
existing_spend_obj.spend = new_spend
|
||||
values_to_update_in_cache.append(
|
||||
(
|
||||
_id,
|
||||
CacheCodec.serialize(existing_spend_obj, model_type=LiteLLM_TeamTableCachedObj),
|
||||
)
|
||||
user_api_key_cache.set_cache(
|
||||
key=_id,
|
||||
value=CacheCodec.serialize(
|
||||
current_team.model_copy(update={"spend": (current_team.spend or 0.0) + response_cost}),
|
||||
model_type=LiteLLM_TeamTableCachedObj,
|
||||
),
|
||||
local_only=True,
|
||||
ttl=get_management_object_ttl(user_api_key_cache),
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
|
|
@ -4536,7 +4527,7 @@ async def update_cache(
|
|||
await _update_end_user_cache()
|
||||
|
||||
if team_id is not None:
|
||||
await _update_team_cache()
|
||||
_update_team_cache()
|
||||
|
||||
if tags is not None:
|
||||
await _update_tag_cache()
|
||||
|
|
@ -20032,9 +20023,9 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFie
|
|||
"personal_key_model_access_from_teams": {
|
||||
"type": "Boolean",
|
||||
"description": (
|
||||
"Caps each personal key (a key with no team) to the models its owner's current teams can call, "
|
||||
"re-evaluated on every request. A user in no team gets no model access. A user in several teams "
|
||||
"can call any model one of those teams can call. Team keys are unaffected."
|
||||
"Caps each personal key (a key with no team) to the models, MCP servers, MCP tools and agents its "
|
||||
"owner's current teams can reach, re-evaluated on every request. A user in no team gets none of them. "
|
||||
"A user in several teams gets the union of what those teams grant. Team keys are unaffected."
|
||||
),
|
||||
},
|
||||
"max_ui_session_budget": {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
|
||||
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
|
||||
from litellm.proxy.utils import PrismaClient, hash_token
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
SETTLES_BACKGROUND_RESPONSE_KWARG,
|
||||
BackgroundResponseSettlement,
|
||||
)
|
||||
from litellm.types.router import DeploymentTypedDict, LiteLLM_Params
|
||||
from litellm.types.utils import (
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
|
|
@ -315,10 +319,12 @@ BATCH_COST_REQUEST_ID_SUFFIX: Final = "_batch_cost"
|
|||
BACKGROUND_RESPONSE_COST_REQUEST_ID_SUFFIX: Final = "_background_cost"
|
||||
|
||||
|
||||
def _cost_row_request_id_suffix(call_type: str, metadata: Mapping[str, object]) -> str:
|
||||
def _cost_row_request_id_suffix(
|
||||
call_type: str, metadata: Mapping[str, object], settles_background_response: bool
|
||||
) -> str:
|
||||
if call_type == CallTypes.aretrieve_batch.value:
|
||||
return BATCH_COST_REQUEST_ID_SUFFIX
|
||||
if (
|
||||
if settles_background_response or (
|
||||
call_type in NON_INFERENCE_CALL_TYPES
|
||||
and metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
):
|
||||
|
|
@ -327,7 +333,7 @@ def _cost_row_request_id_suffix(call_type: str, metadata: Mapping[str, object])
|
|||
|
||||
|
||||
def get_spend_logs_id(
|
||||
call_type: str, response_obj: dict, kwargs: dict, metadata: Mapping[str, object] = EMPTY_MAPPING
|
||||
call_type: str, response_obj: dict, kwargs: Mapping[str, object], metadata: Mapping[str, object] = EMPTY_MAPPING
|
||||
) -> str | None:
|
||||
standard_logging_payload = kwargs.get("standard_logging_object")
|
||||
candidate_ids: Final = (
|
||||
|
|
@ -340,7 +346,10 @@ def get_spend_logs_id(
|
|||
)
|
||||
if resolved_id is None:
|
||||
return None
|
||||
return f"{resolved_id}{_cost_row_request_id_suffix(call_type, metadata)}"
|
||||
settles_background_response: Final = (
|
||||
kwargs.get(SETTLES_BACKGROUND_RESPONSE_KWARG) is BackgroundResponseSettlement.SETTLES
|
||||
)
|
||||
return f"{resolved_id}{_cost_row_request_id_suffix(call_type, metadata, settles_background_response)}"
|
||||
|
||||
|
||||
_MISSING_ATTRIBUTE: Final = object()
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ from litellm.litellm_core_utils.ptu_pricing import (
|
|||
)
|
||||
from litellm.litellm_core_utils.request_timeout_resolver import (
|
||||
get_configured_request_timeout,
|
||||
get_service_tier_timeout,
|
||||
)
|
||||
from litellm.litellm_core_utils.secret_redaction import redact_string
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import (
|
||||
|
|
@ -4773,6 +4774,7 @@ class Router:
|
|||
"""Helper to get stream timeout from kwargs or deployment params"""
|
||||
return (
|
||||
kwargs.get("stream_timeout", None) # the params dynamically set by user
|
||||
or get_service_tier_timeout(kwargs.get("service_tier"), data, "stream_timeout_by_service_tier")
|
||||
or data.get("stream_timeout", None) # timeout set on litellm_params for this deployment
|
||||
or self.stream_timeout # timeout set on router
|
||||
or self.request_timeout # litellm_settings.request_timeout (per-attempt)
|
||||
|
|
@ -4786,6 +4788,7 @@ class Router:
|
|||
return (
|
||||
kwargs.get("timeout", None) # the params dynamically set by user
|
||||
or kwargs.get("request_timeout", None) # the params dynamically set by user
|
||||
or get_service_tier_timeout(kwargs.get("service_tier"), data, "timeout_by_service_tier")
|
||||
or data.get("timeout", None) # timeout set on litellm_params for this deployment
|
||||
or data.get("request_timeout", None) # timeout set on litellm_params for this deployment
|
||||
or self.request_timeout # litellm_settings.request_timeout (per-attempt)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class CacheControlMessageInjectionPoint(TypedDict):
|
|||
|
||||
|
||||
class CacheControlToolConfigInjectionPoint(TypedDict):
|
||||
"""Type for tool_config-level injection points (Bedrock)."""
|
||||
"""Type for tool_config-level injection points (Anthropic and Bedrock)."""
|
||||
|
||||
location: Literal["tool_config"]
|
||||
control: ChatCompletionCachedContent | None
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ class ProviderConnection:
|
|||
request_timeout: float | None = None
|
||||
force_timeout: float | None = None
|
||||
stream_timeout: float | str | None = None
|
||||
timeout_by_service_tier: Mapping[str, float] | None = None
|
||||
stream_timeout_by_service_tier: Mapping[str, float] | None = None
|
||||
max_retries: int | None = None
|
||||
tenant_id: str | None = None
|
||||
client_id: str | None = None
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import builtins
|
|||
from collections.abc import Iterable, Mapping
|
||||
from enum import Enum
|
||||
from os import PathLike
|
||||
from typing import IO, Any, Final, Generic, Literal, Optional, TypeAlias, Union
|
||||
from typing import IO, TYPE_CHECKING, Any, Final, Generic, Literal, Optional, TypeAlias, Union
|
||||
|
||||
import httpx
|
||||
from openai import Omit
|
||||
|
|
@ -96,6 +96,9 @@ from litellm.types.responses.main import (
|
|||
|
||||
from .base import CachedTokensDetails
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
FileContent = IO[bytes] | bytes | PathLike
|
||||
|
||||
FileTypes = (
|
||||
|
|
@ -119,6 +122,7 @@ class BinaryResponseSummary(TypedDict):
|
|||
object: ReadOnly[Literal["binary"]]
|
||||
content_type: ReadOnly[str | None]
|
||||
num_bytes: ReadOnly[int]
|
||||
usage: NotRequired[ReadOnly[Mapping[str, builtins.object]]]
|
||||
|
||||
|
||||
_ResponseT = TypeVar("_ResponseT", bound=httpx.Response | SDKResponse, default=httpx.Response)
|
||||
|
|
@ -126,6 +130,7 @@ _ResponseT = TypeVar("_ResponseT", bound=httpx.Response | SDKResponse, default=h
|
|||
|
||||
class HttpxBinaryResponseContent(_HttpxBinaryResponseContent, Generic[_ResponseT]):
|
||||
_hidden_params: dict
|
||||
usage: "Usage | None"
|
||||
response: _ResponseT # pyright: ignore[reportIncompatibleVariableOverride] # SDK accepts both backends at runtime
|
||||
|
||||
@property
|
||||
|
|
@ -139,6 +144,7 @@ class HttpxBinaryResponseContent(_HttpxBinaryResponseContent, Generic[_ResponseT
|
|||
def __init__(self, response: _ResponseT) -> None:
|
||||
super().__init__(response) # pyright: ignore[reportArgumentType] # SDK accepts both backends at runtime
|
||||
self._hidden_params = {}
|
||||
self.usage = None
|
||||
|
||||
@property
|
||||
def content_type(self) -> str | None:
|
||||
|
|
@ -146,11 +152,12 @@ class HttpxBinaryResponseContent(_HttpxBinaryResponseContent, Generic[_ResponseT
|
|||
return headers.get("content-type")
|
||||
|
||||
def logging_summary(self) -> BinaryResponseSummary:
|
||||
return {
|
||||
summary: Final[BinaryResponseSummary] = {
|
||||
"object": "binary",
|
||||
"content_type": self.content_type,
|
||||
"num_bytes": self._num_bytes(),
|
||||
}
|
||||
return summary if self.usage is None else {**summary, "usage": self.usage.model_dump()}
|
||||
|
||||
def _num_bytes(self) -> int:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY: Final = "litellm_pass_thro
|
|||
# on a collision FastAPI dispatches the built-in handler, which does not carry this flag.
|
||||
LITELLM_PASS_THROUGH_ENDPOINT_MARKER: Final = "__litellm_pass_through_endpoint__"
|
||||
|
||||
SETTLES_BACKGROUND_RESPONSE_KWARG: Final = "settles_background_response"
|
||||
|
||||
|
||||
class BackgroundResponseSettlement(str, Enum):
|
||||
SETTLES = "settles"
|
||||
|
||||
|
||||
class EndpointType(str, Enum):
|
||||
VERTEX_AI = "vertex-ai"
|
||||
|
|
|
|||
|
|
@ -505,6 +505,9 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
|
|||
timeout: float | str | httpx.Timeout | SDKTimeout | None = None # if str, pass in as os.environ/
|
||||
# read timeout of a live stream, a faked stream takes the longer of this and timeout. If str, pass in as os.environ/
|
||||
stream_timeout: float | str | None = None
|
||||
# per service_tier overrides of timeout / stream_timeout, used when the request's service_tier matches a key
|
||||
timeout_by_service_tier: Mapping[str, float] | None = None
|
||||
stream_timeout_by_service_tier: Mapping[str, float] | None = None
|
||||
max_retries: int | None = None
|
||||
drop_params: bool | str | None = None
|
||||
organization: str | None = None # for openai orgs
|
||||
|
|
@ -709,6 +712,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False):
|
|||
responses_api_version: ReadOnly[str | None]
|
||||
timeout: float | str | httpx.Timeout | SDKTimeout | None # writable-ok: preserve timeout updates
|
||||
stream_timeout: float | str | None
|
||||
timeout_by_service_tier: ReadOnly[Mapping[str, float] | None]
|
||||
stream_timeout_by_service_tier: ReadOnly[Mapping[str, float] | None]
|
||||
max_retries: int | None
|
||||
organization: list | str | None # for openai orgs
|
||||
provider_affinity_header: ReadOnly[str | None]
|
||||
|
|
|
|||
|
|
@ -5358,6 +5358,9 @@ def get_optional_params(
|
|||
EXTRA_BODY_ROUTING_KEYS: Final = frozenset({"model"})
|
||||
|
||||
|
||||
LITELLM_ONLY_PARAMS: Final = frozenset(("cache_control_injection_points",))
|
||||
|
||||
|
||||
def add_provider_specific_params_to_optional_params(
|
||||
optional_params: dict,
|
||||
passed_params: dict,
|
||||
|
|
@ -5381,7 +5384,7 @@ def add_provider_specific_params_to_optional_params(
|
|||
if _should_drop_param(k="extra_body", additional_drop_params=additional_drop_params) is False:
|
||||
extra_body: Final = dict(passed_params.pop("extra_body", None) or {})
|
||||
for k in passed_params:
|
||||
if k not in openai_params and passed_params[k] is not None:
|
||||
if k not in openai_params and k not in LITELLM_ONLY_PARAMS and passed_params[k] is not None:
|
||||
extra_body[k] = passed_params[k]
|
||||
if not isinstance(optional_params.get("extra_body"), dict):
|
||||
optional_params["extra_body"] = {}
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@ def test_database_upgrade(proxy: ProxyClient, idp: Keycloak, resources: Resource
|
|||
monitor.assert_complete(state)
|
||||
assert_finished(connection, state)
|
||||
rss_after: Final = read_rss_everywhere(proxy)
|
||||
assert {r.worker for r in rss_before.readings} == {r.worker for r in rss_after.readings}, (
|
||||
"Gateway workers restarted"
|
||||
)
|
||||
assert {(r.replica, r.hostname) for r in rss_before.readings} == {
|
||||
(r.replica, r.hostname) for r in rss_after.readings
|
||||
}, "Gateway pods replaced during the upgrade"
|
||||
observed: Final = Observation(
|
||||
records=outcome.records,
|
||||
mock_requests=tuple(seen for url in urls for seen in read_mock_requests(url)),
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ test.describe("Internal User", () => {
|
|||
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
||||
const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").innerText()).trim();
|
||||
const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").filter({ hasText: /^sk-/ }).innerText()).trim();
|
||||
expect(apiKey).toMatch(/^sk-/);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
// verify it can call /chat/completions for the model it was scoped to.
|
||||
// The mock LLM server (fixtures/mock_llm_server/server.py) replies with
|
||||
// a fixed "This is a mock response." body.
|
||||
const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").innerText()).trim();
|
||||
const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").filter({ hasText: /^sk-/ }).innerText()).trim();
|
||||
expect(apiKey).toMatch(/^sk-/);
|
||||
|
||||
const response = await page.request.post("/chat/completions", {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ test.describe("Second proxy admin", () => {
|
|||
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
||||
const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").innerText()).trim();
|
||||
const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").filter({ hasText: /^sk-/ }).innerText()).trim();
|
||||
expect(apiKey).toMatch(/^sk-/);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ test.describe("Team Admin - Member permissions", () => {
|
|||
await memberPage.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
const saveDialog = memberPage.getByRole("dialog", { name: "Save your Key" });
|
||||
await expect(saveDialog).toBeVisible({ timeout: 15_000 });
|
||||
const apiKey = (await saveDialog.locator("pre").innerText()).trim();
|
||||
const apiKey = (await saveDialog.locator("pre").filter({ hasText: /^sk-/ }).innerText()).trim();
|
||||
expect(apiKey).toMatch(/^sk-/);
|
||||
createdKeys.push(apiKey);
|
||||
await memberPage.keyboard.press("Escape");
|
||||
|
|
|
|||
|
|
@ -33,8 +33,17 @@ async def worker_deployment_ids(model_name: str) -> JSONResponse:
|
|||
return JSONResponse(content={"model_ids": model_ids}, headers={"x-integration-worker-pid": str(os.getpid())})
|
||||
|
||||
|
||||
async def worker_general_setting(name: str) -> JSONResponse:
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
return JSONResponse(
|
||||
content={"value": proxy_config.settings.get(name)}, headers={"x-integration-worker-pid": str(os.getpid())}
|
||||
)
|
||||
|
||||
|
||||
def configure_readiness() -> None:
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
app.add_api_route("/health/integration-readiness", worker_readiness, include_in_schema=False)
|
||||
app.add_api_route("/health/integration-deployment-ids", worker_deployment_ids, include_in_schema=False)
|
||||
app.add_api_route("/health/integration-general-setting", worker_general_setting, include_in_schema=False)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ MANAGED_MODEL: Final = "gpt-4.1-openai"
|
|||
DEPLOYMENT_KEYS: Final = ("sk-fine-tuning-deployment-a", "sk-fine-tuning-deployment-b")
|
||||
PROVIDER_JOB: Final = "ftjob-provider-123"
|
||||
RUN: Final = uuid.uuid4().hex[:12]
|
||||
MANAGED_JOB: Final = f"ftjob-managed-{RUN}"
|
||||
TRAINING_LINE: Final = b'{"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]}\n'
|
||||
JOB_PATH: Final = re.compile(r"^/(?:openai/)?(?:v1/)?fine_tuning/jobs/([^/]+)(/cancel)?$")
|
||||
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
|
|
@ -88,8 +87,18 @@ def _job(job_id: str, status: str, training_file: str = "file-abc123", **fields:
|
|||
}
|
||||
|
||||
|
||||
def _provider_file_id(bearer: str) -> str:
|
||||
return f"file-{RUN}-" + bearer.removeprefix("sk-fine-tuning-")
|
||||
def _provider_file_id(bearer: str, filename: str) -> str:
|
||||
return f"file-{RUN}-" + bearer.removeprefix("sk-fine-tuning-") + "-" + filename.removesuffix(".jsonl")
|
||||
|
||||
|
||||
def _managed_job_id(provider_training_file: str) -> str:
|
||||
return provider_training_file.replace("file-", "ftjob-managed-", 1)
|
||||
|
||||
|
||||
def _uploaded_filename(request: Request) -> str:
|
||||
names: Final = [part.get_filename() for part in _multipart_parts(request) if part.get_filename()]
|
||||
assert len(names) == 1, request.body[:300]
|
||||
return str(names[0])
|
||||
|
||||
|
||||
def _bearer(request: Request) -> str:
|
||||
|
|
@ -118,19 +127,27 @@ def _respond(request: Request) -> Reply:
|
|||
if (request.method, path) == ("POST", "/v1/files"):
|
||||
return _reply(
|
||||
{
|
||||
"id": _provider_file_id(_bearer(request)),
|
||||
"id": _provider_file_id(_bearer(request), _uploaded_filename(request)),
|
||||
"object": "file",
|
||||
"bytes": len(TRAINING_LINE),
|
||||
"created_at": 1700000000,
|
||||
"filename": "train.jsonl",
|
||||
"filename": _uploaded_filename(request),
|
||||
"purpose": "fine-tune",
|
||||
"status": "processed",
|
||||
}
|
||||
)
|
||||
if (request.method, path) in {("POST", "/v1/fine_tuning/jobs"), ("POST", "/openai/fine_tuning/jobs")}:
|
||||
sent: Final = JSON_OBJECT.validate_json(request.body)
|
||||
job_id: Final = MANAGED_JOB if _bearer(request) in DEPLOYMENT_KEYS else PROVIDER_JOB
|
||||
return _reply(_job(job_id, "validating_files", str(sent["training_file"])))
|
||||
managed: Final = _bearer(request) in DEPLOYMENT_KEYS
|
||||
echoed: Final = {"validation_file": sent.get("validation_file")} if managed else {}
|
||||
return _reply(
|
||||
_job(
|
||||
_managed_job_id(str(sent["training_file"])) if managed else PROVIDER_JOB,
|
||||
"validating_files",
|
||||
str(sent["training_file"]),
|
||||
**echoed,
|
||||
)
|
||||
)
|
||||
matched: Final = JOB_PATH.match(path)
|
||||
if matched and matched.group(1) and request.method == "GET":
|
||||
return _reply(_job(matched.group(1), "running"))
|
||||
|
|
@ -329,6 +346,30 @@ def test_azure_create_merges_finetune_settings_and_keeps_azure_fields(rig: Rig)
|
|||
assert Job.model_validate_json(raw.text) == _client_job(PROVIDER_JOB, "validating_files", "file-abc"), raw.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "target", "provider_defaults"),
|
||||
[
|
||||
("openai", "/v1/fine_tuning/jobs", {}),
|
||||
("azure", f"/openai/fine_tuning/jobs?api-version={AZURE_API_VERSION}", {"trainingType": 1}),
|
||||
],
|
||||
)
|
||||
def test_create_sends_the_model_without_its_provider_prefix(
|
||||
rig: Rig, provider: str, target: str, provider_defaults: Mapping[str, JsonValue]
|
||||
) -> None:
|
||||
with rig.sdk("/v1") as client:
|
||||
raw: Final = client.fine_tuning.jobs.with_raw_response.create(
|
||||
model=f"{provider}/gpt-4o-mini", training_file="file-abc123", extra_body={"custom_llm_provider": provider}
|
||||
)
|
||||
sent: Final = _only(_drained(rig), "POST", target)
|
||||
assert JSON_OBJECT.validate_json(sent.body) == {
|
||||
"model": "gpt-4o-mini",
|
||||
"training_file": "file-abc123",
|
||||
"hyperparameters": {},
|
||||
**provider_defaults,
|
||||
}, sent.body
|
||||
assert raw.status_code == 200, raw.text
|
||||
|
||||
|
||||
def test_azure_cancel_merges_finetune_settings(rig: Rig) -> None:
|
||||
with rig.sdk("/v1") as client:
|
||||
raw: Final = client.fine_tuning.jobs.with_raw_response.cancel(
|
||||
|
|
@ -400,10 +441,6 @@ def test_retrieve_reads_the_documented_custom_llm_provider_header(rig: Rig) -> N
|
|||
|
||||
|
||||
def test_managed_create_sends_the_provider_file_id_of_the_pinned_deployment(rig: Rig) -> None:
|
||||
pytest.skip(
|
||||
"BUG: managed create sends the base64 LiteLLM file id and the prefixed model openai/gpt-4.1 to the provider"
|
||||
" and returns the decoded unified id as training_file"
|
||||
)
|
||||
with rig.sdk("/v1") as client:
|
||||
uploaded: Final = client.files.with_raw_response.create(
|
||||
file=("train.jsonl", TRAINING_LINE, "application/jsonl"),
|
||||
|
|
@ -431,12 +468,48 @@ def test_managed_create_sends_the_provider_file_id_of_the_pinned_deployment(rig:
|
|||
assert deployment_key in DEPLOYMENT_KEYS, sent.headers
|
||||
assert JSON_OBJECT.validate_json(sent.body) == {
|
||||
"model": "gpt-4.1",
|
||||
"training_file": _provider_file_id(deployment_key),
|
||||
"training_file": _provider_file_id(deployment_key, "train.jsonl"),
|
||||
"hyperparameters": {},
|
||||
}, sent.body
|
||||
assert raw.status_code == 200, raw.text
|
||||
job: Final = Job.model_validate_json(raw.text)
|
||||
assert re.fullmatch(
|
||||
rf"litellm_proxy;model_id:[0-9a-f]{{64}};generic_response_id:{MANAGED_JOB}", _decoded(job.id)
|
||||
rf"litellm_proxy;model_id:[0-9a-f]{{64}};generic_response_id:"
|
||||
rf"{_managed_job_id(_provider_file_id(deployment_key, 'train.jsonl'))}",
|
||||
_decoded(job.id),
|
||||
), raw.text
|
||||
assert job.training_file == managed_file, raw.text
|
||||
|
||||
|
||||
def _managed_upload(rig: Rig, client: OpenAI, filename: str) -> str:
|
||||
uploaded: Final = client.files.with_raw_response.create(
|
||||
file=(filename, TRAINING_LINE, "application/jsonl"),
|
||||
purpose="fine-tune",
|
||||
extra_body={"target_model_names": MANAGED_MODEL},
|
||||
)
|
||||
assert uploaded.status_code == 200, uploaded.text
|
||||
assert sorted((request.method, request.target, _bearer(request)) for request in _drained(rig)) == [
|
||||
("POST", "/v1/files", key) for key in DEPLOYMENT_KEYS
|
||||
]
|
||||
return str(JSON_OBJECT.validate_json(uploaded.text)["id"])
|
||||
|
||||
|
||||
def test_managed_create_sends_the_provider_ids_of_managed_training_and_validation_files(rig: Rig) -> None:
|
||||
with rig.sdk("/v1") as client:
|
||||
training_file: Final = _managed_upload(rig, client, "tune.jsonl")
|
||||
validation_file: Final = _managed_upload(rig, client, "validate.jsonl")
|
||||
raw: Final = client.fine_tuning.jobs.with_raw_response.create(
|
||||
model=MANAGED_MODEL, training_file=training_file, validation_file=validation_file
|
||||
)
|
||||
sent: Final = _only(_drained(rig), "POST", "/v1/fine_tuning/jobs")
|
||||
deployment_key: Final = _bearer(sent)
|
||||
assert deployment_key in DEPLOYMENT_KEYS, sent.headers
|
||||
assert JSON_OBJECT.validate_json(sent.body) == {
|
||||
"model": "gpt-4.1",
|
||||
"training_file": _provider_file_id(deployment_key, "tune.jsonl"),
|
||||
"validation_file": _provider_file_id(deployment_key, "validate.jsonl"),
|
||||
"hyperparameters": {},
|
||||
}, sent.body
|
||||
assert raw.status_code == 200, raw.text
|
||||
job: Final = Job.model_validate_json(raw.text)
|
||||
assert (job.training_file, job.validation_file) == (training_file, validation_file), raw.text
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
"""Managed fine-tuning creates on a two-worker proxy whose model group has two deployments: a concurrent burst from
|
||||
sync and async clients, traffic pinned to each worker in turn, and a worker killed and replaced, all send every
|
||||
deployment its own provider file ids and hand the caller back its own managed ids. A deployment added to the group
|
||||
after the upload holds no copy of the files, so it never receives the create even when the router prefers it. A model
|
||||
group the files were never uploaded for still gets the create and the provider's own 400, as before routing checked
|
||||
which deployments hold the files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator, Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import eventually, gateway_from_environment
|
||||
from integration._support.database import scratch_database
|
||||
from integration._support.process import OwnedProxy, WorkerHealthcheck, owned_proxy_process
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from openai import AsyncOpenAI, BadRequestError, OpenAI
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
pytestmark: Final = pytest.mark.timeout(480)
|
||||
|
||||
MODEL: Final = "gpt-4.1-chaos"
|
||||
DEPLOYMENT_KEYS: Final = ("sk-chaos-deployment-a", "sk-chaos-deployment-b")
|
||||
LATE_DEPLOYMENT_KEY: Final = "sk-chaos-deployment-late"
|
||||
OTHER_MODEL: Final = "gpt-4.1-chaos-other"
|
||||
OTHER_DEPLOYMENT_KEY: Final = "sk-chaos-deployment-other"
|
||||
RUN: Final = uuid.uuid4().hex[:12]
|
||||
TRAINING_LINE: Final = b'{"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]}\n'
|
||||
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
BURST: Final = 20
|
||||
CREATE: Final = ("POST", "/v1/fine_tuning/jobs")
|
||||
WORKER_BOOT_DISCOVERY: Final = ("GET", "/v1/models")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rig:
|
||||
owned: OwnedProxy
|
||||
wire: Wire
|
||||
training_file: str
|
||||
validation_file: str
|
||||
|
||||
def base_url(self) -> str:
|
||||
return f"{str(self.owned.gateway.client.base_url).rstrip('/')}/v1"
|
||||
|
||||
def sdk(self) -> OpenAI:
|
||||
return OpenAI(base_url=self.base_url(), api_key=self.owned.gateway.key, max_retries=0)
|
||||
|
||||
def async_sdk(self) -> AsyncOpenAI:
|
||||
return AsyncOpenAI(base_url=self.base_url(), api_key=self.owned.gateway.key, max_retries=0)
|
||||
|
||||
|
||||
def _bearer(request: Request) -> str:
|
||||
return request.headers.get("authorization", "").removeprefix("Bearer ")
|
||||
|
||||
|
||||
def _provider_file_id(bearer: str, purpose: str) -> str:
|
||||
return f"file-{RUN}-{bearer.removeprefix('sk-chaos-')}-{purpose}"
|
||||
|
||||
|
||||
def _uploaded_purpose(request: Request) -> str:
|
||||
return "validate" if b'filename="validate.jsonl"' in request.body else "train"
|
||||
|
||||
|
||||
def _invalid_training_file(training_file: str) -> Reply:
|
||||
error: Final = {
|
||||
"message": f"invalid training_file: {training_file}",
|
||||
"type": "invalid_request_error",
|
||||
"param": "training_file",
|
||||
"code": None,
|
||||
}
|
||||
return Reply(status=400, body=json.dumps({"error": error}).encode())
|
||||
|
||||
|
||||
def _respond(request: Request) -> Reply:
|
||||
path: Final = urlsplit(request.target).path
|
||||
if (request.method, path) == ("POST", "/v1/files"):
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": _provider_file_id(_bearer(request), _uploaded_purpose(request)),
|
||||
"object": "file",
|
||||
"bytes": len(TRAINING_LINE),
|
||||
"created_at": 1700000000,
|
||||
"filename": f"{_uploaded_purpose(request)}.jsonl",
|
||||
"purpose": "fine-tune",
|
||||
"status": "processed",
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
if (request.method, path) == ("POST", "/v1/fine_tuning/jobs"):
|
||||
sent: Final = JSON_OBJECT.validate_json(request.body)
|
||||
if not str(sent["training_file"]).startswith(f"file-{RUN}-"):
|
||||
return _invalid_training_file(str(sent["training_file"]))
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": f"ftjob-{uuid.uuid4().hex}",
|
||||
"object": "fine_tuning.job",
|
||||
"created_at": 1700000000,
|
||||
"error": None,
|
||||
"fine_tuned_model": None,
|
||||
"finished_at": None,
|
||||
"hyperparameters": {"n_epochs": 3},
|
||||
"model": sent["model"],
|
||||
"organization_id": "org-chaos",
|
||||
"result_files": [],
|
||||
"seed": 42,
|
||||
"status": "validating_files",
|
||||
"trained_tokens": None,
|
||||
"training_file": sent["training_file"],
|
||||
"validation_file": sent.get("validation_file"),
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
return Reply(body=json.dumps({"object": "list", "data": []}).encode())
|
||||
|
||||
|
||||
def _deployment(wire: Wire, key: str, routing: Mapping[str, JsonValue], model: str = MODEL) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model_name": model,
|
||||
"litellm_params": {"model": "openai/gpt-4.1", "api_key": key, "api_base": f"{wire.url}/v1", **routing},
|
||||
"model_info": {"supported_endpoints": ["/chat/completions", "/fine_tuning"]},
|
||||
}
|
||||
|
||||
|
||||
def _config(wire: Wire, late_preferred_deployment: bool = False) -> dict[str, JsonValue]:
|
||||
late: Final = (_deployment(wire, LATE_DEPLOYMENT_KEY, {"order": 1}),) if late_preferred_deployment else ()
|
||||
return {
|
||||
"model_list": [
|
||||
*(_deployment(wire, key, {}) for key in DEPLOYMENT_KEYS),
|
||||
*late,
|
||||
_deployment(wire, OTHER_DEPLOYMENT_KEY, {}, OTHER_MODEL),
|
||||
],
|
||||
"router_settings": {"num_retries": 0, "disable_cooldowns": True},
|
||||
"general_settings": {"master_key": "os.environ/LITELLM_MASTER_KEY", "database_url": "os.environ/DATABASE_URL"},
|
||||
}
|
||||
|
||||
|
||||
def _config_file(directory: Path, wire: Wire, late_preferred_deployment: bool = False) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
config: Final = directory / "config.yaml"
|
||||
config.write_text(yaml.safe_dump(_config(wire, late_preferred_deployment)))
|
||||
return config
|
||||
|
||||
|
||||
def _upload(client: OpenAI, purpose: str) -> str:
|
||||
uploaded: Final = client.files.with_raw_response.create(
|
||||
file=(f"{purpose}.jsonl", TRAINING_LINE, "application/jsonl"),
|
||||
purpose="fine-tune",
|
||||
extra_body={"target_model_names": MODEL},
|
||||
)
|
||||
assert uploaded.status_code == 200, uploaded.text
|
||||
return str(JSON_OBJECT.validate_json(uploaded.text)["id"])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
|
||||
directory: Final = tmp_path_factory.mktemp("fine-tuning-chaos")
|
||||
with gateway_from_environment() as shared, wire_server(_respond) as wire:
|
||||
config: Final = _config_file(directory, wire)
|
||||
with owned_proxy_process(
|
||||
shared, directory, {}, config=config, workers=2, healthcheck=WorkerHealthcheck.LENIENT
|
||||
) as owned:
|
||||
eventually(owned.worker_pids, lambda pids: len(pids) == 2, seconds=120)
|
||||
unloaded: Final = Rig(owned, wire, "", "")
|
||||
with unloaded.sdk() as client:
|
||||
training_file: Final = _upload(client, "train")
|
||||
validation_file: Final = _upload(client, "validate")
|
||||
wire.drain()
|
||||
yield Rig(owned, wire, training_file, validation_file)
|
||||
|
||||
|
||||
def _create(rig: Rig) -> tuple[int, Mapping[str, JsonValue]]:
|
||||
with rig.sdk() as client:
|
||||
raw: Final = client.fine_tuning.jobs.with_raw_response.create(
|
||||
model=MODEL, training_file=rig.training_file, validation_file=rig.validation_file
|
||||
)
|
||||
return raw.status_code, JSON_OBJECT.validate_json(raw.text)
|
||||
|
||||
|
||||
async def _async_creates(rig: Rig, count: int) -> tuple[tuple[int, Mapping[str, JsonValue]], ...]:
|
||||
async with rig.async_sdk() as client:
|
||||
raws: Final = await asyncio.gather(
|
||||
*(
|
||||
client.fine_tuning.jobs.with_raw_response.create(
|
||||
model=MODEL, training_file=rig.training_file, validation_file=rig.validation_file
|
||||
)
|
||||
for _ in range(count)
|
||||
)
|
||||
)
|
||||
return tuple((raw.status_code, JSON_OBJECT.validate_json(raw.text)) for raw in raws)
|
||||
|
||||
|
||||
def _sent_creates(rig: Rig, expected: int) -> tuple[Request, ...]:
|
||||
collected: Final[list[Request]] = []
|
||||
|
||||
def creates() -> tuple[Request, ...]:
|
||||
collected.extend(rig.wire.drain())
|
||||
return tuple(request for request in collected if (request.method, request.target) == CREATE)
|
||||
|
||||
sent: Final = eventually(creates, lambda found: len(found) >= expected, seconds=30)
|
||||
assert len(sent) == expected, [(request.method, request.target) for request in collected]
|
||||
assert {(request.method, request.target) for request in collected} <= {CREATE, WORKER_BOOT_DISCOVERY}, collected
|
||||
return sent
|
||||
|
||||
|
||||
def _assert_each_deployment_got_its_own_ids(creates: tuple[Request, ...]) -> None:
|
||||
for request in creates:
|
||||
bearer = _bearer(request)
|
||||
assert bearer in DEPLOYMENT_KEYS, request.headers
|
||||
assert JSON_OBJECT.validate_json(request.body) == {
|
||||
"model": "gpt-4.1",
|
||||
"training_file": _provider_file_id(bearer, "train"),
|
||||
"validation_file": _provider_file_id(bearer, "validate"),
|
||||
"hyperparameters": {},
|
||||
}, request.body
|
||||
|
||||
|
||||
def _assert_caller_gets_its_own_ids(rig: Rig, outcomes: tuple[tuple[int, Mapping[str, JsonValue]], ...]) -> None:
|
||||
received: Final = [(status, body.get("training_file"), body.get("validation_file")) for status, body in outcomes]
|
||||
assert received == [(200, rig.training_file, rig.validation_file)] * len(outcomes), outcomes
|
||||
|
||||
|
||||
def test_concurrent_sync_and_async_burst_sends_each_deployment_its_own_file_ids(rig: Rig) -> None:
|
||||
rig.wire.drain()
|
||||
with ThreadPoolExecutor(max_workers=BURST // 2) as pool:
|
||||
synced: Final = tuple(pool.map(_create, (rig,) * (BURST // 2)))
|
||||
awaited: Final = asyncio.run(_async_creates(rig, BURST // 2))
|
||||
creates: Final = _sent_creates(rig, BURST)
|
||||
_assert_each_deployment_got_its_own_ids(creates)
|
||||
assert {_bearer(request) for request in creates} == set(DEPLOYMENT_KEYS)
|
||||
_assert_caller_gets_its_own_ids(rig, synced + awaited)
|
||||
|
||||
|
||||
def test_each_worker_alone_sends_the_provider_file_ids(rig: Rig) -> None:
|
||||
rig.wire.drain()
|
||||
for paused in sorted(rig.owned.worker_pids()):
|
||||
with rig.owned.suspended(paused):
|
||||
outcomes = tuple(_create(rig) for _ in range(4))
|
||||
_assert_caller_gets_its_own_ids(rig, outcomes)
|
||||
_assert_each_deployment_got_its_own_ids(_sent_creates(rig, 4))
|
||||
|
||||
|
||||
def test_a_replaced_worker_still_sends_the_provider_file_ids(rig: Rig) -> None:
|
||||
rig.wire.drain()
|
||||
for victim in sorted(rig.owned.worker_pids()):
|
||||
rig.owned.kill_worker(victim)
|
||||
outcomes: Final = tuple(_create(rig) for _ in range(6))
|
||||
_assert_caller_gets_its_own_ids(rig, outcomes)
|
||||
_assert_each_deployment_got_its_own_ids(_sent_creates(rig, 6))
|
||||
|
||||
|
||||
def test_a_deployment_added_after_the_upload_never_receives_the_create(tmp_path: Path) -> None:
|
||||
with gateway_from_environment() as shared, wire_server(_respond) as wire, scratch_database() as database_url:
|
||||
one_database: Final = {"DATABASE_URL": database_url}
|
||||
before: Final = tmp_path / "before"
|
||||
with owned_proxy_process(shared, before, one_database, config=_config_file(before, wire)) as uploader:
|
||||
with Rig(uploader, wire, "", "").sdk() as client:
|
||||
training_file: Final = _upload(client, "train")
|
||||
validation_file: Final = _upload(client, "validate")
|
||||
after: Final = tmp_path / "after"
|
||||
with owned_proxy_process(
|
||||
shared, after, one_database, config=_config_file(after, wire, late_preferred_deployment=True), workers=2
|
||||
) as owned:
|
||||
eventually(owned.worker_pids, lambda pids: len(pids) == 2, seconds=120)
|
||||
wire.drain()
|
||||
rig: Final = Rig(owned, wire, training_file, validation_file)
|
||||
outcomes: Final = tuple(_create(rig) for _ in range(6))
|
||||
creates: Final = _sent_creates(rig, 6)
|
||||
_assert_caller_gets_its_own_ids(rig, outcomes)
|
||||
_assert_each_deployment_got_its_own_ids(creates)
|
||||
|
||||
|
||||
def test_a_model_group_without_a_copy_of_the_files_still_gets_the_providers_400(rig: Rig) -> None:
|
||||
rig.wire.drain()
|
||||
with rig.sdk() as client, pytest.raises(BadRequestError) as rejected:
|
||||
client.fine_tuning.jobs.create(model=OTHER_MODEL, training_file=rig.training_file)
|
||||
creates: Final = _sent_creates(rig, 1)
|
||||
assert rejected.value.status_code == 400, rejected.value.response.text
|
||||
assert [_bearer(request) for request in creates] == [OTHER_DEPLOYMENT_KEY], creates
|
||||
|
|
@ -1547,8 +1547,8 @@ def test_an_sso_client_removed_from_db_environment_variables_stops_sign_in_on_ev
|
|||
"DATABASE_URL_READ_REPLICA",
|
||||
"GENERIC_CLIENT_ID",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"GOOGLE_CLIENT_SECRET",
|
||||
"MICROSOFT_CLIENT_ID",
|
||||
"MICROSOFT_CLIENT_SECRET",
|
||||
"PROXY_BASE_URL",
|
||||
)
|
||||
with (
|
||||
|
|
@ -1567,7 +1567,7 @@ def test_an_sso_client_removed_from_db_environment_variables_stops_sign_in_on_ev
|
|||
response: Final = first.request(
|
||||
"POST",
|
||||
"/config/update",
|
||||
{"environment_variables": {"GOOGLE_CLIENT_ID": client_id, "GOOGLE_CLIENT_SECRET": secret}},
|
||||
{"environment_variables": {"MICROSOFT_CLIENT_ID": client_id, "MICROSOFT_CLIENT_SECRET": secret}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
|
@ -1584,7 +1584,7 @@ def test_an_sso_client_removed_from_db_environment_variables_stops_sign_in_on_ev
|
|||
moved: Final = first.request(
|
||||
"PATCH",
|
||||
"/update/sso_settings",
|
||||
{"google_client_id": sso_table_client, "google_client_secret": "sso-table-secret"},
|
||||
{"microsoft_client_id": sso_table_client, "microsoft_client_secret": "sso-table-secret"},
|
||||
)
|
||||
assert moved.status_code == 200, moved.text
|
||||
eventually(sign_in_clients, lambda clients: clients == (sso_table_client,) * 2, seconds=30)
|
||||
|
|
|
|||
|
|
@ -946,6 +946,69 @@ GEMINI_3_1_PRO_FALLBACK_VIDEO_TOKENS_AT_INPUT_RATE: Final = CostTrackingTestCase
|
|||
),
|
||||
)
|
||||
|
||||
GEMINI_3_1_PRO_FALLBACK_VIDEO_TOKENS_ABOVE_200K: Final = CostTrackingTestCase(
|
||||
name="gemini-3.1-pro-fallback_video_tokens_above_200k",
|
||||
covers="quota_management.spend_tracking.cost_matrix.logs_cost",
|
||||
model="gemini-3.1-pro",
|
||||
request={
|
||||
"model": "$MODEL",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "a7c1e02d9b44 summarize the attached hour-long recording in one line",
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"file": {
|
||||
"file_data": "data:video/mp4;base64,AAAAGGZ0eXBpc29tAAACAGlzb21pc282AAACCG1kYXQNFBsiKTA3PkVMU1phaG92fYSLkpmgp661vMPK0djf5u30+wIJEBceJSwzOkFIT1ZdZGtyeYCHjpWco6qxuL/GzdTb4unw9/4FDBMaISgvNj1ES1JZYGdudXyDipGYn6attLvCydDX3uXs8/oBCA8WHSQrMjlAR05VXGNqcXh/ho2Um6KpsLe+xczT2uHo7/b9BAsSGSAnLjU8Q0pRWF9mbXR7gomQl56lrLO6wcjP1t3k6/L5AAcOFRwjKjE4P0ZNVFtiaXB3foWMk5qhqK+2vcTL0tng5+71/AMKERgfJi00O0JJUFdeZWxzeoGIj5adpKuyucDHztXc4+rx+P8GDRQbIikwNz5FTFNaYWhvdn2Ei5KZoKeutbzDytHY3+bt9PsCCRAXHiUsMzpBSE9WXWRrcnmAh46VnKOqsbi/xs3U2+Lp8Pf+BQwTGiEoLzY9REtSWWBnbnV8g4qRmJ+mrbS7wsnQ197l7PP6AQgPFh0kKzI5QEdOVVxjanF4f4aNlJuiqbC3vsXM09rh6O/2/QQLEhkgJy41PENKUVhfZm10e4KJkJeepayzusHIz9bd5Ovy+QAHDhUcIyoxOD9GTVRbYmlwd36FjJOaoaivtr3Ey9LZ4Ofu9fwDChEYHyYtNDtCSVBXXmVsc3qBiI+WnaSrsrnAx87V3OPq8fj/Bg==",
|
||||
"format": "mp4",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"stream": False,
|
||||
"allowed_openai_params": [],
|
||||
},
|
||||
response=JsonResponse(
|
||||
content_type="application/json",
|
||||
body={
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "scripted answer a7c1e02d9b44"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 250000,
|
||||
"candidatesTokenCount": 300,
|
||||
"totalTokenCount": 250300,
|
||||
"promptTokensDetails": [
|
||||
{"modality": "TEXT", "tokenCount": 10000},
|
||||
{"modality": "VIDEO", "tokenCount": 240000},
|
||||
],
|
||||
},
|
||||
"modelVersion": "gemini-3.1-pro",
|
||||
},
|
||||
),
|
||||
expected=ExactExpected(
|
||||
spend=1.05567, input_cost=1.05, output_cost=0.00567, prompt_tokens=250000, completion_tokens=300
|
||||
),
|
||||
)
|
||||
|
||||
GEMINI_3_1_PRO_STREAM: Final = CostTrackingTestCase(
|
||||
name="gemini-3.1-pro-stream",
|
||||
covers="quota_management.spend_tracking.scripted_wire.logs_cost",
|
||||
|
|
@ -3587,6 +3650,7 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = (
|
|||
GEMINI_3_1_PRO_WEB_SEARCH_MEDIUM,
|
||||
GEMINI_3_1_PRO_GOOGLE_MAPS_GROUNDING,
|
||||
GEMINI_3_1_PRO_FALLBACK_VIDEO_TOKENS_AT_INPUT_RATE,
|
||||
GEMINI_3_1_PRO_FALLBACK_VIDEO_TOKENS_ABOVE_200K,
|
||||
GEMINI_3_1_PRO_STREAM,
|
||||
GEMINI_3_1_PRO_STREAM_NO_USAGE,
|
||||
GEMINI_3_1_PRO_STREAM_NO_USAGE_TOOL_CALL,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import itertools
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import BaseModel, JsonValue
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue
|
||||
|
||||
from tests.integration._support.client import Gateway, string_value
|
||||
from tests.integration._support.client import Gateway, eventually, held, string_value
|
||||
from tests.integration._support.database import read_rows
|
||||
|
||||
IN_FLIGHT_CALLERS_PER_PROXY: Final = 8
|
||||
BLOCK_CYCLES: Final = 4
|
||||
PROBES_PER_PROXY: Final = 6
|
||||
|
||||
|
||||
class _ObservedRequest(BaseModel):
|
||||
body: dict[str, JsonValue]
|
||||
|
|
@ -35,6 +42,16 @@ class _ErrorResponse(BaseModel):
|
|||
error: _ProxyError
|
||||
|
||||
|
||||
class _Message(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
content: str
|
||||
|
||||
|
||||
class _ChatBody(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
messages: tuple[_Message, ...]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstream(gateway: Gateway) -> Iterator[httpx.Client]:
|
||||
with httpx.Client(base_url=gateway.upstream_url, timeout=15, trust_env=False) as client:
|
||||
|
|
@ -58,6 +75,87 @@ def _models(client: httpx.Client) -> tuple[str, ...]:
|
|||
return tuple(string_value(request.body["model"]) for request in observations.requests)
|
||||
|
||||
|
||||
def _texts(client: httpx.Client) -> tuple[str, ...]:
|
||||
response: Final = client.get("/__observations")
|
||||
assert response.status_code == 200, response.text
|
||||
observations: Final = _Observations.model_validate_json(response.text)
|
||||
return tuple(
|
||||
message.content
|
||||
for message in itertools.chain.from_iterable(
|
||||
_ChatBody.model_validate(request.body).messages for request in observations.requests
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _keep_calling(proxy: Gateway, model: str, key: str, stop: threading.Event) -> tuple[int, ...]:
|
||||
return tuple(
|
||||
_chat(proxy, model, key, f"in flight {uuid.uuid4().hex}").status_code
|
||||
for _ in itertools.takewhile(lambda _: not stop.is_set(), itertools.count())
|
||||
)
|
||||
|
||||
|
||||
def _statuses_on_fresh_connections(proxies: tuple[Gateway, ...], model: str, key: str, marker: str) -> tuple[int, ...]:
|
||||
return tuple(
|
||||
proxy.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": f"{marker} {uuid.uuid4().hex}"}]},
|
||||
key=key,
|
||||
headers={"connection": "close"},
|
||||
).status_code
|
||||
for proxy in proxies * PROBES_PER_PROXY
|
||||
)
|
||||
|
||||
|
||||
def _block_while_team_traffic_is_in_flight(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client, model: str, key: str, team: str
|
||||
) -> tuple[httpx.Response, tuple[int, ...]]:
|
||||
stop: Final = threading.Event()
|
||||
with ThreadPoolExecutor(max_workers=2 * IN_FLIGHT_CALLERS_PER_PROXY) as pool:
|
||||
callers: Final = tuple(
|
||||
pool.submit(_keep_calling, proxy, model, key, stop)
|
||||
for proxy in (gateway, peer) * IN_FLIGHT_CALLERS_PER_PROXY
|
||||
)
|
||||
try:
|
||||
eventually(
|
||||
lambda: len(_models(upstream)), lambda served: served >= 4 * IN_FLIGHT_CALLERS_PER_PROXY, seconds=30
|
||||
)
|
||||
blocked: Final = gateway.request("POST", "/team/block", {"team_id": team})
|
||||
finally:
|
||||
stop.set()
|
||||
statuses: Final = tuple(itertools.chain.from_iterable(caller.result() for caller in callers))
|
||||
return blocked, statuses
|
||||
|
||||
|
||||
def _block_and_unblock_under_team_traffic(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client, model: str, key: str, team: str, cycle: int
|
||||
) -> None:
|
||||
upstream.delete("/__observations").raise_for_status()
|
||||
blocked, in_flight = _block_while_team_traffic_is_in_flight(gateway, peer, upstream, model, key, team)
|
||||
assert blocked.status_code == 200, blocked.text
|
||||
assert in_flight.count(200) >= 4 * IN_FLIGHT_CALLERS_PER_PROXY, repr(in_flight)
|
||||
assert set(in_flight) <= {200, 401}, repr(in_flight)
|
||||
|
||||
marker: Final = f"after block {cycle} {uuid.uuid4().hex}"
|
||||
refused: Final = held(
|
||||
lambda: _statuses_on_fresh_connections((gateway, peer), model, key, marker),
|
||||
lambda statuses: set(statuses) == {401},
|
||||
holding=5,
|
||||
seconds=15,
|
||||
)
|
||||
assert refused == (401,) * (2 * PROBES_PER_PROXY), repr(refused)
|
||||
leaked: Final = tuple(text for text in _texts(upstream) if text.startswith(marker))
|
||||
assert leaked == (), repr(leaked)
|
||||
|
||||
unblocked: Final = gateway.request("POST", "/team/unblock", {"team_id": team})
|
||||
assert unblocked.status_code == 200, unblocked.text
|
||||
eventually(
|
||||
lambda: _statuses_on_fresh_connections((gateway, peer), model, key, f"after unblock {cycle}"),
|
||||
lambda statuses: set(statuses) == {200},
|
||||
seconds=15,
|
||||
)
|
||||
|
||||
|
||||
def test_team_block_and_unblock_update_endpoint_info_and_db_state(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client
|
||||
) -> None:
|
||||
|
|
@ -126,8 +224,6 @@ def test_team_block_and_unblock_update_endpoint_info_and_db_state(
|
|||
def test_unblocking_a_team_restores_warmed_keys_on_both_proxies(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client
|
||||
) -> None:
|
||||
pytest.skip("BUG: SLP-605 /team/unblock does not restore warmed team-key access after blocked auth is cached")
|
||||
|
||||
with gateway.scenario() as scenario:
|
||||
provider_model: Final = f"team-block-{uuid.uuid4().hex}"
|
||||
model: Final = scenario.model(model=f"openai/{provider_model}")
|
||||
|
|
@ -166,8 +262,6 @@ def test_unblocking_a_team_restores_warmed_keys_on_both_proxies(
|
|||
def test_warmed_team_keys_are_refused_on_both_proxies_after_block(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client
|
||||
) -> None:
|
||||
pytest.skip("BUG: SLP-605 blocking a team does not refresh warmed team-key auth state")
|
||||
|
||||
with gateway.scenario() as scenario:
|
||||
provider_model: Final = f"team-block-{uuid.uuid4().hex}"
|
||||
model: Final = scenario.model(model=f"openai/{provider_model}")
|
||||
|
|
@ -202,8 +296,6 @@ def test_warmed_team_keys_are_refused_on_both_proxies_after_block(
|
|||
def test_a_new_team_key_is_refused_on_a_peer_that_cached_the_team_before_block(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client
|
||||
) -> None:
|
||||
pytest.skip("BUG: SLP-605 a peer proxy that cached the team before /team/block admits a never-seen team key")
|
||||
|
||||
with gateway.scenario() as scenario:
|
||||
provider_model: Final = f"team-block-{uuid.uuid4().hex}"
|
||||
model: Final = scenario.model(model=f"openai/{provider_model}")
|
||||
|
|
@ -229,3 +321,45 @@ def test_a_new_team_key_is_refused_on_a_peer_that_cached_the_team_before_block(
|
|||
assert peer_denied.status_code == 401, peer_denied.text
|
||||
assert _ErrorResponse.model_validate_json(peer_denied.text).error.type == "auth_error", peer_denied.text
|
||||
assert after_block == (), repr(after_block)
|
||||
|
||||
|
||||
def test_a_team_model_alias_still_routes_on_both_proxies_after_block_and_unblock(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
provider_model: Final = f"team-block-{uuid.uuid4().hex}"
|
||||
model: Final = scenario.model(model=f"openai/{provider_model}")
|
||||
alias: Final = f"alias-{uuid.uuid4().hex}"
|
||||
team: Final = scenario.team(models=[model], model_aliases={alias: model})
|
||||
key: Final = scenario.key(team_id=team, models=[model])
|
||||
|
||||
warmed: Final = tuple(
|
||||
_chat(proxy, alias, key, f"team alias warm {uuid.uuid4().hex}").status_code for proxy in (gateway, peer)
|
||||
)
|
||||
assert warmed == (200, 200), repr(warmed)
|
||||
assert _models(upstream) == (provider_model, provider_model)
|
||||
upstream.delete("/__observations").raise_for_status()
|
||||
|
||||
blocked: Final = gateway.request("POST", "/team/block", {"team_id": team})
|
||||
assert blocked.status_code == 200, blocked.text
|
||||
unblocked: Final = gateway.request("POST", "/team/unblock", {"team_id": team})
|
||||
assert unblocked.status_code == 200, unblocked.text
|
||||
|
||||
after_unblock: Final = _statuses_on_fresh_connections((gateway, peer), alias, key, "team alias after unblock")
|
||||
assert after_unblock == (200,) * (2 * PROBES_PER_PROXY), repr(after_unblock)
|
||||
assert _models(upstream) == (provider_model,) * (2 * PROBES_PER_PROXY)
|
||||
|
||||
|
||||
def test_a_block_issued_while_team_traffic_is_in_flight_holds_on_every_worker(
|
||||
gateway: Gateway, peer: Gateway, upstream: httpx.Client
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
provider_model: Final = f"team-block-{uuid.uuid4().hex}"
|
||||
model: Final = scenario.model(
|
||||
model=f"openai/{provider_model}", input_cost_per_token=0.000001, output_cost_per_token=0.000002
|
||||
)
|
||||
team: Final = scenario.team(models=[model])
|
||||
key: Final = scenario.key(team_id=team)
|
||||
|
||||
for cycle in range(BLOCK_CYCLES):
|
||||
_block_and_unblock_under_team_traffic(gateway, peer, upstream, model, key, team, cycle)
|
||||
|
|
|
|||
|
|
@ -409,3 +409,81 @@ def test_database_created_search_tool_backend_receives_the_intercepted_query_ove
|
|||
assert targets[-3:] == (("POST", "/v1/messages"), ("GET", "/database/search"), ("POST", "/v1/messages")), (
|
||||
targets
|
||||
)
|
||||
|
||||
|
||||
def test_empty_exa_search_reaches_the_model_without_the_exa_response_envelope(gateway: Gateway, tmp_path: Path) -> None:
|
||||
identity: Final = "websearch-exa-" + uuid.uuid4().hex
|
||||
tool_name: Final = "integration-exa-" + uuid.uuid4().hex
|
||||
envelope: Final = {
|
||||
"requestId": "exa-request-" + identity,
|
||||
"resolvedSearchType": "neural",
|
||||
"searchTime": 98.5,
|
||||
"costDollars": {"total": 0.005, "search": {"neural": 0.005}},
|
||||
}
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
parts: Final = urlsplit(request.target)
|
||||
if parts.path == "/exa/search":
|
||||
assert (request.method, json.loads(request.body)["query"]) == ("POST", _QUERY), request.body
|
||||
return Reply(body=json.dumps({**envelope, "results": []}).encode())
|
||||
assert request.method == "POST" and parts.path == "/v1/messages", request.target
|
||||
body: Final = json.loads(request.body)
|
||||
assert [tool["name"] for tool in body["tools"]] == ["litellm_web_search"], body["tools"]
|
||||
results: Final = [
|
||||
block
|
||||
for message in body["messages"]
|
||||
if isinstance(message["content"], list)
|
||||
for block in message["content"]
|
||||
if block["type"] == "tool_result"
|
||||
]
|
||||
if not results:
|
||||
return _anthropic_reply(identity, [_TEXT_BLOCK, _search_tool_use(identity)], "tool_use")
|
||||
assert results == [{"type": "tool_result", "tool_use_id": identity, "content": "results=[] object='search'"}], (
|
||||
results
|
||||
)
|
||||
return _anthropic_reply(identity, [_FINAL_BLOCK], "end_turn")
|
||||
|
||||
with wire_server(respond) as wire:
|
||||
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
config["search_tools"] = [
|
||||
{
|
||||
"search_tool_name": tool_name,
|
||||
"litellm_params": {
|
||||
"search_provider": "exa_ai",
|
||||
"api_key": "synthetic-exa-key",
|
||||
"api_base": wire.url + "/exa",
|
||||
},
|
||||
}
|
||||
]
|
||||
config["litellm_settings"].update(
|
||||
{
|
||||
"callbacks": ["websearch_interception"],
|
||||
"websearch_interception_params": {
|
||||
"enabled": True,
|
||||
"enabled_providers": ["anthropic"],
|
||||
"search_tool_name": tool_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
path: Final = tmp_path / "websearch-exa.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
environment: Final = {"ANTHROPIC_API_BASE": wire.url}
|
||||
with owned_proxy(gateway, tmp_path, environment, config=path) as candidate, candidate.scenario() as models:
|
||||
model: Final = models.model(
|
||||
model="anthropic/claude-sonnet-4-5-20250929", api_base=wire.url, api_key="synthetic-anthropic-key"
|
||||
)
|
||||
response: Final = candidate.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 64,
|
||||
"messages": [{"role": "user", "content": identity}],
|
||||
"tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["content"][-1] == _FINAL_BLOCK, response.text
|
||||
assert envelope["requestId"] not in response.text, response.text
|
||||
targets: Final = tuple((request.method, urlsplit(request.target).path) for request in wire.drain())
|
||||
assert targets[-3:] == (("POST", "/v1/messages"), ("POST", "/exa/search"), ("POST", "/v1/messages")), targets
|
||||
|
|
|
|||
|
|
@ -386,13 +386,14 @@ def _responses_caller_body(body: dict[str, JsonValue], model: str) -> dict[str,
|
|||
}
|
||||
|
||||
|
||||
def _responses_stream_caller_body(body: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
def _responses_stream_caller_body(body: dict[str, JsonValue], model: str) -> dict[str, JsonValue]:
|
||||
response_id: Final = body.get("id")
|
||||
assert isinstance(response_id, str), body
|
||||
usage: Final = _json_object_value(body["usage"]) if body.get("status") == "completed" else {}
|
||||
return {
|
||||
**body,
|
||||
"id": "<response-id>",
|
||||
"model": model,
|
||||
**({"usage": {**usage, "cost": 4.05e-06}} if usage else {}),
|
||||
}
|
||||
|
||||
|
|
@ -413,7 +414,7 @@ def _responses_caller_stream(reply: Reply, model: str) -> tuple[dict[str, JsonVa
|
|||
**event,
|
||||
"model": model,
|
||||
**(
|
||||
{"response": _responses_stream_caller_body(_json_object_value(event["response"]))}
|
||||
{"response": _responses_stream_caller_body(_json_object_value(event["response"]), model)}
|
||||
if "response" in event
|
||||
else {}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ from integration._support.client import (
|
|||
object_value,
|
||||
string_value,
|
||||
)
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.openai_wire import (
|
||||
answering_model_discovery,
|
||||
chat_stream,
|
||||
|
|
@ -159,14 +158,14 @@ def _error_class(message: JsonValue) -> str:
|
|||
return string_value(message).split(":", 1)[0]
|
||||
|
||||
|
||||
def _failure_row(query: str, groups: tuple[str, ...]) -> dict[str, JsonValue]:
|
||||
rows: Final = eventually(lambda: read_rows(query, groups), lambda found: len(found) >= 1, seconds=_ROW_SECONDS)
|
||||
def _failure_row(gateway: Gateway, query: str, groups: tuple[str, ...]) -> dict[str, JsonValue]:
|
||||
rows: Final = eventually(lambda: gateway.rows(query, groups), lambda found: len(found) >= 1, seconds=_ROW_SECONDS)
|
||||
assert [row["status"] for row in rows] == ["failure"], rows
|
||||
return object_value(object_value(rows[0]["metadata"])["error_information"])
|
||||
|
||||
|
||||
def _logged_failure(model: str) -> tuple[JsonValue, JsonValue]:
|
||||
error: Final = _failure_row(_ROWS, (model,))
|
||||
def _logged_failure(gateway: Gateway, model: str) -> tuple[JsonValue, JsonValue]:
|
||||
error: Final = _failure_row(gateway, _ROWS, (model,))
|
||||
return error["error_code"], error["error_class"]
|
||||
|
||||
|
||||
|
|
@ -188,7 +187,7 @@ def test_a_chat_stream_that_stalls_past_its_stream_timeout_ends_with_a_408_timeo
|
|||
events: Final = parse_sse(response.text)
|
||||
assert _chat_errors(events[:-1]) == () and "error" in events[-1].data, response.text
|
||||
error: Final = object_value(events[-1].data["error"])
|
||||
observed: Final = (error["code"], _error_class(error["message"]), *_logged_failure(model))
|
||||
observed: Final = (error["code"], _error_class(error["message"]), *_logged_failure(gateway, model))
|
||||
assert observed == ("408", "litellm.Timeout", "408", "Timeout"), (error, observed)
|
||||
assert _upstream_targets(wire) == (target,)
|
||||
|
||||
|
|
@ -205,7 +204,7 @@ def test_a_native_responses_stream_that_stalls_past_its_stream_timeout_fails_wit
|
|||
assert "response.output_text.delta" in types, response.text
|
||||
assert types[-1] == "response.failed" and "response.completed" not in types, response.text
|
||||
failed: Final = object_value(object_value(events[-1].data["response"])["error"])
|
||||
observed: Final = (failed["code"], *_logged_failure(model))
|
||||
observed: Final = (failed["code"], *_logged_failure(gateway, model))
|
||||
assert observed == ("request_timeout", "408", "Timeout"), (failed, observed)
|
||||
assert _upstream_targets(wire) == (_RESPONSES_TARGET,)
|
||||
|
||||
|
|
@ -222,7 +221,7 @@ def test_a_messages_stream_that_stalls_past_its_stream_timeout_ends_with_a_timeo
|
|||
assert "content_block_delta" in types and "message_stop" not in types, response.text
|
||||
assert types[-1] == "error", response.text
|
||||
error: Final = object_value(events[-1].data["error"])
|
||||
observed: Final = (_error_class(error["message"]), *_logged_failure(model))
|
||||
observed: Final = (_error_class(error["message"]), *_logged_failure(gateway, model))
|
||||
assert observed == ("litellm.Timeout", "408", "Timeout"), (error, observed)
|
||||
assert _upstream_targets(wire) == (_CHAT_TARGET,)
|
||||
|
||||
|
|
@ -238,7 +237,7 @@ def test_a_chat_stream_that_never_pauses_is_answered_in_full_under_the_same_stre
|
|||
assert _chat_errors(events) == (), response.text
|
||||
assert _finish_reasons(events) == ("stop",), response.text
|
||||
assert _upstream_targets(wire) == (_CHAT_TARGET,)
|
||||
rows: Final = eventually(lambda: read_rows(_ROWS, (model,)), lambda found: len(found) >= 1, _ROW_SECONDS)
|
||||
rows: Final = eventually(lambda: gateway.rows(_ROWS, (model,)), lambda found: len(found) >= 1, _ROW_SECONDS)
|
||||
assert [row["status"] for row in rows] == ["success"], rows
|
||||
|
||||
|
||||
|
|
@ -302,7 +301,7 @@ def test_a_deployment_whose_stream_stalls_stays_out_of_cooldown(
|
|||
with _peer(shape) as wire, cooldown_rig.gateway.scenario() as scenario:
|
||||
group, identity = _deployment(scenario, wire, backend)
|
||||
response: Final = _streamed(cooldown_rig.gateway, endpoint, group)
|
||||
error: Final = _failure_row(_ROWS, (group,))
|
||||
error: Final = _failure_row(cooldown_rig.gateway, _ROWS, (group,))
|
||||
assert not cooled(cooldown_rig.cache, identity), (error, response.text)
|
||||
|
||||
|
||||
|
|
@ -324,7 +323,7 @@ def _fallback_cooled(cooldown_rig: _CooldownRig, fallback_shape: Shape) -> tuple
|
|||
primary, _ = _deployment(scenario, failing)
|
||||
fallback, identity = _deployment(scenario, fallback_wire)
|
||||
response: Final = _streamed(cooldown_rig.gateway, "chat", primary, fallbacks=[fallback])
|
||||
error: Final = _failure_row(_EITHER_GROUP_ROWS, (primary, fallback))
|
||||
error: Final = _failure_row(cooldown_rig.gateway, _EITHER_GROUP_ROWS, (primary, fallback))
|
||||
assert _upstream_targets(fallback_wire) == (_CHAT_TARGET,), response.text
|
||||
return cooled(cooldown_rig.cache, identity), error, response.text
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import threading
|
|||
import time
|
||||
import uuid
|
||||
from collections.abc import Generator, Iterator, Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -53,7 +54,7 @@ JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|||
SESSION_LIMIT_FIELD: Final = "responses_websocket_session_limit_seconds"
|
||||
LIMIT_CLOSE_REASON: Final = "Session duration limit reached"
|
||||
RELOAD_INTERVAL_SECONDS: Final = 3
|
||||
WORKER_SYNC_SECONDS: Final = RELOAD_INTERVAL_SECONDS + 7
|
||||
WORKER_SYNC_DEADLINE_SECONDS: Final = 60
|
||||
CAP_SECONDS: Final = 60
|
||||
CAP_CLOSE_SLACK_SECONDS: Final = 15
|
||||
DEAF_CLOSE_SLACK_SECONDS: Final = 3
|
||||
|
|
@ -839,11 +840,28 @@ def _holding_worker(workers: frozenset[int], client_port: int) -> int | None:
|
|||
return next((pid for pid in sorted(workers) if holds(pid)), None)
|
||||
|
||||
|
||||
def _settled_on_every_worker(written_at: float) -> None:
|
||||
def _session_limit_on_a_worker(candidate: Gateway) -> tuple[int, JsonValue]:
|
||||
response: Final = candidate.request(
|
||||
"GET",
|
||||
"/health/integration-general-setting",
|
||||
params={"name": SESSION_LIMIT_FIELD},
|
||||
headers={"connection": "close"},
|
||||
)
|
||||
assert response.status_code == 200, f"Session limit on a worker: {response.status_code} {response.text}"
|
||||
return int(response.headers["x-integration-worker-pid"]), JSON_OBJECT.validate_json(response.content)["value"]
|
||||
|
||||
|
||||
def _session_limits_by_worker(candidate: Gateway) -> tuple[tuple[int, JsonValue], ...]:
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
probes: Final = tuple(pool.submit(_session_limit_on_a_worker, candidate) for _ in range(16))
|
||||
return tuple(probe.result() for probe in probes)
|
||||
|
||||
|
||||
def _settled_on_every_worker(candidate: Gateway, workers: frozenset[int], limit: JsonValue) -> None:
|
||||
eventually(
|
||||
lambda: time.monotonic() - written_at,
|
||||
lambda elapsed: elapsed >= WORKER_SYNC_SECONDS,
|
||||
seconds=WORKER_SYNC_SECONDS + 5,
|
||||
lambda: _session_limits_by_worker(candidate),
|
||||
lambda answers: frozenset(pid for pid, _ in answers) >= workers and all(value == limit for _, value in answers),
|
||||
seconds=WORKER_SYNC_DEADLINE_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -939,16 +957,14 @@ async def _override_workload(owned: OwnedProxy, key: str, model: str, database_u
|
|||
) as earlier:
|
||||
accepted_at: Final = time.monotonic()
|
||||
updates: Final = await _update_sequence(candidate, (7200, CAP_SECONDS, CAP_SECONDS))
|
||||
written_at: Final = time.monotonic()
|
||||
stored_after_updates: Final = await asyncio.to_thread(_stored_limit, database_url)
|
||||
await asyncio.to_thread(_settled_on_every_worker, written_at)
|
||||
await asyncio.to_thread(_settled_on_every_worker, candidate, workers, CAP_SECONDS)
|
||||
opened: Final = await _sockets_on_every_worker(proxy, key, workers)
|
||||
capped: Final = await asyncio.gather(*tuple(_capped_close(socket) for socket in opened))
|
||||
earlier_result: Final = await _held_turn(earlier, model, accepted_at)
|
||||
delete: Final = await asyncio.to_thread(_delete_limit, candidate)
|
||||
deleted_at: Final = time.monotonic()
|
||||
stored_after_delete: Final = await asyncio.to_thread(_general_settings_row, database_url)
|
||||
await asyncio.to_thread(_settled_on_every_worker, deleted_at)
|
||||
await asyncio.to_thread(_settled_on_every_worker, candidate, workers, None)
|
||||
restored: Final = await _idle_turn(
|
||||
proxy,
|
||||
key,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import json
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.client import Gateway, Scenario
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, JsonValue
|
||||
|
||||
_EXA_KEY: Final = "synthetic-exa-key"
|
||||
_QUERY: Final = "latest AI developments 2024"
|
||||
|
|
@ -43,6 +44,72 @@ class _SearchResponse(BaseModel):
|
|||
results: tuple[_SearchResult, ...]
|
||||
|
||||
|
||||
_EXA_HIGHLIGHTS: Final = {"highlights": {"numSentences": 2}}
|
||||
_EXA_ENVELOPE: Final = {
|
||||
"requestId": "exa-request-5d1f0c",
|
||||
"resolvedSearchType": "neural",
|
||||
"searchTime": 412.5,
|
||||
"costDollars": {"total": 0.007, "search": {"neural": 0.005}, "contents": {"highlights": 0.002}},
|
||||
}
|
||||
_EXA_SCORED_RESULTS: Final = (
|
||||
{
|
||||
"id": "https://result.invalid/margins",
|
||||
"title": "Margin outlook",
|
||||
"url": "https://result.invalid/margins",
|
||||
"publishedDate": "2026-01-15T00:00:00.000Z",
|
||||
"score": 0.91,
|
||||
"highlights": ["Margins hold steady.", "Deposit costs ease."],
|
||||
"highlightScores": [0.82, 0.64],
|
||||
},
|
||||
{
|
||||
"id": "https://result.invalid/deposits",
|
||||
"title": "Deposit pricing",
|
||||
"url": "https://result.invalid/deposits",
|
||||
"score": 0.47,
|
||||
"highlights": ["Deposit betas fall."],
|
||||
"highlightScores": [0.55],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _ScoredSearchResult(_SearchResult):
|
||||
highlights: tuple[str, ...] | None = None
|
||||
highlight_scores: tuple[float, ...] | None = None
|
||||
score: float | None = None
|
||||
|
||||
|
||||
class _ScoredSearchResponse(BaseModel):
|
||||
object: str
|
||||
results: tuple[_ScoredSearchResult, ...]
|
||||
provider_metadata: JsonValue = None
|
||||
|
||||
|
||||
_EXPECTED_SCORED_RESPONSE: Final = _ScoredSearchResponse(
|
||||
object="search",
|
||||
results=(
|
||||
_ScoredSearchResult(
|
||||
title="Margin outlook",
|
||||
url="https://result.invalid/margins",
|
||||
snippet="Margins hold steady.\n\nDeposit costs ease.",
|
||||
date="2026-01-15T00:00:00.000Z",
|
||||
last_updated=None,
|
||||
highlights=("Margins hold steady.", "Deposit costs ease."),
|
||||
highlight_scores=(0.82, 0.64),
|
||||
score=0.91,
|
||||
),
|
||||
_ScoredSearchResult(
|
||||
title="Deposit pricing",
|
||||
url="https://result.invalid/deposits",
|
||||
snippet="Deposit betas fall.",
|
||||
date=None,
|
||||
last_updated=None,
|
||||
highlights=("Deposit betas fall.",),
|
||||
highlight_scores=(0.55,),
|
||||
score=0.47,
|
||||
),
|
||||
),
|
||||
provider_metadata=_EXA_ENVELOPE,
|
||||
)
|
||||
_EXPECTED_RESPONSE: Final = _SearchResponse(
|
||||
object="search",
|
||||
results=(
|
||||
|
|
@ -162,6 +229,70 @@ def test_list_query_reaches_exa_as_one_space_joined_query(gateway: Gateway) -> N
|
|||
assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/exa/search")]
|
||||
|
||||
|
||||
def test_exa_highlights_scores_and_response_envelope_reach_the_caller_on_every_route_spelling(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
def respond(request: Request) -> Reply:
|
||||
assert (request.method, request.target) == ("POST", "/exa/search"), request.target
|
||||
assert json.loads(request.body) == {"query": _QUERY, "numResults": 2, "contents": _EXA_HIGHLIGHTS}, request.body
|
||||
return Reply(body=json.dumps({**_EXA_ENVELOPE, "results": list(_EXA_SCORED_RESULTS)}).encode())
|
||||
|
||||
tool: Final = f"exa-tool-{uuid.uuid4().hex}"
|
||||
body: Final = {"query": _QUERY, "max_results": 2, "contents": _EXA_HIGHLIGHTS}
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
_create_exa_tool(gateway, scenario, tool, f"{wire.url}/exa")
|
||||
spellings: Final = (
|
||||
(f"/v1/search/{tool}", body),
|
||||
(f"/search/{tool}", body),
|
||||
("/v1/search", {"search_tool_name": tool, **body}),
|
||||
("/search", {"model": tool, **body}),
|
||||
)
|
||||
for path, payload in spellings:
|
||||
response = gateway.request("POST", path, payload)
|
||||
assert response.status_code == 200, f"{path}: {response.text}"
|
||||
assert _ScoredSearchResponse.model_validate_json(response.content) == _EXPECTED_SCORED_RESPONSE, (
|
||||
f"{path}: {response.text}"
|
||||
)
|
||||
assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/exa/search")] * len(
|
||||
spellings
|
||||
)
|
||||
|
||||
|
||||
def test_concurrent_exa_searches_each_get_their_own_response_envelope(gateway: Gateway) -> None:
|
||||
def respond(request: Request) -> Reply:
|
||||
query: Final = json.loads(request.body)["query"]
|
||||
results: Final = [] if query.endswith("-empty") else [_EXA_SCORED_RESULTS[1]]
|
||||
return Reply(
|
||||
body=json.dumps({"requestId": f"exa-{query}", "costDollars": {"total": 0.005}, "results": results}).encode()
|
||||
)
|
||||
|
||||
tool: Final = f"exa-tool-{uuid.uuid4().hex}"
|
||||
queries: Final = tuple(f"burst-{uuid.uuid4().hex}{'-empty' if index % 2 else ''}" for index in range(16))
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
_create_exa_tool(gateway, scenario, tool, f"{wire.url}/exa")
|
||||
with ThreadPoolExecutor(max_workers=len(queries)) as pool:
|
||||
responses: Final = tuple(
|
||||
pool.map(lambda query: gateway.request("POST", f"/v1/search/{tool}", {"query": query}), queries)
|
||||
)
|
||||
observed: Final = tuple(
|
||||
(
|
||||
response.status_code,
|
||||
_ScoredSearchResponse.model_validate_json(response.content).provider_metadata,
|
||||
tuple(result.score for result in _ScoredSearchResponse.model_validate_json(response.content).results),
|
||||
)
|
||||
for response in responses
|
||||
)
|
||||
assert observed == tuple(
|
||||
(
|
||||
200,
|
||||
{"requestId": f"exa-{query}", "costDollars": {"total": 0.005}},
|
||||
() if query.endswith("-empty") else (0.47,),
|
||||
)
|
||||
for query in queries
|
||||
), [response.text for response in responses]
|
||||
assert len(wire.drain()) == len(queries)
|
||||
|
||||
|
||||
def _not_found(name: str) -> str:
|
||||
return f"Search tool '{name}' not found in router.search_tools"
|
||||
|
||||
|
|
|
|||
312
tests/integration/providers/test_tool_config_cache_point_wire.py
Normal file
312
tests/integration/providers/test_tool_config_cache_point_wire.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import json
|
||||
import uuid
|
||||
from typing import Final
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.openai_wire import chat_reply, responses_reply
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
|
||||
_BEDROCK_MODEL: Final = "us.anthropic.claude-sonnet-4-6"
|
||||
_OPENAI_MODEL: Final = "gpt-4.1"
|
||||
_GEMINI_MODEL: Final = "gemini-3.8-flash"
|
||||
_NOVA_MODEL: Final = "us.amazon.nova-pro-v1:0"
|
||||
_API_KEY: Final = "synthetic-provider-key"
|
||||
_CONTROL: Final = {"type": "ephemeral", "ttl": "1h"}
|
||||
_TOOL_CONFIG_POINT: Final = [{"location": "tool_config", "control": _CONTROL}]
|
||||
_AWS: Final = {
|
||||
"aws_access_key_id": "AKIAINTEGRATIONTEST",
|
||||
"aws_secret_access_key": "integration-secret",
|
||||
"aws_region_name": "us-east-1",
|
||||
}
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_TOOL_LIST: Final = TypeAdapter(list[dict[str, JsonValue]])
|
||||
|
||||
|
||||
def _openai_tools() -> list[JsonValue]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": f"{name} tool",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
for name in ("get_weather", "get_forecast")
|
||||
]
|
||||
|
||||
|
||||
def _anthropic_tools() -> list[JsonValue]:
|
||||
return [
|
||||
{"name": name, "description": f"{name} tool", "input_schema": {"type": "object", "properties": {}}}
|
||||
for name in ("get_weather", "get_forecast")
|
||||
]
|
||||
|
||||
|
||||
def _anthropic_reply(identity: str) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": _ANTHROPIC_MODEL,
|
||||
"content": [{"type": "text", "text": "done"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 3, "cache_creation_input_tokens": 10},
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def _converse_reply() -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "done"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 12, "outputTokens": 3, "totalTokens": 15},
|
||||
"metrics": {"latencyMs": 1},
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def _assert_last_anthropic_tool_marked(body: dict[str, JsonValue]) -> None:
|
||||
assert "cache_control_injection_points" not in body, body
|
||||
tools: Final = _TOOL_LIST.validate_python(body["tools"])
|
||||
assert [tool["name"] for tool in tools] == ["get_weather", "get_forecast"], tools
|
||||
assert "cache_control" not in tools[0], tools
|
||||
assert tools[1]["cache_control"] == _CONTROL, tools
|
||||
|
||||
|
||||
def test_chat_completions_tool_config_point_marks_the_last_anthropic_tool(gateway: Gateway) -> None:
|
||||
identity: Final = f"anthropic-tool-config-{uuid.uuid4().hex}"
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/v1/messages", request.target
|
||||
_assert_last_anthropic_tool_marked(_JSON_OBJECT.validate_json(request.body))
|
||||
return Reply(body=_anthropic_reply(identity))
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"anthropic/{_ANTHROPIC_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=_API_KEY,
|
||||
cache_control_injection_points=_TOOL_CONFIG_POINT,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": _openai_tools(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["usage"]["cache_creation_input_tokens"] == 10, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
def test_messages_tool_config_point_marks_the_last_anthropic_tool(gateway: Gateway) -> None:
|
||||
identity: Final = f"anthropic-native-tool-config-{uuid.uuid4().hex}"
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/v1/messages", request.target
|
||||
_assert_last_anthropic_tool_marked(_JSON_OBJECT.validate_json(request.body))
|
||||
return Reply(body=_anthropic_reply(identity))
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"anthropic/{_ANTHROPIC_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=_API_KEY,
|
||||
cache_control_injection_points=_TOOL_CONFIG_POINT,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": _anthropic_tools(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["usage"]["cache_creation_input_tokens"] == 10, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
def test_chat_completions_tool_config_point_appends_a_bedrock_converse_cache_point(gateway: Gateway) -> None:
|
||||
def respond(request: Request) -> Reply:
|
||||
path: Final = unquote(urlsplit(request.target).path)
|
||||
assert request.method == "POST" and path == f"/model/{_BEDROCK_MODEL}/converse", request.target
|
||||
body: Final = _JSON_OBJECT.validate_json(request.body)
|
||||
assert "cache_control_injection_points" not in body, body
|
||||
tools: Final = _TOOL_LIST.validate_python(_JSON_OBJECT.validate_python(body["toolConfig"])["tools"])
|
||||
assert [tuple(tool) for tool in tools] == [("toolSpec",), ("toolSpec",), ("cachePoint",)], tools
|
||||
assert tools[2] == {"cachePoint": {"type": "default", "ttl": "1h"}}, tools
|
||||
return Reply(body=_converse_reply())
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"bedrock/{_BEDROCK_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=None,
|
||||
cache_control_injection_points=_TOOL_CONFIG_POINT,
|
||||
**_AWS,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": _openai_tools(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
def test_chat_completions_tool_config_point_never_reaches_openai(gateway: Gateway) -> None:
|
||||
identity: Final = f"openai-tool-config-{uuid.uuid4().hex}"
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/chat/completions", request.target
|
||||
body: Final = _JSON_OBJECT.validate_json(request.body)
|
||||
assert "cache_control_injection_points" not in body, body
|
||||
tools: Final = _TOOL_LIST.validate_python(body["tools"])
|
||||
assert [tuple(sorted(tool)) for tool in tools] == [("function", "type"), ("function", "type")], tools
|
||||
return chat_reply(identity, _OPENAI_MODEL, "done", stream=False)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"openai/{_OPENAI_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=_API_KEY,
|
||||
cache_control_injection_points=_TOOL_CONFIG_POINT,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": _openai_tools(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["id"] == identity, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
def _gemini_reply() -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"candidates": [
|
||||
{"content": {"role": "model", "parts": [{"text": "done"}]}, "finishReason": "STOP", "index": 0}
|
||||
],
|
||||
"usageMetadata": {"promptTokenCount": 12, "candidatesTokenCount": 3, "totalTokenCount": 15},
|
||||
"modelVersion": _GEMINI_MODEL,
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_messages_tool_config_point_never_reaches_openai_through_the_responses_bridge(gateway: Gateway) -> None:
|
||||
identity: Final = f"openai-bridge-tool-config-{uuid.uuid4().hex}"
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/responses", request.target
|
||||
body: Final = _JSON_OBJECT.validate_json(request.body)
|
||||
assert "cache_control_injection_points" not in body, body
|
||||
return responses_reply(identity, _OPENAI_MODEL, "done", stream=False)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"openai/{_OPENAI_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=_API_KEY,
|
||||
cache_control_injection_points=_TOOL_CONFIG_POINT,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": _anthropic_tools(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
def test_messages_tool_config_point_never_reaches_gemini_through_the_chat_bridge(gateway: Gateway) -> None:
|
||||
def respond(request: Request) -> Reply:
|
||||
path: Final = urlsplit(request.target).path
|
||||
assert request.method == "POST" and path == f"/models/{_GEMINI_MODEL}:generateContent", request.target
|
||||
body: Final = _JSON_OBJECT.validate_json(request.body)
|
||||
assert "cache_control_injection_points" not in body, body
|
||||
return Reply(body=_gemini_reply())
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"gemini/{_GEMINI_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=_API_KEY,
|
||||
cache_control_injection_points=_TOOL_CONFIG_POINT,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": _anthropic_tools(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
def test_messages_tool_config_point_appends_a_bedrock_converse_cache_point_through_the_chat_bridge(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
def respond(request: Request) -> Reply:
|
||||
path: Final = unquote(urlsplit(request.target).path)
|
||||
assert request.method == "POST" and path == f"/model/{_NOVA_MODEL}/converse", request.target
|
||||
body: Final = _JSON_OBJECT.validate_json(request.body)
|
||||
assert "cache_control_injection_points" not in body, body
|
||||
tools: Final = _TOOL_LIST.validate_python(_JSON_OBJECT.validate_python(body["toolConfig"])["tools"])
|
||||
assert [tuple(tool) for tool in tools] == [("toolSpec",), ("toolSpec",), ("cachePoint",)], tools
|
||||
assert tools[2] == {"cachePoint": {"type": "default"}}, tools
|
||||
return Reply(body=_converse_reply())
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"bedrock/{_NOVA_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=None,
|
||||
cache_control_injection_points=_TOOL_CONFIG_POINT,
|
||||
**_AWS,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": _anthropic_tools(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
82
tests/integration/sdk/test_exa_search_envelope_sdk.py
Normal file
82
tests/integration/sdk/test_exa_search_envelope_sdk.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import json
|
||||
import uuid
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.search.transformation import SearchResponse
|
||||
|
||||
pytestmark: Final = pytest.mark.timeout(60)
|
||||
|
||||
_ENVELOPE: Final[dict[str, JsonValue]] = {
|
||||
"requestId": "exa-request-sdk",
|
||||
"resolvedSearchType": "neural",
|
||||
"searchTime": 211.0,
|
||||
"costDollars": {"total": 0.005, "search": {"neural": 0.005}},
|
||||
}
|
||||
_RESULT: Final[dict[str, JsonValue]] = {
|
||||
"id": "https://result.invalid/sdk",
|
||||
"title": "SDK result",
|
||||
"url": "https://result.invalid/sdk",
|
||||
"publishedDate": "2026-02-01T00:00:00.000Z",
|
||||
"score": 0.73,
|
||||
"highlights": ["First line.", "Second line."],
|
||||
"highlightScores": [0.61, 0.42],
|
||||
}
|
||||
_EXPECTED: Final[dict[str, JsonValue]] = {
|
||||
"object": "search",
|
||||
"results": [
|
||||
{
|
||||
"title": "SDK result",
|
||||
"url": "https://result.invalid/sdk",
|
||||
"snippet": "First line.\n\nSecond line.",
|
||||
"date": "2026-02-01T00:00:00.000Z",
|
||||
"last_updated": None,
|
||||
"highlights": ["First line.", "Second line."],
|
||||
"highlight_scores": [0.61, 0.42],
|
||||
"score": 0.73,
|
||||
}
|
||||
],
|
||||
"provider_metadata": _ENVELOPE,
|
||||
}
|
||||
|
||||
|
||||
def _respond(request: Request) -> Reply:
|
||||
assert (request.method, request.target) == ("POST", "/exa/search"), request.target
|
||||
return Reply(body=json.dumps({**_ENVELOPE, "results": [_RESULT]}).encode())
|
||||
|
||||
|
||||
def test_sync_exa_search_returns_highlights_scores_and_the_response_envelope() -> None:
|
||||
with wire_server(_respond) as wire:
|
||||
response: Final = litellm.search(
|
||||
query=f"sdk sync {uuid.uuid4().hex}",
|
||||
search_provider="exa_ai",
|
||||
api_key="synthetic-exa-key",
|
||||
api_base=f"{wire.url}/exa",
|
||||
max_results=1,
|
||||
contents={"highlights": {"numSentences": 2}},
|
||||
)
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
assert isinstance(response, SearchResponse), type(response)
|
||||
assert response.model_dump(mode="json") == _EXPECTED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exa_search_returns_highlights_scores_and_the_response_envelope() -> None:
|
||||
with wire_server(_respond) as wire:
|
||||
response: Final = await litellm.asearch(
|
||||
query=f"sdk async {uuid.uuid4().hex}",
|
||||
search_provider="exa_ai",
|
||||
api_key="synthetic-exa-key",
|
||||
api_base=f"{wire.url}/exa",
|
||||
max_results=1,
|
||||
contents={"highlights": {"numSentences": 2}},
|
||||
)
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
assert isinstance(response, SearchResponse), type(response)
|
||||
assert response.model_dump(mode="json") == _EXPECTED
|
||||
69
tests/integration/sdk/test_gemini_tts_speech_logging_sdk.py
Normal file
69
tests/integration/sdk/test_gemini_tts_speech_logging_sdk.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.client import eventually
|
||||
from integration._support.wire import wire_server
|
||||
from integration.providers._gemini_tts_voice_support import (
|
||||
AUDIO_TOKENS,
|
||||
BACKEND,
|
||||
GEMINI_API_KEY,
|
||||
PCM,
|
||||
PROMPT_TOKENS,
|
||||
gemini_peer,
|
||||
marker,
|
||||
received_text,
|
||||
wav_payload,
|
||||
)
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
BINARY_RESPONSE: Final = "HttpxBinaryResponseContent"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Logged:
|
||||
call_type: object
|
||||
response_type: str
|
||||
tokens: tuple[object, object]
|
||||
|
||||
|
||||
class _Recorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._lock: Final = threading.Lock()
|
||||
self.events: tuple[_Logged, ...] = ()
|
||||
|
||||
def log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
payload: Final = kwargs.get("standard_logging_object")
|
||||
logged: Final = payload if isinstance(payload, dict) else {}
|
||||
event: Final = _Logged(
|
||||
call_type=kwargs.get("call_type"),
|
||||
response_type=type(response_obj).__name__,
|
||||
tokens=(logged.get("prompt_tokens"), logged.get("completion_tokens")),
|
||||
)
|
||||
with self._lock:
|
||||
self.events = (*self.events, event)
|
||||
|
||||
|
||||
def test_sync_speech_logs_one_success_event_carrying_the_reply_token_usage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
recorder: Final = _Recorder()
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
with wire_server(gemini_peer) as wire:
|
||||
monkeypatch.setenv("GEMINI_API_BASE", wire.url)
|
||||
text: Final = marker()
|
||||
audio: Final = litellm.speech(model=f"gemini/{BACKEND}", input=text, voice="Kore", api_key=GEMINI_API_KEY)
|
||||
assert wav_payload(audio.content) == PCM
|
||||
assert sum(1 for request in wire.drain() if received_text(request) == text) == 1
|
||||
events: Final = eventually(
|
||||
lambda: recorder.events,
|
||||
lambda found: any(event.response_type == BINARY_RESPONSE for event in found),
|
||||
seconds=30,
|
||||
)
|
||||
assert events == (_Logged("speech", BINARY_RESPONSE, (PROMPT_TOKENS, AUDIO_TOKENS)),)
|
||||
|
|
@ -294,13 +294,9 @@ def test_owner_create_returns_the_unified_job_id(fixture: str, request: pytest.F
|
|||
built: Final[Rig] = request.getfixturevalue(fixture)
|
||||
created: Final = Job.model_validate_json(built.created_text)
|
||||
expected: Final = _client_job(
|
||||
_unified_job_id(built.deployment_id, built.provider_job), "validating_files", built.provider_file
|
||||
)
|
||||
assert created.model_dump(exclude={"training_file"}) == expected.model_dump(exclude={"training_file"}), (
|
||||
"training_file is excluded because test_managed_create_sends_the_provider_file_id_of_the_pinned_deployment "
|
||||
"documents the known managed-create bug",
|
||||
built.created_text,
|
||||
_unified_job_id(built.deployment_id, built.provider_job), "validating_files", built.managed_file
|
||||
)
|
||||
assert created == expected, built.created_text
|
||||
|
||||
|
||||
def test_another_key_cannot_create_with_or_cancel_the_owners_managed_ids(rig: Rig) -> None:
|
||||
|
|
|
|||
334
tests/integration/spend/test_gemini_tts_speech_billing.py
Normal file
334
tests/integration/spend/test_gemini_tts_speech_billing.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import JSON_OBJECT, eventually, gateway_from_environment, object_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
|
||||
from integration._support.vertex import service_account_json
|
||||
from integration._support.wire import Wire, wire_server
|
||||
from integration.providers._gemini_tts_voice_support import (
|
||||
AUDIO_TOKENS,
|
||||
BACKEND,
|
||||
GEMINI_API_KEY,
|
||||
PCM,
|
||||
PROMPT_TOKENS,
|
||||
REJECTED_PREFIX,
|
||||
VERTEX_LOCATION,
|
||||
VERTEX_PROJECT,
|
||||
gemini_peer,
|
||||
marker,
|
||||
received_text,
|
||||
wav_payload,
|
||||
)
|
||||
from pydantic import JsonValue
|
||||
|
||||
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
|
||||
|
||||
Provider = Literal["gemini", "vertex_ai"]
|
||||
MODELS: Final[Mapping[Provider, str]] = {"gemini": "tts-billing-gemini", "vertex_ai": "tts-billing-vertex"}
|
||||
TPM_MODEL: Final = "tts-billing-tpm"
|
||||
TPM_LIMIT: Final = PROMPT_TOKENS + AUDIO_TOKENS
|
||||
BINARY_RESPONSE: Final = "HttpxBinaryResponseContent"
|
||||
CALLBACK_LOG: Final = "TTS_BILLING_CALLBACK_LOG"
|
||||
CALLBACK_MODULE: Final = """
|
||||
import json
|
||||
import os
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
|
||||
def _event(kind, kwargs, response_obj):
|
||||
payload = kwargs.get("standard_logging_object") or {}
|
||||
metadata = payload.get("metadata") or {}
|
||||
response = payload.get("response")
|
||||
usage = response.get("usage") if isinstance(response, dict) else None
|
||||
return {
|
||||
"kind": kind,
|
||||
"key": metadata.get("user_api_key_hash"),
|
||||
"call_type": kwargs.get("call_type"),
|
||||
"response_type": type(response_obj).__name__,
|
||||
"response_cost": payload.get("response_cost"),
|
||||
"prompt_tokens": payload.get("prompt_tokens"),
|
||||
"completion_tokens": payload.get("completion_tokens"),
|
||||
"cache_hit": payload.get("cache_hit"),
|
||||
"saved_cache_cost": payload.get("saved_cache_cost"),
|
||||
"cache_key": payload.get("cache_key"),
|
||||
"logged_usage_total": usage.get("total_tokens") if isinstance(usage, dict) else None,
|
||||
}
|
||||
|
||||
|
||||
class SpeechEvents(CustomLogger):
|
||||
def _write(self, line):
|
||||
with open(os.environ["TTS_BILLING_CALLBACK_LOG"], "a") as sink:
|
||||
sink.write(json.dumps(line) + "\\n")
|
||||
|
||||
def log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self._write(_event("sync_success", kwargs, response_obj))
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self._write(_event("async_success", kwargs, response_obj))
|
||||
|
||||
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self._write(_event("sync_failure", kwargs, response_obj))
|
||||
|
||||
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self._write(_event("async_failure", kwargs, response_obj))
|
||||
|
||||
|
||||
speech_events = SpeechEvents()
|
||||
"""
|
||||
SPEND_ROWS_SQL: Final = (
|
||||
'SELECT call_type, spend, prompt_tokens, completion_tokens, cache_hit FROM "LiteLLM_SpendLogs"'
|
||||
' WHERE api_key = %s ORDER BY "startTime"'
|
||||
)
|
||||
KEY_SPEND_SQL: Final = 'SELECT spend FROM "LiteLLM_VerificationToken" WHERE token = %s'
|
||||
|
||||
Event = dict[str, JsonValue]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BillingProxy:
|
||||
proxy: OwnedProxy
|
||||
callback_log: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Sent:
|
||||
status: int
|
||||
body: bytes
|
||||
cache_key: str
|
||||
|
||||
|
||||
def _hashed(key: str) -> str:
|
||||
return sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def _model_list(token_url: str) -> list[JsonValue]:
|
||||
return [
|
||||
{"model_name": MODELS["gemini"], "litellm_params": {"model": f"gemini/{BACKEND}", "api_key": GEMINI_API_KEY}},
|
||||
{
|
||||
"model_name": TPM_MODEL,
|
||||
"litellm_params": {"model": f"gemini/{BACKEND}", "api_key": GEMINI_API_KEY, "tpm": TPM_LIMIT},
|
||||
},
|
||||
{
|
||||
"model_name": MODELS["vertex_ai"],
|
||||
"litellm_params": {
|
||||
"model": f"vertex_ai/{BACKEND}",
|
||||
"vertex_project": VERTEX_PROJECT,
|
||||
"vertex_location": VERTEX_LOCATION,
|
||||
"vertex_credentials": service_account_json(VERTEX_PROJECT, token_url.rstrip("/")),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _config(directory: Path, token_url: str, *, cached: bool) -> Path:
|
||||
stock: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
settings: Final = object_value(stock["litellm_settings"])
|
||||
kept: Final = {name: value for name, value in settings.items() if name not in {"cache", "cache_params"}}
|
||||
cache: Final[dict[str, JsonValue]] = (
|
||||
{"cache": True, "cache_params": {**object_value(settings["cache_params"]), "namespace": marker()}}
|
||||
if cached
|
||||
else {"cache": False}
|
||||
)
|
||||
routing: Final[dict[str, JsonValue]] = (
|
||||
{}
|
||||
if cached
|
||||
else {
|
||||
"routing_strategy": "usage-based-routing-v2",
|
||||
"redis_host": os.environ["REDIS_HOST"],
|
||||
"redis_port": int(os.environ["REDIS_PORT"]),
|
||||
}
|
||||
)
|
||||
(directory / "tts_billing_callbacks.py").write_text(CALLBACK_MODULE)
|
||||
path: Final = directory / "tts-billing.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
**stock,
|
||||
"model_list": _model_list(token_url),
|
||||
"litellm_settings": {**kept, **cache, "callbacks": "tts_billing_callbacks.speech_events"},
|
||||
"router_settings": {**object_value(stock["router_settings"]), "num_retries": 0, **routing},
|
||||
}
|
||||
)
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _billing_proxy(wire: Wire, directory: Path, *, cached: bool) -> Iterator[BillingProxy]:
|
||||
callback_log: Final = directory / "callback_events.jsonl"
|
||||
callback_log.touch()
|
||||
with gateway_from_environment() as gateway:
|
||||
config: Final = _config(directory, gateway.upstream_url, cached=cached)
|
||||
overrides: Final = {"GEMINI_API_BASE": wire.url, "VERTEXAI_API_BASE": wire.url, CALLBACK_LOG: str(callback_log)}
|
||||
with owned_proxy_process(gateway, directory, overrides, config=config, workers=2) as proxy:
|
||||
yield BillingProxy(proxy, callback_log)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tts_wire() -> Iterator[Wire]:
|
||||
with wire_server(gemini_peer) as wire:
|
||||
yield wire
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def uncached(tts_wire: Wire, tmp_path_factory: pytest.TempPathFactory) -> Iterator[BillingProxy]:
|
||||
yield from _billing_proxy(tts_wire, tmp_path_factory.mktemp("tts-billing-uncached"), cached=False)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cached(tts_wire: Wire, tmp_path_factory: pytest.TempPathFactory) -> Iterator[BillingProxy]:
|
||||
yield from _billing_proxy(tts_wire, tmp_path_factory.mktemp("tts-billing-cached"), cached=True)
|
||||
|
||||
|
||||
def _speech(billing: BillingProxy, model: str, text: str, key: str, voice: str = "Kore") -> Sent:
|
||||
response: Final = billing.proxy.gateway.request(
|
||||
"POST", "/v1/audio/speech", {"model": model, "input": text, "voice": voice}, key=key
|
||||
)
|
||||
return Sent(response.status_code, response.content, response.headers.get("x-litellm-cache-key", ""))
|
||||
|
||||
|
||||
def _upstream_calls(wire: Wire, text: str) -> int:
|
||||
return sum(1 for request in wire.drain() if received_text(request) == text)
|
||||
|
||||
|
||||
def _spend_rows(key: str, count: int) -> list[dict[str, JsonValue]]:
|
||||
return eventually(lambda: read_rows(SPEND_ROWS_SQL, (_hashed(key),)), lambda rows: len(rows) == count, seconds=70)
|
||||
|
||||
|
||||
def _key_spend(key: str, expected: float) -> JsonValue:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(KEY_SPEND_SQL, (_hashed(key),)),
|
||||
lambda found: len(found) == 1 and found[0]["spend"] == pytest.approx(expected),
|
||||
seconds=70,
|
||||
return_last_on_timeout=True,
|
||||
)
|
||||
return rows[0]["spend"]
|
||||
|
||||
|
||||
def _chat_spend(billing: BillingProxy, model: str, key: str) -> float:
|
||||
body: Final[dict[str, JsonValue]] = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": marker()}],
|
||||
"modalities": ["audio"],
|
||||
"audio": {"voice": "Kore", "format": "pcm16"},
|
||||
}
|
||||
response: Final = billing.proxy.gateway.request("POST", "/v1/chat/completions", body, key=key)
|
||||
assert response.status_code == 200, response.text
|
||||
row: Final = _spend_rows(key, 1)[0]
|
||||
assert (row["prompt_tokens"], row["completion_tokens"]) == (PROMPT_TOKENS, AUDIO_TOKENS), row
|
||||
spend: Final = row["spend"]
|
||||
assert isinstance(spend, float) and spend > 0, row
|
||||
return spend
|
||||
|
||||
|
||||
def _events(billing: BillingProxy, key: str) -> tuple[Event, ...]:
|
||||
complete_lines: Final = billing.callback_log.read_text().split("\n")[:-1]
|
||||
events: Final = tuple(JSON_OBJECT.validate_json(line) for line in complete_lines)
|
||||
return tuple(event for event in events if event["key"] == _hashed(key))
|
||||
|
||||
|
||||
def _speech_events(billing: BillingProxy, key: str, count: int) -> tuple[Event, ...]:
|
||||
return eventually(
|
||||
lambda: _events(billing, key),
|
||||
lambda found: sum(1 for event in found if event["response_type"] == BINARY_RESPONSE) >= count,
|
||||
seconds=70,
|
||||
)
|
||||
|
||||
|
||||
def _summary(event: Event) -> tuple[JsonValue, ...]:
|
||||
return (event["call_type"], event["response_type"], event["prompt_tokens"], event["completion_tokens"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["gemini", "vertex_ai"])
|
||||
def test_speech_calls_google_once_and_bills_and_logs_its_token_usage_once(
|
||||
uncached: BillingProxy, tts_wire: Wire, provider: Provider
|
||||
) -> None:
|
||||
with uncached.proxy.gateway.scenario() as scenario:
|
||||
speech_key: Final = scenario.key()
|
||||
text: Final = marker()
|
||||
sent: Final = _speech(uncached, MODELS[provider], text, speech_key)
|
||||
assert sent.status == 200, sent.body[:300]
|
||||
assert wav_payload(sent.body) == PCM
|
||||
assert _upstream_calls(tts_wire, text) == 1
|
||||
chat_spend: Final = _chat_spend(uncached, MODELS[provider], scenario.key())
|
||||
row: Final = _spend_rows(speech_key, 1)[0]
|
||||
assert row["call_type"] == "aspeech", row
|
||||
assert (row["prompt_tokens"], row["completion_tokens"]) == (PROMPT_TOKENS, AUDIO_TOKENS), row
|
||||
assert row["spend"] == pytest.approx(chat_spend), row
|
||||
assert _key_spend(speech_key, chat_spend) == pytest.approx(chat_spend)
|
||||
events: Final = _speech_events(uncached, speech_key, 1)
|
||||
assert [_summary(event) for event in events] == [("aspeech", BINARY_RESPONSE, PROMPT_TOKENS, AUDIO_TOKENS)]
|
||||
assert events[0]["response_cost"] == pytest.approx(chat_spend), events
|
||||
assert events[0]["logged_usage_total"] == PROMPT_TOKENS + AUDIO_TOKENS, events
|
||||
|
||||
|
||||
def test_cached_speech_repeat_is_a_free_cache_hit_after_a_billed_first_call(
|
||||
cached: BillingProxy, tts_wire: Wire
|
||||
) -> None:
|
||||
model: Final = MODELS["gemini"]
|
||||
with cached.proxy.gateway.scenario() as scenario:
|
||||
speech_key: Final = scenario.key()
|
||||
text: Final = marker()
|
||||
first: Final = _speech(cached, model, text, speech_key)
|
||||
repeat: Final = _speech(cached, model, text, speech_key)
|
||||
assert (first.status, repeat.status) == (200, 200), (first.body[:300], repeat.body[:300])
|
||||
assert wav_payload(first.body) == PCM and repeat.body == first.body
|
||||
assert _upstream_calls(tts_wire, text) == 1
|
||||
assert repeat.cache_key, "the cached repeat carried no x-litellm-cache-key header"
|
||||
chat_spend: Final = _chat_spend(cached, model, scenario.key())
|
||||
billed, hit = _spend_rows(speech_key, 2)
|
||||
assert billed["cache_hit"] != "True" and billed["spend"] == pytest.approx(chat_spend), billed
|
||||
assert (billed["prompt_tokens"], billed["completion_tokens"]) == (PROMPT_TOKENS, AUDIO_TOKENS), billed
|
||||
assert (hit["cache_hit"], hit["spend"]) == ("True", 0.0), hit
|
||||
assert _key_spend(speech_key, chat_spend) == pytest.approx(chat_spend)
|
||||
events: Final = _speech_events(cached, speech_key, 2)
|
||||
assert len(events) == 2, events
|
||||
hits: Final = tuple(event for event in events if event["cache_hit"] is True)
|
||||
assert len(hits) == 1, events
|
||||
assert (hits[0]["response_cost"], hits[0]["cache_key"]) == (0.0, repeat.cache_key), hits
|
||||
assert hits[0]["saved_cache_cost"] == pytest.approx(chat_spend), hits
|
||||
paid: Final = tuple(event for event in events if event["cache_hit"] is not True)
|
||||
assert len(paid) == 1 and paid[0]["response_cost"] == pytest.approx(chat_spend), events
|
||||
|
||||
|
||||
def test_speech_rejected_by_google_reaches_the_caller_and_logs_one_failure_after_one_call(
|
||||
uncached: BillingProxy, tts_wire: Wire
|
||||
) -> None:
|
||||
with uncached.proxy.gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
text: Final = marker()
|
||||
sent: Final = _speech(uncached, MODELS["gemini"], text, key, voice="Custom-Voice")
|
||||
assert sent.status == 400, sent.body[:300]
|
||||
assert f"{REJECTED_PREFIX}Custom-Voice" in sent.body.decode(), sent.body[:300]
|
||||
assert _upstream_calls(tts_wire, text) == 1
|
||||
events: Final = eventually(lambda: _events(uncached, key), lambda found: len(found) >= 2, seconds=70)
|
||||
logged: Final = sorted((str(event["kind"]), event["call_type"]) for event in events)
|
||||
assert logged == [("async_failure", "aspeech"), ("sync_failure", "aspeech")], events
|
||||
|
||||
|
||||
def test_a_speech_reply_fills_the_deployment_tpm_limit_so_the_next_request_gets_429(
|
||||
uncached: BillingProxy, tts_wire: Wire
|
||||
) -> None:
|
||||
eventually(lambda: datetime.now(UTC), lambda now: now.second < 10, seconds=65)
|
||||
with uncached.proxy.gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
first_text: Final = marker()
|
||||
first: Final = _speech(uncached, TPM_MODEL, first_text, key)
|
||||
assert first.status == 200, first.body[:300]
|
||||
_spend_rows(key, 1)
|
||||
second_text: Final = marker()
|
||||
second: Final = _speech(uncached, TPM_MODEL, second_text, key)
|
||||
assert second.status == 429, second.body[:300]
|
||||
assert b"No deployments available" in second.body, second.body[:300]
|
||||
received: Final = tuple(received_text(request) for request in tts_wire.drain())
|
||||
assert (received.count(first_text), received.count(second_text)) == (1, 0)
|
||||
|
|
@ -3,6 +3,7 @@ import json
|
|||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
|
@ -13,6 +14,7 @@ import pytest
|
|||
import respx
|
||||
from fastapi import HTTPException
|
||||
from openai import APIConnectionError
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
|
@ -22,7 +24,7 @@ from litellm_enterprise.proxy.hooks.managed_files import (
|
|||
_provider_file_retrieve_credentials,
|
||||
)
|
||||
from litellm.caching import DualCache
|
||||
from litellm.proxy._types import CallTypes, LiteLLM_ManagedFileTable
|
||||
from litellm.proxy._types import CallTypes, LiteLLM_ManagedFileTable, UserAPIKeyAuth
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
BATCH_CREATE_HIDDEN_PARAM,
|
||||
is_base64_encoded_unified_file_id,
|
||||
|
|
@ -6050,6 +6052,164 @@ async def test_create_from_raw_provider_file_id_skips_managed_ownership_lookup(c
|
|||
assert not any("unified_file_id" in where for where in table.find_first_calls), table.find_first_calls
|
||||
|
||||
|
||||
def _owned_managed_file(unified_uuid: str, model_mappings: Mapping[str, str]) -> tuple[str, LiteLLM_ManagedFileTable]:
|
||||
raw: Final = f"litellm_proxy:application/jsonl;unified_id,{unified_uuid};target_model_names,gpt-4o-mini"
|
||||
unified_file_id: Final = base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
||||
return unified_file_id, LiteLLM_ManagedFileTable(
|
||||
unified_file_id=unified_file_id,
|
||||
file_object=None,
|
||||
model_mappings=dict(model_mappings),
|
||||
flat_model_file_ids=list(model_mappings.values()),
|
||||
created_by="user_a",
|
||||
team_id="team_a",
|
||||
)
|
||||
|
||||
|
||||
async def _provider_kwargs(
|
||||
managed_files: PROXY_LiteLLMManagedFiles, call_type: str, request: Mapping[str, str], deployment_id: str
|
||||
) -> Mapping[str, object]:
|
||||
prepared: Final = await 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=dict(request),
|
||||
call_type=call_type,
|
||||
)
|
||||
assert isinstance(prepared, dict), prepared
|
||||
sent: Final = await managed_files.async_pre_call_deployment_hook(
|
||||
kwargs={**prepared, "litellm_metadata": {"model_info": {"id": deployment_id}}}, call_type=CallTypes(call_type)
|
||||
)
|
||||
assert sent is not None
|
||||
return sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("call_type", "file_field"), _INPUT_FILE_CREATE_CALLS)
|
||||
@pytest.mark.parametrize(("deployment_id", "provider_file_id"), [("model-a", "file-a"), ("model-b", "file-b")])
|
||||
async def test_create_from_managed_file_sends_the_routed_deployments_provider_file_id(
|
||||
call_type: str, file_field: str, deployment_id: str, provider_file_id: str
|
||||
) -> None:
|
||||
unified_file_id, row = _owned_managed_file(
|
||||
"0b6f2c1e-9a4d-4c3e-8f71-2d5a6b7c8d90", {"model-a": "file-a", "model-b": "file-b"}
|
||||
)
|
||||
managed_files, _ = _managed_files_with_fake_prisma(row)
|
||||
|
||||
sent: Final = await _provider_kwargs(
|
||||
managed_files, call_type, {"model": "gpt-4o-mini", file_field: unified_file_id}, deployment_id
|
||||
)
|
||||
|
||||
assert sent[file_field] == provider_file_id, sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fine_tuning_create_swaps_managed_training_and_validation_files_independently() -> None:
|
||||
training_file, training_row = _owned_managed_file("5c1d9e0a-3b7f-4e2a-9c6d-1f8e7a6b5c40", {"model-a": "file-train"})
|
||||
validation_file, validation_row = _owned_managed_file(
|
||||
"8e2f4a6c-1d3b-4f5e-a7c9-0b2d4f6a8c10", {"model-a": "file-validate"}
|
||||
)
|
||||
managed_files, _ = _managed_files_with_fake_prisma(training_row, validation_row)
|
||||
|
||||
sent: Final = await _provider_kwargs(
|
||||
managed_files,
|
||||
"acreate_fine_tuning_job",
|
||||
{"model": "gpt-4o-mini", "training_file": training_file, "validation_file": validation_file},
|
||||
"model-a",
|
||||
)
|
||||
|
||||
assert (sent["training_file"], sent["validation_file"]) == ("file-train", "file-validate"), sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fine_tuning_create_keeps_a_raw_validation_file_next_to_a_managed_training_file() -> None:
|
||||
training_file, training_row = _owned_managed_file("5c1d9e0a-3b7f-4e2a-9c6d-1f8e7a6b5c40", {"model-a": "file-train"})
|
||||
managed_files, _ = _managed_files_with_fake_prisma(training_row)
|
||||
|
||||
sent: Final = await _provider_kwargs(
|
||||
managed_files,
|
||||
"acreate_fine_tuning_job",
|
||||
{"model": "gpt-4o-mini", "training_file": training_file, "validation_file": "file-raw-validate"},
|
||||
"model-a",
|
||||
)
|
||||
|
||||
assert (sent["training_file"], sent["validation_file"]) == ("file-train", "file-raw-validate"), sent
|
||||
|
||||
|
||||
_REQUEST_KWARGS: Final = TypeAdapter(dict[str, object])
|
||||
_DEPLOYMENTS: Final = TypeAdapter(list[dict[str, dict[str, str]]])
|
||||
|
||||
|
||||
async def _routable_deployment_ids(
|
||||
managed_files: PROXY_LiteLLMManagedFiles, call_type: str, request: Mapping[str, str]
|
||||
) -> tuple[str, ...]:
|
||||
prepared: Final = _REQUEST_KWARGS.validate_python(
|
||||
await 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=dict(request),
|
||||
call_type=call_type,
|
||||
)
|
||||
)
|
||||
routable: Final = _DEPLOYMENTS.validate_python(
|
||||
await managed_files.async_filter_deployments(
|
||||
model="gpt-4o-mini",
|
||||
healthy_deployments=[{"model_info": {"id": model_id}} for model_id in ("model-a", "model-b", "model-c")],
|
||||
messages=None,
|
||||
request_kwargs=prepared,
|
||||
)
|
||||
)
|
||||
return tuple(deployment["model_info"]["id"] for deployment in routable)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("call_type", "files", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"acreate_fine_tuning_job",
|
||||
{"training_file": {"model-a": "f-1", "model-b": "f-2"}, "validation_file": {"model-b": "f-3"}},
|
||||
("model-b",),
|
||||
id="fine-tune-routes-to-the-deployment-holding-both-files",
|
||||
),
|
||||
pytest.param(
|
||||
"acreate_fine_tuning_job",
|
||||
{"training_file": {"model-a": "f-1", "model-b": "f-2"}, "validation_file": "file-raw-validate"},
|
||||
("model-a", "model-b"),
|
||||
id="fine-tune-raw-validation-file-does-not-narrow",
|
||||
),
|
||||
pytest.param(
|
||||
"acreate_fine_tuning_job",
|
||||
{"training_file": {"model-a": "f-1"}, "validation_file": {"model-b": "f-3"}},
|
||||
("model-a", "model-b", "model-c"),
|
||||
id="fine-tune-files-on-disjoint-deployments-keep-every-deployment",
|
||||
),
|
||||
pytest.param(
|
||||
"acreate_fine_tuning_job",
|
||||
{"training_file": {"model-z": "f-5"}},
|
||||
("model-a", "model-b", "model-c"),
|
||||
id="fine-tune-file-of-another-model-group-keeps-every-deployment",
|
||||
),
|
||||
pytest.param("acreate_batch", {"input_file_id": {"model-c": "f-4"}}, ("model-c",), id="batch-input-file"),
|
||||
pytest.param(
|
||||
"acreate_batch", {"input_file_id": {"model-z": "f-6"}}, (), id="batch-input-file-of-another-model-group"
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_create_from_managed_files_routes_only_to_deployments_holding_every_file(
|
||||
call_type: str, files: Mapping[str, Mapping[str, str] | str], expected: tuple[str, ...]
|
||||
) -> None:
|
||||
owned: Final = {
|
||||
field: _owned_managed_file(str(uuid.uuid5(uuid.NAMESPACE_URL, field)), mappings)
|
||||
for field, mappings in files.items()
|
||||
if not isinstance(mappings, str)
|
||||
}
|
||||
managed_files, _ = _managed_files_with_fake_prisma(*(row for _, row in owned.values()))
|
||||
request: Final = {
|
||||
"model": "gpt-4o-mini",
|
||||
**{field: owned[field][0] if field in owned else str(mappings) for field, mappings in files.items()},
|
||||
}
|
||||
|
||||
assert await _routable_deployment_ids(managed_files, call_type, request) == expected
|
||||
|
||||
|
||||
_MANAGED_READ_SALT_KEY: Final = "managed-read-unit-test-salt-key"
|
||||
|
||||
|
||||
|
|
|
|||
0
tests/unit/fine_tuning/__init__.py
Normal file
0
tests/unit/fine_tuning/__init__.py
Normal file
54
tests/unit/fine_tuning/test_main.py
Normal file
54
tests/unit/fine_tuning/test_main.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import json
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
|
||||
API_BASE: Final = "https://fine-tuning.test"
|
||||
BASE_MODEL: Final = "gpt-4.1-nano-2025-04-14"
|
||||
PROVIDER_JOB: Final = {
|
||||
"id": "ftjob-abc123",
|
||||
"object": "fine_tuning.job",
|
||||
"created_at": 1700000000,
|
||||
"error": None,
|
||||
"fine_tuned_model": None,
|
||||
"finished_at": None,
|
||||
"hyperparameters": {"n_epochs": 3},
|
||||
"model": BASE_MODEL,
|
||||
"organization_id": "org-test",
|
||||
"result_files": [],
|
||||
"seed": 42,
|
||||
"status": "validating_files",
|
||||
"trained_tokens": None,
|
||||
"training_file": "file-abc123",
|
||||
"validation_file": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
@pytest.mark.parametrize(
|
||||
("custom_llm_provider", "route"),
|
||||
[("openai", f"{API_BASE}/v1/fine_tuning/jobs"), ("azure", f"{API_BASE}/openai/fine_tuning/jobs")],
|
||||
)
|
||||
@pytest.mark.parametrize("prefixed", [True, False], ids=["provider-prefixed", "bare"])
|
||||
async def test_create_sends_the_model_without_the_provider_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch, custom_llm_provider: str, route: str, prefixed: bool
|
||||
) -> None:
|
||||
sent: Final = respx.post(url__startswith=route).mock(return_value=httpx.Response(200, json=PROVIDER_JOB))
|
||||
|
||||
async with httpx.AsyncClient() as session:
|
||||
monkeypatch.setattr(litellm, "aclient_session", session)
|
||||
await litellm.acreate_fine_tuning_job(
|
||||
model=f"{custom_llm_provider}/{BASE_MODEL}" if prefixed else BASE_MODEL,
|
||||
training_file="file-abc123",
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_key="sk-test",
|
||||
api_base=f"{API_BASE}/v1" if custom_llm_provider == "openai" else API_BASE,
|
||||
**({"api_version": "2024-10-21"} if custom_llm_provider == "azure" else {}),
|
||||
)
|
||||
|
||||
assert json.loads(sent.calls.last.request.content)["model"] == BASE_MODEL
|
||||
|
|
@ -670,3 +670,61 @@ class TestLegacyPathMatchesNewPath:
|
|||
"text",
|
||||
]
|
||||
assert out["content"][1]["tool_use_id"] == out["content"][0]["id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected_text"),
|
||||
[
|
||||
(SearchResponse(results=[], object="search"), "results=[] object='search'"),
|
||||
(
|
||||
SearchResponse.model_validate(
|
||||
{
|
||||
"results": [],
|
||||
"object": "search",
|
||||
"provider_metadata": {"requestId": "exa-request", "costDollars": {"total": 0.005}},
|
||||
}
|
||||
),
|
||||
"results=[] object='search'",
|
||||
),
|
||||
(
|
||||
SearchResponse.model_validate(
|
||||
{
|
||||
"results": [],
|
||||
"object": "search",
|
||||
"search_id": "search-1",
|
||||
"provider_metadata": {"requestId": "exa-request"},
|
||||
}
|
||||
),
|
||||
"results=[] object='search' search_id='search-1'",
|
||||
),
|
||||
(
|
||||
SearchResponse.model_validate({"results": [], "object": "search", "query": "q", "cls": 1}),
|
||||
"results=[] object='search' query='q' cls=1",
|
||||
),
|
||||
(
|
||||
SearchResponse.model_validate(
|
||||
{
|
||||
"results": [
|
||||
SearchResult.model_validate(
|
||||
{
|
||||
"title": "T",
|
||||
"url": "https://example.com",
|
||||
"snippet": "S",
|
||||
"highlights": ["S"],
|
||||
"highlight_scores": [0.5],
|
||||
"score": 0.9,
|
||||
}
|
||||
)
|
||||
],
|
||||
"object": "search",
|
||||
"provider_metadata": {"requestId": "exa-request"},
|
||||
}
|
||||
),
|
||||
"Title: T\nURL: https://example.com\nSnippet: S",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_format_search_response_keeps_the_provider_metadata_out_of_the_model_text(
|
||||
response: SearchResponse, expected_text: str
|
||||
):
|
||||
assert WebSearchTransformation.format_search_response(response) == expected_text
|
||||
|
|
|
|||
|
|
@ -450,6 +450,66 @@ def test_video_tokens_fallback_to_base_cost():
|
|||
assert round(completion_cost, 12) == round((600 + 1120) * 2e-6, 12)
|
||||
|
||||
|
||||
def test_prompt_audio_and_video_tokens_without_modality_rates_bill_at_tiered_input_rate():
|
||||
"""Prompt audio/video tokens with no modality rate are prompt tokens, so a >200k prompt bills them above-200k."""
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_model_info = {
|
||||
"input_cost_per_token": 2e-6,
|
||||
"input_cost_per_token_above_200k_tokens": 4e-6,
|
||||
"output_cost_per_token": 1.2e-5,
|
||||
"output_cost_per_token_above_200k_tokens": 1.8e-5,
|
||||
}
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=816032,
|
||||
completion_tokens=4559,
|
||||
total_tokens=820591,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=9033, audio_tokens=14999, video_tokens=792000),
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(text_tokens=4559),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.litellm_core_utils.llm_cost_calc.utils.get_model_info",
|
||||
return_value=mock_model_info,
|
||||
):
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model="test-model", usage=usage, custom_llm_provider="vertex_ai"
|
||||
)
|
||||
|
||||
assert round(prompt_cost, 9) == round(816032 * 4e-6, 9)
|
||||
assert round(completion_cost, 9) == round(4559 * 1.8e-5, 9)
|
||||
|
||||
|
||||
def test_prompt_modality_rates_still_win_over_tiered_input_rate():
|
||||
"""An explicit input_cost_per_audio_token / input_cost_per_video_token beats the fallback, even above 200k."""
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_model_info = {
|
||||
"input_cost_per_token": 2e-6,
|
||||
"input_cost_per_token_above_200k_tokens": 4e-6,
|
||||
"input_cost_per_audio_token": 3e-6,
|
||||
"input_cost_per_video_token": 1e-6,
|
||||
"output_cost_per_token": 1.2e-5,
|
||||
}
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=300000,
|
||||
completion_tokens=10,
|
||||
total_tokens=300010,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=100000, audio_tokens=100000, video_tokens=100000),
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(text_tokens=10),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.litellm_core_utils.llm_cost_calc.utils.get_model_info",
|
||||
return_value=mock_model_info,
|
||||
):
|
||||
prompt_cost, _ = generic_cost_per_token(model="test-model", usage=usage, custom_llm_provider="vertex_ai")
|
||||
|
||||
assert round(prompt_cost, 9) == round(100000 * 4e-6 + 100000 * 3e-6 + 100000 * 1e-6, 9)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_above_200k_tokens(_local_model_cost_map):
|
||||
# gemini-2.5-pro-exp-03-25 was removed; gemini-2.5-pro has same above-200k pricing
|
||||
model = "gemini-2.5-pro"
|
||||
|
|
|
|||
|
|
@ -1375,7 +1375,6 @@ def test_dict_fields_take_the_json_tool_on_a_native_structured_output_model(fiel
|
|||
assert ([tool["name"] for tool in optional_params.get("tools", [])] == ["json_tool_call"]) is not native
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "thinking_params"),
|
||||
[
|
||||
|
|
@ -1388,7 +1387,13 @@ def test_dict_fields_take_the_json_tool_on_a_native_structured_output_model(fiel
|
|||
{"tools": [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}]},
|
||||
),
|
||||
],
|
||||
ids=["adaptive-thinking", "reasoning-effort", "reasoning-effort-none", "model-without-forced-tools", "caller-tools"],
|
||||
ids=[
|
||||
"adaptive-thinking",
|
||||
"reasoning-effort",
|
||||
"reasoning-effort-none",
|
||||
"model-without-forced-tools",
|
||||
"caller-tools",
|
||||
],
|
||||
)
|
||||
def test_dict_fields_keep_native_output_when_the_json_tool_would_not_be_forced(model, thinking_params):
|
||||
optional_params: Final = AnthropicConfig().map_openai_params(
|
||||
|
|
@ -1414,6 +1419,7 @@ def test_dict_fields_keep_native_output_when_the_json_tool_would_not_be_forced(m
|
|||
assert "output_format" in optional_params
|
||||
assert "json_tool_call" not in [tool["name"] for tool in optional_params.get("tools", [])]
|
||||
|
||||
|
||||
def test_native_structured_output_uses_bundled_capability_when_remote_map_lags(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -3704,7 +3710,9 @@ def _block_ids(message: dict[str, object], block_type: str, id_key: str) -> list
|
|||
return sorted(block[id_key] for block in message["content"] if block["type"] == block_type)
|
||||
|
||||
|
||||
_SEARCH_THAT_FOUND_NOTHING: Final = _tool_search_turn([], {"type": "text", "text": "I have no tool that books flights."})
|
||||
_SEARCH_THAT_FOUND_NOTHING: Final = _tool_search_turn(
|
||||
[], {"type": "text", "text": "I have no tool that books flights."}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -9528,7 +9536,9 @@ def test_map_openai_params_runs_tool_search_on_a_custom_named_deployment_whose_m
|
|||
AzureAnthropicConfig().map_openai_params(
|
||||
non_default_params={"tools": tools}, optional_params={}, model="my-claude-deployment", drop_params=False
|
||||
)
|
||||
litellm.register_model({"azure_ai/my-claude-deployment": {"supports_tool_search": True, "litellm_provider": "azure_ai"}})
|
||||
litellm.register_model(
|
||||
{"azure_ai/my-claude-deployment": {"supports_tool_search": True, "litellm_provider": "azure_ai"}}
|
||||
)
|
||||
mapped: Final = AzureAnthropicConfig().map_openai_params(
|
||||
non_default_params={"tools": tools}, optional_params={}, model="my-claude-deployment", drop_params=False
|
||||
)
|
||||
|
|
@ -9653,3 +9663,81 @@ def test_non_dict_upstream_body_still_transforms_without_refusal_fields() -> Non
|
|||
)
|
||||
provider_specific_fields = result.choices[0].message.provider_specific_fields or {}
|
||||
assert "stop_details" not in provider_specific_fields
|
||||
|
||||
|
||||
def _two_function_tools() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": f"tool_{i}", "description": f"Tool {i}", "parameters": {"type": "object"}},
|
||||
}
|
||||
for i in range(2)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"control, expected_control",
|
||||
[
|
||||
(None, {"type": "ephemeral"}),
|
||||
({"type": "ephemeral", "ttl": "1h"}, {"type": "ephemeral", "ttl": "1h"}),
|
||||
],
|
||||
)
|
||||
def test_transform_request_tool_config_point_marks_last_tool_and_drops_the_key(control, expected_control):
|
||||
"""LIT-9880: a tool_config injection point used to be spread into the Anthropic body, which
|
||||
Anthropic rejected with 400 Extra inputs are not permitted."""
|
||||
tools = _two_function_tools()
|
||||
tools_before = copy.deepcopy(tools)
|
||||
point = {"location": "tool_config", **({"control": control} if control is not None else {})}
|
||||
litellm_params = {"metadata": {}, "model_info": {"id": "deployment-1"}}
|
||||
|
||||
data = _make_transform_request(
|
||||
optional_params={"tools": tools, "cache_control_injection_points": [point]},
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
assert "cache_control_injection_points" not in data
|
||||
assert data["tools"][-1]["cache_control"] == expected_control
|
||||
assert "cache_control" not in data["tools"][0]
|
||||
assert tools == tools_before
|
||||
assert litellm_params["metadata"]["litellm_gateway_injected_cache"] == "deployment-1"
|
||||
|
||||
|
||||
def test_transform_request_tool_config_point_keeps_the_clients_tool_marker():
|
||||
tools = _two_function_tools()
|
||||
tools[0]["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
data = _make_transform_request(
|
||||
optional_params={"tools": tools, "cache_control_injection_points": [{"location": "tool_config"}]},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert "cache_control_injection_points" not in data
|
||||
assert data["tools"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "cache_control" not in data["tools"][1]
|
||||
|
||||
|
||||
def test_transform_request_tool_config_point_skips_tool_search_tools():
|
||||
tool_search_type = next(iter(ANTHROPIC_TOOL_SEARCH_TOOL_TYPES))
|
||||
tools = [*_two_function_tools(), {"type": tool_search_type, "name": "tool_search"}]
|
||||
|
||||
data = _make_transform_request(
|
||||
optional_params={"tools": tools, "cache_control_injection_points": [{"location": "tool_config"}]},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert data["tools"][1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "cache_control" not in data["tools"][2]
|
||||
assert "cache_control" not in data["tools"][0]
|
||||
|
||||
|
||||
def test_transform_request_tool_config_point_without_tools_drops_the_key():
|
||||
litellm_params = {"metadata": {}}
|
||||
|
||||
data = _make_transform_request(
|
||||
optional_params={"cache_control_injection_points": [{"location": "tool_config"}]},
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
assert "cache_control_injection_points" not in data
|
||||
assert "tools" not in data
|
||||
assert litellm_params["metadata"] == {}
|
||||
|
|
|
|||
|
|
@ -1103,7 +1103,9 @@ async def test_messages_honors_a_chat_pin_on_a_prefixed_responses_deployment(
|
|||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
with respx.mock(assert_all_called=True) as respx_mock:
|
||||
upstream: Final = respx_mock.route(host="api.perplexity.ai").mock(side_effect=httpx.ConnectError("cut at the wire"))
|
||||
upstream: Final = respx_mock.route(host="api.perplexity.ai").mock(
|
||||
side_effect=httpx.ConnectError("cut at the wire")
|
||||
)
|
||||
with pytest.raises(litellm.exceptions.InternalServerError):
|
||||
await litellm.anthropic.messages.acreate(
|
||||
max_tokens=100,
|
||||
|
|
@ -1524,6 +1526,7 @@ async def test_provider_messages_api_base_env_is_not_shadowed_by_the_chat_defaul
|
|||
|
||||
assert seen_urls == ["https://deepseek.internal.example/anthropic/v1/messages"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_messages_forwards_safeguards_and_unknown_beta_to_anthropic():
|
||||
"""Shapes are what Claude Code 2.1.278 sends and api.anthropic.com returns, captured 2026-09-21."""
|
||||
|
|
@ -1638,7 +1641,9 @@ def _claude_code_auto_mode_request() -> tuple[list[dict[str, object]], list[dict
|
|||
return safeguards, safeguard_results
|
||||
|
||||
|
||||
def _upstream_answering_with(safeguard_results: list[dict[str, object]], captured: dict[str, object]) -> AsyncHTTPHandler:
|
||||
def _upstream_answering_with(
|
||||
safeguard_results: list[dict[str, object]], captured: dict[str, object]
|
||||
) -> AsyncHTTPHandler:
|
||||
def upstream_records_the_request(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content)
|
||||
captured["anthropic-beta"] = request.headers.get("anthropic-beta")
|
||||
|
|
@ -1664,7 +1669,9 @@ def _upstream_answering_with(safeguard_results: list[dict[str, object]], capture
|
|||
|
||||
|
||||
_CLIENT_BETA_HEADERS: Final = (
|
||||
pytest.param({"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, id="client_sends_beta"),
|
||||
pytest.param(
|
||||
{"anthropic-beta": "dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14"}, id="client_sends_beta"
|
||||
),
|
||||
pytest.param({"anthropic-beta": "interleaved-thinking-2025-05-14"}, id="client_omits_beta"),
|
||||
pytest.param({}, id="client_sends_no_beta_header"),
|
||||
)
|
||||
|
|
@ -1789,7 +1796,11 @@ def _tool_use_sse(model: str) -> bytes:
|
|||
"delta": {"type": "input_json_delta", "partial_json": json.dumps(_CURL_TOOL_USE["input"])},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": None}, "usage": {"output_tokens": 4}},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
|
||||
"usage": {"output_tokens": 4},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
)
|
||||
return "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events).encode()
|
||||
|
|
@ -1957,26 +1968,20 @@ async def test_anthropic_messages_litellm_router_latency_metadata_tracking():
|
|||
print("Call kwargs:", json.dumps(call_kwargs, indent=2, default=str))
|
||||
|
||||
# Verify that litellm_metadata was passed and contains _latency_per_deployment
|
||||
assert (
|
||||
"litellm_metadata" in call_kwargs
|
||||
), "litellm_metadata should be passed to anthropic_messages"
|
||||
assert "litellm_metadata" in call_kwargs, "litellm_metadata should be passed to anthropic_messages"
|
||||
|
||||
litellm_metadata = call_kwargs["litellm_metadata"]
|
||||
assert litellm_metadata is not None, "litellm_metadata should not be None"
|
||||
assert isinstance(
|
||||
litellm_metadata, dict
|
||||
), "litellm_metadata should be a dictionary"
|
||||
assert isinstance(litellm_metadata, dict), "litellm_metadata should be a dictionary"
|
||||
|
||||
# Verify _latency_per_deployment is present
|
||||
assert (
|
||||
"_latency_per_deployment" in litellm_metadata
|
||||
), "litellm_metadata should contain _latency_per_deployment field"
|
||||
assert "_latency_per_deployment" in litellm_metadata, (
|
||||
"litellm_metadata should contain _latency_per_deployment field"
|
||||
)
|
||||
|
||||
# Verify the structure of _latency_per_deployment
|
||||
latency_per_deployment = litellm_metadata["_latency_per_deployment"]
|
||||
assert isinstance(
|
||||
latency_per_deployment, dict
|
||||
), "_latency_per_deployment should be a dictionary"
|
||||
assert isinstance(latency_per_deployment, dict), "_latency_per_deployment should be a dictionary"
|
||||
|
||||
print(f"✅ Latency per deployment data: {latency_per_deployment}")
|
||||
|
||||
|
|
@ -2134,9 +2139,7 @@ async def test_anthropic_messages_bedrock_credentials_passthrough():
|
|||
when using anthropic.messages.acreate with a bedrock model
|
||||
"""
|
||||
# Mock the get_credentials method
|
||||
with unittest.mock.patch.object(
|
||||
BaseAWSLLM, "get_credentials"
|
||||
) as mock_get_credentials:
|
||||
with unittest.mock.patch.object(BaseAWSLLM, "get_credentials") as mock_get_credentials:
|
||||
# Create a proper mock for credentials with the necessary attributes
|
||||
mock_credentials = unittest.mock.MagicMock()
|
||||
mock_credentials.access_key = "mock_access_key"
|
||||
|
|
@ -2147,9 +2150,7 @@ async def test_anthropic_messages_bedrock_credentials_passthrough():
|
|||
# We also need to mock the actual AWS request signing to avoid real API calls
|
||||
with unittest.mock.patch("botocore.auth.SigV4Auth.add_auth"):
|
||||
# Set up mock for AsyncHTTPHandler.post to avoid actual API calls
|
||||
with unittest.mock.patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post"
|
||||
) as mock_post:
|
||||
with unittest.mock.patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post") as mock_post:
|
||||
# Configure mock response
|
||||
mock_response = unittest.mock.MagicMock()
|
||||
mock_response.raise_for_status = unittest.mock.MagicMock()
|
||||
|
|
@ -2191,9 +2192,7 @@ async def test_anthropic_messages_bedrock_credentials_passthrough():
|
|||
|
||||
# Assert that our test credentials were passed correctly
|
||||
for param_name, param_value in aws_params.items():
|
||||
assert (
|
||||
call_args[param_name] == param_value
|
||||
), f"Parameter {param_name} was not passed correctly"
|
||||
assert call_args[param_name] == param_value, f"Parameter {param_name} was not passed correctly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2221,11 +2220,8 @@ async def test_anthropic_messages_bedrock_dynamic_region():
|
|||
# Patch necessary AWS components
|
||||
with (
|
||||
unittest.mock.patch("botocore.auth.SigV4Auth.add_auth"),
|
||||
unittest.mock.patch.object(
|
||||
BaseAWSLLM, "get_credentials"
|
||||
) as mock_get_credentials,
|
||||
unittest.mock.patch.object(BaseAWSLLM, "get_credentials") as mock_get_credentials,
|
||||
):
|
||||
|
||||
# Setup mock credentials
|
||||
mock_credentials = unittest.mock.MagicMock()
|
||||
mock_credentials.access_key = "test_access_key"
|
||||
|
|
@ -2254,9 +2250,9 @@ async def test_anthropic_messages_bedrock_dynamic_region():
|
|||
|
||||
# Check that the URL contains the correct region
|
||||
url = call_args.kwargs.get("url", "")
|
||||
assert (
|
||||
f"bedrock-runtime.{test_region}.amazonaws.com" in url
|
||||
), f"URL does not contain the correct region. URL: {url}"
|
||||
assert f"bedrock-runtime.{test_region}.amazonaws.com" in url, (
|
||||
f"URL does not contain the correct region. URL: {url}"
|
||||
)
|
||||
|
||||
# Verify get_credentials was called with the correct region
|
||||
mock_get_credentials.assert_called_once()
|
||||
|
|
@ -2294,3 +2290,42 @@ async def test_anthropic_messages_with_safeguards_and_no_classifier_keep_the_ups
|
|||
|
||||
assert rejected.value.status_code == 400
|
||||
assert "invalid_request_error" in str(rejected.value)
|
||||
|
||||
|
||||
def test_native_messages_tool_config_point_marks_last_tool_and_drops_the_key(monkeypatch):
|
||||
"""LIT-9880 on /v1/messages: the hook wrote the tool_config point back into kwargs and the
|
||||
native path dropped it silently, so the tools went out without any cache breakpoint."""
|
||||
from litellm.llms.anthropic.pass_through.messages import handler
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_native(**kwargs):
|
||||
captured["params"] = kwargs["anthropic_messages_optional_request_params"]
|
||||
captured["kwargs"] = kwargs["kwargs"]
|
||||
captured["messages"] = kwargs["messages"]
|
||||
return "native-passthrough"
|
||||
|
||||
monkeypatch.setattr(handler.base_llm_http_handler, "anthropic_messages_handler", fake_native)
|
||||
tools = [{"name": f"tool_{i}", "description": f"Tool {i}", "input_schema": {"type": "object"}} for i in range(2)]
|
||||
tools_before = copy.deepcopy(tools)
|
||||
|
||||
result = handler.anthropic_messages_handler(
|
||||
max_tokens=100,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
model="anthropic/claude-opus-4-6",
|
||||
api_key="sk-test",
|
||||
tools=tools,
|
||||
litellm_metadata={},
|
||||
cache_control_injection_points=[
|
||||
{"location": "message", "role": "user"},
|
||||
{"location": "tool_config", "control": {"type": "ephemeral", "ttl": "1h"}},
|
||||
],
|
||||
)
|
||||
|
||||
assert result == "native-passthrough"
|
||||
assert "cache_control_injection_points" not in captured["params"]
|
||||
assert "cache_control_injection_points" not in captured["kwargs"]
|
||||
assert captured["params"]["tools"][-1]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
|
||||
assert "cache_control" not in captured["params"]["tools"][0]
|
||||
assert captured["messages"][0]["content"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert tools == tools_before
|
||||
|
|
|
|||
|
|
@ -123,12 +123,83 @@ def test_transform_search_response_maps_every_exa_result_in_provider_order():
|
|||
|
||||
assert response.object == "search"
|
||||
assert response.results == [
|
||||
SearchResult(
|
||||
title="First", url="https://example.com/first", snippet="First text", date="2024-01-05T00:00:00.000Z"
|
||||
SearchResult.model_validate(
|
||||
{
|
||||
"title": "First",
|
||||
"url": "https://example.com/first",
|
||||
"snippet": "First text",
|
||||
"date": "2024-01-05T00:00:00.000Z",
|
||||
"highlights": ["ignored when text is present"],
|
||||
"highlight_scores": [0.5],
|
||||
"score": 0.42,
|
||||
}
|
||||
),
|
||||
SearchResult.model_validate(
|
||||
{
|
||||
"title": "Second",
|
||||
"url": "https://example.com/second",
|
||||
"snippet": "one\n\ntwo",
|
||||
"highlights": ["one", "two"],
|
||||
}
|
||||
),
|
||||
SearchResult(title="Second", url="https://example.com/second", snippet="one\n\ntwo"),
|
||||
SearchResult(title="Third", url="https://example.com/third", snippet="Summary"),
|
||||
]
|
||||
assert response.model_extra == {
|
||||
"provider_metadata": {
|
||||
"requestId": "b5947044c4b78efa9552a7c89b306d95",
|
||||
"resolvedSearchType": "neural",
|
||||
"costDollars": {"total": 0.005},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exa_fields", "expected_extras"),
|
||||
[
|
||||
({"text": "full text"}, {}),
|
||||
({"score": 0.3}, {"score": 0.3}),
|
||||
({"highlights": ["a highlight"]}, {"highlights": ["a highlight"]}),
|
||||
({"highlightScores": [0.1]}, {"highlight_scores": [0.1]}),
|
||||
({"score": None}, {"score": None}),
|
||||
({"highlights": [], "highlightScores": [], "score": 0}, {"highlights": [], "highlight_scores": [], "score": 0}),
|
||||
({"id": "https://example.com", "author": "Ada", "image": None, "summary": "a summary"}, {}),
|
||||
],
|
||||
)
|
||||
def test_transform_search_response_attaches_only_the_exa_result_fields_it_returned(
|
||||
exa_fields: dict[str, object], expected_extras: dict[str, object]
|
||||
):
|
||||
response: Final = _transform(
|
||||
{
|
||||
"results": [
|
||||
{"title": "Plain", "url": "https://example.com/plain", "text": "plain"},
|
||||
{"title": "Title", "url": "https://example.com", **exa_fields},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert [result.model_extra for result in response.results] == [{}, expected_extras]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected_metadata"),
|
||||
[
|
||||
({}, {}),
|
||||
({"results": []}, {}),
|
||||
({"requestId": "abc"}, {"requestId": "abc"}),
|
||||
({"context": "joined result text", "results": []}, {"context": "joined result text"}),
|
||||
(
|
||||
{"requestId": "abc", "searchTime": 412.5, "costDollars": {"total": 0.007}, "results": [{"text": "t"}]},
|
||||
{"requestId": "abc", "searchTime": 412.5, "costDollars": {"total": 0.007}},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_transform_search_response_keeps_the_exa_envelope_as_provider_metadata(
|
||||
payload: dict[str, object], expected_metadata: dict[str, object]
|
||||
):
|
||||
response: Final = _transform(payload)
|
||||
|
||||
assert response.model_extra == {"provider_metadata": expected_metadata}
|
||||
assert response.model_dump(mode="json")["provider_metadata"] == expected_metadata
|
||||
|
||||
|
||||
def test_transform_search_response_result_without_optional_fields_gets_empty_defaults():
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ class TestCustomLogger(CustomLogger):
|
|||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_create_vertex_fine_tune_jobs_mocked():
|
||||
@pytest.mark.parametrize("model_prefix", ["", "vertex_ai/"], ids=["bare", "provider-prefixed"])
|
||||
async def test_create_vertex_fine_tune_jobs_mocked(model_prefix: str):
|
||||
# Define reusable variables for the test
|
||||
project_id = "633608382793"
|
||||
location = "us-central1"
|
||||
|
|
@ -90,7 +91,7 @@ async def test_create_vertex_fine_tune_jobs_mocked():
|
|||
),
|
||||
):
|
||||
create_fine_tuning_response = await litellm.acreate_fine_tuning_job(
|
||||
model=base_model,
|
||||
model=f"{model_prefix}{base_model}",
|
||||
custom_llm_provider="vertex_ai",
|
||||
training_file=training_file,
|
||||
vertex_project=project_id,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -326,3 +326,288 @@ def test_managed_scope_step_up_only_accepts_unambiguous_bearer_challenges(status
|
|||
wrapped = RuntimeError("transport failure")
|
||||
wrapped.__cause__ = cause
|
||||
assert upstream_insufficient_scope(wrapped) == expected
|
||||
|
||||
|
||||
def _rate_limited_error(retry_after: str | None = None) -> httpx.HTTPStatusError:
|
||||
request: Final = httpx.Request("POST", "https://mcp.example.com/mcp")
|
||||
headers: Final = {"retry-after": retry_after} if retry_after is not None else {}
|
||||
return httpx.HTTPStatusError(
|
||||
"rate limited",
|
||||
request=request,
|
||||
response=httpx.Response(429, headers=headers, request=request),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_upstream_rate_limit_backs_off_then_reraises() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
sleeps: Final[list[float]] = []
|
||||
calls: Final[list[httpx.HTTPStatusError]] = []
|
||||
failure: Final = _rate_limited_error()
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def always_limited() -> None:
|
||||
calls.append(failure)
|
||||
raise failure
|
||||
|
||||
expected_error: Final = pytest.raises(httpx.HTTPStatusError)
|
||||
with expected_error as exc_info:
|
||||
await retry_upstream_rate_limit(
|
||||
always_limited,
|
||||
"srv",
|
||||
max_retries=3,
|
||||
base_delay=0.5,
|
||||
max_delay=5.0,
|
||||
sleep=record,
|
||||
jitter=lambda: 0.0,
|
||||
)
|
||||
|
||||
assert exc_info.value is failure
|
||||
assert sleeps == [0.5, 1.0, 2.0]
|
||||
assert len(calls) == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_upstream_rate_limit_honors_retry_after_up_to_cap() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
sleeps: Final[list[float]] = []
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def always_limited() -> None:
|
||||
raise _rate_limited_error("120")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await retry_upstream_rate_limit(
|
||||
always_limited,
|
||||
"srv",
|
||||
max_retries=3,
|
||||
base_delay=0.5,
|
||||
max_delay=5.0,
|
||||
sleep=record,
|
||||
jitter=lambda: 0.0,
|
||||
)
|
||||
|
||||
assert sleeps == [5.0, 5.0, 5.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_upstream_rate_limit_parses_http_date_retry_after() -> None:
|
||||
import email.utils
|
||||
import time
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
sleeps: Final[list[float]] = []
|
||||
attempts: Final[list[None]] = []
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def limited() -> str:
|
||||
attempts.append(None)
|
||||
if len(attempts) == 1:
|
||||
raise _rate_limited_error(email.utils.formatdate(time.time() + 3, usegmt=True))
|
||||
raise _rate_limited_error("abc")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await retry_upstream_rate_limit(
|
||||
limited,
|
||||
"srv",
|
||||
max_retries=3,
|
||||
base_delay=0.5,
|
||||
max_delay=5.0,
|
||||
sleep=record,
|
||||
jitter=lambda: 0.0,
|
||||
)
|
||||
|
||||
assert 0 < sleeps[0] <= 3.5
|
||||
assert sleeps[1:] == [1.0, 2.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_upstream_rate_limit_passes_through_non_429() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
sleeps: Final[list[float]] = []
|
||||
calls = 0
|
||||
failure: Final = ValueError("broken")
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def broken() -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise failure
|
||||
|
||||
expected_error: Final = pytest.raises(ValueError, match="broken")
|
||||
with expected_error as exc_info:
|
||||
await retry_upstream_rate_limit(
|
||||
broken, "srv", max_retries=3, base_delay=0.5, max_delay=5.0, sleep=record, jitter=lambda: 0.0
|
||||
)
|
||||
|
||||
assert exc_info.value is failure
|
||||
assert calls == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_upstream_rate_limit_negative_max_retries_is_a_single_attempt() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
sleeps: Final[list[float]] = []
|
||||
calls = 0
|
||||
failure: Final = _rate_limited_error("0")
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def always_limited() -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise failure
|
||||
|
||||
expected_error: Final = pytest.raises(httpx.HTTPStatusError)
|
||||
with expected_error as exc_info:
|
||||
await retry_upstream_rate_limit(
|
||||
always_limited,
|
||||
"srv",
|
||||
max_retries=-1,
|
||||
base_delay=0.5,
|
||||
max_delay=5.0,
|
||||
sleep=record,
|
||||
jitter=lambda: 0.0,
|
||||
)
|
||||
|
||||
assert exc_info.value is failure
|
||||
assert calls == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_deadline_skips_sleep_that_would_pass_deadline() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
now: Final[list[float]] = [100.0]
|
||||
sleeps: Final[list[float]] = []
|
||||
calls = 0
|
||||
failure: Final = _rate_limited_error()
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def always_limited() -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise failure
|
||||
|
||||
expected_error: Final = pytest.raises(httpx.HTTPStatusError)
|
||||
with expected_error as exc_info:
|
||||
await retry_upstream_rate_limit(
|
||||
always_limited,
|
||||
"srv",
|
||||
max_retries=3,
|
||||
base_delay=2.0,
|
||||
max_delay=5.0,
|
||||
sleep=record,
|
||||
jitter=lambda: 0.0,
|
||||
deadline_seconds=1.0,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
|
||||
assert exc_info.value is failure
|
||||
assert calls == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_attempt_is_interrupted_at_deadline() -> None:
|
||||
import anyio
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
calls = 0
|
||||
failure: Final = _rate_limited_error("0")
|
||||
|
||||
async def fetch() -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise failure
|
||||
await anyio.sleep(5)
|
||||
|
||||
expected_error: Final = pytest.raises(httpx.HTTPStatusError)
|
||||
with anyio.fail_after(5):
|
||||
with expected_error as exc_info:
|
||||
await retry_upstream_rate_limit(
|
||||
fetch, "srv", max_retries=3, base_delay=0.0, max_delay=5.0, deadline_seconds=0.2
|
||||
)
|
||||
|
||||
assert exc_info.value is failure
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_deadline_does_not_bound_first_attempt() -> None:
|
||||
import anyio
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
async def slow() -> str:
|
||||
await anyio.sleep(0.15)
|
||||
return "tools"
|
||||
|
||||
result: Final = await retry_upstream_rate_limit(
|
||||
slow, "srv", max_retries=3, base_delay=0.5, max_delay=5.0, deadline_seconds=0.05
|
||||
)
|
||||
|
||||
assert result == "tools"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_upstream_rate_limit_never_retries_cancellation() -> None:
|
||||
import asyncio
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import retry_upstream_rate_limit
|
||||
|
||||
sleeps: Final[list[float]] = []
|
||||
calls = 0
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def cancelled() -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await retry_upstream_rate_limit(
|
||||
cancelled, "srv", max_retries=3, base_delay=0.5, max_delay=5.0, sleep=record, jitter=lambda: 0.0
|
||||
)
|
||||
|
||||
assert calls == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_listing_failed_outright_only_when_every_server_faulted_non_auth() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
|
||||
ServerListOk,
|
||||
listing_failed_outright,
|
||||
)
|
||||
|
||||
assert not listing_failed_outright({})
|
||||
assert listing_failed_outright({"a": ServerListFault(tag="upstream_error", status_code=429)})
|
||||
assert listing_failed_outright(
|
||||
{"a": ServerListFault(tag="timeout"), "b": ServerListFault(tag="rate_limited", status_code=429)}
|
||||
)
|
||||
assert not listing_failed_outright(
|
||||
{"a": ServerListFault(tag="upstream_error", status_code=500), "b": ServerListOk(tool_count=2)}
|
||||
)
|
||||
assert not listing_failed_outright({"a": ServerListFault(tag="auth_required", status_code=401)})
|
||||
assert not listing_failed_outright({"a": ServerListFault(tag="forbidden", status_code=403)})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,19 +6,22 @@ import json
|
|||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Final, Literal
|
||||
from typing import Callable, Final, Literal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from mcp import ReadResourceResult, Resource
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.types import (
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
BlobResourceContents,
|
||||
CallToolResult,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
Prompt,
|
||||
ResourceTemplate,
|
||||
TextContent,
|
||||
|
|
@ -32,6 +35,7 @@ from starlette.types import Message, Receive, Scope, Send
|
|||
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import active_mcp_request_ctx_var
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ListedToolsCaller
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import AggregateToolListing
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_MCPServerTable,
|
||||
MCPTransport,
|
||||
|
|
@ -1331,9 +1335,12 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails():
|
|||
"litellm.proxy._experimental.mcp_server.operations.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
):
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.catalog.verbose_logger",
|
||||
) as mock_logger, patch("litellm.proxy._experimental.mcp_server.operations.verbose_logger", mock_logger):
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.catalog.verbose_logger",
|
||||
) as mock_logger,
|
||||
patch("litellm.proxy._experimental.mcp_server.operations.verbose_logger", mock_logger),
|
||||
):
|
||||
# Test with server-specific auth headers
|
||||
mcp_server_auth_headers = {
|
||||
"working": {"Authorization": "Bearer working-token"},
|
||||
|
|
@ -1427,9 +1434,12 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing():
|
|||
"litellm.proxy._experimental.mcp_server.operations.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
):
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.catalog.verbose_logger",
|
||||
) as mock_logger, patch("litellm.proxy._experimental.mcp_server.operations.verbose_logger", mock_logger):
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.catalog.verbose_logger",
|
||||
) as mock_logger,
|
||||
patch("litellm.proxy._experimental.mcp_server.operations.verbose_logger", mock_logger),
|
||||
):
|
||||
# Test with server-specific auth headers
|
||||
mcp_server_auth_headers = {
|
||||
"failing1": {"Authorization": "Bearer failing1-token"},
|
||||
|
|
@ -3531,8 +3541,15 @@ async def test_initialize_request_with_existing_session_tracks_new_session():
|
|||
patch( # test-quality-ok: registry is empty in unit tests; key owns one server
|
||||
"litellm.proxy._experimental.mcp_server.operations._get_allowed_mcp_servers",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[MCPServer(server_id="new-server", name="new-server", url="http://upstream/mcp",
|
||||
transport=MCPTransport.http, auth_type=MCPAuth.none)],
|
||||
return_value=[
|
||||
MCPServer(
|
||||
server_id="new-server",
|
||||
name="new-server",
|
||||
url="http://upstream/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
)
|
||||
],
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED",
|
||||
|
|
@ -3756,10 +3773,13 @@ async def test_owner_fingerprint_hashes_custom_api_keys():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("body", [
|
||||
b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}',
|
||||
b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":0}}',
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}',
|
||||
b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":0}}',
|
||||
],
|
||||
)
|
||||
async def test_stateful_mcp_session_owner_mismatch_returns_403(body: bytes):
|
||||
"""
|
||||
A stateful mcp-session-id is bound to its creator. A different
|
||||
|
|
@ -5876,6 +5896,76 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab
|
|||
assert spend_meta["per_server_list_outcomes"] == {"server_a": {"status": "ok", "tool_count": 1}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tools_from_mcp_servers_all_failed_listing_never_runs_proxy_failure_hooks() -> None:
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
servers: Final = (
|
||||
MCPServer(
|
||||
server_id="server_a",
|
||||
name="server_a",
|
||||
alias="server_a",
|
||||
server_name="server_a",
|
||||
transport=MCPTransport.http,
|
||||
),
|
||||
MCPServer(
|
||||
server_id="server_b",
|
||||
name="server_b",
|
||||
alias="server_b",
|
||||
server_name="server_b",
|
||||
transport=MCPTransport.http,
|
||||
),
|
||||
)
|
||||
user_auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user")
|
||||
logging_obj: Final = MagicMock()
|
||||
logging_obj.model_call_details = {"metadata": {"spend_logs_metadata": {}}}
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
proxy_logging_obj: Final = MagicMock()
|
||||
proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||||
manager: Final = MagicMock()
|
||||
manager.get_tools_from_server = AsyncMock(side_effect=RuntimeError("server unavailable"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.operations._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=servers),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.operations._prepare_mcp_server_headers",
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch("litellm.proxy._experimental.mcp_server.operations.global_mcp_server_manager", manager),
|
||||
patch("litellm.proxy._experimental.mcp_server.operations.filter_tools_by_allowed_tools", return_value=[]),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.operations.filter_tools_by_key_team_permissions",
|
||||
new=AsyncMock(return_value=[]),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.operations.function_setup",
|
||||
return_value=(logging_obj, None),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj),
|
||||
):
|
||||
listing: Final = await mcp_operations._get_tools_from_mcp_servers(
|
||||
user_api_key_auth=user_auth,
|
||||
mcp_auth_header=None,
|
||||
mcp_servers=["server_a", "server_b"],
|
||||
mcp_server_auth_headers=None,
|
||||
params=None,
|
||||
log_list_tools_to_spendlogs=True,
|
||||
enforce_rate_limits=False,
|
||||
)
|
||||
|
||||
assert listing.tools == []
|
||||
logging_obj.async_success_handler.assert_awaited_once()
|
||||
spend_meta: Final = logging_obj.model_call_details["metadata"]["spend_logs_metadata"]
|
||||
assert spend_meta["per_server_list_outcomes"] == {
|
||||
"server_a": {"status": "internal"},
|
||||
"server_b": {"status": "internal"},
|
||||
}
|
||||
proxy_logging_obj.post_call_failure_hook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tools_from_mcp_servers_returns_tools_when_success_logging_fails():
|
||||
"""
|
||||
|
|
@ -7254,9 +7344,9 @@ async def test_oauth_passthrough_preflight_preserves_status_contract(probe_statu
|
|||
user_api_key_auth=UserAPIKeyAuth(user_id="admitted-user"),
|
||||
mcp_servers=["passthrough_server"],
|
||||
client_ip=None,
|
||||
oauth2_headers={"Authorization": "Bearer upstream-token"},
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers={"x-litellm-api-key": "sk-litellm-proxy-key", "Authorization": "Bearer upstream-token"},
|
||||
oauth2_headers={"Authorization": "Bearer upstream-token"},
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers={"x-litellm-api-key": "sk-litellm-proxy-key", "Authorization": "Bearer upstream-token"},
|
||||
)
|
||||
assert result is None
|
||||
else:
|
||||
|
|
@ -7266,9 +7356,9 @@ async def test_oauth_passthrough_preflight_preserves_status_contract(probe_statu
|
|||
user_api_key_auth=UserAPIKeyAuth(user_id="admitted-user"),
|
||||
mcp_servers=["passthrough_server"],
|
||||
client_ip=None,
|
||||
oauth2_headers={"Authorization": "Bearer upstream-token"},
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers={"x-litellm-api-key": "sk-litellm-proxy-key", "Authorization": "Bearer upstream-token"},
|
||||
oauth2_headers={"Authorization": "Bearer upstream-token"},
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers={"x-litellm-api-key": "sk-litellm-proxy-key", "Authorization": "Bearer upstream-token"},
|
||||
)
|
||||
assert exc_info.value.status_code == expected_status
|
||||
if expected_status == 401:
|
||||
|
|
@ -10071,6 +10161,131 @@ async def test_handle_list_tools_attaches_outcome_meta(_mcp_request_ctx):
|
|||
assert outcomes_meta["broken"] == {"status": "unreachable"}
|
||||
|
||||
|
||||
async def _list_tools_with_listing(
|
||||
_mcp_request_ctx: Callable[..., ServerRequestContext],
|
||||
listing: AggregateToolListing,
|
||||
params: PaginatedRequestParams,
|
||||
) -> ListToolsResult:
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.server import handle_list_tools
|
||||
except ImportError:
|
||||
pytest.skip("MCP server not available")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server.get_or_extract_auth_context",
|
||||
new=AsyncMock(return_value=(None, None, None, None, None, None, None)),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.operations._list_mcp_tools",
|
||||
new=AsyncMock(return_value=listing),
|
||||
),
|
||||
):
|
||||
return await handle_list_tools(_mcp_request_ctx(), params)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_list_tools_errors_when_every_server_failed(
|
||||
_mcp_request_ctx: Callable[..., ServerRequestContext],
|
||||
) -> None:
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import INTERNAL_ERROR
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
|
||||
SERVER_OUTCOMES_META_KEY,
|
||||
AggregateToolListing,
|
||||
ServerListFault,
|
||||
)
|
||||
|
||||
listing: Final = AggregateToolListing(
|
||||
tools=[],
|
||||
outcomes={
|
||||
"a": ServerListFault(tag="upstream_error", status_code=429),
|
||||
"b": ServerListFault(tag="timeout"),
|
||||
},
|
||||
)
|
||||
|
||||
expected_error: Final = pytest.raises(MCPError)
|
||||
with expected_error as exc_info:
|
||||
await _list_tools_with_listing(_mcp_request_ctx, listing, _paged_params())
|
||||
|
||||
assert exc_info.value.error.code == INTERNAL_ERROR
|
||||
assert exc_info.value.error.data[SERVER_OUTCOMES_META_KEY] == {
|
||||
"a": {"status": "upstream_error", "http_status": 429},
|
||||
"b": {"status": "timeout"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_list_tools_auth_fault_keeps_degraded_result(
|
||||
_mcp_request_ctx: Callable[..., ServerRequestContext],
|
||||
) -> None:
|
||||
from mcp.types import ListToolsResult
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
|
||||
SERVER_OUTCOMES_META_KEY,
|
||||
AggregateToolListing,
|
||||
ServerListFault,
|
||||
)
|
||||
|
||||
listing: Final = AggregateToolListing(
|
||||
tools=[],
|
||||
outcomes={"a": ServerListFault(tag="auth_required", status_code=401), "b": ServerListFault(tag="timeout")},
|
||||
)
|
||||
|
||||
result: Final = await _list_tools_with_listing(_mcp_request_ctx, listing, _paged_params())
|
||||
|
||||
assert isinstance(result, ListToolsResult)
|
||||
assert result.tools == []
|
||||
wire: Final = result.model_dump(by_alias=True)
|
||||
assert wire["_meta"][SERVER_OUTCOMES_META_KEY]["a"] == {"status": "auth_required", "http_status": 401}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_list_tools_all_faults_on_continuation_stays_degraded(
|
||||
_mcp_request_ctx: Callable[..., ServerRequestContext],
|
||||
) -> None:
|
||||
from mcp.types import ListToolsResult, PaginatedRequestParams
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
|
||||
SERVER_OUTCOMES_META_KEY,
|
||||
AggregateToolListing,
|
||||
ServerListFault,
|
||||
)
|
||||
|
||||
listing: Final = AggregateToolListing(
|
||||
tools=[],
|
||||
outcomes={"a": ServerListFault(tag="upstream_error", status_code=429)},
|
||||
)
|
||||
|
||||
result: Final = await _list_tools_with_listing(
|
||||
_mcp_request_ctx, listing, PaginatedRequestParams(cursor="cursor-1")
|
||||
)
|
||||
|
||||
assert isinstance(result, ListToolsResult)
|
||||
assert result.tools == []
|
||||
wire: Final = result.model_dump(by_alias=True)
|
||||
assert wire["_meta"][SERVER_OUTCOMES_META_KEY]["a"] == {"status": "upstream_error", "http_status": 429}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_list_tools_empty_outcomes_is_a_clean_empty_result(
|
||||
_mcp_request_ctx: Callable[..., ServerRequestContext],
|
||||
) -> None:
|
||||
from mcp.types import ListToolsResult
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import AggregateToolListing
|
||||
|
||||
listing: Final = AggregateToolListing(tools=[], outcomes={})
|
||||
|
||||
result: Final = await _list_tools_with_listing(_mcp_request_ctx, listing, _paged_params())
|
||||
|
||||
assert isinstance(result, ListToolsResult)
|
||||
assert result.tools == []
|
||||
wire: Final = result.model_dump(by_alias=True)
|
||||
assert wire.get("_meta") is None
|
||||
|
||||
|
||||
def _make_oauth2_server(
|
||||
alias: str,
|
||||
*,
|
||||
|
|
@ -10511,6 +10726,7 @@ async def test_post_mcp_call_guardrails_return_the_rewritten_result():
|
|||
logging_obj = _mock_mcp_logging_obj()
|
||||
raw_result = _call_tool_result(False, "jane@example.com")
|
||||
masked_result = _call_tool_result(False, "<EMAIL_ADDRESS>")
|
||||
auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user")
|
||||
proxy_logging_mock = _mock_mcp_proxy_logging()
|
||||
proxy_logging_mock.post_mcp_call_hook = AsyncMock(return_value=masked_result)
|
||||
|
||||
|
|
@ -10518,7 +10734,8 @@ async def test_post_mcp_call_guardrails_return_the_rewritten_result():
|
|||
returned = await _run_post_mcp_call_guardrails(
|
||||
result=raw_result,
|
||||
litellm_logging_obj=logging_obj,
|
||||
user_api_key_auth=UserAPIKeyAuth(api_key="test-key", user_id="test-user"),
|
||||
original_auth=auth,
|
||||
guardrail_auth=auth,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
|
@ -10542,6 +10759,7 @@ async def test_post_mcp_call_guardrails_run_without_a_logging_object():
|
|||
|
||||
raw_result = _call_tool_result(False, "jane@example.com")
|
||||
masked_result = _call_tool_result(False, "<EMAIL_ADDRESS>")
|
||||
auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user")
|
||||
proxy_logging_mock = _mock_mcp_proxy_logging()
|
||||
proxy_logging_mock.post_mcp_call_hook = AsyncMock(return_value=masked_result)
|
||||
|
||||
|
|
@ -10549,7 +10767,8 @@ async def test_post_mcp_call_guardrails_run_without_a_logging_object():
|
|||
returned = await _run_post_mcp_call_guardrails(
|
||||
result=raw_result,
|
||||
litellm_logging_obj=None,
|
||||
user_api_key_auth=UserAPIKeyAuth(api_key="test-key", user_id="test-user"),
|
||||
original_auth=auth,
|
||||
guardrail_auth=auth,
|
||||
request_data={"name": "fetch_record"},
|
||||
)
|
||||
|
||||
|
|
@ -10576,11 +10795,56 @@ async def test_post_mcp_call_guardrails_propagate_a_block():
|
|||
await _run_post_mcp_call_guardrails(
|
||||
result=_call_tool_result(False, "jane@example.com"),
|
||||
litellm_logging_obj=_mock_mcp_logging_obj(),
|
||||
user_api_key_auth=None,
|
||||
original_auth=None,
|
||||
guardrail_auth=None,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_mcp_call_guardrails_preserve_and_deduplicate_mapping_entries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from litellm.proxy._experimental.mcp_server.server import _run_post_mcp_call_guardrails
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.utils.premium_user_check", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "premium_user", True, raising=False)
|
||||
original_auth: Final = UserAPIKeyAuth(api_key="personal-key", user_id="alice")
|
||||
guardrail_auth: Final = original_auth.model_copy(
|
||||
update={
|
||||
"team_id": "team-a",
|
||||
"team_metadata": {
|
||||
"guardrails": [
|
||||
{"request-guardrail": {"team": True}},
|
||||
{"team-guardrail": {"x": 1}},
|
||||
{"team-guardrail": {"x": 2}},
|
||||
"request-guardrail",
|
||||
"string-guardrail",
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
proxy_logging_mock = _mock_mcp_proxy_logging()
|
||||
proxy_logging_mock.post_mcp_call_hook = AsyncMock(return_value=_call_tool_result(False, "ok"))
|
||||
|
||||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
|
||||
await _run_post_mcp_call_guardrails(
|
||||
result=_call_tool_result(False, "ok"),
|
||||
litellm_logging_obj=None,
|
||||
original_auth=original_auth,
|
||||
guardrail_auth=guardrail_auth,
|
||||
request_data={"metadata": {"guardrails": [{"request-guardrail": {"request": True}}]}},
|
||||
)
|
||||
|
||||
request_data: Final = proxy_logging_mock.post_mcp_call_hook.await_args.kwargs["request_data"]
|
||||
assert request_data["metadata"]["guardrails"] == [
|
||||
{"request-guardrail": {"request": True}},
|
||||
{"team-guardrail": {"x": 1}},
|
||||
"string-guardrail",
|
||||
]
|
||||
|
||||
|
||||
class TestListFiltersHonorThePrefixBoundary:
|
||||
"""The listing filters compare a published (prefixed) tool name against
|
||||
configured entries, so they have to locate the boundary with the server's
|
||||
|
|
@ -11637,8 +11901,7 @@ async def test_passthrough_probes_bind_each_token_to_its_authorized_server(allow
|
|||
)
|
||||
assert result is None
|
||||
assert tuple(
|
||||
(str(call.request.url), call.request.headers["Authorization"])
|
||||
for call in (*first.calls, *second.calls)
|
||||
(str(call.request.url), call.request.headers["Authorization"]) for call in (*first.calls, *second.calls)
|
||||
) == (
|
||||
(("http://first/mcp", "Bearer first-token"), ("http://second/mcp", "Bearer second-token"))
|
||||
if allowed and header_shape != "non_authorization"
|
||||
|
|
@ -11700,6 +11963,7 @@ async def test_streamable_http_admission_auth_sees_only_peeked_jsonrpc_method()
|
|||
stateless_handle.assert_not_awaited()
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"payload, valid",
|
||||
|
|
@ -11792,16 +12056,22 @@ async def test_progress_text_obeys_request_output_guardrails(monkeypatch, action
|
|||
from litellm.types.guardrails import BlockedWord, ContentFilterAction, GuardrailEventHooks
|
||||
|
||||
policy = ContentFilterGuardrail(
|
||||
guardrail_name="progress-policy", event_hook=GuardrailEventHooks.post_mcp_call,
|
||||
guardrail_name="progress-policy",
|
||||
event_hook=GuardrailEventHooks.post_mcp_call,
|
||||
blocked_words=[BlockedWord(keyword="halfway", action=ContentFilterAction(action))],
|
||||
keyword_redaction_tag="[masked]", default_on=False,
|
||||
keyword_redaction_tag="[masked]",
|
||||
default_on=False,
|
||||
)
|
||||
monkeypatch.setattr(litellm, "callbacks", [policy])
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", ProxyLogging(user_api_key_cache=DualCache()))
|
||||
delivered = AsyncMock()
|
||||
caller = UserAPIKeyAuth(user_id="progress-user")
|
||||
callback = _guarded_progress_callback(
|
||||
delivered, None, caller, {"metadata": {"guardrails": ["progress-policy"] if selected else []}}
|
||||
delivered,
|
||||
None,
|
||||
caller,
|
||||
caller,
|
||||
{"metadata": {"guardrails": ["progress-policy"] if selected else []}},
|
||||
)
|
||||
assert callback is not None
|
||||
await callback(0, None, message)
|
||||
|
|
@ -11815,8 +12085,98 @@ async def test_progress_callback_preserves_absence_and_cancellation():
|
|||
|
||||
from litellm.proxy._experimental.mcp_server.operations import _guarded_progress_callback
|
||||
|
||||
assert _guarded_progress_callback(None, None, None, {}) is None
|
||||
callback = _guarded_progress_callback(AsyncMock(side_effect=asyncio.CancelledError), None, None, {})
|
||||
assert _guarded_progress_callback(None, None, None, None, {}) is None
|
||||
callback = _guarded_progress_callback(AsyncMock(side_effect=asyncio.CancelledError), None, None, None, {})
|
||||
assert callback is not None
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await callback(0, 0, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_mcp_tool_derives_team_guardrail_auth_for_personal_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import litellm
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy._experimental.mcp_server.utils import get_server_prefix
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, LiteLLM_UserTable
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
user_api_key_auth: Final = UserAPIKeyAuth(api_key="sk-personal", user_id="owner")
|
||||
server: Final = MCPServer(
|
||||
server_id="team-server",
|
||||
name="math_a",
|
||||
server_name="math_a",
|
||||
transport=MCPTransport.http,
|
||||
url="http://localhost/mcp",
|
||||
)
|
||||
team: Final = LiteLLM_TeamTable(
|
||||
team_id="team-a",
|
||||
organization_id="org-team-a",
|
||||
object_permission_id="perm-team-a",
|
||||
metadata={"guardrails": ["team-guardrail"]},
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="perm-team-a",
|
||||
mcp_servers=["team-server"],
|
||||
mcp_tool_permissions={"team-server": ["test_tool"]},
|
||||
),
|
||||
)
|
||||
proxy_logging_obj: Final = ProxyLogging(user_api_key_cache=DualCache())
|
||||
proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})
|
||||
|
||||
async def fake_handle_managed_mcp_tool(
|
||||
*,
|
||||
server_name: str,
|
||||
name: str,
|
||||
arguments: dict[str, object],
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
guardrail_auth: UserAPIKeyAuth | None,
|
||||
**_: object,
|
||||
) -> CallToolResult:
|
||||
await global_mcp_server_manager.pre_call_tool_check(
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
server_name=server_name,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
server=server,
|
||||
guardrail_auth=guardrail_auth,
|
||||
)
|
||||
return CallToolResult(content=[], is_error=False)
|
||||
|
||||
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "should_load_db_object", lambda _: False)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", proxy_logging_obj)
|
||||
monkeypatch.setattr("litellm.proxy.utils.premium_user_check", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_user_object",
|
||||
AsyncMock(return_value=LiteLLM_UserTable(user_id="owner", teams=["team-a"])),
|
||||
)
|
||||
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=team))
|
||||
monkeypatch.setattr(auth_checks, "get_team_membership", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(auth_checks, "get_org_object", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(auth_checks, "_personal_key_team_within_budget", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(global_mcp_server_manager, "check_tool_permission_for_key_team", AsyncMock(return_value=None))
|
||||
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
|
||||
monkeypatch.setattr(mcp_operations, "_list_tools_before_first_call", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(mcp_operations, "_handle_managed_mcp_tool", fake_handle_managed_mcp_tool)
|
||||
|
||||
await mcp_operations.execute_mcp_tool(
|
||||
name=f"{get_server_prefix(server)}-test_tool",
|
||||
arguments={},
|
||||
allowed_mcp_servers=[server],
|
||||
start_time=datetime.now(),
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
requested_server_id=server.server_id,
|
||||
)
|
||||
|
||||
guardrail_auth: Final = proxy_logging_obj.pre_call_hook.await_args.kwargs["user_api_key_dict"]
|
||||
assert guardrail_auth.team_id == "team-a"
|
||||
assert guardrail_auth.team_metadata == {"guardrails": ["team-guardrail"]}
|
||||
|
|
|
|||
|
|
@ -1263,6 +1263,45 @@ async def test_discovery_preserves_caller_scope_and_proxy_restrictions():
|
|||
assert listed.await_args.args[0].mcp_servers == ("only-this",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_outright_tools_failure_still_advertises_other_capabilities() -> None:
|
||||
from mcp.types import DiscoverRequest, ListPromptsResult, ListToolsResult, Prompt
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import operations
|
||||
from litellm.proxy._experimental.mcp_server.catalog import ToolListingFailedOutright
|
||||
|
||||
tools: Final = AsyncMock(side_effect=ToolListingFailedOutright(code=-32603, message="every server failed"))
|
||||
prompts: Final = AsyncMock(return_value=ListPromptsResult(prompts=[Prompt(name="p")]))
|
||||
context: Final = prepare_context(UserAPIKeyAuth(user_id="scoped"), mcp_servers=["broken"])
|
||||
with (
|
||||
patch.object(operations, "_execute_handle_list_tools", tools),
|
||||
patch.object(operations, "_execute_list_prompts", prompts),
|
||||
patch.object(operations, "_execute_list_resources", AsyncMock(return_value=ListToolsResult(tools=[]))),
|
||||
patch.object(operations, "_execute_list_resource_templates", AsyncMock(return_value=ListToolsResult(tools=[]))),
|
||||
):
|
||||
result: Final = await GatewayOperations().execute(DiscoverRequest(), context)
|
||||
assert result.capabilities.tools is None
|
||||
assert result.capabilities.prompts is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_propagates_non_outright_mcp_errors_from_tools_listing() -> None:
|
||||
from mcp import MCPError
|
||||
from mcp.types import INVALID_PARAMS, DiscoverRequest
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import operations
|
||||
|
||||
tools: Final = AsyncMock(side_effect=MCPError(code=INVALID_PARAMS, message="bad cursor"))
|
||||
context: Final = prepare_context(
|
||||
UserAPIKeyAuth(user_id="scoped"), mcp_servers=["broken"], mcp_proxy_mode=True, protocol_version="2025-06-18"
|
||||
)
|
||||
with patch.object(operations, "_execute_handle_list_tools", tools):
|
||||
expected_error: Final = pytest.raises(MCPError)
|
||||
with expected_error as exc_info:
|
||||
await GatewayOperations().execute(DiscoverRequest(), context)
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_denial_cannot_advertise_tools():
|
||||
from mcp.types import DiscoverRequest
|
||||
|
|
|
|||
|
|
@ -1564,7 +1564,13 @@ class TestListToolsRestAPI:
|
|||
monkeypatch,
|
||||
):
|
||||
"""The REST tools/list path should include tools beyond the upstream first page."""
|
||||
from mcp.types import Implementation, InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities
|
||||
from mcp.types import (
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
ServerCapabilities,
|
||||
)
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
||||
import litellm.experimental_mcp_client.client as mcp_client_module
|
||||
|
|
@ -1604,11 +1610,13 @@ class TestListToolsRestAPI:
|
|||
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_instance = AsyncMock()
|
||||
mock_session_instance.initialize = AsyncMock(return_value=InitializeResult(
|
||||
protocol_version="2025-11-25",
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="stub", version="1"),
|
||||
))
|
||||
mock_session_instance.initialize = AsyncMock(
|
||||
return_value=InitializeResult(
|
||||
protocol_version="2025-11-25",
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="stub", version="1"),
|
||||
)
|
||||
)
|
||||
mock_session_instance.list_tools.side_effect = [
|
||||
ListToolsResult(
|
||||
tools=[
|
||||
|
|
@ -2556,10 +2564,23 @@ class TestCallToolRestAPI:
|
|||
assert logged_data["metadata"]["model_group"] == "MCP: demo-tool"
|
||||
logging_obj = failure_log.await_args.args[0]
|
||||
assert logging_obj.model_call_details["mcp_tool_call_metadata"] == {
|
||||
"name": "demo-tool", "mcp_server_name": "Example server",
|
||||
"name": "demo-tool",
|
||||
"mcp_server_name": "Example server",
|
||||
}
|
||||
|
||||
async def test_executes_tool_when_allowed(self, monkeypatch):
|
||||
@pytest.mark.parametrize(
|
||||
("tool_name", "native_tool_name"),
|
||||
(
|
||||
("math_a-add", "add"),
|
||||
("add", "add"),
|
||||
("math_a-math_a-foo", "math_a-foo"),
|
||||
),
|
||||
)
|
||||
async def test_executes_tool_when_allowed(self, monkeypatch, tool_name, native_tool_name):
|
||||
import litellm
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler, MCPServerAccess
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
async def fake_contexts(user_api_key_auth):
|
||||
return [user_api_key_auth]
|
||||
|
||||
|
|
@ -2568,8 +2589,9 @@ class TestCallToolRestAPI:
|
|||
|
||||
class StubServer:
|
||||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
alias = "math_a"
|
||||
server_name = "math_a"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -2577,6 +2599,17 @@ class TestCallToolRestAPI:
|
|||
auth_type = None
|
||||
|
||||
stub_server = StubServer()
|
||||
original_auth: Final = UserAPIKeyAuth(api_key="sk-personal", user_id="owner")
|
||||
source_auth: Final = original_auth.model_copy(update={"team_id": "team-a", "org_id": "org-a"})
|
||||
|
||||
async def fake_attributing_source(
|
||||
auth: UserAPIKeyAuth,
|
||||
server_id: str,
|
||||
tool_name: str,
|
||||
grants: tuple[tuple[UserAPIKeyAuth, MCPServerAccess], ...] | None = None,
|
||||
) -> UserAPIKeyAuth | None:
|
||||
captured["attributed_tool_name"] = tool_name
|
||||
return source_auth if server_id == "server-1" and tool_name == native_tool_name else None
|
||||
|
||||
async def fake_add_litellm_data_to_request(**kwargs):
|
||||
return kwargs.get("data", {})
|
||||
|
|
@ -2585,6 +2618,11 @@ class TestCallToolRestAPI:
|
|||
|
||||
async def fake_execute_mcp_tool(**kwargs):
|
||||
captured.update(kwargs)
|
||||
captured["executed_tool_name"] = mcp_operations.resolve_native_tool_name_for_server(
|
||||
kwargs["name"],
|
||||
stub_server,
|
||||
kwargs["allowed_mcp_servers"],
|
||||
)
|
||||
return _OK_TOOL_RESULT
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -2605,6 +2643,13 @@ class TestCallToolRestAPI:
|
|||
lambda server_id: stub_server if server_id == "server-1" else None,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
global_mcp_server_manager,
|
||||
"get_mcp_server_from_tool_name",
|
||||
lambda tool_name: stub_server if tool_name.startswith("math_a-") else None,
|
||||
)
|
||||
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
||||
monkeypatch.setattr(MCPRequestHandler, "personal_key_attributing_source", fake_attributing_source)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.add_litellm_data_to_request",
|
||||
fake_add_litellm_data_to_request,
|
||||
|
|
@ -2631,7 +2676,7 @@ class TestCallToolRestAPI:
|
|||
|
||||
request_payload = {
|
||||
"server_id": "server-1",
|
||||
"name": "demo-tool",
|
||||
"name": tool_name,
|
||||
"arguments": {"foo": "bar"},
|
||||
}
|
||||
request = _build_request(
|
||||
|
|
@ -2642,16 +2687,84 @@ class TestCallToolRestAPI:
|
|||
|
||||
result = await rest_endpoints.call_tool_rest_api(
|
||||
request,
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
user_api_key_dict=original_auth,
|
||||
)
|
||||
|
||||
assert result == _OK_TOOL_RESULT
|
||||
assert captured["name"] == "demo-tool"
|
||||
assert captured["name"] == tool_name
|
||||
assert captured["executed_tool_name"] == native_tool_name
|
||||
assert captured["attributed_tool_name"] == native_tool_name
|
||||
assert captured["arguments"] == {"foo": "bar"}
|
||||
assert captured["allowed_mcp_servers"] == [stub_server]
|
||||
assert captured["oauth2_headers"] is None
|
||||
assert captured["user_api_key_auth"].team_id is None
|
||||
assert captured["user_api_key_auth"].org_id is None
|
||||
assert captured["guardrail_auth"].team_id == "team-a"
|
||||
assert captured["guardrail_auth"].org_id == "org-a"
|
||||
assert fire_logging.await_args.kwargs["user_api_key_auth"] is captured["guardrail_auth"]
|
||||
logging_obj = fire_logging.await_args.args[0]
|
||||
assert logging_obj.model_call_details["litellm_params"]["metadata"]["user_api_key_team_id"] == "team-a"
|
||||
fire_logging.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_billing_uses_target_server_when_global_tool_map_is_empty(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, LiteLLM_UserTable
|
||||
from litellm.proxy.auth import auth_checks
|
||||
|
||||
target_server: Final = MCPServer(
|
||||
server_id="server-b",
|
||||
name="server-b",
|
||||
server_name="server-b",
|
||||
transport=MCPTransport.http,
|
||||
)
|
||||
team: Final = LiteLLM_TeamTable(
|
||||
team_id="team-b",
|
||||
object_permission_id="perm-team-b",
|
||||
organization_id="org-team-b",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="perm-team-b",
|
||||
mcp_servers=["server-b"],
|
||||
mcp_tool_permissions={"server-b": ["search"]},
|
||||
),
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(api_key="sk-personal", user_id="alice")
|
||||
|
||||
async def _get_team_object(team_id: str, **_: object) -> LiteLLM_TeamTable:
|
||||
assert team_id == "team-b"
|
||||
return team
|
||||
|
||||
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_user_object",
|
||||
AsyncMock(return_value=LiteLLM_UserTable(user_id="alice", teams=["team-b"])),
|
||||
)
|
||||
monkeypatch.setattr(auth_checks, "get_team_object", _get_team_object)
|
||||
monkeypatch.setattr(auth_checks, "get_team_membership", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(auth_checks, "get_org_object", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(auth_checks, "_check_team_member_budget", AsyncMock(return_value=None))
|
||||
monkeypatch.setitem(global_mcp_server_manager.registry, target_server.server_id, target_server)
|
||||
monkeypatch.setattr(global_mcp_server_manager, "tool_name_to_mcp_server_name_mapping", {})
|
||||
|
||||
resolved_server, billed = await rest_endpoints._rest_tool_call_server_and_auth(
|
||||
auth,
|
||||
(target_server,),
|
||||
target_server.server_id,
|
||||
"search",
|
||||
)
|
||||
|
||||
assert resolved_server is target_server
|
||||
assert billed.team_id == "team-b"
|
||||
|
||||
async def test_extract_mcp_headers_scrubs_admitted_caller_credential(self) -> None:
|
||||
caller_key: Final = "sk-rest-caller-admission-key-123"
|
||||
upstream_token: Final = "Bearer unrelated-upstream-token"
|
||||
|
|
@ -2736,6 +2849,7 @@ class TestCallToolRestAPI:
|
|||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -2813,6 +2927,7 @@ class TestCallToolRestAPI:
|
|||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -2876,6 +2991,7 @@ class TestCallToolRestAPI:
|
|||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -2943,6 +3059,7 @@ class TestCallToolRestAPI:
|
|||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -3036,6 +3153,7 @@ class TestCallToolRestAPI:
|
|||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -3123,6 +3241,7 @@ class TestCallToolRestAPI:
|
|||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -3192,6 +3311,7 @@ class TestCallToolRestAPI:
|
|||
server_id = "server-1"
|
||||
alias = "server-1"
|
||||
server_name = "server-1"
|
||||
tool_name_to_display_name = None
|
||||
name = "stub"
|
||||
allowed_tools = None
|
||||
mcp_info = {"server_name": "stub"}
|
||||
|
|
@ -3291,19 +3411,29 @@ class TestCallToolRestAPI:
|
|||
from litellm.proxy import proxy_server
|
||||
|
||||
guardrail_error = HTTPException(status_code=400, detail={"error": "Content blocked"})
|
||||
server = MagicMock()
|
||||
server.server_id = "server-1"
|
||||
server.alias = "server-1"
|
||||
server.server_name = "server-1"
|
||||
server.short_prefix = None
|
||||
server.tool_name_to_display_name = None
|
||||
server.name = "stub"
|
||||
|
||||
async def fake_add_litellm_data_to_request(**kwargs):
|
||||
return kwargs.get("data", {})
|
||||
|
||||
async def blocking_pre_call_hook(user_api_key_dict, data, call_type, skip_guardrails=False):
|
||||
raise guardrail_error
|
||||
|
||||
failure_logging = AsyncMock(side_effect=RuntimeError("spend log db down"))
|
||||
execute_tool = AsyncMock(side_effect=guardrail_error)
|
||||
monkeypatch.setattr(
|
||||
rest_endpoints,
|
||||
"_resolve_allowed_mcp_servers_with_ip_filter",
|
||||
AsyncMock(return_value=([server], "server-1")),
|
||||
)
|
||||
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", execute_tool)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "add_litellm_data_to_request", fake_add_litellm_data_to_request, raising=False
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "proxy_config", {}, raising=False)
|
||||
monkeypatch.setattr(proxy_server.proxy_logging_obj, "pre_call_hook", blocking_pre_call_hook)
|
||||
monkeypatch.setattr(rest_endpoints, "fire_mcp_tool_call_failure_logging", failure_logging, raising=False)
|
||||
|
||||
request = _build_request(
|
||||
|
|
@ -3318,6 +3448,7 @@ class TestCallToolRestAPI:
|
|||
)
|
||||
|
||||
assert exc_info.value is guardrail_error
|
||||
execute_tool.assert_awaited_once()
|
||||
failure_logging.assert_awaited_once()
|
||||
|
||||
async def test_success_logging_cancellation_propagates(self, monkeypatch):
|
||||
|
|
@ -3792,7 +3923,11 @@ class TestGetToolsForSingleServer:
|
|||
from litellm.proxy._experimental.mcp_server.server import MCPServer
|
||||
from litellm.types.mcp import MCPTransport
|
||||
|
||||
mock_tools = [Tool(name="tool1", description="tool1", inputSchema={}), Tool(name="tool2", description="tool2", inputSchema={}), Tool(name="tool3", description="tool3", inputSchema={})]
|
||||
mock_tools = [
|
||||
Tool(name="tool1", description="tool1", inputSchema={}),
|
||||
Tool(name="tool2", description="tool2", inputSchema={}),
|
||||
Tool(name="tool3", description="tool3", inputSchema={}),
|
||||
]
|
||||
|
||||
async def fake_get_tools_from_server(**kwargs):
|
||||
return mock_tools
|
||||
|
|
@ -4531,6 +4666,7 @@ class TestToolResponseMcpInfoEnrichment:
|
|||
assert response.model_dump(by_alias=True, exclude={"mcp_info"}) == original
|
||||
assert tool.model_dump(by_alias=True) == original
|
||||
|
||||
|
||||
class TestRestListToolsetFiltering:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_list_filters_toolset_only_key_to_toolset_tools(self, monkeypatch):
|
||||
|
|
@ -4799,8 +4935,11 @@ async def test_preview_client_honors_protocol_metadata(revision: MCPUpstreamProt
|
|||
from litellm.experimental_mcp_client.client import MCPClient
|
||||
|
||||
payload: Final = NewMCPServerRequest(
|
||||
server_name="preview", url="http://127.0.0.1:9/mcp", transport="http",
|
||||
auth_type=MCPAuth.none, mcp_info={"protocol_version": revision},
|
||||
server_name="preview",
|
||||
url="http://127.0.0.1:9/mcp",
|
||||
transport="http",
|
||||
auth_type=MCPAuth.none,
|
||||
mcp_info={"protocol_version": revision},
|
||||
)
|
||||
|
||||
async def inspect_client(client: MCPClient) -> dict[str, str]:
|
||||
|
|
@ -4824,8 +4963,10 @@ async def test_preview_client_honors_protocol_metadata(revision: MCPUpstreamProt
|
|||
),
|
||||
)
|
||||
async def test_saved_preview_protocol_omission_and_explicit_edits(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_type: MCPAuth,
|
||||
metadata: dict[str, str] | None, expected: MCPUpstreamProtocol,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
auth_type: MCPAuth,
|
||||
metadata: dict[str, str] | None,
|
||||
expected: MCPUpstreamProtocol,
|
||||
) -> None:
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
|
|
@ -4834,19 +4975,29 @@ async def test_saved_preview_protocol_omission_and_explicit_edits(
|
|||
from litellm.proxy.management_endpoints import mcp_management_endpoints
|
||||
|
||||
saved: Final = MCPServer(
|
||||
server_id="saved-preview", name="preview", url="https://example.com/mcp",
|
||||
transport="http", auth_type=auth_type, protocol_version="2025-11-25",
|
||||
server_id="saved-preview",
|
||||
name="preview",
|
||||
url="https://example.com/mcp",
|
||||
transport="http",
|
||||
auth_type=auth_type,
|
||||
protocol_version="2025-11-25",
|
||||
authentication_token="stored-token",
|
||||
authorization_url="https://example.com/authorize", token_url="https://example.com/token",
|
||||
authorization_url="https://example.com/authorize",
|
||||
token_url="https://example.com/token",
|
||||
)
|
||||
manager: Final = MCPServerManager()
|
||||
manager.registry = {saved.server_id: saved}
|
||||
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
||||
monkeypatch.setattr(mcp_management_endpoints, "global_mcp_server_manager", manager)
|
||||
payload: Final = NewMCPServerRequest(
|
||||
server_id=saved.server_id, server_name=saved.name, url=saved.url, transport="http",
|
||||
auth_type=auth_type, mcp_info=metadata,
|
||||
authorization_url=saved.authorization_url, token_url=saved.token_url,
|
||||
server_id=saved.server_id,
|
||||
server_name=saved.name,
|
||||
url=saved.url,
|
||||
transport="http",
|
||||
auth_type=auth_type,
|
||||
mcp_info=metadata,
|
||||
authorization_url=saved.authorization_url,
|
||||
token_url=saved.token_url,
|
||||
)
|
||||
staged: Final = rest_endpoints._stage_server_test(
|
||||
payload, Headers({"x-litellm-api-key": "sk-admin", "authorization": "Bearer preview-token"})
|
||||
|
|
@ -4856,8 +5007,10 @@ async def test_saved_preview_protocol_omission_and_explicit_edits(
|
|||
return {"protocol_version": client.protocol_version}
|
||||
|
||||
result: Final = await rest_endpoints._execute_with_mcp_client(
|
||||
staged.request, inspect_client,
|
||||
mcp_auth_header=staged.mcp_auth_header, oauth2_headers=staged.oauth2_headers,
|
||||
staged.request,
|
||||
inspect_client,
|
||||
mcp_auth_header=staged.mcp_auth_header,
|
||||
oauth2_headers=staged.oauth2_headers,
|
||||
)
|
||||
assert result == {"protocol_version": expected}
|
||||
assert saved.protocol_version == "2025-11-25"
|
||||
|
|
@ -4866,14 +5019,20 @@ async def test_saved_preview_protocol_omission_and_explicit_edits(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("virtual", [False, True])
|
||||
@pytest.mark.parametrize("bearer,claims,expected", [
|
||||
("sk-guardrail-admission", None, None),
|
||||
("caller.jwt.assertion", {"sub": "caller"}, "caller.jwt.assertion"),
|
||||
("upstream-token", None, "upstream-token"),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"bearer,claims,expected",
|
||||
[
|
||||
("sk-guardrail-admission", None, None),
|
||||
("caller.jwt.assertion", {"sub": "caller"}, "caller.jwt.assertion"),
|
||||
("upstream-token", None, "upstream-token"),
|
||||
],
|
||||
)
|
||||
async def test_rest_guardrail_bearer_excludes_admission_key(
|
||||
monkeypatch: pytest.MonkeyPatch, virtual: bool,
|
||||
bearer: str, claims: dict[str, str] | None, expected: str | None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
virtual: bool,
|
||||
bearer: str,
|
||||
claims: dict[str, str] | None,
|
||||
expected: str | None,
|
||||
) -> None:
|
||||
import litellm
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
|
|
@ -4884,8 +5043,11 @@ async def test_rest_guardrail_bearer_excludes_admission_key(
|
|||
|
||||
class CaptureBearer(CustomLogger):
|
||||
async def async_pre_call_hook(
|
||||
self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache,
|
||||
data: dict[str, object], call_type: str,
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: DualCache,
|
||||
data: dict[str, object],
|
||||
call_type: str,
|
||||
) -> None:
|
||||
if call_type == "call_mcp_tool" and "incoming_bearer_token" in data:
|
||||
observed.put_nowait(data["incoming_bearer_token"])
|
||||
|
|
@ -4894,23 +5056,36 @@ async def test_rest_guardrail_bearer_excludes_admission_key(
|
|||
return "guarded-result"
|
||||
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="sk-guardrail-admission", jwt_claims=claims,
|
||||
object_permission={"object_permission_id": "guarded", "mcp_servers": ["guarded"], "mcp_tool_search_enabled": virtual},
|
||||
api_key="sk-guardrail-admission",
|
||||
jwt_claims=claims,
|
||||
object_permission={
|
||||
"object_permission_id": "guarded",
|
||||
"mcp_servers": ["guarded"],
|
||||
"mcp_tool_search_enabled": virtual,
|
||||
},
|
||||
)
|
||||
managed: Final = MCPServer(
|
||||
server_id="guarded", name="guarded", transport=MCPTransport.http,
|
||||
spec_path="/catalog.yaml", allow_all_keys=True,
|
||||
server_id="guarded",
|
||||
name="guarded",
|
||||
transport=MCPTransport.http,
|
||||
spec_path="/catalog.yaml",
|
||||
allow_all_keys=True,
|
||||
)
|
||||
monkeypatch.setitem(rest_endpoints.global_mcp_server_manager.registry, managed.server_id, managed)
|
||||
monkeypatch.setattr(litellm, "callbacks", [CaptureBearer()])
|
||||
global_mcp_tool_registry.register_tool(
|
||||
name="guarded-echo", description="Echo", input_schema={"type": "object"}, handler=echo,
|
||||
name="guarded-echo",
|
||||
description="Echo",
|
||||
input_schema={"type": "object"},
|
||||
handler=echo,
|
||||
)
|
||||
headers: Final = {"authorization": f"Bearer {bearer}", **(
|
||||
{} if claims else {"x-litellm-api-key": "sk-guardrail-admission"}
|
||||
)}
|
||||
headers: Final = {
|
||||
"authorization": f"Bearer {bearer}",
|
||||
**({} if claims else {"x-litellm-api-key": "sk-guardrail-admission"}),
|
||||
}
|
||||
payload: Final = {
|
||||
"server_id": "guarded", "name": "mcp_tool_call" if virtual else "guarded-echo",
|
||||
"server_id": "guarded",
|
||||
"name": "mcp_tool_call" if virtual else "guarded-echo",
|
||||
"arguments": {"tool_name": "guarded-echo", "arguments": {}} if virtual else {},
|
||||
}
|
||||
try:
|
||||
|
|
@ -4923,7 +5098,8 @@ async def test_rest_guardrail_bearer_excludes_admission_key(
|
|||
assert observed.get_nowait() == expected
|
||||
if claims is None:
|
||||
list_headers: Final = rest_endpoints._extract_mcp_headers_from_request(
|
||||
_build_request(headers), auth,
|
||||
_build_request(headers),
|
||||
auth,
|
||||
)[2]
|
||||
authorization: Final = list_headers.get("authorization", "")
|
||||
assert (authorization.removeprefix("Bearer ") or None) == expected
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from mcp.server.context import ServerRequestContext
|
|||
from mcp.server.session import ServerSession
|
||||
from mcp.server.subscriptions import ListenHandler
|
||||
from mcp.shared.subscriptions import PromptsListChanged, ToolsListChanged
|
||||
from mcp.types import SubscriptionFilter, SubscriptionsListenRequestParams
|
||||
from mcp.types import ListToolsRequest, ListToolsResult, SubscriptionFilter, SubscriptionsListenRequestParams
|
||||
|
||||
from litellm.experimental_mcp_client.client import MCPClient
|
||||
from litellm.proxy._experimental.mcp_server import subscriptions
|
||||
|
|
@ -45,6 +45,18 @@ def context(session: ServerSession, revision: str = "2026-07-28") -> ServerReque
|
|||
)
|
||||
|
||||
|
||||
class _ListingGateway:
|
||||
def __init__(self, *, result: ListToolsResult | None = None, error: MCPError | None = None) -> None:
|
||||
self._result: ListToolsResult | None = result
|
||||
self._error: MCPError | None = error
|
||||
|
||||
async def execute(self, _operation: ListToolsRequest, _context: OperationContext) -> ListToolsResult:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
assert self._result is not None
|
||||
return self._result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_notification_rechecks_authority_before_delivery() -> None:
|
||||
bus: Final = subscriptions._ListenerBus()
|
||||
|
|
@ -486,3 +498,39 @@ async def test_legacy_listener_catalog_refreshes_do_not_consume_server_rate_limi
|
|||
finally:
|
||||
listener.cancel()
|
||||
await asyncio.gather(listener, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_failed_tools_listing_keeps_empty_catalog_digest(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from litellm.proxy._experimental.mcp_server.catalog import ToolListingFailedOutright
|
||||
|
||||
listener_context: Final = OperationContext(_caller=UserAPIKeyAuth(user_id="listener"))
|
||||
empty_gateway: Final = _ListingGateway(result=ListToolsResult(tools=[]))
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy._experimental.mcp_server.operations.GatewayOperations", lambda: empty_gateway
|
||||
)
|
||||
empty_digest: Final = await subscriptions._listing(ToolsListChanged(), listener_context)
|
||||
|
||||
failed_gateway: Final = _ListingGateway(
|
||||
error=ToolListingFailedOutright(code=-32603, message="Every MCP server in scope failed")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy._experimental.mcp_server.operations.GatewayOperations", lambda: failed_gateway
|
||||
)
|
||||
failed_digest: Final = await subscriptions._listing(ToolsListChanged(), listener_context)
|
||||
|
||||
assert failed_digest == empty_digest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_listing_propagates_other_mcp_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
failure: Final = MCPError(code=-32602, message="Bad cursor")
|
||||
gateway: Final = _ListingGateway(error=failure)
|
||||
monkeypatch.setattr("litellm.proxy._experimental.mcp_server.operations.GatewayOperations", lambda: gateway)
|
||||
listener_context: Final = OperationContext(_caller=UserAPIKeyAuth(user_id="listener"))
|
||||
|
||||
expected_error: Final = pytest.raises(MCPError)
|
||||
with expected_error as exc_info:
|
||||
await subscriptions._listing(ToolsListChanged(), listener_context)
|
||||
|
||||
assert exc_info.value is failure
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -418,6 +418,124 @@ def _base_patches(
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("identity_managed", "requires_fresh_policy", "strict"),
|
||||
[(True, False, True), (False, True, True), (False, False, False)],
|
||||
)
|
||||
async def test_invoke_agent_a2a_passes_billed_auth_to_common_processing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
identity_managed: bool,
|
||||
requires_fresh_policy: bool,
|
||||
strict: bool,
|
||||
) -> None:
|
||||
import litellm
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, LiteLLM_UserTable
|
||||
from litellm.proxy.agent_endpoints import agent_registry
|
||||
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.litellm_pre_call_utils import add_guardrails_from_auth_metadata
|
||||
|
||||
agent: Final = _make_agent_mock()
|
||||
agent.identity_managed = identity_managed
|
||||
request: Final = _make_request_mock("message/send", _HELLO_MESSAGE_PARAMS)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="sk-personal",
|
||||
user_id="alice",
|
||||
team_id=None,
|
||||
)
|
||||
auth.requires_fresh_policy = requires_fresh_policy
|
||||
processed_metadata: Final[dict[str, object]] = {}
|
||||
processed_request_data: Final[dict[str, object]] = {"metadata": processed_metadata}
|
||||
|
||||
async def _common_processing(
|
||||
*, user_api_key_dict: UserAPIKeyAuth, **_: object
|
||||
) -> tuple[dict[str, object], MagicMock]:
|
||||
add_guardrails_from_auth_metadata(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=processed_request_data,
|
||||
metadata_variable_name="metadata",
|
||||
)
|
||||
return {}, MagicMock()
|
||||
|
||||
common_processing: Final = AsyncMock(side_effect=_common_processing)
|
||||
|
||||
async def _get_team_object(team_id: str, **_: object) -> LiteLLM_TeamTable:
|
||||
team: Final = LiteLLM_TeamTable(
|
||||
team_id=team_id,
|
||||
object_permission_id=f"perm-{team_id}",
|
||||
organization_id=f"org-{team_id}",
|
||||
metadata={"guardrails": ["team-guardrail"]} if team_id == "team-b" else None,
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id=f"perm-{team_id}",
|
||||
agents=["test-agent"] if team_id == "team-b" else [],
|
||||
agent_access_groups=["billing-group"] if team_id == "team-b" else [],
|
||||
),
|
||||
)
|
||||
return team
|
||||
|
||||
database: Final = MagicMock()
|
||||
database.db.litellm_agentstable.find_many = AsyncMock(return_value=[])
|
||||
database.writer_db.litellm_agentstable.find_many = AsyncMock(return_value=[])
|
||||
team_loader: Final = AsyncMock(side_effect=_get_team_object)
|
||||
|
||||
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
||||
monkeypatch.setattr("litellm.proxy.utils.premium_user_check", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", database)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", MagicMock())
|
||||
monkeypatch.setattr(agent_registry, "global_agent_registry", AgentRegistry())
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_user_object",
|
||||
AsyncMock(return_value=LiteLLM_UserTable(user_id="alice", teams=["team-a", "team-b"])),
|
||||
)
|
||||
monkeypatch.setattr(auth_checks, "get_team_object", team_loader)
|
||||
monkeypatch.setattr(auth_checks, "get_org_object", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(auth_checks, "get_team_membership", AsyncMock(return_value=None))
|
||||
|
||||
with ExitStack() as stack:
|
||||
for patcher in _base_patches(agent):
|
||||
stack.enter_context(patcher)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
ProxyBaseLLMRequestProcessing,
|
||||
"common_processing_pre_call_logic",
|
||||
new=common_processing,
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch("litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", False))
|
||||
|
||||
from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a
|
||||
|
||||
response: Final = await invoke_agent_a2a(
|
||||
agent_id="test-agent",
|
||||
request=request,
|
||||
fastapi_response=MagicMock(),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
|
||||
assert json.loads(response.body.decode())["error"]["code"] == -32603
|
||||
billed_auth: Final = common_processing.await_args.kwargs["user_api_key_dict"]
|
||||
assert billed_auth is not auth
|
||||
assert (billed_auth.team_id, billed_auth.org_id) == ("team-b", "org-team-b")
|
||||
assert billed_auth.team_metadata == {"guardrails": ["team-guardrail"]}
|
||||
assert processed_metadata["guardrails"] == ["team-guardrail"]
|
||||
assert (auth.team_id, auth.org_id) == (None, None)
|
||||
assert auth.team_metadata is None
|
||||
assert len(team_loader.await_args_list) == 2
|
||||
assert all(call.kwargs["check_db_only"] is strict for call in team_loader.await_args_list)
|
||||
if strict:
|
||||
database.writer_db.litellm_agentstable.find_many.assert_awaited_once()
|
||||
database.db.litellm_agentstable.find_many.assert_not_awaited()
|
||||
else:
|
||||
database.db.litellm_agentstable.find_many.assert_awaited_once()
|
||||
database.writer_db.litellm_agentstable.find_many.assert_not_awaited()
|
||||
|
||||
|
||||
async def _add_proxy_data(data: dict[str, object], **kwargs: object) -> dict[str, object]:
|
||||
return {
|
||||
**data,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from litellm.proxy._types import (
|
|||
LiteLLM_EndUserTable,
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TagTable,
|
||||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
|
|
@ -48,6 +49,8 @@ from litellm.proxy.auth.auth_checks import (
|
|||
_check_agent_access_group_model_access,
|
||||
check_end_user_budget,
|
||||
_check_team_member_budget,
|
||||
personal_key_owner_teams,
|
||||
personal_key_owner_teams_with_status,
|
||||
fetch_key_object_from_db_with_reconnect,
|
||||
_get_fuzzy_user_object,
|
||||
CallerTeamLoader,
|
||||
|
|
@ -82,6 +85,7 @@ from litellm.constants import (
|
|||
)
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.auth.user_api_key_auth import check_api_key_for_custom_headers_or_pass_through_endpoints
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_bearer_token, encrypt_value_helper
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
|
|
@ -4642,6 +4646,188 @@ async def test_check_team_member_budget_dispatches_the_configured_alert_before_t
|
|||
assert call_info.max_budget_alert_emails == {"50": [], "100": ["finance@co.com"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personal_key_owner_teams_reports_over_budget_and_blocked_teams(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from litellm.proxy.auth import auth_checks
|
||||
|
||||
cache: Final = UserApiKeyCache()
|
||||
logging_obj: Final = ProxyLogging(user_api_key_cache=cache)
|
||||
owner: Final = LiteLLM_UserTable(
|
||||
user_id="owner",
|
||||
teams=["team-a", "team-b", "team-blocked", "team-other-org"],
|
||||
)
|
||||
loaded_teams: Final = {
|
||||
"team-a": LiteLLM_TeamTable(team_id="team-a", organization_id="org-1"),
|
||||
"team-b": LiteLLM_TeamTable(team_id="team-b", organization_id="org-1"),
|
||||
"team-blocked": LiteLLM_TeamTable(team_id="team-blocked", organization_id="org-1", blocked=True),
|
||||
"team-other-org": LiteLLM_TeamTable(team_id="team-other-org", organization_id="org-2"),
|
||||
}
|
||||
memberships: Final = {
|
||||
"team-a": LiteLLM_TeamMembership(
|
||||
user_id="owner",
|
||||
team_id="team-a",
|
||||
spend=1.0,
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=5.0),
|
||||
),
|
||||
"team-b": LiteLLM_TeamMembership(
|
||||
user_id="owner",
|
||||
team_id="team-b",
|
||||
spend=1.0,
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=1.0),
|
||||
),
|
||||
}
|
||||
get_user = AsyncMock(return_value=owner)
|
||||
get_membership = AsyncMock(side_effect=lambda *, team_id, **_: memberships[team_id])
|
||||
|
||||
async def get_team_object(team_id: str, **_: object) -> LiteLLM_TeamTable:
|
||||
return loaded_teams[team_id]
|
||||
|
||||
async def current_spend(
|
||||
counter_key: str,
|
||||
fallback_spend: float,
|
||||
max_budget: float | None = None,
|
||||
) -> float:
|
||||
assert counter_key.startswith("spend:team_member:") and max_budget is not None
|
||||
return fallback_spend
|
||||
|
||||
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
||||
monkeypatch.setattr(auth_checks, "get_user_object", get_user)
|
||||
monkeypatch.setattr(auth_checks, "get_team_object", get_team_object)
|
||||
monkeypatch.setattr(auth_checks, "get_team_membership", get_membership)
|
||||
monkeypatch.setattr(proxy_server, "get_current_spend", current_spend)
|
||||
|
||||
result: Final = await personal_key_owner_teams_with_status(
|
||||
valid_token=UserAPIKeyAuth(api_key="sk-personal", user_id="owner", org_id="org-1"),
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
assert tuple(team.team_id for team in result.teams) == ("team-a",)
|
||||
assert {team.team_id for team in result.withheld} == {"team-b", "team-blocked"}
|
||||
assert all(team.team_id != "team-other-org" for team in (*result.teams, *result.withheld))
|
||||
assert result.unreadable is False
|
||||
assert get_user.await_count == 1
|
||||
assert {call.kwargs["team_id"] for call in get_membership.await_args_list} == {"team-a", "team-b"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personal_key_owner_teams_check_db_only_refreshes_cached_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from litellm.proxy.auth import auth_checks
|
||||
|
||||
user_id = "owner-cache-refresh"
|
||||
cached_owner: Final = LiteLLM_UserTable(
|
||||
user_id=user_id,
|
||||
user_email="owner@example.com",
|
||||
teams=["team-a"],
|
||||
)
|
||||
database_owner: Final = LiteLLM_UserTable(
|
||||
user_id=user_id,
|
||||
user_email="owner@example.com",
|
||||
teams=[],
|
||||
)
|
||||
cache: Final = UserApiKeyCache()
|
||||
await cache.async_set_cache(key=user_id, value=cached_owner, model_type=LiteLLM_UserTable)
|
||||
logging_obj: Final = ProxyLogging(user_api_key_cache=cache)
|
||||
prisma: Final = MagicMock()
|
||||
prisma.writer_db.litellm_usertable.find_unique = AsyncMock(return_value=database_owner)
|
||||
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=LiteLLM_TeamTable(team_id="team-a")))
|
||||
monkeypatch.setattr(auth_checks, "get_team_membership", AsyncMock(return_value=None))
|
||||
valid_token: Final = UserAPIKeyAuth(api_key="sk-personal", user_id=user_id)
|
||||
|
||||
cached_result: Final = await personal_key_owner_teams_with_status(
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma,
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=logging_obj,
|
||||
)
|
||||
fresh_result: Final = await personal_key_owner_teams_with_status(
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma,
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=logging_obj,
|
||||
check_db_only=True,
|
||||
)
|
||||
|
||||
assert tuple(team.team_id for team in cached_result.teams) == ("team-a",)
|
||||
assert fresh_result.teams == ()
|
||||
prisma.writer_db.litellm_usertable.find_unique.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personal_key_owner_teams_check_db_only_uses_fresh_membership_budget(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from litellm.proxy.auth import auth_checks
|
||||
|
||||
user_id: Final = "owner-membership-refresh"
|
||||
team_id: Final = "team-membership-refresh"
|
||||
cached_membership: Final = LiteLLM_TeamMembership(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
spend=1.0,
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=5.0),
|
||||
)
|
||||
database_membership: Final = LiteLLM_TeamMembership(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
spend=10.0,
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=5.0),
|
||||
)
|
||||
database_row: Final = MagicMock()
|
||||
database_row.dict.return_value = database_membership.model_dump(mode="python")
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.litellm_teammembership.find_unique = AsyncMock()
|
||||
prisma.writer_db.litellm_teammembership.find_unique = AsyncMock(return_value=database_row)
|
||||
cache: Final = UserApiKeyCache()
|
||||
await cache.async_set_cache(
|
||||
key=auth_checks.team_membership_reservation_cache_key(user_id=user_id, team_id=team_id),
|
||||
value=cached_membership,
|
||||
model_type=LiteLLM_TeamMembership,
|
||||
)
|
||||
logging_obj: Final = ProxyLogging(user_api_key_cache=cache)
|
||||
owner: Final = LiteLLM_UserTable(user_id=user_id, teams=[team_id])
|
||||
team: Final = LiteLLM_TeamTable(team_id=team_id)
|
||||
|
||||
async def current_spend(
|
||||
counter_key: str,
|
||||
fallback_spend: float,
|
||||
max_budget: float | None = None,
|
||||
) -> float:
|
||||
return fallback_spend
|
||||
|
||||
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
||||
monkeypatch.setattr(auth_checks, "get_user_object", AsyncMock(return_value=owner))
|
||||
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=team))
|
||||
monkeypatch.setattr(proxy_server, "get_current_spend", current_spend)
|
||||
valid_token: Final = UserAPIKeyAuth(api_key="sk-personal", user_id=user_id)
|
||||
|
||||
cached_result: Final = await personal_key_owner_teams_with_status(
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma,
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=logging_obj,
|
||||
)
|
||||
fresh_result: Final = await personal_key_owner_teams_with_status(
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma,
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=logging_obj,
|
||||
check_db_only=True,
|
||||
)
|
||||
|
||||
assert tuple(team.team_id for team in cached_result.teams) == (team_id,)
|
||||
assert cached_result.withheld == ()
|
||||
assert fresh_result.teams == ()
|
||||
assert tuple(team.team_id for team in fresh_result.withheld) == (team_id,)
|
||||
prisma.db.litellm_teammembership.find_unique.assert_not_awaited()
|
||||
prisma.writer_db.litellm_teammembership.find_unique.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spend, max_budget, expect_alert",
|
||||
[
|
||||
|
|
@ -8422,6 +8608,84 @@ async def test_get_team_membership_db_fetch_returns_validated_membership():
|
|||
assert result.spend == 1.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_membership_check_db_only_bypasses_stale_cache():
|
||||
from litellm.proxy._types import LiteLLM_TeamMembership
|
||||
from litellm.proxy.auth.auth_checks import get_team_membership
|
||||
from litellm.proxy.common_utils.user_api_key_cache import team_membership_reservation_cache_key
|
||||
|
||||
cache: Final = UserApiKeyCache()
|
||||
cache_key: Final = team_membership_reservation_cache_key(user_id="u-fresh", team_id="t-fresh")
|
||||
stale_membership: Final = LiteLLM_TeamMembership(user_id="u-fresh", team_id="t-fresh", spend=1.0)
|
||||
await cache.async_set_cache(key=cache_key, value=stale_membership, model_type=LiteLLM_TeamMembership)
|
||||
|
||||
database_row: Final = MagicMock()
|
||||
database_row.dict.return_value = {"user_id": "u-fresh", "team_id": "t-fresh", "spend": 9.0}
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.litellm_teammembership.find_unique = AsyncMock(return_value=database_row)
|
||||
prisma.writer_db.litellm_teammembership.find_unique = AsyncMock(return_value=database_row)
|
||||
|
||||
cached: Final = await get_team_membership(
|
||||
user_id="u-fresh",
|
||||
team_id="t-fresh",
|
||||
prisma_client=prisma,
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
fresh: Final = await get_team_membership(
|
||||
user_id="u-fresh",
|
||||
team_id="t-fresh",
|
||||
prisma_client=prisma,
|
||||
user_api_key_cache=cache,
|
||||
check_db_only=True,
|
||||
)
|
||||
|
||||
assert cached is not None and cached.spend == 1.0
|
||||
assert fresh is not None and fresh.spend == 9.0
|
||||
prisma.db.litellm_teammembership.find_unique.assert_not_awaited()
|
||||
prisma.writer_db.litellm_teammembership.find_unique.assert_awaited_once()
|
||||
with pytest.raises(Exception, match=r"^No db connected$"):
|
||||
await get_team_membership(
|
||||
user_id="u-fresh",
|
||||
team_id="t-fresh",
|
||||
prisma_client=None,
|
||||
user_api_key_cache=cache,
|
||||
check_db_only=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_membership_cache_miss_reads_replica_and_check_db_only_reads_writer():
|
||||
from litellm.proxy.auth.auth_checks import get_team_membership
|
||||
|
||||
reader_row: Final = MagicMock()
|
||||
reader_row.dict.return_value = {"user_id": "u-read", "team_id": "t-read", "spend": 2.0}
|
||||
writer_row: Final = MagicMock()
|
||||
writer_row.dict.return_value = {"user_id": "u-read", "team_id": "t-read", "spend": 9.0}
|
||||
prisma_client: Final = MagicMock()
|
||||
prisma_client.db.litellm_teammembership.find_unique = AsyncMock(return_value=reader_row)
|
||||
prisma_client.writer_db.litellm_teammembership.find_unique = AsyncMock(return_value=writer_row)
|
||||
cache: Final = UserApiKeyCache()
|
||||
|
||||
reader_membership: Final = await get_team_membership(
|
||||
user_id="u-read",
|
||||
team_id="t-read",
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
writer_membership: Final = await get_team_membership(
|
||||
user_id="u-read",
|
||||
team_id="t-read",
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
check_db_only=True,
|
||||
)
|
||||
|
||||
assert reader_membership is not None and reader_membership.spend == 2.0
|
||||
assert writer_membership is not None and writer_membership.spend == 9.0
|
||||
prisma_client.db.litellm_teammembership.find_unique.assert_awaited_once()
|
||||
prisma_client.writer_db.litellm_teammembership.find_unique.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_membership_negative_caches_a_missing_row():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -860,35 +860,6 @@ async def test_allowed_routes_admin(
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_cache_update_called():
|
||||
import litellm
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
# Use setattr to replace the method on the user_api_key_cache object
|
||||
cache = DualCache()
|
||||
|
||||
setattr(
|
||||
litellm.proxy.proxy_server,
|
||||
"user_api_key_cache",
|
||||
cache,
|
||||
)
|
||||
|
||||
with patch.object(cache, "async_batch_get_cache", new=AsyncMock(return_value=[None])) as mock_call_cache:
|
||||
# Call the function under test
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id="1234",
|
||||
response_cost=20,
|
||||
parent_otel_span=None,
|
||||
) # type: ignore
|
||||
|
||||
await asyncio.sleep(3)
|
||||
mock_call_cache.assert_awaited_once_with(keys=["team_id:1234"], parent_otel_span=None, throttle_redis=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_jwt_key():
|
||||
import json
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import inspect
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -27,7 +28,9 @@ from litellm.proxy.client.cli.commands.agents import (
|
|||
opencode_model_sync_env,
|
||||
run_agent,
|
||||
verify_proxy_key,
|
||||
verify_subscription_key,
|
||||
)
|
||||
from litellm.proxy.client.cli.commands.claude_settings import ClaudeSettingsError
|
||||
from litellm.proxy.client.cli.main import cli as lite
|
||||
|
||||
AGENTS_MODULE = "litellm.proxy.client.cli.commands.agents"
|
||||
|
|
@ -173,6 +176,19 @@ class _FakeJsonResponse:
|
|||
return self._payload
|
||||
|
||||
|
||||
def _overlay_writer(tmp_path):
|
||||
def write(text, environ):
|
||||
target = tmp_path / "overlay.json"
|
||||
target.write_text(text)
|
||||
return target
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _overlay(args):
|
||||
return json.loads(Path(args[2]).read_text())
|
||||
|
||||
|
||||
class TestAgentProfile:
|
||||
def test_claude_is_anthropic(self):
|
||||
name, profiles = agent_profile("claude")
|
||||
|
|
@ -263,6 +279,55 @@ class TestBuildAgentEnv:
|
|||
assert env["PATH"] == "/usr/bin"
|
||||
assert base == {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"}
|
||||
|
||||
def test_subscription_sends_the_key_in_a_header_and_sets_no_credential(self):
|
||||
env = build_agent_env({}, "http://localhost:4000/", "sk-key", frozenset({"anthropic"}), subscription=True)
|
||||
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000"
|
||||
assert env["ANTHROPIC_CUSTOM_HEADERS"] == "x-litellm-api-key: Bearer sk-key"
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
def test_subscription_clears_inherited_credentials(self):
|
||||
base = {"ANTHROPIC_AUTH_TOKEN": "stale-token", "ANTHROPIC_API_KEY": "real-key"}
|
||||
env = build_agent_env(base, "http://localhost:4000", "sk-key", frozenset({"anthropic"}), subscription=True)
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert base == {"ANTHROPIC_AUTH_TOKEN": "stale-token", "ANTHROPIC_API_KEY": "real-key"}
|
||||
|
||||
def test_subscription_keeps_the_users_other_custom_headers(self):
|
||||
env = build_agent_env(
|
||||
{"ANTHROPIC_CUSTOM_HEADERS": "x-team: blue\n\nx-trace: 1\n"},
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
frozenset({"anthropic"}),
|
||||
subscription=True,
|
||||
)
|
||||
assert env["ANTHROPIC_CUSTOM_HEADERS"].splitlines() == [
|
||||
"x-team: blue",
|
||||
"x-trace: 1",
|
||||
"x-litellm-api-key: Bearer sk-key",
|
||||
]
|
||||
|
||||
def test_subscription_replaces_a_stale_litellm_key_header_whatever_its_case(self):
|
||||
env = build_agent_env(
|
||||
{"ANTHROPIC_CUSTOM_HEADERS": "X-LiteLLM-Api-Key: Bearer old\nx-team: blue"},
|
||||
"http://localhost:4000",
|
||||
"sk-new",
|
||||
frozenset({"anthropic"}),
|
||||
subscription=True,
|
||||
)
|
||||
assert env["ANTHROPIC_CUSTOM_HEADERS"].splitlines() == ["x-team: blue", "x-litellm-api-key: Bearer sk-new"]
|
||||
|
||||
def test_subscription_leaves_openai_agents_on_the_bearer_key(self):
|
||||
env = build_agent_env({}, "http://localhost:4000", "sk-key", frozenset({"openai"}), subscription=True)
|
||||
assert env["OPENAI_API_KEY"] == "sk-key"
|
||||
assert "ANTHROPIC_CUSTOM_HEADERS" not in env
|
||||
|
||||
def test_default_mode_does_not_touch_custom_headers(self):
|
||||
env = build_agent_env(
|
||||
{"ANTHROPIC_CUSTOM_HEADERS": "x-team: blue"}, "http://localhost:4000", "sk-key", frozenset({"anthropic"})
|
||||
)
|
||||
assert env["ANTHROPIC_CUSTOM_HEADERS"] == "x-team: blue"
|
||||
|
||||
|
||||
class TestAgentLaunchArgs:
|
||||
def test_claude_and_opencode_get_no_extra_args(self):
|
||||
|
|
@ -330,6 +395,32 @@ class TestVerifyProxyKey:
|
|||
get=lambda *a, **k: _FakeResponse(500),
|
||||
)
|
||||
|
||||
def test_key_header_moves_the_bearer_off_authorization(self):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers, timeout):
|
||||
captured["headers"] = headers
|
||||
return _FakeResponse(200)
|
||||
|
||||
verify_proxy_key("http://localhost:4000", "sk-key", get=fake_get, key_header="x-litellm-api-key")
|
||||
|
||||
assert captured["headers"] == {"x-litellm-api-key": "Bearer sk-key"}
|
||||
|
||||
def test_subscription_probe_sends_the_key_only_in_the_litellm_header(self):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers, timeout):
|
||||
captured["headers"] = headers
|
||||
return _FakeResponse(200)
|
||||
|
||||
verify_subscription_key("http://localhost:4000", "sk-key", get=fake_get)
|
||||
|
||||
assert captured["headers"] == {"x-litellm-api-key": "Bearer sk-key"}
|
||||
|
||||
def test_subscription_probe_reports_a_rejected_key(self):
|
||||
with pytest.raises(AgentRunError, match="rejected your key"):
|
||||
verify_subscription_key("http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(401))
|
||||
|
||||
|
||||
class TestOpencodeModelSync:
|
||||
@staticmethod
|
||||
|
|
@ -1195,6 +1286,327 @@ class TestRunAgent:
|
|||
with pytest.raises(AgentRunError):
|
||||
run_agent("http://localhost:4000", "sk-key", [])
|
||||
|
||||
def test_subscription_launch_probes_with_the_header_and_exports_it(self):
|
||||
probed = []
|
||||
launched = {}
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude"],
|
||||
subscription=True,
|
||||
base_env={"ANTHROPIC_AUTH_TOKEN": "stale"},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify=lambda *a: probed.append(("bearer", a)),
|
||||
verify_subscription=lambda *a: probed.append(("header", a)),
|
||||
launcher=lambda p, a, e: launched.update(env=dict(e)),
|
||||
read_settings=lambda path: {},
|
||||
)
|
||||
assert probed == [("header", ("http://localhost:4000", "sk-key"))]
|
||||
assert launched["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "x-litellm-api-key: Bearer sk-key"
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in launched["env"]
|
||||
|
||||
def test_subscription_launch_blanks_the_credentials_claude_code_would_load_from_settings_json(self, tmp_path):
|
||||
launched = {}
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude", "--resume"],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda p, a, e: launched.update(args=tuple(a)),
|
||||
read_settings=lambda path: {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
)
|
||||
assert launched["args"][:2] == ("claude", "--settings")
|
||||
assert _overlay(launched["args"]) == {
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "",
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
||||
}
|
||||
}
|
||||
assert launched["args"][3:] == ("--resume",)
|
||||
|
||||
def test_subscription_launch_keeps_the_key_out_of_argv_and_in_one_private_file_next_to_settings_json(
|
||||
self, tmp_path
|
||||
):
|
||||
config_dir = tmp_path / "cfg"
|
||||
config_dir.mkdir()
|
||||
stale = config_dir / "litellm-subscription-overlay.json"
|
||||
stale.write_text('{"env": {"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer old-key"}}')
|
||||
stale.chmod(0o644)
|
||||
launched = {}
|
||||
for _ in range(2):
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude"],
|
||||
subscription=True,
|
||||
base_env={"CLAUDE_CONFIG_DIR": str(config_dir)},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda p, a, e: launched.update(args=list(a)),
|
||||
read_settings=lambda path: {},
|
||||
)
|
||||
assert "sk-key" not in " ".join(launched["args"])
|
||||
assert Path(launched["args"][2]) == stale
|
||||
assert sorted(path.name for path in config_dir.iterdir()) == ["litellm-subscription-overlay.json"]
|
||||
assert json.loads(stale.read_text())["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "x-litellm-api-key: Bearer sk-key"
|
||||
if os.name == "posix":
|
||||
assert stat.S_IMODE(stale.stat().st_mode) == 0o600
|
||||
|
||||
def test_subscription_launch_accepts_inline_settings_json_longer_than_a_file_name(self, tmp_path):
|
||||
long_value = "a" * 400
|
||||
launched = {}
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude", "--settings", json.dumps({"env": {"FOO": long_value}})],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda p, a, e: launched.update(args=list(a)),
|
||||
read_settings=lambda path: {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
)
|
||||
assert _overlay(launched["args"])["env"]["FOO"] == long_value
|
||||
|
||||
def test_subscription_launch_reports_a_missing_settings_file_as_a_clean_cli_error(self, tmp_path):
|
||||
with pytest.raises(AgentRunError, match="file not found"):
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude", "--settings", str(tmp_path / "nope.json")],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda *a: None,
|
||||
read_settings=lambda path: {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
)
|
||||
|
||||
def test_subscription_launch_reads_the_settings_files_claude_code_merges_below_the_flag(self, tmp_path):
|
||||
seen = []
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude"],
|
||||
subscription=True,
|
||||
base_env={"CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda *a: None,
|
||||
read_settings=lambda path: seen.append(path) or {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
project_dir=tmp_path / "repo",
|
||||
)
|
||||
assert seen == [
|
||||
tmp_path / "repo" / ".claude" / "settings.local.json",
|
||||
tmp_path / "repo" / ".claude" / "settings.json",
|
||||
tmp_path / "cfg" / "settings.json",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("separate_flag", [True, False])
|
||||
def test_subscription_launch_merges_the_blanks_into_the_users_settings_flag(self, tmp_path, separate_flag):
|
||||
settings_file = tmp_path / "mine.json"
|
||||
settings_file.write_text('{"model": "opus", "env": {"ANTHROPIC_AUTH_TOKEN": "mine", "FOO": "bar"}}')
|
||||
user_flag = ["--settings", str(settings_file)] if separate_flag else [f"--settings={settings_file}"]
|
||||
launched = {}
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude", "--resume", *user_flag, "-p", "hi"],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda p, a, e: launched.update(args=list(a)),
|
||||
read_settings=lambda path: {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
)
|
||||
assert launched["args"][:2] == ["claude", "--settings"]
|
||||
assert _overlay(launched["args"]) == {
|
||||
"model": "opus",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "",
|
||||
"FOO": "bar",
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
||||
},
|
||||
}
|
||||
assert launched["args"][3:] == ["--resume", "-p", "hi"]
|
||||
|
||||
def test_subscription_launch_merges_the_blanks_into_inline_settings_json(self, tmp_path):
|
||||
launched = {}
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude", "--settings", '{"env": {"ANTHROPIC_API_KEY": "mine"}, "theme": "dark"}'],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda p, a, e: launched.update(args=list(a)),
|
||||
read_settings=lambda path: {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
)
|
||||
assert launched["args"][:2] == ["claude", "--settings"]
|
||||
assert _overlay(launched["args"]) == {
|
||||
"theme": "dark",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": "",
|
||||
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
||||
},
|
||||
}
|
||||
assert launched["args"][3:] == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flag_headers", "local_headers", "project_headers", "user_headers", "expected"),
|
||||
[
|
||||
("X-Flag: f", "X-Local: l", "X-Proj: p", "X-User: u", "X-Flag: f\nx-litellm-api-key: Bearer sk-key"),
|
||||
(None, "X-Local: l", "X-Proj: p", "X-User: u", "X-Local: l\nx-litellm-api-key: Bearer sk-key"),
|
||||
(
|
||||
None,
|
||||
None,
|
||||
"X-Proj: p\nx-litellm-api-key: Bearer stale",
|
||||
"X-User: u",
|
||||
"X-Proj: p\nx-litellm-api-key: Bearer sk-key",
|
||||
),
|
||||
(None, None, None, "X-User: u", "X-User: u\nx-litellm-api-key: Bearer sk-key"),
|
||||
(None, None, None, None, "X-Proc: h\nx-litellm-api-key: Bearer sk-key"),
|
||||
],
|
||||
)
|
||||
def test_subscription_overlay_keeps_the_custom_headers_claude_code_would_apply_and_adds_the_key(
|
||||
self, tmp_path, flag_headers, local_headers, project_headers, user_headers, expected
|
||||
):
|
||||
"""Claude Code applies settings `env` over the process env, in precedence order, so the overlay must too."""
|
||||
user_flag = (
|
||||
["--settings", json.dumps({"env": {"ANTHROPIC_CUSTOM_HEADERS": flag_headers}})] if flag_headers else []
|
||||
)
|
||||
files = {
|
||||
tmp_path / "repo" / ".claude" / "settings.local.json": local_headers,
|
||||
tmp_path / "repo" / ".claude" / "settings.json": project_headers,
|
||||
tmp_path / "cfg" / "settings.json": user_headers,
|
||||
}
|
||||
launched = {}
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude", *user_flag],
|
||||
subscription=True,
|
||||
base_env={"ANTHROPIC_CUSTOM_HEADERS": "X-Proc: h", "CLAUDE_CONFIG_DIR": str(tmp_path / "cfg")},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda p, a, e: launched.update(args=list(a), env=dict(e)),
|
||||
read_settings=lambda path: {"env": {"ANTHROPIC_CUSTOM_HEADERS": files[path]}} if files[path] else {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
project_dir=tmp_path / "repo",
|
||||
)
|
||||
assert _overlay(launched["args"])["env"]["ANTHROPIC_CUSTOM_HEADERS"] == expected
|
||||
assert launched["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "X-Proc: h\nx-litellm-api-key: Bearer sk-key"
|
||||
|
||||
def test_subscription_launch_strips_every_settings_flag_and_merges_the_last_one_claude_code_would_keep(
|
||||
self, tmp_path
|
||||
):
|
||||
launched = {}
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
[
|
||||
"claude",
|
||||
"--settings",
|
||||
'{"env": {"ANTHROPIC_AUTH_TOKEN": "first"}, "theme": "light"}',
|
||||
"--resume",
|
||||
'--settings={"theme": "dark"}',
|
||||
],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda p, a, e: launched.update(args=list(a)),
|
||||
read_settings=lambda path: {},
|
||||
write_overlay=_overlay_writer(tmp_path),
|
||||
)
|
||||
assert launched["args"][:2] == ["claude", "--settings"]
|
||||
assert _overlay(launched["args"]) == {
|
||||
"theme": "dark",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "",
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
"ANTHROPIC_CUSTOM_HEADERS": "x-litellm-api-key: Bearer sk-key",
|
||||
},
|
||||
}
|
||||
assert launched["args"][3:] == ["--resume"]
|
||||
|
||||
def test_subscription_launch_reports_a_malformed_settings_file_as_a_clean_cli_error(self):
|
||||
def broken(path):
|
||||
raise ClaudeSettingsError(f"{path} contains invalid JSON")
|
||||
|
||||
with pytest.raises(AgentRunError, match="contains invalid JSON"):
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude"],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda *a: None,
|
||||
read_settings=broken,
|
||||
)
|
||||
|
||||
def test_subscription_launch_rejects_a_settings_flag_that_is_not_a_json_object(self):
|
||||
with pytest.raises(AgentRunError, match="is not a JSON object"):
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude", "--settings", '{"env": 1, '],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=lambda *a: None,
|
||||
launcher=lambda *a: None,
|
||||
read_settings=lambda path: {},
|
||||
)
|
||||
|
||||
def test_default_launch_still_probes_with_the_bearer(self):
|
||||
probed = []
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude"],
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify=lambda *a: probed.append(("bearer", a)),
|
||||
verify_subscription=lambda *a: probed.append(("header", a)),
|
||||
launcher=lambda *a: None,
|
||||
)
|
||||
assert probed == [("bearer", ("http://localhost:4000", "sk-key"))]
|
||||
|
||||
def test_subscription_rejection_aborts_before_launch(self):
|
||||
launched = []
|
||||
|
||||
def boom(*a):
|
||||
raise AgentRunError("rejected")
|
||||
|
||||
with pytest.raises(AgentRunError):
|
||||
run_agent(
|
||||
"http://localhost:4000",
|
||||
"sk-key",
|
||||
["claude"],
|
||||
subscription=True,
|
||||
base_env={},
|
||||
which=lambda name: "/usr/local/bin/claude",
|
||||
verify_subscription=boom,
|
||||
launcher=lambda *a: launched.append(a),
|
||||
)
|
||||
assert launched == []
|
||||
|
||||
def test_reattach_terminal_runs_just_before_launch(self):
|
||||
order = []
|
||||
run_agent(
|
||||
|
|
@ -1552,6 +1964,71 @@ class TestAgentCommands:
|
|||
assert captured["command"] == ["opencode"]
|
||||
assert "routing OpenCode through proxy at http://localhost:4000" in result.output
|
||||
|
||||
def test_claude_subscription_flag_reaches_run_agent_and_is_not_forwarded(self):
|
||||
captured = {}
|
||||
|
||||
def fake_run_agent(base_url, api_key, command, **kwargs):
|
||||
captured["command"] = list(command)
|
||||
captured["subscription"] = kwargs.get("subscription")
|
||||
|
||||
with patch(f"{AGENTS_MODULE}.run_agent", side_effect=fake_run_agent):
|
||||
result = self.runner.invoke(
|
||||
_agent_command("claude"),
|
||||
["--subscription", "--resume"],
|
||||
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["subscription"] is True
|
||||
assert captured["command"] == ["claude", "--resume"]
|
||||
assert "keeping your claude.ai login" in result.output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured", "args", "expected"),
|
||||
[
|
||||
(False, [], False),
|
||||
(True, [], True),
|
||||
(True, ["--no-subscription"], False),
|
||||
(False, ["--subscription"], True),
|
||||
],
|
||||
)
|
||||
def test_subscription_comes_from_the_flag_else_the_saved_setting(self, configured, args, expected):
|
||||
captured = {}
|
||||
with (
|
||||
patch(f"{AGENTS_MODULE}.claude_subscription_default", return_value=configured),
|
||||
patch(
|
||||
f"{AGENTS_MODULE}.run_agent",
|
||||
side_effect=lambda b, k, c, **kw: captured.update(sub=kw.get("subscription"), command=list(c)),
|
||||
),
|
||||
):
|
||||
result = self.runner.invoke(
|
||||
_agent_command("claude"), args, obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["sub"] is expected
|
||||
assert captured["command"] == ["claude"]
|
||||
assert ("keeping your claude.ai login" in result.output) is expected
|
||||
|
||||
@pytest.mark.parametrize("agent", ["codex", "opencode"])
|
||||
def test_the_saved_claude_setting_does_not_change_other_agents(self, agent):
|
||||
captured = {}
|
||||
with (
|
||||
patch(f"{AGENTS_MODULE}.claude_subscription_default", return_value=True),
|
||||
patch(
|
||||
f"{AGENTS_MODULE}.run_agent",
|
||||
side_effect=lambda b, k, c, **kw: captured.update(sub=kw.get("subscription")),
|
||||
),
|
||||
):
|
||||
result = self.runner.invoke(
|
||||
_agent_command(agent), [], obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["sub"] is False
|
||||
|
||||
@pytest.mark.parametrize("agent", ["codex", "opencode", "pi"])
|
||||
def test_only_claude_offers_the_subscription_flag(self, agent):
|
||||
assert "--subscription" not in {opt for param in _agent_command(agent).params for opt in param.opts}
|
||||
|
||||
def test_skip_verify_is_consumed_not_forwarded(self):
|
||||
captured = {}
|
||||
|
||||
|
|
@ -1676,7 +2153,9 @@ class TestPrepareCodex:
|
|||
def test_registers_the_installed_script_as_a_session_scoped_stop_hook(self):
|
||||
from litellm.proxy.client.cli.commands.agents import prepare_codex
|
||||
|
||||
args = prepare_codex("http://localhost:4000", "sk-key", {}, install=lambda: "/py /home/me/.litellm/statusline.py")
|
||||
args = prepare_codex(
|
||||
"http://localhost:4000", "sk-key", {}, install=lambda: "/py /home/me/.litellm/statusline.py"
|
||||
)
|
||||
assert args == (
|
||||
"-c",
|
||||
'hooks.Stop=[{hooks=[{type="command",command="/py /home/me/.litellm/statusline.py"}]}]',
|
||||
|
|
@ -1697,11 +2176,20 @@ class TestPrepareCodex:
|
|||
|
||||
warnings = []
|
||||
env = {"CODEX_HOME": str(tmp_path)}
|
||||
for body in ('[[hooks.Stop]]\nhooks = [{ type = "command", command = "mine" }]\n', 'hooks.Stop = []\n', "[hooks]\n"):
|
||||
for body in (
|
||||
'[[hooks.Stop]]\nhooks = [{ type = "command", command = "mine" }]\n',
|
||||
"hooks.Stop = []\n",
|
||||
"[hooks]\n",
|
||||
):
|
||||
(tmp_path / "config.toml").write_text(body)
|
||||
assert prepare_codex("http://localhost:4000", "sk", env, install=lambda: "/py /s.py", warn=warnings.append) == ()
|
||||
assert (
|
||||
prepare_codex("http://localhost:4000", "sk", env, install=lambda: "/py /s.py", warn=warnings.append)
|
||||
== ()
|
||||
)
|
||||
(tmp_path / "config.toml").write_text('model = "gpt-5.6-sol"\n[projects."/x"]\ntrust_level = "trusted"\n')
|
||||
assert prepare_codex("http://localhost:4000", "sk", env, install=lambda: "/py /s.py", warn=warnings.append) != ()
|
||||
assert (
|
||||
prepare_codex("http://localhost:4000", "sk", env, install=lambda: "/py /s.py", warn=warnings.append) != ()
|
||||
)
|
||||
assert len(warnings) == 3 and "already declares hooks" in warnings[0]
|
||||
|
||||
def test_a_config_that_cannot_be_read_or_decoded_still_lets_codex_launch(self, tmp_path):
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import time
|
|||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from click.testing import CliRunner
|
||||
|
|
@ -26,7 +25,6 @@ from litellm.litellm_core_utils.cli_token_utils import (
|
|||
)
|
||||
from litellm.proxy.client.cli import cli
|
||||
from litellm.proxy.client.cli.commands import claude_settings as claude_settings_module
|
||||
from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner
|
||||
from litellm.proxy.client.cli.commands.auth import (
|
||||
get_stored_api_key,
|
||||
login,
|
||||
|
|
@ -34,6 +32,7 @@ from litellm.proxy.client.cli.commands.auth import (
|
|||
print_token,
|
||||
whoami,
|
||||
)
|
||||
from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -1718,6 +1717,35 @@ class TestPkceLoginCommand:
|
|||
save.assert_called_once()
|
||||
assert _FakeSession.instances[0].posts == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("saved", "args", "uses_pkce"),
|
||||
[
|
||||
("true", [], True),
|
||||
("false", [], False),
|
||||
(None, [], False),
|
||||
("true", ["--no-pkce"], False),
|
||||
("false", ["--pkce"], True),
|
||||
],
|
||||
)
|
||||
def test_login_uses_pkce_when_the_flag_or_the_saved_setting_says_so(self, saved, args, uses_pkce):
|
||||
from litellm.proxy.client.cli.commands.config import save_config
|
||||
|
||||
if saved is not None:
|
||||
save_config({"pkce": saved})
|
||||
with (
|
||||
patch("litellm.proxy.client.cli.commands.auth.run_pkce_login", return_value=_pkce_credential()),
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth._start_cli_sso_flow",
|
||||
side_effect=RuntimeError("classic sso flow"),
|
||||
),
|
||||
patch("litellm.proxy.client.cli.commands.auth.save_token", return_value=SecretStored()),
|
||||
patch("litellm.proxy.client.cli.interface.show_commands"),
|
||||
):
|
||||
result = self.runner.invoke(login, args, obj={"base_url": PKCE_BASE_URL})
|
||||
|
||||
assert ("Login successful!" in result.output) is uses_pkce
|
||||
assert ("Authentication failed: classic sso flow" in result.output) is (not uses_pkce)
|
||||
|
||||
def test_pkce_login_saves_the_refreshable_record_and_skips_the_sso_poll(self):
|
||||
with (
|
||||
patch("litellm.proxy.client.cli.commands.auth.run_pkce_login", return_value=_pkce_credential()) as run,
|
||||
|
|
@ -2050,7 +2078,10 @@ class TestGetStoredApiKeyRefresh:
|
|||
@pytest.mark.parametrize(
|
||||
"body, expected_detail",
|
||||
[
|
||||
('{"detail": "Too many CLI login attempts.", "retry": {"after": [30, null]}}', ": Too many CLI login attempts."),
|
||||
(
|
||||
'{"detail": "Too many CLI login attempts.", "retry": {"after": [30, null]}}',
|
||||
": Too many CLI login attempts.",
|
||||
),
|
||||
('{"detail": 5}', ""),
|
||||
('{"detail": ""}', ""),
|
||||
('{"detail": {"message": "nested"}}', ""),
|
||||
|
|
|
|||
|
|
@ -6,16 +6,16 @@ from unittest.mock import patch
|
|||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
|
||||
from litellm.litellm_core_utils.private_json import write_private_json
|
||||
from litellm.proxy.client.cli import cli
|
||||
from litellm.proxy.client.cli.commands.config import (
|
||||
claude_subscription_default,
|
||||
get_config_file_path,
|
||||
get_config_value,
|
||||
load_config,
|
||||
pkce_default,
|
||||
save_config,
|
||||
)
|
||||
from litellm.litellm_core_utils.private_json import write_private_json
|
||||
from litellm.proxy.client.cli.interface import show_commands
|
||||
|
||||
|
||||
|
|
@ -360,3 +360,51 @@ class TestWritePrivateJson:
|
|||
|
||||
assert not target.exists()
|
||||
assert list(tmp_path.glob(".tmp-*")) == []
|
||||
|
||||
|
||||
class TestClaudeSubscriptionSetting:
|
||||
@pytest.mark.parametrize("word", ["true", "TRUE", "on", "1", "yes"])
|
||||
def test_truthy_words_are_stored_as_true(self, cli_runner, isolated_home, word):
|
||||
result = cli_runner.invoke(cli, ["config", "set", "claude_subscription", word])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert load_config()["claude_subscription"] == "true"
|
||||
assert claude_subscription_default() is True
|
||||
|
||||
@pytest.mark.parametrize("word", ["false", "Off", "0", "no"])
|
||||
def test_falsy_words_are_stored_as_false(self, cli_runner, isolated_home, word):
|
||||
cli_runner.invoke(cli, ["config", "set", "claude_subscription", "true"])
|
||||
result = cli_runner.invoke(cli, ["config", "set", "claude_subscription", word])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert load_config()["claude_subscription"] == "false"
|
||||
assert claude_subscription_default() is False
|
||||
|
||||
def test_anything_else_is_rejected_and_nothing_is_saved(self, cli_runner, isolated_home):
|
||||
result = cli_runner.invoke(cli, ["config", "set", "claude_subscription", "maybe"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "true or false" in result.output
|
||||
assert "claude_subscription" not in load_config()
|
||||
|
||||
def test_unset_means_off(self, isolated_home):
|
||||
assert claude_subscription_default() is False
|
||||
|
||||
|
||||
class TestPkceSetting:
|
||||
@pytest.mark.parametrize(("word", "expected"), [("true", True), ("on", True), ("false", False), ("off", False)])
|
||||
def test_the_saved_value_drives_the_default(self, cli_runner, isolated_home, word, expected):
|
||||
result = cli_runner.invoke(cli, ["config", "set", "pkce", word])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert pkce_default() is expected
|
||||
|
||||
def test_a_non_boolean_is_rejected_and_nothing_is_saved(self, cli_runner, isolated_home):
|
||||
result = cli_runner.invoke(cli, ["config", "set", "pkce", "sometimes"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "true or false" in result.output
|
||||
assert "pkce" not in load_config()
|
||||
|
||||
def test_unset_means_off(self, isolated_home):
|
||||
assert pkce_default() is False
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ from litellm.proxy.client.cli.commands.claude_settings import ClaudeSettingsErro
|
|||
from litellm.proxy.client.cli.commands.up import (
|
||||
BackupRecord,
|
||||
UpError,
|
||||
ensure_fresh_login,
|
||||
down,
|
||||
ensure_fresh_login,
|
||||
load_json_or_empty,
|
||||
merge_claude_settings,
|
||||
read_backup,
|
||||
|
|
@ -372,6 +372,30 @@ class TestEnsureFreshLogin:
|
|||
|
||||
assert login_calls == [("http://proxy-a:4000", True)]
|
||||
|
||||
def test_logs_in_with_pkce_when_the_saved_pkce_default_is_on(self, monkeypatch):
|
||||
"""`lite config set pkce true` is honoured by the login `lite up` starts, not only by `lite login`."""
|
||||
monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: True)
|
||||
store = _FakeTokenStore(monkeypatch, {"key": "sk-a", "base_url": "http://proxy-a:4000"}, {})
|
||||
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
|
||||
monkeypatch.setattr(up_module, "pkce_default", lambda: True)
|
||||
login_calls = _capture_login(
|
||||
monkeypatch,
|
||||
on_login=lambda: store.log_in(_pkce_record("http://proxy-b:4000", seconds_left=86_400), "sk-pkce-fresh"),
|
||||
)
|
||||
|
||||
ensure_fresh_login(_make_ctx("http://proxy-b:4000"))
|
||||
|
||||
assert login_calls == [("http://proxy-b:4000", True)]
|
||||
|
||||
def test_names_the_pkce_login_in_the_non_interactive_error_when_the_saved_pkce_default_is_on(self, monkeypatch):
|
||||
monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: False)
|
||||
_FakeTokenStore(monkeypatch, {"key": "sk-a", "base_url": "http://proxy-a:4000"}, {})
|
||||
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
|
||||
monkeypatch.setattr(up_module, "pkce_default", lambda: True)
|
||||
|
||||
with pytest.raises(UpError, match="Run `lite login --pkce` first"):
|
||||
ensure_fresh_login(_make_ctx("http://proxy-b:4000"))
|
||||
|
||||
def test_names_the_pkce_login_in_the_non_interactive_error_for_a_pkce_credential(self, monkeypatch):
|
||||
monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: False)
|
||||
_FakeTokenStore(monkeypatch, _pkce_record("http://proxy-a:4000", seconds_left=-10), {})
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ RAW_FILE_ID = "file-victim-abc123"
|
|||
RAW_JOB_ID = "ftjob-victim-abc123"
|
||||
|
||||
|
||||
def _unified_file_id() -> str:
|
||||
def _unified_file_id(unified_id: str = "victim-unified-id", provider_file_id: str = RAW_FILE_ID) -> str:
|
||||
unified = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format(
|
||||
"application/json", "victim-unified-id", "gpt-4o-mini", RAW_FILE_ID, "gpt-4o-mini-id"
|
||||
"application/json", unified_id, "gpt-4o-mini", provider_file_id, "gpt-4o-mini-id"
|
||||
)
|
||||
return base64.urlsafe_b64encode(unified.encode()).decode().rstrip("=")
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ def _decode_unified_id(encoded_id: str) -> str:
|
|||
return base64.urlsafe_b64decode(f"{encoded_id}{padding}").decode()
|
||||
|
||||
|
||||
def _job() -> LiteLLMFineTuningJob:
|
||||
def _job(validation_file: str | None = None) -> LiteLLMFineTuningJob:
|
||||
job = LiteLLMFineTuningJob(
|
||||
id=RAW_JOB_ID,
|
||||
created_at=1234567890,
|
||||
|
|
@ -69,7 +69,7 @@ def _job() -> LiteLLMFineTuningJob:
|
|||
status="running",
|
||||
trained_tokens=None,
|
||||
training_file=RAW_FILE_ID,
|
||||
validation_file=None,
|
||||
validation_file=validation_file,
|
||||
)
|
||||
job._hidden_params = {}
|
||||
return job
|
||||
|
|
@ -225,6 +225,31 @@ async def test_create__unified_training_file_allowed_when_managed_files_required
|
|||
assert response.hidden_params["unified_file_id"] == _decode_unified_id(unified_file_id)
|
||||
|
||||
|
||||
MANAGED_VALIDATION_FILE_ID: Final = _unified_file_id("validation-unified-id", "file-provider-validation")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("validation_file", "provider_validation_file", "expected_validation_file"),
|
||||
[
|
||||
(None, None, None),
|
||||
(RAW_FILE_ID, RAW_FILE_ID, RAW_FILE_ID),
|
||||
(MANAGED_VALIDATION_FILE_ID, "file-provider-validation", MANAGED_VALIDATION_FILE_ID),
|
||||
],
|
||||
ids=["no-validation-file", "raw-validation-file", "managed-validation-file"],
|
||||
)
|
||||
async def test_create__managed_job_returns_the_callers_file_ids(
|
||||
seams, validation_file: str | None, provider_validation_file: str | None, expected_validation_file: str | None
|
||||
):
|
||||
seams.logging.get_proxy_hook.return_value = ManagedResourceAccessCheckerStub()
|
||||
seams.router.acreate_fine_tuning_job.return_value = _job(validation_file=provider_validation_file)
|
||||
training_file: Final = _unified_file_id()
|
||||
|
||||
response: Final = await _create(training_file, validation_file=validation_file)
|
||||
|
||||
assert (response.training_file, response.validation_file) == (training_file, expected_validation_file)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create__other_teams_unified_training_file_rejected(seams):
|
||||
seams.logging.get_proxy_hook.return_value = ManagedResourceAccessCheckerStub(file_access=False)
|
||||
|
|
|
|||
|
|
@ -3938,6 +3938,126 @@ async def test_mcp_per_team_rate_limit_is_enforced_from_team_metadata_v3() -> No
|
|||
await handler.enforce_mcp_server_rate_limits(second_key, server)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherited_mcp_team_rate_limit_deduplicates_team_and_source_descriptors_v3() -> None:
|
||||
handler, _ = _make_mcp_handler()
|
||||
server: Final = MCPServer(
|
||||
server_id="math-a",
|
||||
name="math_a",
|
||||
server_name="math_a",
|
||||
transport=MCPTransport.http,
|
||||
)
|
||||
inherited_auth: Final = UserAPIKeyAuth(
|
||||
api_key=hash_token("sk-inherited"),
|
||||
team_id="team-a",
|
||||
team_metadata={"mcp_rpm_limit": {"math_a": 1}},
|
||||
).model_copy(update={"mcp_source_team_rpm_limits": {"team-a": {"math_a": 1}}})
|
||||
descriptors: Final[list[RateLimitDescriptor]] = []
|
||||
handler._add_mcp_per_key_rate_limit_descriptor(inherited_auth, "math_a", descriptors)
|
||||
|
||||
assert descriptors == []
|
||||
team_descriptors: Final[list[RateLimitDescriptor]] = []
|
||||
handler._add_mcp_per_team_rate_limit_descriptor(inherited_auth, "math_a", team_descriptors)
|
||||
assert [(descriptor["key"], descriptor["value"]) for descriptor in team_descriptors] == [
|
||||
("mcp_per_team", "team-a:math_a")
|
||||
]
|
||||
await handler.enforce_mcp_server_rate_limits(inherited_auth, server)
|
||||
|
||||
with pytest.raises(ProxyRateLimitError, match="mcp_per_team"):
|
||||
await handler.enforce_mcp_server_rate_limits(inherited_auth, server)
|
||||
|
||||
|
||||
def test_inherited_mcp_team_rate_limit_uses_source_limit_when_primary_metadata_is_missing() -> None:
|
||||
handler, _ = _make_mcp_handler()
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key=hash_token("sk-inherited-source-limit"),
|
||||
team_id="team-a",
|
||||
).model_copy(update={"mcp_source_team_rpm_limits": {"team-a": {"math_a": 1}}})
|
||||
descriptors: Final[list[RateLimitDescriptor]] = []
|
||||
|
||||
handler._add_mcp_per_team_rate_limit_descriptor(auth, "math_a", descriptors)
|
||||
|
||||
assert descriptors == [
|
||||
{
|
||||
"key": "mcp_per_team",
|
||||
"value": "team-a:math_a",
|
||||
"rate_limit": {
|
||||
"requests_per_unit": 1,
|
||||
"tokens_per_unit": None,
|
||||
"window_size": handler.window_size,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billed_personal_key_auth_produces_team_rpm_descriptors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.models.budget import LiteLLM_BudgetTable
|
||||
from litellm.models.team_membership import LiteLLM_TeamMembership
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable
|
||||
|
||||
server: Final = MCPServer(
|
||||
server_id="math_a",
|
||||
name="math_a",
|
||||
server_name="math_a",
|
||||
transport=MCPTransport.http,
|
||||
)
|
||||
team: Final = LiteLLM_TeamTable(
|
||||
team_id="team-a",
|
||||
organization_id="org-team-a",
|
||||
rpm_limit=7,
|
||||
object_permission_id="perm-team-a",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="perm-team-a",
|
||||
mcp_servers=["math_a"],
|
||||
mcp_tool_permissions={"math_a": ["add"]},
|
||||
),
|
||||
)
|
||||
membership: Final = LiteLLM_TeamMembership(
|
||||
user_id="owner",
|
||||
team_id="team-a",
|
||||
litellm_budget_table=LiteLLM_BudgetTable(rpm_limit=3),
|
||||
)
|
||||
user_api_key_auth: Final = UserAPIKeyAuth(
|
||||
api_key=hash_token("sk-billed-personal"),
|
||||
user_id="owner",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "should_load_db_object", lambda _: False)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", MagicMock())
|
||||
monkeypatch.setattr(auth_checks, "personal_key_owner_teams", AsyncMock(return_value=(team,)))
|
||||
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=team))
|
||||
monkeypatch.setattr(auth_checks, "get_team_membership", AsyncMock(return_value=membership))
|
||||
monkeypatch.setattr(auth_checks, "get_org_object", AsyncMock(return_value=None))
|
||||
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
|
||||
|
||||
billed_auth: Final = await MCPRequestHandler.billing_auth_for_tool_call(user_api_key_auth, "math_a-add")
|
||||
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(DualCache()))
|
||||
|
||||
descriptors: Final = handler.create_rate_limit_descriptors(
|
||||
user_api_key_dict=billed_auth,
|
||||
data={},
|
||||
rpm_limit_type=None,
|
||||
tpm_limit_type=None,
|
||||
model_has_failures=False,
|
||||
)
|
||||
|
||||
assert [
|
||||
(descriptor["key"], descriptor["value"], descriptor["rate_limit"]["requests_per_unit"])
|
||||
for descriptor in descriptors
|
||||
if descriptor["key"] in ("team", "team_member")
|
||||
] == [("team", "team-a", 7), ("team_member", "team-a:owner", 3)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_server_rpm_is_shared_across_keys_v3() -> None:
|
||||
handler, _ = _make_mcp_handler()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.proxy._types import LiteLLM_UserTable
|
||||
from litellm.proxy.management_endpoints.scim.scim_v2 import _apply_patch_ops, patch_user
|
||||
from litellm.types.proxy.management_endpoints.scim_v2 import (
|
||||
SCIM_ENTERPRISE_METADATA_KEY,
|
||||
SCIM_ENTERPRISE_USER_SCHEMA,
|
||||
SCIMEnterpriseUser,
|
||||
SCIMPatchOp,
|
||||
SCIMPatchOperation,
|
||||
SCIMUser,
|
||||
|
|
@ -536,3 +542,553 @@ def test_apply_patch_ops_replace_groups_empty_value_does_not_use_path_filter():
|
|||
_, final_team_set = _apply_patch_ops(existing_user=user, patch_ops=patch_ops)
|
||||
|
||||
assert final_team_set == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_user_pathless_enterprise_extension_merges_existing_metadata():
|
||||
mock_user: Final = _user_with_metadata({SCIM_ENTERPRISE_METADATA_KEY: {"employeeNumber": "42"}})
|
||||
mock_client: Final = MagicMock()
|
||||
mock_db: Final = MagicMock()
|
||||
mock_client.db = mock_db
|
||||
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
|
||||
|
||||
async def mock_update(*, where: Mapping[str, object], data: Mapping[str, object]) -> LiteLLM_UserTable:
|
||||
assert where == {"user_id": "user-mva"}
|
||||
metadata_json: Final = data["metadata"]
|
||||
assert isinstance(metadata_json, str)
|
||||
metadata: Final = TypeAdapter(dict[str, object]).validate_json(metadata_json)
|
||||
return _user_with_metadata(metadata)
|
||||
|
||||
mock_db.litellm_usertable.update = AsyncMock(side_effect=mock_update)
|
||||
mock_db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
value={
|
||||
SCIM_ENTERPRISE_USER_SCHEMA: {
|
||||
"department": "Eng",
|
||||
"costCenter": "CC-10",
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_client),
|
||||
):
|
||||
result: Final = await patch_user(user_id="user-mva", patch_ops=patch_ops)
|
||||
|
||||
update_data: Final = mock_db.litellm_usertable.update.call_args.kwargs["data"]
|
||||
metadata_json: Final = update_data["metadata"]
|
||||
assert isinstance(metadata_json, str)
|
||||
metadata: Final = TypeAdapter(dict[str, object]).validate_json(metadata_json)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"employeeNumber": "42",
|
||||
"department": "Eng",
|
||||
"costCenter": "CC-10",
|
||||
}
|
||||
assert not any(isinstance(key, str) and key.casefold().startswith("urn:") for key in metadata)
|
||||
assert result.enterprise_user == SCIMEnterpriseUser(
|
||||
employeeNumber="42",
|
||||
department="Eng",
|
||||
costCenter="CC-10",
|
||||
)
|
||||
|
||||
|
||||
def test_apply_patch_ops_replace_enterprise_urn_path_from_idp():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
|
||||
value="Eng",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(existing_user=_user_with_metadata({}), patch_ops=patch_ops)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {"department": "Eng"}
|
||||
assert not any(isinstance(key, str) and key.casefold().startswith("urn:") for key in metadata)
|
||||
|
||||
|
||||
def test_apply_patch_ops_pathless_enterprise_manager_sub_attribute_merges():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
value={
|
||||
f"{SCIM_ENTERPRISE_USER_SCHEMA.upper()}:MANAGER.VALUE": "manager-1",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{SCIM_ENTERPRISE_METADATA_KEY: {"manager": {"displayName": "Engineering Manager"}}}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"manager": {"value": "manager-1", "displayName": "Engineering Manager"}
|
||||
}
|
||||
|
||||
|
||||
def test_apply_patch_ops_replace_enterprise_manager_normalizes_case_insensitive_keys():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:manager",
|
||||
value={"VALUE": "mgr-2"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": "mgr-1", "displayName": "Boss"},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": "mgr-2", "displayName": "Boss"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "value"),
|
||||
(
|
||||
pytest.param(
|
||||
f"{SCIM_ENTERPRISE_USER_SCHEMA}:manager",
|
||||
{"displayName": "Q"},
|
||||
id="pathed",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
{SCIM_ENTERPRISE_USER_SCHEMA: {"manager": {"displayName": "Q"}}},
|
||||
id="pathless",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
{SCIM_ENTERPRISE_USER_SCHEMA: {"MANAGER": {"displayName": "Q"}}},
|
||||
id="pathless-uppercase-key",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_apply_patch_ops_replace_manager_object_preserves_unspecified_sub_attributes(
|
||||
path: str | None,
|
||||
value: object,
|
||||
) -> None:
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[SCIMPatchOperation(op="replace", path=path, value=value)]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"department": "A",
|
||||
"manager": {"value": "m", "displayName": "B"},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"department": "A",
|
||||
"manager": {"value": "m", "displayName": "Q"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("manager_path", "manager_value"),
|
||||
(
|
||||
pytest.param(
|
||||
f"{SCIM_ENTERPRISE_USER_SCHEMA}:manager",
|
||||
{"$ref": "new"},
|
||||
id="pathed",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
{SCIM_ENTERPRISE_USER_SCHEMA: {"manager": {"$ref": "new"}}},
|
||||
id="pathless",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_apply_patch_ops_batched_manager_ref_update_replaces_previous_link(
|
||||
manager_path: str | None,
|
||||
manager_value: object,
|
||||
) -> None:
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:department",
|
||||
value="B",
|
||||
),
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
path=manager_path,
|
||||
value=manager_value,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"department": "A",
|
||||
"manager": {"value": "m", "$ref": "old"},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"department": "B",
|
||||
"manager": {"value": "m", "$ref": "new"},
|
||||
}
|
||||
|
||||
|
||||
def test_apply_patch_ops_add_enterprise_manager_merges_existing_sub_attributes():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="add",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:manager",
|
||||
value={"displayName": "New Boss"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"employeeNumber": "42",
|
||||
"manager": {
|
||||
"value": "mgr-1",
|
||||
"$ref": "https://idp.example/Users/mgr-1",
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"employeeNumber": "42",
|
||||
"manager": {
|
||||
"value": "mgr-1",
|
||||
"$ref": "https://idp.example/Users/mgr-1",
|
||||
"displayName": "New Boss",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_apply_patch_ops_pathless_add_enterprise_manager_merges_existing_sub_attributes():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="add",
|
||||
value={
|
||||
SCIM_ENTERPRISE_USER_SCHEMA: {
|
||||
"manager": {"displayName": "New Boss"},
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"employeeNumber": "42",
|
||||
"manager": {
|
||||
"value": "mgr-1",
|
||||
"$ref": "https://idp.example/Users/mgr-1",
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"employeeNumber": "42",
|
||||
"manager": {
|
||||
"value": "mgr-1",
|
||||
"$ref": "https://idp.example/Users/mgr-1",
|
||||
"displayName": "New Boss",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_apply_patch_ops_remove_enterprise_manager_sub_attribute_preserves_other_fields():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="remove",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:manager.value",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": "mgr-1", "displayName": "Boss"},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"displayName": "Boss"},
|
||||
}
|
||||
|
||||
|
||||
def test_apply_patch_ops_remove_enterprise_attribute_preserves_other_attributes():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="remove",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:costCenter",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata({SCIM_ENTERPRISE_METADATA_KEY: {"costCenter": "CC-10", "department": "Eng"}}),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {"department": "Eng"}
|
||||
|
||||
|
||||
def test_apply_patch_ops_remove_last_enterprise_attribute_drops_metadata_key():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="remove",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:employeeNumber",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata({SCIM_ENTERPRISE_METADATA_KEY: {"employeeNumber": "42"}}),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert SCIM_ENTERPRISE_METADATA_KEY not in metadata
|
||||
|
||||
|
||||
def test_apply_patch_ops_unknown_enterprise_attribute_preserves_existing_and_applies_other_ops() -> None:
|
||||
existing_enterprise: Final = {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": "mgr-1", "displayName": "Boss"},
|
||||
}
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:shoeSize",
|
||||
value="10",
|
||||
),
|
||||
SCIMPatchOperation(op="replace", path="displayName", value="Updated User"),
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata({SCIM_ENTERPRISE_METADATA_KEY: existing_enterprise}),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == existing_enterprise
|
||||
assert not any(isinstance(key, str) and key.casefold().startswith("urn:") for key in metadata)
|
||||
assert update_data["user_alias"] == "Updated User"
|
||||
|
||||
|
||||
def test_apply_patch_ops_display_name_only_preserves_enterprise_value_unchanged() -> None:
|
||||
existing_enterprise: Final = {"Department": "Eng", "manager": "mgr-1"}
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[SCIMPatchOperation(op="replace", path="displayName", value="Ada")]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata({SCIM_ENTERPRISE_METADATA_KEY: existing_enterprise}),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {"Department": "Eng", "manager": "mgr-1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ("manager.foo", "manager.a.b", "department.x"))
|
||||
def test_apply_patch_ops_unknown_enterprise_nested_attribute_preserves_existing(path: str) -> None:
|
||||
existing_enterprise: Final = {
|
||||
"department": "Engineering",
|
||||
"manager": {"value": "mgr-1", "displayName": "Boss"},
|
||||
}
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:{path}",
|
||||
value="ignored",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata({SCIM_ENTERPRISE_METADATA_KEY: existing_enterprise}),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == existing_enterprise
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manager_id", ("mgr-id", ""))
|
||||
def test_apply_patch_ops_add_enterprise_manager_string_replaces_existing_manager(manager_id: str) -> None:
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="add",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:manager",
|
||||
value=manager_id,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": "old", "displayName": "Old Boss"},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": manager_id},
|
||||
}
|
||||
|
||||
|
||||
def test_apply_patch_ops_pathless_add_enterprise_manager_string_replaces_existing_manager() -> None:
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="add",
|
||||
value={SCIM_ENTERPRISE_USER_SCHEMA: {"manager": "mgr-id"}},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{
|
||||
SCIM_ENTERPRISE_METADATA_KEY: {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": "old", "displayName": "Old Boss"},
|
||||
}
|
||||
}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert metadata[SCIM_ENTERPRISE_METADATA_KEY] == {
|
||||
"employeeNumber": "42",
|
||||
"manager": {"value": "mgr-id"},
|
||||
}
|
||||
|
||||
|
||||
def test_apply_patch_ops_remove_enterprise_manager_drops_manager() -> None:
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="remove",
|
||||
path=f"{SCIM_ENTERPRISE_USER_SCHEMA}:manager",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
update_data, _ = _apply_patch_ops(
|
||||
existing_user=_user_with_metadata(
|
||||
{SCIM_ENTERPRISE_METADATA_KEY: {"manager": {"value": "mgr-1", "displayName": "Boss"}}}
|
||||
),
|
||||
patch_ops=patch_ops,
|
||||
)
|
||||
|
||||
metadata: Final = update_data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
assert SCIM_ENTERPRISE_METADATA_KEY not in metadata
|
||||
|
||||
|
||||
def test_apply_patch_ops_pathless_enterprise_value_must_be_a_dict():
|
||||
patch_ops: Final = SCIMPatchOp(
|
||||
Operations=[
|
||||
SCIMPatchOperation(
|
||||
op="replace",
|
||||
value={SCIM_ENTERPRISE_USER_SCHEMA: "not-a-dict"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_apply_patch_ops(existing_user=_user_with_metadata({}), patch_ops=patch_ops)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
|
|
|||
|
|
@ -2636,10 +2636,99 @@ async def test_team_model_add_delete_keep_model_aliases_in_team_cache(endpoint_n
|
|||
assert team_model_aliases(cached_team) == {"fast": "gpt-4o"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("endpoint_name", "blocked"), [("block_team", True), ("unblock_team", False)])
|
||||
async def test_team_block_unblock_reach_warmed_auth_on_every_worker(
|
||||
endpoint_name: str, blocked: bool, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
import litellm.proxy.common_utils.auth_cache_invalidation_pubsub as pubsub_module
|
||||
from litellm.proxy._types import BlockTeamRequest
|
||||
from litellm.proxy.auth.auth_checks import get_team_object
|
||||
from litellm.proxy.auth.team_grants import team_model_aliases
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.management_endpoints import team_endpoints
|
||||
|
||||
team_id: Final = "team-kill-switch"
|
||||
columns: Final = {"team_id": team_id, "team_alias": "kill-switch", "blocked": not blocked}
|
||||
alias_table: Final = {"id": 1, "model_aliases": '{"fast": "gpt-4o"}', "created_by": "admin", "updated_by": "admin"}
|
||||
|
||||
async def update(
|
||||
where: Mapping[str, str], data: Mapping[str, bool], include: Mapping[str, bool] | None = None
|
||||
) -> LiteLLM_TeamTable:
|
||||
relations: Final = {"litellm_model_table": alias_table} if (include or {}).get("litellm_model_table") else {}
|
||||
return LiteLLM_TeamTable.model_validate({**columns, **data, **relations})
|
||||
|
||||
prisma_client: Final = MagicMock()
|
||||
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=LiteLLM_TeamTable(**columns))
|
||||
prisma_client.db.litellm_teamtable.update = AsyncMock(side_effect=update)
|
||||
cache: Final = UserApiKeyCache()
|
||||
redis: Final = _BroadcastRecordingRedis()
|
||||
monkeypatch.setattr(pubsub_module, "coordination_redis_cache", lambda: redis)
|
||||
monkeypatch.setattr(pubsub_module, "_pending_publishes", set())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", cache)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", None)
|
||||
|
||||
warmed: Final = await get_team_object(team_id=team_id, prisma_client=prisma_client, user_api_key_cache=cache)
|
||||
assert warmed.blocked is not blocked
|
||||
|
||||
response: Final = await getattr(team_endpoints, endpoint_name)(
|
||||
data=BlockTeamRequest(team_id=team_id),
|
||||
http_request=MagicMock(),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
|
||||
)
|
||||
await asyncio.gather(*pubsub_module._pending_publishes) # pyright: ignore[reportPrivateUsage] # drain module-level tasks
|
||||
|
||||
seen_by_auth: Final = await get_team_object(team_id=team_id, prisma_client=prisma_client, user_api_key_cache=cache)
|
||||
assert seen_by_auth.blocked is blocked
|
||||
assert team_model_aliases(seen_by_auth) == {"fast": "gpt-4o"}
|
||||
assert redis.broadcast_keys == [f"team_id:{team_id}", "team_alias:kill-switch"]
|
||||
assert (response.blocked, response.litellm_model_table, response.object_permission) == (blocked, None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("endpoint_name", ["block_team", "unblock_team"])
|
||||
@pytest.mark.parametrize(
|
||||
("caller", "stored_team", "status_code"),
|
||||
[
|
||||
(
|
||||
UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice"),
|
||||
LiteLLM_TeamTable(team_id="team-1234", members_with_roles=[Member(user_id="alice", role="user")]),
|
||||
403,
|
||||
),
|
||||
(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"), None, 404),
|
||||
],
|
||||
)
|
||||
async def test_team_block_unblock_refuse_before_writing(
|
||||
endpoint_name: str,
|
||||
caller: UserAPIKeyAuth,
|
||||
stored_team: LiteLLM_TeamTable | None,
|
||||
status_code: int,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from litellm.proxy._types import BlockTeamRequest
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.management_endpoints import team_endpoints
|
||||
|
||||
prisma_client: Final = MagicMock()
|
||||
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=stored_team)
|
||||
prisma_client.db.litellm_teamtable.update = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", UserApiKeyCache())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await getattr(team_endpoints, endpoint_name)(
|
||||
data=BlockTeamRequest(team_id="team-1234"), http_request=MagicMock(), user_api_key_dict=caller
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status_code
|
||||
prisma_client.db.litellm_teamtable.update.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name",
|
||||
["team_model_add", "team_model_delete", "update_team_member_permissions"],
|
||||
["team_model_add", "team_model_delete", "update_team_member_permissions", "block_team", "unblock_team"],
|
||||
)
|
||||
async def test_team_write_404s_when_row_vanishes_before_update(endpoint_name):
|
||||
"""A team deleted between the read and the write must 404.
|
||||
|
|
@ -2662,9 +2751,12 @@ async def test_team_write_404s_when_row_vanishes_before_update(endpoint_name):
|
|||
TeamModelDeleteRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy._types import BlockTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
block_team,
|
||||
team_model_add,
|
||||
team_model_delete,
|
||||
unblock_team,
|
||||
update_team_member_permissions,
|
||||
)
|
||||
|
||||
|
|
@ -2701,6 +2793,16 @@ async def test_team_write_404s_when_row_vanishes_before_update(endpoint_name):
|
|||
http_request=mock_request,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
),
|
||||
"block_team": lambda: block_team(
|
||||
data=BlockTeamRequest(team_id="team-1234"),
|
||||
http_request=mock_request,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
),
|
||||
"unblock_team": lambda: unblock_team(
|
||||
data=BlockTeamRequest(team_id="team-1234"),
|
||||
http_request=mock_request,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
),
|
||||
}[endpoint_name]
|
||||
|
||||
with (
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
from dataclasses import dataclass
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, Final, List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.token_counter import high_detail_image_token_upper_bound
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_passthrough_logging_handler import (
|
||||
OpenAIPassthroughLoggingHandler,
|
||||
count_relayed_prompt_tokens,
|
||||
)
|
||||
from litellm.proxy._types import SpendLogsPayload
|
||||
from litellm.proxy.pass_through_endpoints.success_handler import (
|
||||
PassThroughEndpointLogging,
|
||||
)
|
||||
|
|
@ -1837,10 +1844,7 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog:
|
|||
|
||||
def _expected_spend(self) -> float:
|
||||
rates = litellm.model_cost[self.MODEL_MAP_KEY]
|
||||
return (
|
||||
self.INPUT_TOKENS * rates["input_cost_per_token"]
|
||||
+ self.OUTPUT_TOKENS * rates["output_cost_per_token"]
|
||||
)
|
||||
return self.INPUT_TOKENS * rates["input_cost_per_token"] + self.OUTPUT_TOKENS * rates["output_cost_per_token"]
|
||||
|
||||
def _responses_stream_chunks(self) -> List[str]:
|
||||
"""Usage arrives only on the terminal `response.completed` event, nested under
|
||||
|
|
@ -1953,6 +1957,28 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog:
|
|||
row_metadata = json.loads(spend_log_row["metadata"])
|
||||
assert row_metadata["model_map_information"]["model_map_key"] == self.MODEL_MAP_KEY
|
||||
|
||||
def test_resumed_responses_stream_spend_log_is_labeled_with_the_streamed_model(self, local_model_cost_map):
|
||||
result: Final = OpenAIPassthroughLoggingHandler._handle_logging_openai_collected_chunks(
|
||||
litellm_logging_obj=self._logging_obj(),
|
||||
passthrough_success_handler_obj=None,
|
||||
url_route=f"https://api.openai.com/v1/responses/{self.RESPONSE_ID}?stream=true",
|
||||
request_body={},
|
||||
endpoint_type=None,
|
||||
start_time=self.start_time,
|
||||
all_chunks=self._responses_stream_chunks(),
|
||||
end_time=self.end_time,
|
||||
)
|
||||
|
||||
spend_log_row: Final = get_logging_payload(
|
||||
kwargs=result["kwargs"],
|
||||
response_obj=result["result"],
|
||||
start_time=self.start_time,
|
||||
end_time=self.end_time,
|
||||
)
|
||||
|
||||
assert spend_log_row["model"] == self.MODEL_MAP_KEY
|
||||
assert spend_log_row["spend"] == pytest.approx(self._expected_spend())
|
||||
|
||||
|
||||
class TestOpenAIPassthroughEmbeddingsSpendLog:
|
||||
"""An OpenAI-passthrough `/v1/embeddings` call must write a priced spend log row
|
||||
|
|
@ -2043,6 +2069,402 @@ class TestOpenAIPassthroughEmbeddingsSpendLog:
|
|||
assert spend_log_row["request_id"] == self.CALL_ID
|
||||
|
||||
|
||||
RESPONSES_URL: Final = "https://api.openai.com/v1/responses"
|
||||
STORED_RESPONSE_ID: Final = "resp_080b10ff5d740c69006ad3de732e5087"
|
||||
DISPATCH_TIME: Final = datetime(2026, 10, 17, 13, 47, 3)
|
||||
RESPONSES_USAGE: Final = {
|
||||
"input_tokens": 11,
|
||||
"input_tokens_details": {"cached_tokens": 0},
|
||||
"output_tokens": 5,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 16,
|
||||
}
|
||||
CREATE_REQUEST: Final = {"model": "gpt-5.4-mini", "input": "Say hi in one word"}
|
||||
COMPACT_REQUEST: Final = {"model": "gpt-5.4-mini", "input": "Remember the number 42."}
|
||||
COMPACTION: Final = {
|
||||
"id": STORED_RESPONSE_ID,
|
||||
"object": "response.compaction",
|
||||
"created_at": 1792270025,
|
||||
"output": [{"id": "cmp_082492f9", "type": "compaction", "encrypted_content": "gAAAA"}],
|
||||
"usage": RESPONSES_USAGE,
|
||||
}
|
||||
ASSISTANT_MESSAGE: Final = {
|
||||
"id": "msg_080b10ff5d740c69",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Hi", "annotations": []}],
|
||||
}
|
||||
|
||||
|
||||
def _stored_response(status: str, background: bool, usage: Mapping[str, object] | None) -> dict[str, object]:
|
||||
return {
|
||||
"id": STORED_RESPONSE_ID,
|
||||
"object": "response",
|
||||
"created_at": 1792269939,
|
||||
"status": status,
|
||||
"background": background,
|
||||
"error": None,
|
||||
"incomplete_details": None,
|
||||
"instructions": None,
|
||||
"metadata": {},
|
||||
"model": "gpt-5.4-mini-2026-03-17",
|
||||
"output": [ASSISTANT_MESSAGE] if usage else [],
|
||||
"parallel_tool_calls": True,
|
||||
"temperature": 1.0,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"top_p": 0.98,
|
||||
"usage": usage,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LoggedCall:
|
||||
spend_row: SpendLogsPayload
|
||||
callback_response_id: str
|
||||
|
||||
|
||||
class _CallbackPayloadId(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
class _SpendRowRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.call: Final[asyncio.Future[_LoggedCall]] = asyncio.get_running_loop().create_future()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
self.call.set_result(
|
||||
_LoggedCall(
|
||||
spend_row=get_logging_payload(
|
||||
kwargs=kwargs, response_obj=response_obj, start_time=start_time, end_time=end_time
|
||||
),
|
||||
callback_response_id=_CallbackPayloadId.model_validate(kwargs.get("standard_logging_object")).id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestOpenAIPassthroughResponsesSubRouteBilling:
|
||||
def _dispatch(
|
||||
self,
|
||||
method: str,
|
||||
sub_route: str,
|
||||
request_body: dict[str, object],
|
||||
response_body: dict[str, object],
|
||||
base_url: str = RESPONSES_URL,
|
||||
):
|
||||
url_route: Final = f"{base_url}{sub_route}"
|
||||
logging_obj: Final = LiteLLMLoggingObj(
|
||||
model="gpt-5.4-mini",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=DISPATCH_TIME,
|
||||
litellm_call_id="6c1f3a52-93d4-4b1e-9f0e-6f2f4f6b9a10",
|
||||
function_id="1234",
|
||||
)
|
||||
return PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(
|
||||
httpx_response=httpx.Response(200, json=response_body, request=httpx.Request(method, url_route)),
|
||||
response_body=response_body,
|
||||
request_body=request_body,
|
||||
logging_obj=logging_obj,
|
||||
url_route=url_route,
|
||||
result="",
|
||||
start_time=DISPATCH_TIME,
|
||||
end_time=DISPATCH_TIME,
|
||||
cache_hit=False,
|
||||
custom_llm_provider="openai",
|
||||
litellm_params={},
|
||||
call_type="pass_through_endpoint",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "sub_route", "request_body", "response_body"),
|
||||
[
|
||||
pytest.param(
|
||||
"POST", "", CREATE_REQUEST, _stored_response("completed", False, RESPONSES_USAGE), id="create"
|
||||
),
|
||||
pytest.param(
|
||||
"POST",
|
||||
"/",
|
||||
CREATE_REQUEST,
|
||||
_stored_response("completed", False, RESPONSES_USAGE),
|
||||
id="create_trailing_slash",
|
||||
),
|
||||
pytest.param("POST", "/compact", COMPACT_REQUEST, COMPACTION, id="compact"),
|
||||
pytest.param("POST", "/%2563ompact", COMPACT_REQUEST, COMPACTION, id="percent_encoded_compact"),
|
||||
pytest.param(
|
||||
"GET",
|
||||
f"/{STORED_RESPONSE_ID}",
|
||||
{},
|
||||
_stored_response("completed", True, RESPONSES_USAGE),
|
||||
id="background_retrieve_completed",
|
||||
),
|
||||
pytest.param(
|
||||
"POST",
|
||||
f"/{STORED_RESPONSE_ID}/cancel",
|
||||
{},
|
||||
_stored_response("cancelled", True, RESPONSES_USAGE),
|
||||
id="background_cancel",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_billed_responses_call_records_cost(
|
||||
self,
|
||||
local_model_cost_map,
|
||||
method: str,
|
||||
sub_route: str,
|
||||
request_body: dict[str, object],
|
||||
response_body: dict[str, object],
|
||||
):
|
||||
dispatched: Final = self._dispatch(method, sub_route, request_body, response_body)
|
||||
|
||||
assert dispatched["standard_logging_response_object"] is not None
|
||||
assert dispatched["kwargs"]["response_cost"] > 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "sub_route", "request_body", "response_body"),
|
||||
[
|
||||
pytest.param(
|
||||
"POST",
|
||||
"/input_tokens",
|
||||
{"model": "gpt-5.4-mini", "input": "hi"},
|
||||
{"object": "response.input_tokens", "input_tokens": 7},
|
||||
id="input_tokens",
|
||||
),
|
||||
pytest.param(
|
||||
"GET",
|
||||
f"/{STORED_RESPONSE_ID}",
|
||||
{},
|
||||
_stored_response("completed", False, RESPONSES_USAGE),
|
||||
id="retrieve",
|
||||
),
|
||||
pytest.param(
|
||||
"GET",
|
||||
f"/{STORED_RESPONSE_ID}/input_items",
|
||||
{},
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "msg_074176e3",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Say hi in one word"}],
|
||||
}
|
||||
],
|
||||
"first_id": "msg_074176e3",
|
||||
"last_id": "msg_074176e3",
|
||||
"has_more": False,
|
||||
},
|
||||
id="input_items",
|
||||
),
|
||||
pytest.param(
|
||||
"DELETE",
|
||||
f"/{STORED_RESPONSE_ID}",
|
||||
{},
|
||||
{"id": STORED_RESPONSE_ID, "object": "response.deleted", "deleted": True},
|
||||
id="delete",
|
||||
),
|
||||
pytest.param(
|
||||
"POST",
|
||||
f"/{STORED_RESPONSE_ID}/cancel",
|
||||
{},
|
||||
_stored_response("cancelled", True, None),
|
||||
id="background_cancel_before_any_output",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_unbilled_responses_sub_route_records_no_cost_and_no_error(
|
||||
self,
|
||||
local_model_cost_map,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
method: str,
|
||||
sub_route: str,
|
||||
request_body: dict[str, object],
|
||||
response_body: dict[str, object],
|
||||
):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
dispatched: Final = self._dispatch(method, sub_route, request_body, response_body)
|
||||
|
||||
assert not dispatched["kwargs"].get("response_cost")
|
||||
assert [record.getMessage() for record in caplog.records if record.levelno >= logging.ERROR] == []
|
||||
|
||||
async def _logged_call(
|
||||
self,
|
||||
method: str,
|
||||
sub_route: str,
|
||||
request_body: dict[str, object],
|
||||
response_body: dict[str, object],
|
||||
litellm_call_id: str,
|
||||
) -> _LoggedCall:
|
||||
url_route: Final = f"{RESPONSES_URL}{sub_route}"
|
||||
recorder: Final = _SpendRowRecorder()
|
||||
logging_obj: Final = LiteLLMLoggingObj(
|
||||
model="unknown",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=DISPATCH_TIME,
|
||||
litellm_call_id=litellm_call_id,
|
||||
function_id="1234",
|
||||
dynamic_async_success_callbacks=[recorder],
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
model="unknown", user="unknown", optional_params={}, litellm_params={}, call_type="pass_through_endpoint"
|
||||
)
|
||||
|
||||
await PassThroughEndpointLogging().pass_through_async_success_handler(
|
||||
httpx_response=httpx.Response(200, json=response_body, request=httpx.Request(method, url_route)),
|
||||
response_body=response_body,
|
||||
logging_obj=logging_obj,
|
||||
url_route=url_route,
|
||||
result="",
|
||||
start_time=DISPATCH_TIME,
|
||||
end_time=DISPATCH_TIME,
|
||||
cache_hit=False,
|
||||
request_body=request_body,
|
||||
passthrough_logging_payload=PassthroughStandardLoggingPayload(
|
||||
url=url_route, request_body=request_body, request_method=method
|
||||
),
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
return await asyncio.wait_for(recorder.call, timeout=5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbilled_retrieve_spend_log_keeps_the_response_model(self, local_model_cost_map):
|
||||
response_body: Final = _stored_response("completed", False, RESPONSES_USAGE)
|
||||
|
||||
spend_log_row: Final = (
|
||||
await self._logged_call(
|
||||
"GET", f"/{STORED_RESPONSE_ID}", {}, response_body, "0f6f3d2e-6a51-4c4e-9a43-6f1b0d1e9c21"
|
||||
)
|
||||
).spend_row
|
||||
|
||||
assert spend_log_row["model"] == response_body["model"]
|
||||
assert not spend_log_row["spend"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("method", "sub_route", "response_body"),
|
||||
[
|
||||
pytest.param(
|
||||
"GET",
|
||||
f"/{STORED_RESPONSE_ID}",
|
||||
_stored_response("completed", True, RESPONSES_USAGE),
|
||||
id="background_retrieve_completed",
|
||||
),
|
||||
pytest.param(
|
||||
"POST",
|
||||
f"/{STORED_RESPONSE_ID}/cancel",
|
||||
_stored_response("cancelled", True, RESPONSES_USAGE),
|
||||
id="background_cancel",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_billed_background_read_spend_log_survives_next_to_its_create_and_poll_rows(
|
||||
self, local_model_cost_map, method: str, sub_route: str, response_body: dict[str, object]
|
||||
):
|
||||
create: Final = await self._logged_call(
|
||||
"POST",
|
||||
"",
|
||||
{**CREATE_REQUEST, "background": True},
|
||||
_stored_response("queued", True, None),
|
||||
"3a4f0c1e-2b7d-4c55-8e0e-0d7a51d9f6a1",
|
||||
)
|
||||
poll: Final = await self._logged_call(
|
||||
"GET",
|
||||
f"/{STORED_RESPONSE_ID}",
|
||||
{},
|
||||
_stored_response("in_progress", True, None),
|
||||
"5d8c2f71-0e4b-4a9d-b3c6-7f1e2a9d0c44",
|
||||
)
|
||||
read: Final = await self._logged_call(
|
||||
method, sub_route, {}, response_body, "9b2e6d40-71c3-4f8a-a5d2-4e3c8f0b7d12"
|
||||
)
|
||||
|
||||
assert read.spend_row["request_id"] not in {create.spend_row["request_id"], poll.spend_row["request_id"]}
|
||||
assert read.spend_row["request_id"].startswith(STORED_RESPONSE_ID)
|
||||
assert read.spend_row["spend"] > 0
|
||||
assert not poll.spend_row["spend"]
|
||||
assert read.callback_response_id == STORED_RESPONSE_ID
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamed_background_create_and_its_finished_read_share_one_spend_row(self, local_model_cost_map):
|
||||
finished: Final = _stored_response("completed", True, RESPONSES_USAGE)
|
||||
stream_logging_obj: Final = LiteLLMLoggingObj(
|
||||
model="gpt-5.4-mini",
|
||||
messages=[],
|
||||
stream=True,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=DISPATCH_TIME,
|
||||
litellm_call_id="7e3b9a14-5c2d-4f60-8d1a-2b6c0e9f4a37",
|
||||
function_id="1234",
|
||||
)
|
||||
stream_logging_obj.model_call_details["custom_llm_provider"] = "openai"
|
||||
streamed_create: Final = OpenAIPassthroughLoggingHandler._handle_logging_openai_collected_chunks(
|
||||
litellm_logging_obj=stream_logging_obj,
|
||||
passthrough_success_handler_obj=None,
|
||||
url_route=RESPONSES_URL,
|
||||
request_body={**CREATE_REQUEST, "background": True, "stream": True},
|
||||
endpoint_type=None,
|
||||
start_time=DISPATCH_TIME,
|
||||
all_chunks=[
|
||||
f"data: {json.dumps({'type': 'response.completed', 'sequence_number': 1, 'response': finished})}",
|
||||
"data: [DONE]",
|
||||
],
|
||||
end_time=DISPATCH_TIME,
|
||||
)
|
||||
create_row: Final = get_logging_payload(
|
||||
kwargs=streamed_create["kwargs"],
|
||||
response_obj=streamed_create["result"],
|
||||
start_time=DISPATCH_TIME,
|
||||
end_time=DISPATCH_TIME,
|
||||
)
|
||||
|
||||
read: Final = await self._logged_call(
|
||||
"GET", f"/{STORED_RESPONSE_ID}", {}, finished, "1c8d5e2a-9f47-4b3e-a6d0-5e7f2c1b8a93"
|
||||
)
|
||||
|
||||
assert create_row["spend"] > 0
|
||||
assert read.spend_row["request_id"] == create_row["request_id"]
|
||||
assert read.callback_response_id == STORED_RESPONSE_ID
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
pytest.param("https://my-resource.openai.azure.com/openai/v1/responses", id="azure_openai_v1"),
|
||||
pytest.param("https://my-resource.cognitiveservices.azure.com/openai/responses", id="azure_openai_native"),
|
||||
],
|
||||
)
|
||||
def test_azure_responses_sub_routes_bill_like_openai(self, local_model_cost_map, base_url: str):
|
||||
retrieve: Final = self._dispatch(
|
||||
"GET", f"/{STORED_RESPONSE_ID}", {}, _stored_response("completed", False, RESPONSES_USAGE), base_url
|
||||
)
|
||||
input_tokens: Final = self._dispatch(
|
||||
"POST",
|
||||
"/input_tokens",
|
||||
{"model": "gpt-5.4-mini", "input": "hi"},
|
||||
{"object": "response.input_tokens", "input_tokens": 7},
|
||||
base_url,
|
||||
)
|
||||
background_retrieve: Final = self._dispatch(
|
||||
"GET", f"/{STORED_RESPONSE_ID}", {}, _stored_response("completed", True, RESPONSES_USAGE), base_url
|
||||
)
|
||||
compact: Final = self._dispatch("POST", "/compact", COMPACT_REQUEST, COMPACTION, base_url)
|
||||
|
||||
assert not retrieve["kwargs"].get("response_cost")
|
||||
assert not input_tokens["kwargs"].get("response_cost")
|
||||
assert background_retrieve["kwargs"]["response_cost"] > 0
|
||||
assert compact["kwargs"]["response_cost"] > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue