mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
Merge branch 'main' into litellm_scope_cooldown_reads_to_candidates
This commit is contained in:
commit
fc39b04774
51 changed files with 1470 additions and 886 deletions
11
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
11
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
|
|
@ -4,6 +4,15 @@
|
|||
Drop every section you have nothing to put in, heading included: a bare "## Relevant issues" or
|
||||
"## Affected release" with nothing under it must not appear in the final description -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Two or three plain sentences a teammate can read in 10 seconds, written the way you'd explain it in Slack
|
||||
Say what the PR does for users and why, then anything that keeps it working over time (a sync job, a config someone owns)
|
||||
No headings, bullets, tables, or code names here. Everything else goes inside the Details block below -->
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
## TLDR
|
||||
|
||||
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max
|
||||
|
|
@ -164,3 +173,5 @@ Example checklists:
|
|||
## Final Attestation
|
||||
|
||||
- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -59,16 +59,6 @@ class PendingBatchRead:
|
|||
previous_access_times: dict[str, float | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeclaredBatchRead:
|
||||
"""A ``async_batch_get_cache`` split in two: the memory half done, the Redis half declared on a ``RedisBatch``
|
||||
so it rides that batch's next round trip, resolved later with ``async_resolve_batch_get``."""
|
||||
|
||||
keys: tuple[str, ...]
|
||||
pending: PendingBatchRead
|
||||
result: BatchResult[Mapping[str, object]] | None
|
||||
|
||||
|
||||
def _log_deferred_increment_failure(future: asyncio.Future[float]) -> None:
|
||||
if future.cancelled():
|
||||
return
|
||||
|
|
@ -382,22 +372,6 @@ class DualCache(BaseCache):
|
|||
await self.in_memory_cache.async_set_cache(key, value, **self._backfill_kwargs(kwargs))
|
||||
return merged
|
||||
|
||||
async def declare_batch_get(self, keys: Sequence[str], batch: RedisBatch) -> DeclaredBatchRead:
|
||||
pending: Final = await self._prepare_batch_get(
|
||||
list(keys),
|
||||
local_only=False,
|
||||
throttle_redis=False,
|
||||
)
|
||||
return DeclaredBatchRead(
|
||||
keys=tuple(keys),
|
||||
pending=pending,
|
||||
result=batch.mget(pending.redis_keys) if pending.redis_keys else None,
|
||||
)
|
||||
|
||||
async def async_resolve_batch_get(self, declared: DeclaredBatchRead) -> list[object | None]:
|
||||
redis_result: Final = None if declared.result is None else await declared.result
|
||||
return await self._apply_batch_get(declared.pending, redis_result)
|
||||
|
||||
async def async_batch_get_cache(
|
||||
self,
|
||||
keys: list,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,12 @@ _INCREMENT_WITH_FLOOR_LUA: Final = (
|
|||
"return count"
|
||||
)
|
||||
|
||||
_INCREMENT_IF_PRESENT_LUA: Final = (
|
||||
"if redis.call('EXISTS', KEYS[1]) == 0 then return false end return redis.call('INCRBYFLOAT', KEYS[1], ARGV[1])"
|
||||
)
|
||||
|
||||
_LUA_COUNT: Final = TypeAdapter(int)
|
||||
_LUA_OPTIONAL_TOTAL: Final[TypeAdapter[float | None]] = TypeAdapter(float | None)
|
||||
_OPTIONAL_COUNTS: Final = TypeAdapter(tuple[int | None, ...])
|
||||
|
||||
|
||||
|
|
@ -675,7 +680,7 @@ class RedisCache(BaseCache):
|
|||
# redis namespaces
|
||||
self.namespace = namespace
|
||||
# for high traffic, we store the redis results in memory and then batch write to redis
|
||||
self.redis_batch_writing_buffer: list = []
|
||||
self.redis_batch_writing_buffer: list[tuple[str, object]] = []
|
||||
if redis_flush_size is None:
|
||||
self.redis_flush_size: int = 100
|
||||
else:
|
||||
|
|
@ -1568,6 +1573,46 @@ class RedisCache(BaseCache):
|
|||
count: Final = await _redis_client.eval(_INCREMENT_WITH_FLOOR_LUA, 1, namespaced_key, value, ttl)
|
||||
return _LUA_COUNT.validate_python(count)
|
||||
|
||||
@_redis_circuit_breaker_guard
|
||||
async def async_increment_if_present(
|
||||
self, key: str, value: float, parent_otel_span: Span | None = None
|
||||
) -> float | None:
|
||||
_redis_client: Final = self._async_commands()
|
||||
namespaced_key: Final = self.check_and_fix_namespace(key=key)
|
||||
start_time: Final = time.time()
|
||||
try:
|
||||
total: Final = _LUA_OPTIONAL_TOTAL.validate_python(
|
||||
await _redis_client.eval(_INCREMENT_IF_PRESENT_LUA, 1, namespaced_key, value)
|
||||
)
|
||||
except Exception as e:
|
||||
failed_at: Final = time.time()
|
||||
asyncio.create_task(
|
||||
self.service_logger_obj.async_service_failure_hook(
|
||||
service=ServiceTypes.REDIS,
|
||||
duration=failed_at - start_time,
|
||||
error=e,
|
||||
call_type="async_increment_if_present",
|
||||
caller=_get_call_stack_info(),
|
||||
start_time=start_time,
|
||||
end_time=failed_at,
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
)
|
||||
raise
|
||||
end_time: Final = time.time()
|
||||
asyncio.create_task(
|
||||
self.service_logger_obj.async_service_success_hook(
|
||||
service=ServiceTypes.REDIS,
|
||||
duration=end_time - start_time,
|
||||
call_type="async_increment_if_present",
|
||||
caller=_get_call_stack_info(),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
)
|
||||
return total
|
||||
|
||||
async def flush_cache_buffer(self):
|
||||
print_verbose(f"flushing to redis....reached size of buffer {len(self.redis_batch_writing_buffer)}")
|
||||
await self.async_set_cache_pipeline(self.redis_batch_writing_buffer)
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ way (`pod_lock`, `budget_reset`, `spend_queue`, `health_check`, `scheduler_queue
|
|||
rather than a bare `redis.set`; `tests/unit/test_internal_context.py` scans every
|
||||
module under `litellm/` and `enterprise/` that calls a shared cache or declares a
|
||||
read or write on the request batch (`reserve_redis_batch_reads`,
|
||||
`declare_batch_get`, `batch.mget`, `batch.set`, `batch.script`) and fails when
|
||||
`batch.mget`, `batch.set`, `batch.script`) and fails when
|
||||
one has no declared family, with the process-local `InMemoryCache` callers listed
|
||||
as the only exemptions. A batch op carries the family that was active when it was
|
||||
declared, so the routing prefetch armed before deployment selection
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ _SERVICE_VERB_BY_CALL_TYPE: Final[dict[str, str]] = {
|
|||
"increment_cache": "incr",
|
||||
"async_increment": "incr",
|
||||
"async_increment_pipeline": "incr",
|
||||
"async_increment_if_present": "incr",
|
||||
"delete_cache": "delete",
|
||||
"async_delete_cache": "delete",
|
||||
"async_rpush": "rpush",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ class _AssumeRoleParams(TypedDict):
|
|||
Tags: ReadOnly[NotRequired[tuple[AwsSessionTag, ...]]]
|
||||
|
||||
|
||||
class _StsClientKwargs(TypedDict):
|
||||
verify: ReadOnly[bool | str]
|
||||
endpoint_url: ReadOnly[NotRequired[str]]
|
||||
region_name: ReadOnly[NotRequired[str]]
|
||||
|
||||
|
||||
def _assume_role_params(
|
||||
aws_role_name: str,
|
||||
aws_session_name: str,
|
||||
|
|
@ -275,7 +281,7 @@ class BaseAWSLLM(SignsRequestsWithAWS):
|
|||
"aws_session_tags",
|
||||
]
|
||||
|
||||
def _get_ssl_verify(self, ssl_verify: bool | str | None = None):
|
||||
def _get_ssl_verify(self, ssl_verify: bool | str | None = None) -> bool | str:
|
||||
"""
|
||||
Get SSL verification setting for boto3 clients.
|
||||
|
||||
|
|
@ -875,15 +881,18 @@ class BaseAWSLLM(SignsRequestsWithAWS):
|
|||
aws_sts_endpoint: str | None = None,
|
||||
ssl_verify: bool | str | None = None,
|
||||
aws_region_name: str | None = None,
|
||||
) -> dict:
|
||||
) -> _StsClientKwargs:
|
||||
"""STS client kwargs with aligned endpoint_url and region_name (SigV4)."""
|
||||
kwargs: Final[dict] = {"verify": self._get_ssl_verify(ssl_verify)}
|
||||
if aws_sts_endpoint is not None:
|
||||
kwargs["endpoint_url"] = aws_sts_endpoint
|
||||
sts_region: Final = self._resolve_sts_region(aws_sts_endpoint, aws_region_name)
|
||||
if sts_region is not None:
|
||||
kwargs["region_name"] = sts_region
|
||||
return kwargs
|
||||
verify: Final = self._get_ssl_verify(ssl_verify)
|
||||
match (aws_sts_endpoint, self._resolve_sts_region(aws_sts_endpoint, aws_region_name)):
|
||||
case (None, None):
|
||||
return _StsClientKwargs(verify=verify)
|
||||
case (None, region):
|
||||
return _StsClientKwargs(verify=verify, region_name=region)
|
||||
case (endpoint, None):
|
||||
return _StsClientKwargs(verify=verify, endpoint_url=endpoint)
|
||||
case (endpoint, region):
|
||||
return _StsClientKwargs(verify=verify, endpoint_url=endpoint, region_name=region)
|
||||
|
||||
def get_aws_region_name_for_non_llm_api_calls(
|
||||
self,
|
||||
|
|
@ -1813,13 +1822,13 @@ async def run_aws_signing(
|
|||
)
|
||||
|
||||
|
||||
async def sign_request_off_loop_if_aws(
|
||||
async def run_off_loop_if_aws(
|
||||
provider_config: object,
|
||||
sign_request: Callable[_SignParams, _SignedRequest],
|
||||
call: Callable[_SignParams, _SignedRequest],
|
||||
/,
|
||||
*args: _SignParams.args,
|
||||
**kwargs: _SignParams.kwargs, # kwargs-ok: ParamSpec forwarding keeps the wrapped sign_request signature
|
||||
**kwargs: _SignParams.kwargs, # kwargs-ok: ParamSpec forwarding keeps the wrapped call's signature
|
||||
) -> _SignedRequest:
|
||||
if isinstance(provider_config, SignsRequestsWithAWS):
|
||||
return await run_aws_signing(sign_request, *args, **kwargs)
|
||||
return sign_request(*args, **kwargs)
|
||||
return await run_aws_signing(call, *args, **kwargs)
|
||||
return call(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from typing import Any, Final
|
||||
from typing import Final
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.pass_through.messages.transformation import (
|
||||
DEFAULT_ANTHROPIC_API_VERSION,
|
||||
AnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.llms.bedrock.base_aws_llm import run_aws_signing
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
|
|
@ -22,14 +23,14 @@ class BedrockClaudePlatformMessagesConfig(BedrockClaudePlatformMixin, AnthropicM
|
|||
|
||||
def validate_anthropic_messages_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
headers: dict[str, str], # mutable-ok: overrides the base signature
|
||||
model: str,
|
||||
messages: list[Any],
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
messages: list[object], # mutable-ok: overrides the base signature
|
||||
optional_params: dict[str, object], # mutable-ok: overrides the base signature
|
||||
litellm_params: dict[str, object], # mutable-ok: overrides the base signature
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> tuple[dict, str | None]:
|
||||
) -> tuple[dict[str, str], str | None]: # mutable-ok: overrides the base signature
|
||||
workspace_id: Final = self._get_workspace_id(optional_params, litellm_params)
|
||||
if workspace_id is None:
|
||||
raise litellm.AuthenticationError(
|
||||
|
|
@ -66,6 +67,27 @@ class BedrockClaudePlatformMessagesConfig(BedrockClaudePlatformMixin, AnthropicM
|
|||
api_base,
|
||||
)
|
||||
|
||||
async def avalidate_anthropic_messages_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: mirrors validate_anthropic_messages_environment
|
||||
model: str,
|
||||
messages: list[object], # mutable-ok: mirrors validate_anthropic_messages_environment
|
||||
optional_params: dict[str, object], # mutable-ok: mirrors validate_anthropic_messages_environment
|
||||
litellm_params: dict[str, object], # mutable-ok: mirrors validate_anthropic_messages_environment
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> tuple[dict[str, str], str | None]: # mutable-ok: mirrors validate_anthropic_messages_environment
|
||||
return await run_aws_signing(
|
||||
self.validate_anthropic_messages_environment,
|
||||
headers=headers,
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
)
|
||||
|
||||
def transform_anthropic_messages_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig):
|
|||
verbose_logger.debug("Processing CountTokens request for resolved model: %s", resolved_model)
|
||||
|
||||
# Get AWS region using existing LiteLLM function
|
||||
aws_region_name: Final = self.get_aws_region_name(
|
||||
aws_region_name: Final = await run_aws_signing(
|
||||
self.get_aws_region_name,
|
||||
optional_params=litellm_params,
|
||||
model=resolved_model,
|
||||
model_id=None,
|
||||
|
|
@ -64,7 +65,8 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig):
|
|||
# Get endpoint URL using simplified function
|
||||
api_base: Final = litellm_params.get("api_base", None)
|
||||
aws_bedrock_runtime_endpoint: Final = litellm_params.get("aws_bedrock_runtime_endpoint", None)
|
||||
endpoint_url: Final = self.get_bedrock_count_tokens_endpoint(
|
||||
endpoint_url: Final = await run_aws_signing(
|
||||
self.get_bedrock_count_tokens_endpoint,
|
||||
model=resolved_model,
|
||||
aws_region_name=aws_region_name,
|
||||
api_base=api_base,
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ class BedrockMantleCountTokensHandler(BaseAWSLLM):
|
|||
) -> dict[str, JsonValue]:
|
||||
try:
|
||||
request: Final = _COUNT_REQUEST.validate_python(request_data)
|
||||
aws_region_name: Final = self.get_aws_region_name(
|
||||
optional_params=litellm_params, model=resolved_model, model_id=None
|
||||
aws_region_name: Final = await run_aws_signing(
|
||||
self.get_aws_region_name, optional_params=litellm_params, model=resolved_model, model_id=None
|
||||
)
|
||||
endpoint_url: Final = self.get_mantle_count_tokens_endpoint(aws_region_name)
|
||||
endpoint_url: Final = await run_aws_signing(self.get_mantle_count_tokens_endpoint, aws_region_name)
|
||||
body: Final = self._anthropic_config.transform_request_to_count_tokens(
|
||||
model=resolved_model,
|
||||
messages=request["messages"],
|
||||
|
|
|
|||
|
|
@ -337,10 +337,7 @@ class BedrockRealtime(BaseAWSLLM):
|
|||
|
||||
# Get AWS region
|
||||
if aws_region_name is None:
|
||||
optional_params: Final = {
|
||||
"aws_region_name": aws_region_name,
|
||||
}
|
||||
aws_region_name = self._get_aws_region_name(optional_params, model)
|
||||
aws_region_name = await run_aws_signing(self.get_aws_region_name, {}, model)
|
||||
|
||||
# Get endpoint URL
|
||||
if api_base is not None:
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ from litellm.llms.base_llm.vector_store_files.transformation import (
|
|||
BaseVectorStoreFilesConfig,
|
||||
)
|
||||
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
|
||||
from litellm.llms.bedrock.base_aws_llm import SignsRequestsWithAWS, run_aws_signing, sign_request_off_loop_if_aws
|
||||
from litellm.llms.bedrock.base_aws_llm import SignsRequestsWithAWS, run_aws_signing, run_off_loop_if_aws
|
||||
from litellm.llms.custom_httpx.container_handler import raise_for_error_status
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
|
|
@ -2085,14 +2085,18 @@ class BaseLLMHTTPHandler:
|
|||
raise ValueError(f"No provider config found for provider: {custom_llm_provider}")
|
||||
|
||||
# Validate environment and get headers
|
||||
headers = provider_config.validate_environment(
|
||||
headers = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.validate_environment,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
# Transform the request first
|
||||
data: Final = provider_config.transform_search_request(
|
||||
data: Final = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.transform_search_request,
|
||||
query=query,
|
||||
optional_params=optional_params,
|
||||
api_key=api_key,
|
||||
|
|
@ -2108,7 +2112,7 @@ class BaseLLMHTTPHandler:
|
|||
api_key=api_key,
|
||||
)
|
||||
|
||||
signed_headers, signed_json_body = await sign_request_off_loop_if_aws(
|
||||
signed_headers, signed_json_body = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.sign_request,
|
||||
headers=headers,
|
||||
|
|
@ -2236,7 +2240,7 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
provider_config.transform_anthropic_messages_request_on_http_error(e=e, request_data=request_body)
|
||||
recovered_errors = (*recovered_errors, e)
|
||||
headers, signed_json_body = await sign_request_off_loop_if_aws(
|
||||
headers, signed_json_body = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.sign_request,
|
||||
headers=headers,
|
||||
|
|
@ -2405,7 +2409,9 @@ class BaseLLMHTTPHandler:
|
|||
logging_obj.model_call_details.update(request_body)
|
||||
|
||||
# Make the request
|
||||
request_url: Final = anthropic_messages_provider_config.get_complete_url(
|
||||
request_url: Final = await run_off_loop_if_aws(
|
||||
anthropic_messages_provider_config,
|
||||
anthropic_messages_provider_config.get_complete_url,
|
||||
api_base=api_base,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
|
|
@ -2416,7 +2422,7 @@ class BaseLLMHTTPHandler:
|
|||
stream=stream,
|
||||
)
|
||||
|
||||
headers, signed_json_body = await sign_request_off_loop_if_aws(
|
||||
headers, signed_json_body = await run_off_loop_if_aws(
|
||||
anthropic_messages_provider_config,
|
||||
anthropic_messages_provider_config.sign_request,
|
||||
headers=headers,
|
||||
|
|
@ -2967,7 +2973,9 @@ class BaseLLMHTTPHandler:
|
|||
stream = response_api_optional_request_params.get("stream", False)
|
||||
caller_requested_stream: Final = bool(stream or (extra_body or {}).get("stream"))
|
||||
|
||||
api_base: Final = responses_api_provider_config.get_complete_url(
|
||||
api_base: Final = await run_off_loop_if_aws(
|
||||
responses_api_provider_config,
|
||||
responses_api_provider_config.get_complete_url,
|
||||
api_base=litellm_params.api_base,
|
||||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
|
@ -3009,7 +3017,7 @@ class BaseLLMHTTPHandler:
|
|||
fake_stream=fake_stream,
|
||||
)
|
||||
|
||||
headers, signed_body = await sign_request_off_loop_if_aws(
|
||||
headers, signed_body = await run_off_loop_if_aws(
|
||||
responses_api_provider_config,
|
||||
responses_api_provider_config.sign_request,
|
||||
headers=validated_headers,
|
||||
|
|
@ -3856,7 +3864,9 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=litellm_params,
|
||||
api_key=api_key,
|
||||
)
|
||||
complete_api_base: Final = provider_config.get_complete_file_url(
|
||||
complete_api_base: Final = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.get_complete_file_url,
|
||||
api_base=api_base,
|
||||
api_key=api_key,
|
||||
model="",
|
||||
|
|
@ -3867,7 +3877,9 @@ class BaseLLMHTTPHandler:
|
|||
if not complete_api_base:
|
||||
raise ValueError("api_base is required for create_file")
|
||||
return await self.async_create_file(
|
||||
transformed_request=provider_config.transform_create_file_request(
|
||||
transformed_request=await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.transform_create_file_request,
|
||||
model="",
|
||||
create_file_data=create_file_data,
|
||||
litellm_params=litellm_params,
|
||||
|
|
@ -4334,7 +4346,9 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=litellm_params,
|
||||
api_key=api_key,
|
||||
)
|
||||
complete_api_base: Final = provider_config.get_complete_batch_url(
|
||||
complete_api_base: Final = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.get_complete_batch_url,
|
||||
api_base=api_base,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
|
|
@ -4345,7 +4359,9 @@ class BaseLLMHTTPHandler:
|
|||
if not complete_api_base:
|
||||
raise ValueError("api_base is required for create_batch")
|
||||
return await self.async_create_batch(
|
||||
transformed_request=provider_config.transform_create_batch_request(
|
||||
transformed_request=await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.transform_create_batch_request,
|
||||
model=model,
|
||||
create_batch_data=create_batch_data,
|
||||
litellm_params=litellm_params,
|
||||
|
|
@ -4818,7 +4834,9 @@ class BaseLLMHTTPHandler:
|
|||
**(extra_headers or {}),
|
||||
}
|
||||
|
||||
api_base: Final = responses_api_provider_config.get_complete_url(
|
||||
api_base: Final = await run_off_loop_if_aws(
|
||||
responses_api_provider_config,
|
||||
responses_api_provider_config.get_complete_url,
|
||||
api_base=litellm_params.api_base,
|
||||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
|
@ -4836,7 +4854,7 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
data = BaseResponsesAPIConfig.normalize_responses_api_request_dict(data)
|
||||
|
||||
headers, signed_body = await sign_request_off_loop_if_aws(
|
||||
headers, signed_body = await run_off_loop_if_aws(
|
||||
responses_api_provider_config,
|
||||
responses_api_provider_config.sign_request,
|
||||
headers=validated_headers,
|
||||
|
|
@ -4962,7 +4980,9 @@ class BaseLLMHTTPHandler:
|
|||
async_httpx_client = client
|
||||
|
||||
# Get URL and params from provider config
|
||||
url, params = provider_config.transform_retrieve_file_request(
|
||||
url, params = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.transform_retrieve_file_request,
|
||||
file_id=file_id,
|
||||
optional_params={},
|
||||
litellm_params=litellm_params,
|
||||
|
|
@ -5089,7 +5109,9 @@ class BaseLLMHTTPHandler:
|
|||
async_httpx_client = client
|
||||
|
||||
# Get URL and params from provider config
|
||||
url, params = provider_config.transform_delete_file_request(
|
||||
url, params = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.transform_delete_file_request,
|
||||
file_id=file_id,
|
||||
optional_params={},
|
||||
litellm_params=litellm_params,
|
||||
|
|
@ -5214,7 +5236,9 @@ class BaseLLMHTTPHandler:
|
|||
async_httpx_client = client
|
||||
|
||||
# Get URL and params from provider config
|
||||
url, params = provider_config.transform_list_files_request(
|
||||
url, params = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.transform_list_files_request,
|
||||
purpose=purpose,
|
||||
optional_params={},
|
||||
litellm_params=litellm_params,
|
||||
|
|
@ -5298,7 +5322,9 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
yield page_files[: MAX_FILE_LIST_LIMIT - listed_count]
|
||||
listed_count += len(page_files)
|
||||
next_request = self._next_listing_request(latest_page, provider_config, litellm_params, listed_count)
|
||||
next_request = await run_off_loop_if_aws(
|
||||
provider_config, self._next_listing_request, latest_page, provider_config, litellm_params, listed_count
|
||||
)
|
||||
if next_request is None:
|
||||
return
|
||||
url, params = next_request
|
||||
|
|
@ -5524,7 +5550,9 @@ class BaseLLMHTTPHandler:
|
|||
headers: dict,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> "_PreparedFileContentRequest":
|
||||
url, params = provider_config.transform_file_content_request(
|
||||
url, params = await run_off_loop_if_aws(
|
||||
provider_config,
|
||||
provider_config.transform_file_content_request,
|
||||
file_content_request=file_content_request,
|
||||
optional_params={},
|
||||
litellm_params=litellm_params,
|
||||
|
|
@ -10332,7 +10360,9 @@ class BaseLLMHTTPHandler:
|
|||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
api_base: Final = vector_store_provider_config.get_complete_url(
|
||||
api_base: Final = await run_off_loop_if_aws(
|
||||
vector_store_provider_config,
|
||||
vector_store_provider_config.get_complete_url,
|
||||
api_base=litellm_params.api_base,
|
||||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
|
@ -10366,7 +10396,7 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
all_optional_params: Final[dict[str, object]] = dict(litellm_params)
|
||||
all_optional_params.update(vector_store_search_optional_params or {})
|
||||
headers, signed_json_body = await sign_request_off_loop_if_aws(
|
||||
headers, signed_json_body = await run_off_loop_if_aws(
|
||||
vector_store_provider_config,
|
||||
vector_store_provider_config.sign_request,
|
||||
headers=headers,
|
||||
|
|
|
|||
|
|
@ -400,7 +400,11 @@ class LiteLLM:
|
|||
|
||||
|
||||
class Chat:
|
||||
def __init__(self, params, router_obj: "Router | None"):
|
||||
def __init__(
|
||||
self,
|
||||
params: dict[str, object], # mutable-ok: pops the acompletion flag in place
|
||||
router_obj: "Router | None",
|
||||
):
|
||||
self.params = params
|
||||
if self.params.get("acompletion", False) is True:
|
||||
self.params.pop("acompletion")
|
||||
|
|
@ -480,6 +484,7 @@ async def acompletion(
|
|||
service_tier: str | None = None,
|
||||
store: bool | None = None,
|
||||
prompt_cache_key: str | None = None,
|
||||
prompt_cache_retention: str | None = None,
|
||||
# set api_base, api_version, api_key
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
|
|
@ -657,6 +662,7 @@ async def acompletion(
|
|||
"service_tier": service_tier,
|
||||
"store": store,
|
||||
"prompt_cache_key": prompt_cache_key,
|
||||
"prompt_cache_retention": prompt_cache_retention,
|
||||
"extra_headers": extra_headers,
|
||||
"acompletion": True, # assuming this is a required parameter
|
||||
"thinking": thinking,
|
||||
|
|
@ -5356,6 +5362,7 @@ def completion(
|
|||
service_tier: str | None = None,
|
||||
store: bool | None = None,
|
||||
prompt_cache_key: str | None = None,
|
||||
prompt_cache_retention: str | None = None,
|
||||
# soon to be deprecated params by OpenAI
|
||||
functions: list | None = None,
|
||||
function_call: str | None = None,
|
||||
|
|
@ -5478,6 +5485,7 @@ def completion(
|
|||
service_tier=service_tier,
|
||||
store=store,
|
||||
prompt_cache_key=prompt_cache_key,
|
||||
prompt_cache_retention=prompt_cache_retention,
|
||||
base_url=base_url,
|
||||
api_version=api_version,
|
||||
api_key=api_key,
|
||||
|
|
@ -5843,6 +5851,7 @@ def completion(
|
|||
"service_tier": service_tier,
|
||||
"store": store,
|
||||
"prompt_cache_key": prompt_cache_key,
|
||||
"prompt_cache_retention": prompt_cache_retention,
|
||||
"allowed_openai_params": allowed_openai_params,
|
||||
"base_model": base_model,
|
||||
}
|
||||
|
|
@ -9143,7 +9152,7 @@ async def ahealth_check(
|
|||
|
||||
####### HELPER FUNCTIONS ################
|
||||
## Set verbose to true -> ```litellm.set_verbose = True```
|
||||
def print_verbose(print_statement):
|
||||
def print_verbose(print_statement: object):
|
||||
try:
|
||||
verbose_logger.debug(print_statement)
|
||||
if litellm.set_verbose:
|
||||
|
|
|
|||
|
|
@ -796,7 +796,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
# logic becomes shared across providers.
|
||||
|
||||
#### CALL HOOKS - proxy only ####
|
||||
def _load_credentials(self, bearer_token: str | None = None):
|
||||
def _load_credentials(self, api_key: str | None = None):
|
||||
try:
|
||||
from botocore.credentials import Credentials
|
||||
except ImportError:
|
||||
|
|
@ -820,7 +820,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
|
||||
credentials: Final[Credentials | None] = (
|
||||
None
|
||||
if bearer_token is not None
|
||||
if bedrock_bearer_token(api_key) is not None
|
||||
else self.get_credentials(
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
|
|
@ -1017,9 +1017,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
start_time: "datetime",
|
||||
allow_chunking: bool,
|
||||
) -> BedrockGuardrailResponse:
|
||||
credentials, aws_region_name = await run_aws_signing(
|
||||
self._load_credentials, bearer_token=bedrock_bearer_token(api_key)
|
||||
)
|
||||
credentials, aws_region_name = await run_aws_signing(self._load_credentials, api_key=api_key)
|
||||
|
||||
completed_chunk_usages: Final[list[BedrockGuardrailUsage]] = [] # mutable-ok: billed-chunk usage accumulator
|
||||
try:
|
||||
|
|
@ -1960,9 +1958,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
return BedrockGuardrailResponse()
|
||||
|
||||
api_key: Final[str | None] = request_data.get("api_key") if request_data else None
|
||||
credentials, aws_region_name = await run_aws_signing(
|
||||
self._load_credentials, bearer_token=bedrock_bearer_token(api_key)
|
||||
)
|
||||
credentials, aws_region_name = await run_aws_signing(self._load_credentials, api_key=api_key)
|
||||
body: Final[dict[str, object]] = {"messages": checks_messages, "checks": self.checks}
|
||||
|
||||
prepared_request: Final = await run_aws_signing(
|
||||
|
|
|
|||
|
|
@ -359,7 +359,6 @@ class _ProxyDBLogger(CustomLogger):
|
|||
increment_spend_counters,
|
||||
proxy_logging_obj,
|
||||
update_cache,
|
||||
update_cache_read_keys,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug("INSIDE _PROXY_track_cost_callback")
|
||||
|
|
@ -454,12 +453,6 @@ class _ProxyDBLogger(CustomLogger):
|
|||
request_tags=tags,
|
||||
model_access_groups=model_access_groups,
|
||||
project_id=project_id,
|
||||
update_cache_read_keys=update_cache_read_keys(
|
||||
user_id=user_id,
|
||||
end_user_id=end_user_id,
|
||||
tags=tags,
|
||||
response_cost=response_cost,
|
||||
),
|
||||
)
|
||||
if not charged:
|
||||
return
|
||||
|
|
@ -783,7 +776,6 @@ async def _update_database_and_spend_counters(
|
|||
request_tags: list[str] | None = None,
|
||||
model_access_groups: Sequence[str] | None = None,
|
||||
project_id: str | None = None,
|
||||
update_cache_read_keys: Sequence[str] = (),
|
||||
) -> bool:
|
||||
"""The reservation is reconciled before the spend is persisted, from its own read. One spend counter batch then
|
||||
spans the database write and the counter update, so the post-call counters are read with a single MGET after the
|
||||
|
|
@ -825,7 +817,6 @@ async def _update_database_and_spend_counters(
|
|||
request_tags=request_tags,
|
||||
model_access_groups=model_access_groups,
|
||||
project_id=project_id,
|
||||
update_cache_read_keys=update_cache_read_keys,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -846,10 +837,7 @@ async def _update_database_and_spend_counters_in_batch(
|
|||
request_tags: list[str] | None,
|
||||
model_access_groups: Sequence[str] | None,
|
||||
project_id: str | None,
|
||||
update_cache_read_keys: Sequence[str],
|
||||
) -> bool:
|
||||
from litellm.proxy.proxy_server import arm_update_cache_read
|
||||
|
||||
try:
|
||||
charged: Final = await proxy_logging_obj.db_spend_update_writer.update_database(
|
||||
token=user_api_key,
|
||||
|
|
@ -881,7 +869,6 @@ async def _update_database_and_spend_counters_in_batch(
|
|||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
return False
|
||||
|
||||
await arm_update_cache_read(update_cache_read_keys)
|
||||
try:
|
||||
await increment_spend_counters(
|
||||
token=user_api_key,
|
||||
|
|
|
|||
|
|
@ -1459,7 +1459,6 @@ async def bedrock_proxy_route(
|
|||
"""
|
||||
create_request_copy(request)
|
||||
|
||||
aws_region_name: Final = get_secret_str(secret_name="AWS_REGION_NAME")
|
||||
if not _is_bedrock_agent_runtime_route(endpoint=endpoint):
|
||||
return await bedrock_llm_proxy_route(
|
||||
endpoint=endpoint,
|
||||
|
|
@ -1474,6 +1473,10 @@ async def bedrock_proxy_route(
|
|||
detail="bedrock-agent-runtime pass-through is disabled on this proxy.",
|
||||
)
|
||||
|
||||
from litellm.llms.bedrock.base_aws_llm import run_aws_signing, sign_aws_json_post
|
||||
from litellm.llms.bedrock.chat import BedrockConverseLLM
|
||||
|
||||
aws_region_name: Final = await run_aws_signing(get_secret_str, secret_name="AWS_REGION_NAME")
|
||||
base_target_url: Final = f"https://bedrock-agent-runtime.{aws_region_name}.{get_aws_dns_suffix(aws_region_name)}"
|
||||
encoded_endpoint = httpx.URL(endpoint).path
|
||||
|
||||
|
|
@ -1488,10 +1491,6 @@ async def bedrock_proxy_route(
|
|||
path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path(base_url, encoded_endpoint)
|
||||
)
|
||||
|
||||
# Add or update query parameters
|
||||
from litellm.llms.bedrock.base_aws_llm import run_aws_signing, sign_aws_json_post
|
||||
from litellm.llms.bedrock.chat import BedrockConverseLLM
|
||||
|
||||
bedrock_llm: Final = BedrockConverseLLM()
|
||||
# Assuming the body contains JSON data, parse it
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ from litellm.proxy._types import (
|
|||
TransformRequestBody,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
is_sensitive_callback_key,
|
||||
normalize_callback_names,
|
||||
|
|
@ -308,10 +307,8 @@ from litellm._logging import (
|
|||
verbose_router_logger,
|
||||
)
|
||||
from litellm.caching.caching import DualCache, RedisCache
|
||||
from litellm.caching.dual_cache import DeclaredBatchRead
|
||||
from litellm.caching.redis_batch import (
|
||||
active_post_call_redis_batch,
|
||||
active_request_redis_batches,
|
||||
drain_post_call_redis_batches,
|
||||
)
|
||||
from litellm.caching.redis_cache import RedisCircuitBreakerOpenError, is_redis_timeout_failure
|
||||
|
|
@ -4236,73 +4233,61 @@ async def run_spend_counter_pipeline(pending: Sequence[PendingSpendIncrement]) -
|
|||
return tuple(float(current_value) for current_value in results or ())
|
||||
|
||||
|
||||
def update_cache_read_keys(
|
||||
user_id: str | None,
|
||||
end_user_id: str | None,
|
||||
tags: Sequence[object] | None,
|
||||
response_cost: float | None,
|
||||
) -> tuple[str, ...]:
|
||||
if response_cost is None:
|
||||
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 ()
|
||||
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 + tag_keys
|
||||
_CachedSpendObjectT = TypeVar(
|
||||
"_CachedSpendObjectT", LiteLLM_UserTable, LiteLLM_EndUserTable, LiteLLM_TeamTableCachedObj, LiteLLM_TagTable
|
||||
)
|
||||
|
||||
|
||||
_UPDATE_CACHE_PREFETCH_SLOT: Final = "update_cache_read"
|
||||
|
||||
|
||||
async def arm_update_cache_read(keys: Sequence[str], cache: DualCache | None = None) -> None:
|
||||
"""Declares the ``update_cache`` read on the request pipeline once the spend is persisted, so it rides the same
|
||||
round trip as the post-call spend counter read instead of its own."""
|
||||
request: Final = active_request_redis_batches()
|
||||
target: Final = user_api_key_cache if cache is None else cache
|
||||
if request is None or target.redis_cache is None or not keys:
|
||||
def _add_spend_to_locally_cached_object(
|
||||
cache: UserApiKeyCache, key: str, model_type: type[_CachedSpendObjectT], response_cost: float
|
||||
) -> None:
|
||||
current: Final = cache.get_cache(key=key, local_only=True, model_type=model_type)
|
||||
if current is None:
|
||||
return
|
||||
with service_target(AUTH_OBJECTS_TARGET):
|
||||
request.prefetched[_UPDATE_CACHE_PREFETCH_SLOT] = await target.declare_batch_get(
|
||||
keys, request.batch(target.redis_cache)
|
||||
)
|
||||
cache.set_cache(
|
||||
key=key,
|
||||
value=current.model_copy(update={"spend": (current.spend or 0.0) + response_cost}),
|
||||
local_only=True,
|
||||
model_type=model_type,
|
||||
ttl=get_management_object_ttl(cache),
|
||||
)
|
||||
|
||||
|
||||
async def _take_armed_update_cache_read(keys: Sequence[str], cache: DualCache) -> Mapping[str, object] | None:
|
||||
request: Final = active_request_redis_batches()
|
||||
if request is None:
|
||||
async def _add_spend_to_shared_global_proxy_spend(
|
||||
cache: UserApiKeyCache, response_cost: float, parent_otel_span: Span | None
|
||||
) -> float | None:
|
||||
if cache.redis_cache is None:
|
||||
return None
|
||||
armed: Final = request.prefetched.pop(_UPDATE_CACHE_PREFETCH_SLOT, None)
|
||||
if not isinstance(armed, DeclaredBatchRead) or armed.keys != tuple(keys):
|
||||
return None
|
||||
values: Final = await cache.async_resolve_batch_get(armed)
|
||||
return MappingProxyType({key: value for key, value in zip(keys, values) if value is not None})
|
||||
|
||||
|
||||
async def _read_update_cache_values(
|
||||
keys: Sequence[str], parent_otel_span: Span | None, cache: DualCache | None = None
|
||||
) -> Mapping[str, object]:
|
||||
"""One batched read for every object ``update_cache`` refreshes; a failed read leaves them all untouched,
|
||||
exactly as a failed per-object GET left that object untouched."""
|
||||
if not keys:
|
||||
return MappingProxyType({})
|
||||
target: Final = user_api_key_cache if cache is None else cache
|
||||
try:
|
||||
armed: Final = await _take_armed_update_cache_read(keys, target)
|
||||
if armed is not None:
|
||||
return armed
|
||||
values: Final = await target.async_batch_get_cache(
|
||||
keys=list(keys), parent_otel_span=parent_otel_span, throttle_redis=False
|
||||
return await cache.redis_cache.async_increment_if_present(
|
||||
key=GLOBAL_PROXY_SPEND_CACHE_KEY, value=response_cost, parent_otel_span=parent_otel_span
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Spend tracking - failed to read cached spend objects. Budget enforcement may use stale spend values. "
|
||||
"keys=%s - %s",
|
||||
keys,
|
||||
"Spend tracking - failed to add spend to the global proxy spend in Redis. "
|
||||
"Budget enforcement may use stale spend values. response_cost=%s - %s",
|
||||
response_cost,
|
||||
str(e),
|
||||
)
|
||||
return MappingProxyType({})
|
||||
if values is None:
|
||||
return MappingProxyType({})
|
||||
return MappingProxyType({key: value for key, value in zip(keys, values) if value is not None})
|
||||
return None
|
||||
|
||||
|
||||
async def _add_spend_to_global_proxy_spend(
|
||||
cache: UserApiKeyCache, response_cost: float, parent_otel_span: Span | None
|
||||
) -> None:
|
||||
local_before: Final = cache.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, local_only=True)
|
||||
shared_total: Final = await _add_spend_to_shared_global_proxy_spend(cache, response_cost, parent_otel_span)
|
||||
local_total: Final = cache.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, local_only=True)
|
||||
if not isinstance(local_total, (int, float)):
|
||||
return
|
||||
if shared_total is not None and local_total != local_before and shared_total < local_total:
|
||||
return
|
||||
cache.set_cache(
|
||||
key=GLOBAL_PROXY_SPEND_CACHE_KEY,
|
||||
value=local_total + response_cost if shared_total is None else shared_total,
|
||||
local_only=True,
|
||||
ttl=get_management_object_ttl(cache),
|
||||
)
|
||||
|
||||
|
||||
async def update_cache(
|
||||
|
|
@ -4313,6 +4298,7 @@ async def update_cache(
|
|||
response_cost: float | None,
|
||||
parent_otel_span: Span | None,
|
||||
tags: list[str] | None = None,
|
||||
cache: UserApiKeyCache | None = None,
|
||||
):
|
||||
"""
|
||||
Use this to update the cache with new user spend.
|
||||
|
|
@ -4320,14 +4306,7 @@ async def update_cache(
|
|||
Put any alerting logic in here.
|
||||
"""
|
||||
|
||||
values_to_update_in_cache: Final[list[tuple[str, object]]] = []
|
||||
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, tags=tags, response_cost=response_cost
|
||||
),
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
target: Final = user_api_key_cache if cache is None else cache
|
||||
|
||||
### UPDATE KEY SPEND ###
|
||||
async def _update_key_cache(token: str, response_cost: float):
|
||||
|
|
@ -4337,7 +4316,7 @@ async def update_cache(
|
|||
else:
|
||||
hashed_token = token
|
||||
verbose_proxy_logger.debug("_update_key_cache: hashed_token=%s", hashed_token)
|
||||
existing_spend_obj = await user_api_key_cache.async_get_cache(key=hashed_token, model_type=UserAPIKeyAuth)
|
||||
existing_spend_obj = await target.async_get_cache(key=hashed_token, model_type=UserAPIKeyAuth)
|
||||
verbose_proxy_logger.debug("_update_key_cache: existing_spend_obj=%s", existing_spend_obj)
|
||||
if existing_spend_obj is None:
|
||||
return
|
||||
|
|
@ -4379,184 +4358,17 @@ async def update_cache(
|
|||
)
|
||||
# set cooldown on alert
|
||||
|
||||
### UPDATE USER SPEND ###
|
||||
async def _update_user_cache():
|
||||
## UPDATE CACHE FOR USER ID + GLOBAL PROXY
|
||||
if response_cost is None:
|
||||
return
|
||||
user_ids: Final = [user_id]
|
||||
def _add_spend(key: str, model_type: type[_CachedSpendObjectT], cost: float) -> None:
|
||||
try:
|
||||
for _id in user_ids:
|
||||
# Fetch the existing cost for the given user
|
||||
if _id is None:
|
||||
continue
|
||||
cached_user = cached_values.get(_id)
|
||||
if cached_user is None:
|
||||
# do nothing if there is no cache value
|
||||
return
|
||||
existing_spend_obj = CacheCodec.deserialize(cached_user, LiteLLM_UserTable)
|
||||
if existing_spend_obj is None:
|
||||
return
|
||||
verbose_proxy_logger.debug(
|
||||
"_update_user_db: existing spend: %s; response_cost: %s", existing_spend_obj, response_cost
|
||||
)
|
||||
|
||||
existing_spend = existing_spend_obj.spend or 0.0
|
||||
# Calculate the new cost by adding the existing cost and response_cost
|
||||
new_spend = 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_UserTable),
|
||||
)
|
||||
)
|
||||
## UPDATE GLOBAL PROXY ##
|
||||
global_proxy_spend: Final = cached_values.get(GLOBAL_PROXY_SPEND_CACHE_KEY)
|
||||
if not isinstance(global_proxy_spend, (int, float)):
|
||||
# do nothing if not in cache
|
||||
return
|
||||
elif response_cost is not None:
|
||||
increment: Final = global_proxy_spend + response_cost
|
||||
values_to_update_in_cache.append((GLOBAL_PROXY_SPEND_CACHE_KEY, increment))
|
||||
_add_spend_to_locally_cached_object(target, key, model_type, cost)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Spend tracking - failed to update user spend in cache. "
|
||||
"Spend tracking - failed to update %s spend in cache. "
|
||||
"Budget enforcement may use stale spend values. "
|
||||
"user_id=%s, response_cost=%s - %s\n%s",
|
||||
user_id,
|
||||
response_cost,
|
||||
str(e),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
### UPDATE END-USER SPEND ###
|
||||
async def _update_end_user_cache():
|
||||
if end_user_id is None or response_cost is None:
|
||||
return
|
||||
|
||||
_id: Final = end_user_cache_key(end_user_id)
|
||||
try:
|
||||
# Fetch the existing cost for the given user
|
||||
cached_end_user: Final = cached_values.get(_id)
|
||||
if cached_end_user is None:
|
||||
# if user does not exist in LiteLLM_UserTable, create a new user
|
||||
# do nothing if end-user not in api key cache
|
||||
return
|
||||
existing_spend_obj: Final = CacheCodec.deserialize(cached_end_user, LiteLLM_EndUserTable)
|
||||
if existing_spend_obj is None:
|
||||
return
|
||||
verbose_proxy_logger.debug(
|
||||
"_update_end_user_db: existing spend: %s; response_cost: %s", existing_spend_obj, response_cost
|
||||
)
|
||||
|
||||
existing_spend: Final = 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_EndUserTable),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Spend tracking - failed to update end user spend in cache. "
|
||||
"Budget enforcement may use stale spend values. "
|
||||
"end_user_id=%s, response_cost=%s - %s\n%s",
|
||||
end_user_id,
|
||||
response_cost,
|
||||
str(e),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
### UPDATE TEAM SPEND ###
|
||||
def _update_team_cache():
|
||||
if team_id is None or response_cost is None:
|
||||
return
|
||||
|
||||
_id: Final = f"team_id:{team_id}"
|
||||
try:
|
||||
current_team: Final = CacheCodec.deserialize(
|
||||
user_api_key_cache.get_cache(key=_id, local_only=True), LiteLLM_TeamTableCachedObj
|
||||
)
|
||||
if current_team is None:
|
||||
return
|
||||
verbose_proxy_logger.debug(
|
||||
"_update_team_db: existing spend: %s; response_cost: %s", current_team, response_cost
|
||||
)
|
||||
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(
|
||||
"Spend tracking - failed to update team spend in cache. "
|
||||
"Budget enforcement may use stale spend values. "
|
||||
"team_id=%s, response_cost=%s - %s\n%s",
|
||||
team_id,
|
||||
response_cost,
|
||||
str(e),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
### UPDATE TAG SPEND ###
|
||||
async def _update_tag_cache():
|
||||
"""
|
||||
Update the tag cache with the new spend.
|
||||
"""
|
||||
if tags is None or response_cost is None:
|
||||
return
|
||||
|
||||
try:
|
||||
for tag_name in tags:
|
||||
if not tag_name or not isinstance(tag_name, str):
|
||||
continue
|
||||
|
||||
cache_key = tag_cache_key(tag_name)
|
||||
# Fetch the existing tag object from cache
|
||||
cached_tag = cached_values.get(cache_key)
|
||||
if cached_tag is None:
|
||||
# do nothing if tag not in api key cache
|
||||
continue
|
||||
|
||||
existing_tag_obj = CacheCodec.deserialize(cached_tag, LiteLLM_TagTable)
|
||||
if existing_tag_obj is None:
|
||||
continue
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"_update_tag_cache: existing spend for tag=%s: %s; response_cost: %s",
|
||||
tag_name,
|
||||
existing_tag_obj,
|
||||
response_cost,
|
||||
)
|
||||
|
||||
existing_spend = existing_tag_obj.spend or 0.0
|
||||
# Calculate the new cost by adding the existing cost and response_cost
|
||||
new_spend = existing_spend + response_cost
|
||||
|
||||
existing_tag_obj.spend = new_spend
|
||||
values_to_update_in_cache.append(
|
||||
(
|
||||
cache_key,
|
||||
CacheCodec.serialize(existing_tag_obj, model_type=LiteLLM_TagTable),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Spend tracking - failed to update tag spend in cache. "
|
||||
"Budget enforcement may use stale spend values. "
|
||||
"tags=%s, response_cost=%s - %s\n%s",
|
||||
tags,
|
||||
response_cost,
|
||||
"key=%s, response_cost=%s - %s\n%s",
|
||||
model_type.__name__,
|
||||
key,
|
||||
cost,
|
||||
str(e),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
|
@ -4564,42 +4376,23 @@ async def update_cache(
|
|||
with service_target(AUTH_OBJECTS_TARGET):
|
||||
if token is not None and response_cost is not None:
|
||||
await _update_key_cache(token=token, response_cost=response_cost)
|
||||
if response_cost is None:
|
||||
return
|
||||
|
||||
if user_id is not None:
|
||||
await _update_user_cache()
|
||||
if user_id is not None:
|
||||
_add_spend(user_id, LiteLLM_UserTable, response_cost)
|
||||
if end_user_id is not None:
|
||||
_add_spend(end_user_cache_key(end_user_id), LiteLLM_EndUserTable, response_cost)
|
||||
if team_id is not None:
|
||||
_add_spend(f"team_id:{team_id}", LiteLLM_TeamTableCachedObj, response_cost)
|
||||
for tag_name in tags or ():
|
||||
if tag_name and isinstance(tag_name, str):
|
||||
_add_spend(tag_cache_key(tag_name), LiteLLM_TagTable, response_cost)
|
||||
|
||||
if end_user_id is not None:
|
||||
await _update_end_user_cache()
|
||||
|
||||
if team_id is not None:
|
||||
_update_team_cache()
|
||||
|
||||
if tags is not None:
|
||||
await _update_tag_cache()
|
||||
|
||||
global_proxy_spend_key: Final = GLOBAL_PROXY_SPEND_CACHE_KEY
|
||||
local_object_updates: Final = tuple((k, v) for k, v in values_to_update_in_cache if k != global_proxy_spend_key)
|
||||
shared_scalar_updates: Final = tuple((k, v) for k, v in values_to_update_in_cache if k == global_proxy_spend_key)
|
||||
|
||||
if local_object_updates:
|
||||
with service_target(AUTH_OBJECTS_TARGET):
|
||||
asyncio.create_task(
|
||||
user_api_key_cache.async_set_cache_pipeline(
|
||||
cache_list=list(local_object_updates),
|
||||
ttl=get_management_object_ttl(user_api_key_cache),
|
||||
litellm_parent_otel_span=parent_otel_span,
|
||||
local_only=True,
|
||||
)
|
||||
)
|
||||
if shared_scalar_updates:
|
||||
with service_target(SPEND_COUNTERS_TARGET):
|
||||
asyncio.create_task(
|
||||
user_api_key_cache.async_set_cache_pipeline(
|
||||
cache_list=list(shared_scalar_updates),
|
||||
ttl=get_management_object_ttl(user_api_key_cache),
|
||||
litellm_parent_otel_span=parent_otel_span,
|
||||
)
|
||||
)
|
||||
if litellm.max_budget <= 0:
|
||||
return
|
||||
with service_target(SPEND_COUNTERS_TARGET):
|
||||
await _add_spend_to_global_proxy_spend(target, response_cost, parent_otel_span)
|
||||
|
||||
|
||||
def run_ollama_serve():
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
- {id: llm.chat_completions.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "gpt-4o vision; high usage"}
|
||||
- {id: llm.chat_completions.openai.prompt_cache_5m.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_5m, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Prompt caching cost optimization"}
|
||||
- {id: llm.chat_completions.openai.service_tier.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: service_tier, streaming: nonstream, assertions: [works], source: "OpenAI service_tier param", rationale: "OpenAI scale-tier request option is forwarded and echoed"}
|
||||
- {id: llm.chat_completions.openai.prompt_cache_24h.nonstream.forwards_retention, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_24h, streaming: nonstream, assertions: [forwards_retention], source: "main.py completion()", fail_before_fix: proven, rationale: "prompt_cache_retention sent through the proxy, litellm.completion or Router.completion reaches the body sent to OpenAI /v1/chat/completions, so a caller asking for 24h prompt caching gets it instead of a silent drop"}
|
||||
- {id: llm.chat_completions.openai.prompt_cache_24h.nonstream.bridge_forwards_retention, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_24h, streaming: nonstream, assertions: [forwards_retention], source: "main.py completion()", fail_before_fix: proven, rationale: "prompt_cache_retention on an openai/responses/ model (chat completions in, Responses out) reaches the body sent to OpenAI /v1/responses"}
|
||||
- {id: llm.chat_completions.openai.service_tier.stream.echoes_served_tier, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: service_tier, streaming: stream, assertions: [works], source: "litellm_core_utils/streaming_handler.py", fail_before_fix: proven, rationale: "Every relayed stream chunk carries the service_tier OpenAI stamped on it, so a streaming caller can see which tier served the request"}
|
||||
- {id: llm.chat_completions.openai.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "o-series reasoning; emerging"}
|
||||
- {id: llm.chat_completions.openai.structured_output.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: structured_output, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "response_schema extraction"}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ LlmCapability = Literal[
|
|||
"pdf_input",
|
||||
"logprobs",
|
||||
"prompt_cache_1h",
|
||||
"prompt_cache_24h",
|
||||
"prompt_cache_5m",
|
||||
"response_headers",
|
||||
"service_tier",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Live e2e for model-specific request features: service_tier.
|
||||
"""Live e2e for model-specific request features: service_tier and prompt_cache_retention.
|
||||
|
||||
Each case asserts the feature took effect, not just a 200.
|
||||
|
||||
|
|
@ -11,26 +11,38 @@ avoided here because it is capacity-constrained and returns a transient 429 when
|
|||
flex resources are unavailable. Bedrock and Vertex do not accept service_tier, so
|
||||
that cell is OpenAI-only by design.
|
||||
|
||||
Prompt caching lives in test_cache_control.py.
|
||||
prompt_cache_retention is judged on the body OpenAI receives, captured by a live
|
||||
provider edge, not on the reply: OpenAI echoes "24h" on gpt-5.5 even when the
|
||||
request never carried the param, so an echo would pass with the param dropped.
|
||||
|
||||
Prompt caching hits and misses live in test_cache_control.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_config import PROVIDER_EDGE_ADVERTISE_HOST, PROVIDER_EDGE_BIND_HOST, unique_marker
|
||||
from e2e_http import unwrap
|
||||
from e2e_metadata import Domain, Mode, Provider, Route, Subject, meta
|
||||
from lifecycle import ResourceManager
|
||||
from models import ChatBody, ChatMessage, LiteLLMParamsBody
|
||||
from passthrough_client import PassthroughClient
|
||||
from provider_edge import LiveEdge, start_provider_edge
|
||||
from proxy_client import ProxyClient
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
SERVICE_TIER = "priority"
|
||||
OPENAI_BACKEND: Final = "openai/gpt-5.5"
|
||||
OPENAI_RESPONSES_BRIDGE_BACKEND: Final = "openai/responses/gpt-5.5"
|
||||
PROMPT_CACHE_RETENTION: Final = "24h"
|
||||
|
||||
|
||||
class TestServiceTier:
|
||||
|
|
@ -74,3 +86,153 @@ class TestServiceTier:
|
|||
f"service_tier not honored: sent {SERVICE_TIER!r}, response reported "
|
||||
f"{response.service_tier!r} ({response})"
|
||||
)
|
||||
|
||||
|
||||
class _OutboundRetention(BaseModel, frozen=True):
|
||||
prompt_cache_retention: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _OpenAIBodyCapture:
|
||||
"""The raw bodies sent to one OpenAI path, seen by a live edge in front of
|
||||
api.openai.com. Pass ``observe`` as the edge's ``observe_request``."""
|
||||
|
||||
path: str
|
||||
_bodies: queue.SimpleQueue[bytes] = field(default_factory=queue.SimpleQueue)
|
||||
|
||||
def observe(self, url: str, headers: Mapping[str, str], body: bytes | None) -> None:
|
||||
if body is not None and url.split("?")[0].endswith(self.path):
|
||||
self._bodies.put(body)
|
||||
|
||||
def retentions(self) -> tuple[str | None, ...]:
|
||||
bodies: Final = tuple(self._bodies.get_nowait() for _ in range(self._bodies.qsize()))
|
||||
return tuple(_OutboundRetention.model_validate_json(body).prompt_cache_retention for body in bodies)
|
||||
|
||||
|
||||
def _openai_edge_api_base(resources: ResourceManager, capture: _OpenAIBodyCapture, *, reachable_from_proxy: bool) -> str:
|
||||
"""The proxy may run in a container, so its edge binds where the proxy can
|
||||
reach it; an in-process SDK call reaches the edge on loopback."""
|
||||
edge: Final = (
|
||||
start_provider_edge(
|
||||
LiveEdge(observe_request=capture.observe),
|
||||
bind_host=PROVIDER_EDGE_BIND_HOST,
|
||||
advertise_host=PROVIDER_EDGE_ADVERTISE_HOST,
|
||||
)
|
||||
if reachable_from_proxy
|
||||
else start_provider_edge(LiveEdge(observe_request=capture.observe))
|
||||
)
|
||||
resources.defer(edge.shutdown)
|
||||
return f"{edge.edge.api_base('openai')}/v1"
|
||||
|
||||
|
||||
def _retention_backends() -> tuple[object, ...]:
|
||||
"""gpt-5.5 straight to /v1/chat/completions, and the openai/responses/ bridge
|
||||
(chat completions in, Responses out) to /v1/responses."""
|
||||
return (
|
||||
pytest.param(
|
||||
OPENAI_BACKEND,
|
||||
"/chat/completions",
|
||||
marks=(
|
||||
pytest.mark.covers("llm.chat_completions.openai.prompt_cache_24h.nonstream.forwards_retention"),
|
||||
meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(OPENAI_BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
),
|
||||
),
|
||||
id="chat_completions",
|
||||
),
|
||||
pytest.param(
|
||||
OPENAI_RESPONSES_BRIDGE_BACKEND,
|
||||
"/responses",
|
||||
marks=(
|
||||
pytest.mark.covers("llm.chat_completions.openai.prompt_cache_24h.nonstream.bridge_forwards_retention"),
|
||||
meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(OPENAI_RESPONSES_BRIDGE_BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
),
|
||||
),
|
||||
id="responses_bridge",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPromptCacheRetention:
|
||||
@pytest.mark.provider_edge_host
|
||||
@pytest.mark.parametrize(("backend", "openai_path"), _retention_backends())
|
||||
def test_proxy_chat_completions_forwards_prompt_cache_retention_to_openai(
|
||||
self, proxy: ProxyClient, resources: ResourceManager, backend: str, openai_path: str
|
||||
) -> None:
|
||||
capture: Final = _OpenAIBodyCapture(openai_path)
|
||||
api_base: Final = _openai_edge_api_base(resources, capture, reachable_from_proxy=True)
|
||||
model: Final = f"e2e-cache-retention-{unique_marker()}"
|
||||
model_id: Final = proxy.create_model(
|
||||
model, LiteLLMParamsBody(model=backend, api_key="os.environ/OPENAI_API_KEY", api_base=api_base)
|
||||
)
|
||||
resources.defer(lambda: proxy.delete_model(model_id))
|
||||
|
||||
unwrap(
|
||||
proxy.chat(
|
||||
resources.key(),
|
||||
ChatBody(
|
||||
model=model,
|
||||
messages=[ChatMessage(role="user", content="reply with one word")],
|
||||
prompt_cache_retention=PROMPT_CACHE_RETENTION,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert capture.retentions() == (PROMPT_CACHE_RETENTION,), (
|
||||
f"one {openai_path} call to OpenAI must carry prompt_cache_retention={PROMPT_CACHE_RETENTION!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("backend", "openai_path"), _retention_backends())
|
||||
def test_sdk_completion_forwards_prompt_cache_retention_to_openai(
|
||||
self, resources: ResourceManager, backend: str, openai_path: str
|
||||
) -> None:
|
||||
import litellm
|
||||
|
||||
capture: Final = _OpenAIBodyCapture(openai_path)
|
||||
api_base: Final = _openai_edge_api_base(resources, capture, reachable_from_proxy=False)
|
||||
|
||||
litellm.completion(
|
||||
model=backend,
|
||||
messages=[{"role": "user", "content": "reply with one word"}],
|
||||
api_base=api_base,
|
||||
prompt_cache_retention=PROMPT_CACHE_RETENTION,
|
||||
)
|
||||
|
||||
assert capture.retentions() == (PROMPT_CACHE_RETENTION,), (
|
||||
f"one {openai_path} call to OpenAI must carry prompt_cache_retention={PROMPT_CACHE_RETENTION!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("backend", "openai_path"), _retention_backends())
|
||||
def test_router_completion_forwards_prompt_cache_retention_to_openai(
|
||||
self, resources: ResourceManager, backend: str, openai_path: str
|
||||
) -> None:
|
||||
import litellm
|
||||
|
||||
capture: Final = _OpenAIBodyCapture(openai_path)
|
||||
api_base: Final = _openai_edge_api_base(resources, capture, reachable_from_proxy=False)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[{"model_name": "cache-retention", "litellm_params": {"model": backend, "api_base": api_base}}]
|
||||
)
|
||||
|
||||
router.completion( # pyright: ignore[reportUnknownMemberType] # Router.completion forwards an untyped **kwargs to litellm.completion
|
||||
model="cache-retention",
|
||||
messages=[{"role": "user", "content": "reply with one word"}],
|
||||
prompt_cache_retention=PROMPT_CACHE_RETENTION,
|
||||
)
|
||||
|
||||
assert capture.retentions() == (PROMPT_CACHE_RETENTION,), (
|
||||
f"one {openai_path} call to OpenAI must carry prompt_cache_retention={PROMPT_CACHE_RETENTION!r}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ class ChatBody(BaseModel):
|
|||
thinking: ThinkingParam | None = None
|
||||
service_tier: str | None = None
|
||||
prompt_cache_key: str | None = None
|
||||
prompt_cache_retention: str | None = None
|
||||
tools: Sequence[ChatTool | McpChatTool | GeminiUrlContextTool] | None = None
|
||||
tool_choice: str | None = None
|
||||
guardrails: list[str] | None = None
|
||||
|
|
|
|||
|
|
@ -62,24 +62,24 @@ CASE: Final = PerformanceTestCase(
|
|||
),
|
||||
limits=(
|
||||
Limit("unexpected_status_count", max=3),
|
||||
Limit("gateway_added_seconds", percentile=50, max=0.58),
|
||||
Limit("gateway_added_seconds", percentile=99, max=6.1),
|
||||
Limit("litellm_overhead_seconds", percentile=50, max=0.1),
|
||||
Limit("litellm_overhead_seconds", percentile=99, max=3.4),
|
||||
Limit("litellm_response_seconds", percentile=50, max=1.0),
|
||||
Limit("litellm_response_seconds", percentile=99, max=3.8),
|
||||
Limit("prometheus_redis_latency_mean_seconds", max=0.005),
|
||||
Limit("postgres_active_sessions", max=0.016),
|
||||
Limit("postgres_slowest_statement_seconds", max=0.25),
|
||||
Limit("postgres_peak_connections", max=45),
|
||||
Limit("time_to_first_token_seconds", percentile=50, max=2.7),
|
||||
Limit("time_to_first_token_seconds", percentile=99, max=8.8),
|
||||
Limit("gateway_added_seconds", percentile=50, max=0.29),
|
||||
Limit("gateway_added_seconds", percentile=99, max=2.7),
|
||||
Limit("litellm_overhead_seconds", percentile=50, max=0.052),
|
||||
Limit("litellm_overhead_seconds", percentile=99, max=1.8),
|
||||
Limit("litellm_response_seconds", percentile=50, max=0.93),
|
||||
Limit("litellm_response_seconds", percentile=99, max=2.7),
|
||||
Limit("prometheus_redis_latency_mean_seconds", max=0.002),
|
||||
Limit("postgres_active_sessions", max=0.0094),
|
||||
Limit("postgres_slowest_statement_seconds", max=0.042),
|
||||
Limit("postgres_peak_connections", max=23),
|
||||
Limit("time_to_first_token_seconds", percentile=50, max=1.56),
|
||||
Limit("time_to_first_token_seconds", percentile=99, max=4.5),
|
||||
Limit("memory_growth_megabytes", max=600),
|
||||
Limit("cpu_seconds_per_request", max=1.26),
|
||||
Limit("postgres_calls_per_request", max=3.5),
|
||||
Limit("postgres_max_statement_per_request", max=0.5),
|
||||
Limit("postgres_max_background_statement_per_request", max=0.9),
|
||||
Limit("redis_calls_per_request", max=21.0),
|
||||
Limit("cpu_seconds_per_request", max=0.57),
|
||||
Limit("postgres_calls_per_request", max=2.64),
|
||||
Limit("postgres_max_statement_per_request", max=0.43),
|
||||
Limit("postgres_max_background_statement_per_request", max=0.86),
|
||||
Limit("redis_calls_per_request", max=15.4),
|
||||
),
|
||||
background_cap_exempt_tables=frozenset({"LiteLLM_DailyGuardrailMetrics"}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,21 +64,21 @@ CASE: Final = PerformanceTestCase(
|
|||
),
|
||||
limits=(
|
||||
Limit("unexpected_status_count", max=3),
|
||||
Limit("gateway_added_seconds", percentile=50, max=0.29),
|
||||
Limit("litellm_overhead_seconds", percentile=50, max=0.012),
|
||||
Limit("gateway_added_seconds", percentile=50, max=0.165),
|
||||
Limit("litellm_overhead_seconds", percentile=50, max=0.006),
|
||||
Limit("litellm_overhead_seconds", percentile=99, max=0.35),
|
||||
Limit("litellm_response_seconds", percentile=50, max=1.0),
|
||||
Limit("litellm_response_seconds", percentile=99, max=2.0),
|
||||
Limit("prometheus_redis_latency_mean_seconds", max=0.006),
|
||||
Limit("postgres_active_sessions", max=0.01),
|
||||
Limit("postgres_slowest_statement_seconds", max=0.025),
|
||||
Limit("postgres_peak_connections", max=45),
|
||||
Limit("time_to_first_token_seconds", percentile=99, max=3.7),
|
||||
Limit("litellm_response_seconds", percentile=50, max=0.91),
|
||||
Limit("litellm_response_seconds", percentile=99, max=1.6),
|
||||
Limit("prometheus_redis_latency_mean_seconds", max=0.0026),
|
||||
Limit("postgres_active_sessions", max=0.0052),
|
||||
Limit("postgres_slowest_statement_seconds", max=0.011),
|
||||
Limit("postgres_peak_connections", max=23),
|
||||
Limit("time_to_first_token_seconds", percentile=99, max=2.05),
|
||||
Limit("memory_growth_megabytes", max=100),
|
||||
Limit("postgres_calls_per_request", max=1.8),
|
||||
Limit("postgres_max_statement_per_request", max=0.5),
|
||||
Limit("postgres_max_background_statement_per_request", max=0.9),
|
||||
Limit("redis_calls_per_request", max=18),
|
||||
Limit("cpu_seconds_per_request", max=0.29),
|
||||
Limit("postgres_calls_per_request", max=1.31),
|
||||
Limit("postgres_max_statement_per_request", max=0.27),
|
||||
Limit("postgres_max_background_statement_per_request", max=0.92),
|
||||
Limit("redis_calls_per_request", max=13.8),
|
||||
Limit("cpu_seconds_per_request", max=0.15),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -295,19 +295,30 @@ def test_invalid_200_is_not_an_allowed_non_200(failed: WireRecord) -> None:
|
|||
_ = Probe().monitor().poll((failed,))
|
||||
|
||||
|
||||
def _ceiling(metric: str, percentile: int | None = None) -> float:
|
||||
return next(
|
||||
limit.max for limit in upgrade_case().limits if limit.metric == metric and limit.percentile == percentile
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failures,overhead,ttft,growth,expected",
|
||||
"over,expected",
|
||||
[
|
||||
(3, 0.29, 3.7, 100, ()),
|
||||
(4, 0.29, 3.7, 100, ("unexpected_status_count",)),
|
||||
(3, 0.30, 3.7, 100, ("gateway_added_seconds percentile 50",)),
|
||||
(3, 0.29, 3.8, 100, ("time_to_first_token_seconds percentile 99",)),
|
||||
(3, 0.29, 3.7, 101, ("memory_growth_megabytes",)),
|
||||
("", ()),
|
||||
("unexpected_status_count", ("unexpected_status_count",)),
|
||||
("gateway_added_seconds", ("gateway_added_seconds percentile 50",)),
|
||||
("time_to_first_token_seconds", ("time_to_first_token_seconds percentile 99",)),
|
||||
("memory_growth_megabytes", ("memory_growth_megabytes",)),
|
||||
],
|
||||
)
|
||||
def test_upgrade_verdict_uses_regular_limits_without_cpu_or_datastore_samples(
|
||||
failures: int, overhead: float, ttft: float, growth: float, expected: tuple[str, ...]
|
||||
over: str, expected: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Each sample sits exactly on the case's ceiling, so only the one nudged past it goes over."""
|
||||
failures: Final = int(_ceiling("unexpected_status_count")) + (over == "unexpected_status_count")
|
||||
overhead: Final = _ceiling("gateway_added_seconds", 50) * (1.01 if over == "gateway_added_seconds" else 1)
|
||||
ttft: Final = _ceiling("time_to_first_token_seconds", 99) * (1.01 if over == "time_to_first_token_seconds" else 1)
|
||||
growth: Final = _ceiling("memory_growth_megabytes") + (over == "memory_growth_megabytes")
|
||||
nonstream: Final = tuple(record(i).model_copy(update={"total_s": overhead, "mock_delay_s": 0}) for i in range(100))
|
||||
stream: Final = record(100).model_copy(update={"stream": True, "first_token_s": ttft})
|
||||
observed: Final = Observation(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import httpx
|
|||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, eventually, object_value, string_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
|
||||
|
|
@ -108,7 +107,7 @@ def _create_agent(candidate: Gateway, wire: Wire, agent_name: str, cleanups: Exi
|
|||
def _delete_agent(candidate: Gateway, agent_id: str) -> None:
|
||||
deleted: Final = candidate.request("DELETE", f"/v1/agents/{agent_id}")
|
||||
assert deleted.status_code == 200, deleted.text
|
||||
assert read_rows('SELECT agent_id FROM "LiteLLM_AgentsTable" WHERE agent_id=%s', (agent_id,)) == []
|
||||
assert candidate.rows('SELECT agent_id FROM "LiteLLM_AgentsTable" WHERE agent_id=%s', (agent_id,)) == []
|
||||
|
||||
|
||||
def _card(candidate: Gateway, agent_id: str, key: str) -> httpx.Response:
|
||||
|
|
|
|||
|
|
@ -4478,6 +4478,7 @@ CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT: Final = CostTrackingTestCase(
|
|||
"output_tokens_details": {"thinking_tokens": 0},
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global",
|
||||
"speed": "standard",
|
||||
},
|
||||
"diagnostics": None,
|
||||
},
|
||||
|
|
@ -4502,7 +4503,7 @@ CLAUDE_SONNET_5_5_MESSAGES_STREAM: Final = CostTrackingTestCase(
|
|||
response=SseResponse(
|
||||
content_type="text/event-stream",
|
||||
frames=(
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-sonnet-5-5","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"diagnostics":null}}',
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-sonnet-5-5","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"diagnostics":null}}',
|
||||
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}',
|
||||
'event: ping\ndata: {"type":"ping"}',
|
||||
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}',
|
||||
|
|
@ -4877,6 +4878,7 @@ CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT: Final = CostTrackingTestCase(
|
|||
"output_tokens_details": {"thinking_tokens": 0},
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global",
|
||||
"speed": "standard",
|
||||
},
|
||||
"diagnostics": None,
|
||||
},
|
||||
|
|
@ -4901,7 +4903,7 @@ CLAUDE_HAIKU_5_5_MESSAGES_STREAM: Final = CostTrackingTestCase(
|
|||
response=SseResponse(
|
||||
content_type="text/event-stream",
|
||||
frames=(
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"diagnostics":null}}',
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"diagnostics":null}}',
|
||||
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}',
|
||||
'event: ping\ndata: {"type":"ping"}',
|
||||
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}',
|
||||
|
|
@ -5639,6 +5641,7 @@ CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT: Final = CostTrackingTestCase(
|
|||
"output_tokens_details": {"thinking_tokens": 0},
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global",
|
||||
"speed": "standard",
|
||||
},
|
||||
"diagnostics": None,
|
||||
},
|
||||
|
|
@ -5663,7 +5666,7 @@ CLAUDE_FABLE_5_1_MESSAGES_STREAM: Final = CostTrackingTestCase(
|
|||
response=SseResponse(
|
||||
content_type="text/event-stream",
|
||||
frames=(
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-fable-5-1","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"diagnostics":null}}',
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-fable-5-1","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"diagnostics":null}}',
|
||||
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
|
||||
'event: ping\ndata: {"type":"ping"}',
|
||||
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"scripted "}}',
|
||||
|
|
@ -5974,6 +5977,7 @@ CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT: Final = CostTrackingTestCase(
|
|||
"output_tokens_details": {"thinking_tokens": 0},
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global",
|
||||
"speed": "standard",
|
||||
},
|
||||
"diagnostics": None,
|
||||
},
|
||||
|
|
@ -5998,7 +6002,7 @@ CLAUDE_OPUS_5_5_MESSAGES_STREAM: Final = CostTrackingTestCase(
|
|||
response=SseResponse(
|
||||
content_type="text/event-stream",
|
||||
frames=(
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-opus-5-5","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"diagnostics":null}}',
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude-opus-5-5","id":"msg_$REQUEST_ID","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1840,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"diagnostics":null}}',
|
||||
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}',
|
||||
'event: ping\ndata: {"type":"ping"}',
|
||||
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ BRIDGE_COST_PARITY_CASES: Final[tuple[BridgeCostParityTestCase, ...]] = (
|
|||
*anthropic.BRIDGE_COST_PARITY,
|
||||
*gemini.BRIDGE_COST_PARITY,
|
||||
*vertex_ai.BRIDGE_COST_PARITY,
|
||||
*misc.BRIDGE_COST_PARITY,
|
||||
)
|
||||
_PARITY_LEGS: Final = frozenset(leg.name for pair in PARITY_CASES for leg in (pair.plain, pair.streamed)) | frozenset(
|
||||
leg.name for pair in BRIDGE_COST_PARITY_CASES for leg in (pair.native, pair.bridged)
|
||||
|
|
|
|||
|
|
@ -597,6 +597,7 @@ COST_MAP_ENTRIES: Final[dict[str, dict[str, JsonValue]]] = {
|
|||
"cache_read_input_token_cost_priority": 2.16e-07,
|
||||
"google_maps_grounding_cost_per_query": 0.0168,
|
||||
"input_cost_per_audio_token": 2.4e-06,
|
||||
"input_cost_per_audio_token_priority": 4.32e-06,
|
||||
"input_cost_per_token": 2.4e-06,
|
||||
"input_cost_per_token_flex": 1.2e-06,
|
||||
"input_cost_per_token_priority": 4.32e-06,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ class CostMapEntry(BaseModel):
|
|||
tiered_pricing: tuple[TieredPrice, ...] | None = None
|
||||
output_cost_per_reasoning_token: float | None = None
|
||||
input_cost_per_audio_token: float | None = None
|
||||
input_cost_per_audio_token_priority: float | None = None
|
||||
input_cost_per_second: float | None = None
|
||||
output_cost_per_second: float | None = None
|
||||
input_cost_per_character: float | None = None
|
||||
|
|
|
|||
|
|
@ -1683,8 +1683,8 @@ def test_responses_input_tokens_sends_mantle_the_tool_use_invoke_inference_sends
|
|||
wire_server(_mantle(), port=mantle_port) as mantle,
|
||||
gateway.scenario() as scenario,
|
||||
):
|
||||
counted_model: Final = _deployment(scenario, runtime.url)
|
||||
invoked_model: Final = _deployment(scenario, runtime.url, f"invoke/{_OPUS}")
|
||||
counted_model: Final = _fallback_deployment(scenario, runtime.url)
|
||||
invoked_model: Final = _fallback_deployment(scenario, runtime.url, f"invoke/{_OPUS}")
|
||||
generated: Final = gateway.request(
|
||||
"POST", "/v1/responses", {**_FORCED_TOOL_CHOICE, "model": invoked_model, "max_output_tokens": 16}
|
||||
)
|
||||
|
|
@ -1729,7 +1729,7 @@ def test_nova_count_sends_the_tool_specs_converse_inference_sends(counting_proxy
|
|||
path, body, count_field = _NOVA_COUNTS[route]
|
||||
gateway: Final = counting_proxy.gateway
|
||||
with wire_server(_counting_every_model) as runtime, gateway.scenario() as scenario:
|
||||
model: Final = _deployment(scenario, runtime.url, _NOVA)
|
||||
model: Final = _fallback_deployment(scenario, runtime.url, _NOVA)
|
||||
generated: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ _RESPONSES_TARGET: Final = "/v1/responses"
|
|||
_STREAM_TIMEOUT_SECONDS: Final = 1
|
||||
_DEPLOYMENT_TIMEOUT_SECONDS: Final = 30
|
||||
_PAUSE_SECONDS: Final = 3.0
|
||||
_HOLD_SECONDS: Final = 30.0
|
||||
_ROW_SECONDS: Final = 60
|
||||
_HOLD_SECONDS: Final = 2.0 * _ROW_SECONDS
|
||||
_EXTRA: Final[dict[str, JsonValue]] = {"num_retries": 0, "cache": {"no-cache": True}}
|
||||
_ROWS: Final = 'SELECT status, metadata FROM "LiteLLM_SpendLogs" WHERE model_group=%s'
|
||||
_EITHER_GROUP_ROWS: Final = 'SELECT status, metadata FROM "LiteLLM_SpendLogs" WHERE model_group IN (%s, %s)'
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ from integration._support.client import (
|
|||
object_value,
|
||||
string_value,
|
||||
)
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import graceful_stop_seconds, owned_proxy
|
||||
from integration._support.responses_ws_peer import (
|
||||
PROVIDER_MODEL,
|
||||
|
|
@ -260,9 +259,9 @@ async def _until(read: Callable[[], float], satisfied: Callable[[float], bool])
|
|||
return await asyncio.to_thread(eventually, read, satisfied, 15)
|
||||
|
||||
|
||||
def _failure_rows(key: str) -> list[dict[str, JsonValue]]:
|
||||
def _failure_rows(gateway: Gateway, key: str) -> list[dict[str, JsonValue]]:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(SPEND_ROWS_FOR_KEY, (_key_hash(key),)),
|
||||
lambda: gateway.rows(SPEND_ROWS_FOR_KEY, (_key_hash(key),)),
|
||||
lambda found: any(row["status"] == "failure" for row in found),
|
||||
seconds=70,
|
||||
)
|
||||
|
|
@ -291,7 +290,7 @@ def test_a_turn_past_the_deployment_budget_is_refused_on_the_open_socket_and_log
|
|||
_assert_refused(second)
|
||||
_assert_refused(third)
|
||||
assert deployment.forwarded_turns() == 1
|
||||
failures: Final = _failure_rows(key)
|
||||
failures: Final = _failure_rows(limited, key)
|
||||
assert len(failures) == 1, failures
|
||||
assert failures[0]["model_group"] == deployment.name, failures
|
||||
assert tag in list_value(failures[0]["request_tags"]), failures
|
||||
|
|
@ -355,7 +354,7 @@ def test_a_turn_whose_admission_check_fails_gets_a_500_frame_and_the_socket_serv
|
|||
_assert_turn_check_failed(third, fault)
|
||||
completed(fourth)
|
||||
assert deployment.forwarded_turns() == 2
|
||||
failures: Final = _failure_rows(key)
|
||||
failures: Final = _failure_rows(faulting, key)
|
||||
assert len(failures) == 1, failures
|
||||
assert failures[0]["model_group"] == deployment.name, failures
|
||||
recorded: Final = object_value(object_value(failures[0]["metadata"])["error_information"])
|
||||
|
|
@ -387,7 +386,7 @@ def test_a_turn_still_streaming_when_the_next_turns_check_fails_completes_and_is
|
|||
billed: Final = completed(streamed)
|
||||
assert deployment.forwarded_turns() == 1
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(SPEND_ROWS_FOR_KEY, (_key_hash(key),)),
|
||||
lambda: faulting.rows(SPEND_ROWS_FOR_KEY, (_key_hash(key),)),
|
||||
lambda found: any(row["request_id"] == billed["id"] for row in found),
|
||||
seconds=70,
|
||||
)
|
||||
|
|
|
|||
58
tests/integration/spend/test_global_proxy_spend_counter.py
Normal file
58
tests/integration/spend/test_global_proxy_spend_counter.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import os
|
||||
import uuid
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.caching.redis_cache import RedisCache
|
||||
from litellm.constants import GLOBAL_PROXY_SPEND_CACHE_KEY
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.proxy_server import update_cache
|
||||
|
||||
|
||||
async def _add_request_cost(cache: UserApiKeyCache, response_cost: float) -> None:
|
||||
await update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=response_cost,
|
||||
parent_otel_span=None,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_proxy_spend_adds_every_instance_cost_and_stays_cleared_after_a_budget_reset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "max_budget", 100.0)
|
||||
redis_cache: Final = RedisCache(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
port=int(os.environ["REDIS_PORT"]),
|
||||
namespace=f"global-proxy-spend-{uuid.uuid4().hex}",
|
||||
)
|
||||
gateway: Final = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=redis_cache)
|
||||
peer: Final = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=redis_cache)
|
||||
|
||||
try:
|
||||
await gateway.async_set_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, value=10.0, ttl=60)
|
||||
await _add_request_cost(peer, 0.5)
|
||||
await _add_request_cost(gateway, 0.25)
|
||||
before_reset: Final = (
|
||||
gateway.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, local_only=True),
|
||||
await redis_cache.async_get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY),
|
||||
)
|
||||
ttl_before_reset: Final = await redis_cache.async_get_ttl(key=GLOBAL_PROXY_SPEND_CACHE_KEY)
|
||||
|
||||
await gateway.async_delete_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY)
|
||||
await _add_request_cost(peer, 0.5)
|
||||
await _add_request_cost(gateway, 0.25)
|
||||
|
||||
assert before_reset == (10.75, 10.75)
|
||||
assert ttl_before_reset is not None and 0 < ttl_before_reset <= 60
|
||||
assert await redis_cache.async_get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY) is None
|
||||
finally:
|
||||
await redis_cache.async_delete_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY)
|
||||
|
|
@ -12,7 +12,6 @@ import httpx
|
|||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, eventually, gateway_from_environment
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.forward_proxy import tunnelling_forward_proxy
|
||||
from integration._support.process import graceful_stop_seconds, owned_proxy
|
||||
from integration._support.tls import server_context, write_self_signed_cert
|
||||
|
|
@ -289,9 +288,9 @@ def gateway_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
|||
yield _Rig(proxy, upstream)
|
||||
|
||||
|
||||
def _spend_row(key: str) -> dict[str, JsonValue]:
|
||||
def _spend_row(gateway: Gateway, key: str) -> dict[str, JsonValue]:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
lambda: gateway.rows(
|
||||
'SELECT spend, custom_llm_provider FROM "LiteLLM_SpendLogs" WHERE api_key=%s',
|
||||
(sha256(key.encode()).hexdigest(),),
|
||||
),
|
||||
|
|
@ -301,14 +300,14 @@ def _spend_row(key: str) -> dict[str, JsonValue]:
|
|||
return rows[0]
|
||||
|
||||
|
||||
def _spend_for_key(key: str) -> float:
|
||||
spend: Final = _spend_row(key)["spend"]
|
||||
def _spend_for_key(gateway: Gateway, key: str) -> float:
|
||||
spend: Final = _spend_row(gateway, key)["spend"]
|
||||
assert isinstance(spend, float), key
|
||||
return spend
|
||||
|
||||
|
||||
def _provider_for_key(key: str) -> JsonValue:
|
||||
return _spend_row(key)["custom_llm_provider"]
|
||||
def _provider_for_key(gateway: Gateway, key: str) -> JsonValue:
|
||||
return _spend_row(gateway, key)["custom_llm_provider"]
|
||||
|
||||
|
||||
def _post_and_read(rig: _Rig, path: str, body: Mapping[str, JsonValue], headers: Mapping[str, str]) -> None:
|
||||
|
|
@ -347,7 +346,7 @@ def test_claude_on_vertex_passthrough_spend_carries_the_regional_uplift_whether_
|
|||
{**body, "stream": action == "streamRawPredict"},
|
||||
{"x-litellm-api-key": f"Bearer {key}", "Authorization": f"Bearer {_CALLER_GOOGLE_TOKEN}"},
|
||||
)
|
||||
spend: Final = {cell: _spend_for_key(key) for cell, key in keys.items()}
|
||||
spend: Final = {cell: _spend_for_key(rig.proxy, key) for cell, key in keys.items()}
|
||||
standard: Final = _list_price(f"vertex_ai/{_CLAUDE_ROUTE_MODEL}")
|
||||
regional: Final = standard * _uplift(f"vertex_ai/{_CLAUDE_ROUTE_MODEL}")
|
||||
assert spend == pytest.approx(
|
||||
|
|
@ -370,7 +369,7 @@ def test_v1beta_generate_content_on_a_vertex_deployment_bills_the_deployment_loc
|
|||
keys: Final = {cell: scenario.key() for cell in cells}
|
||||
for (deployment, action), key in keys.items():
|
||||
_post_and_read(rig, f"/v1beta/models/{deployment}:{action}", body, {"x-goog-api-key": key})
|
||||
spend: Final = {cell: _spend_for_key(key) for cell, key in keys.items()}
|
||||
spend: Final = {cell: _spend_for_key(rig.proxy, key) for cell, key in keys.items()}
|
||||
standard: Final = _list_price(f"vertex_ai/{_GEMINI}")
|
||||
regional: Final = standard * _uplift(f"vertex_ai/{_GEMINI}")
|
||||
assert spend == pytest.approx(
|
||||
|
|
@ -404,8 +403,8 @@ def test_gemini_passthrough_bills_a_named_custom_priced_deployment_only_for_keys
|
|||
for key in (allowed[action], plain[action]):
|
||||
_post_and_read(rig, f"/gemini/v1beta/models/{_GEMINI}:{action}", body, {"x-goog-api-key": key})
|
||||
spend: Final = {
|
||||
**{("allowed", action): _spend_for_key(key) for action, key in allowed.items()},
|
||||
**{("plain", action): _spend_for_key(key) for action, key in plain.items()},
|
||||
**{("allowed", action): _spend_for_key(rig.proxy, key) for action, key in allowed.items()},
|
||||
**{("plain", action): _spend_for_key(rig.proxy, key) for action, key in plain.items()},
|
||||
}
|
||||
custom_price: Final = _PROMPT_TOKENS * _CUSTOM_INPUT_RATE + _COMPLETION_TOKENS * _CUSTOM_OUTPUT_RATE
|
||||
list_price: Final = _list_price(f"gemini/{_GEMINI}")
|
||||
|
|
@ -441,7 +440,10 @@ def test_openai_passthrough_stream_cut_off_after_its_usage_bills_what_the_finish
|
|||
assert response.status_code == 200
|
||||
with pytest.raises(httpx.RemoteProtocolError):
|
||||
response.read()
|
||||
spend: Final = {"finished": _spend_for_key(finished_key), "cut off": _spend_for_key(cut_key)}
|
||||
spend: Final = {
|
||||
"finished": _spend_for_key(rig.proxy, finished_key),
|
||||
"cut off": _spend_for_key(rig.proxy, cut_key),
|
||||
}
|
||||
list_price: Final = _list_price(_OPENAI_MODEL)
|
||||
assert spend == pytest.approx({"finished": list_price, "cut off": list_price})
|
||||
|
||||
|
|
@ -484,7 +486,7 @@ def test_a_body_base_model_reprices_passthrough_spend_only_for_keys_that_allow_c
|
|||
},
|
||||
_caller_headers(route, key),
|
||||
)
|
||||
spend: Final = {cell: _spend_for_key(key) for cell, key in keys.items()}
|
||||
spend: Final = {cell: _spend_for_key(rig.proxy, key) for cell, key in keys.items()}
|
||||
forwarded: Final = rig.upstream.drain()
|
||||
marked_bodies: Final = {
|
||||
cell: tuple(request.body for request in forwarded if marker.encode() in request.body)
|
||||
|
|
@ -521,7 +523,7 @@ def test_a_non_string_body_base_model_bills_the_list_price_instead_of_nothing(ri
|
|||
{"contents": [{"role": "user", "parts": [{"text": uuid.uuid4().hex}]}], "base_model": 123},
|
||||
_caller_headers(route, key),
|
||||
)
|
||||
spend: Final = {cell: _spend_for_key(key) for cell, key in keys.items()}
|
||||
spend: Final = {cell: _spend_for_key(rig.proxy, key) for cell, key in keys.items()}
|
||||
list_price: Final = {
|
||||
"vertex_ai": _list_price(f"vertex_ai/{_GEMINI}"),
|
||||
"gemini": _list_price(f"gemini/{_GEMINI}"),
|
||||
|
|
@ -538,8 +540,8 @@ def test_gemini_passthrough_through_a_gemini_api_base_gateway_logs_and_bills_as_
|
|||
keys: Final = {action: scenario.key() for action in _GEMINI_ACTIONS}
|
||||
for action, key in keys.items():
|
||||
_post_and_read(gateway_rig, f"/gemini/v1beta/models/{_GEMINI}:{action}", body, {"x-goog-api-key": key})
|
||||
providers: Final = {action: _provider_for_key(key) for action, key in keys.items()}
|
||||
spend: Final = {action: _spend_for_key(key) for action, key in keys.items()}
|
||||
providers: Final = {action: _provider_for_key(gateway_rig.proxy, key) for action, key in keys.items()}
|
||||
spend: Final = {action: _spend_for_key(gateway_rig.proxy, key) for action, key in keys.items()}
|
||||
forwarded: Final = tuple(
|
||||
path
|
||||
for path in (urlsplit(request.target).path for request in gateway_rig.upstream.drain())
|
||||
|
|
@ -570,8 +572,8 @@ def test_a_configured_passthrough_to_a_non_google_gateway_bills_as_vertex_whatev
|
|||
body,
|
||||
{"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
providers: Final = {cell: _provider_for_key(key) for cell, key in keys.items()}
|
||||
spend: Final = {cell: _spend_for_key(key) for cell, key in keys.items()}
|
||||
providers: Final = {cell: _provider_for_key(gateway_rig.proxy, key) for cell, key in keys.items()}
|
||||
spend: Final = {cell: _spend_for_key(gateway_rig.proxy, key) for cell, key in keys.items()}
|
||||
short_path_spend: Final = {action: spend[("short gateway path", action)] for action in _GEMINI_ACTIONS}
|
||||
assert providers == {cell: "vertex_ai" for cell in cells}
|
||||
assert {action: spend[("vertex resource", action)] for action in _GEMINI_ACTIONS} == pytest.approx(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import httpx
|
|||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, eventually, gateway_from_environment
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.forward_proxy import ForwardProxy, tunnelling_forward_proxy
|
||||
from integration._support.process import graceful_stop_seconds, owned_proxy
|
||||
from integration._support.tls import server_context, write_self_signed_cert
|
||||
|
|
@ -150,12 +149,12 @@ def _spend(rig: _Rig, key: str, location: str, action: str, model: str = _MODEL)
|
|||
body: Final = response.read()
|
||||
assert response.status_code == 200, body
|
||||
call_id: Final = response.headers["x-litellm-call-id"]
|
||||
return _logged_spend(call_id)
|
||||
return _logged_spend(rig.proxy, call_id)
|
||||
|
||||
|
||||
def _logged_spend(call_id: str) -> float:
|
||||
def _logged_spend(gateway: Gateway, call_id: str) -> float:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows('SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (call_id,)),
|
||||
lambda: gateway.rows('SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (call_id,)),
|
||||
lambda found: len(found) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
|
|
@ -193,7 +192,7 @@ def test_custom_priced_vertex_passthrough_bills_the_deployment_price_even_when_t
|
|||
call_id: Final = response.headers["x-litellm-call-id"]
|
||||
with pytest.raises(httpx.RemoteProtocolError):
|
||||
response.read()
|
||||
cut_off: Final = _logged_spend(call_id)
|
||||
cut_off: Final = _logged_spend(rig.proxy, call_id)
|
||||
custom_price: Final = _PROMPT_TOKENS * _CUSTOM_INPUT_RATE + _COMPLETION_TOKENS * _CUSTOM_OUTPUT_RATE
|
||||
assert {**finished, "cut off": cut_off} == pytest.approx(
|
||||
{_GENERATE: custom_price, _STREAM: custom_price, "cut off": custom_price}
|
||||
|
|
|
|||
|
|
@ -10,31 +10,27 @@ import datetime
|
|||
import hashlib
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm._internal_context import current_service_target
|
||||
from litellm.caching.caching import Cache
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.caching.redis_batch import (
|
||||
active_post_call_redis_batch,
|
||||
active_request_redis_batches,
|
||||
drain_post_call_redis_batches,
|
||||
flush_post_call_redis_batches,
|
||||
request_redis_batch_scope,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
|
||||
from litellm.proxy.auth.auth_object_prefetch import AUTH_OBJECTS_TARGET
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
||||
PARALLEL_RELEASE_SCRIPT,
|
||||
TOKEN_INCREMENT_SCRIPT,
|
||||
ParallelSlotAcquisition,
|
||||
RequestRateLimiterStash,
|
||||
PROXY_MaxParallelRequestsHandler_v3,
|
||||
RequestRateLimiterStash,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.spend_counter_batch import PendingSpendIncrement
|
||||
from litellm.proxy.utils import InternalUsageCache
|
||||
|
|
@ -518,140 +514,3 @@ async def test_a_cancelled_post_call_flush_keeps_the_shared_spend_counter_and_co
|
|||
assert redis_cache.alone == [], "a cancel says nothing about the shared counter, so Redis keeps it"
|
||||
assert spend_cache.in_memory_cache.get_cache("spend:key:k1") == 3.5, "the local copy counts the cancelled spend"
|
||||
assert spend_cache.in_memory_cache.get_cache("spend:team:t1") is None, "an absent local copy is not seeded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_update_cache_read_armed_before_accounting_rides_the_pipeline_of_the_reconcile_read():
|
||||
from litellm.proxy.proxy_server import _read_update_cache_values, arm_update_cache_read
|
||||
|
||||
client = FakeClient(_ok_replies)
|
||||
redis_cache = PostCallFakeRedisCache(client)
|
||||
cache = DualCache()
|
||||
cache.attach_redis_cache(redis_cache)
|
||||
keys = ["user-1", "team_id:t1"]
|
||||
|
||||
with request_redis_batch_scope() as request:
|
||||
await arm_update_cache_read(keys, cache=cache)
|
||||
assert client.pipelines == []
|
||||
await request.batch(redis_cache).mget(["spend:key:k1"]) # the spend reconcile read of the same request
|
||||
values = await _read_update_cache_values(keys, None, cache=cache)
|
||||
|
||||
assert len(client.pipelines) == 1
|
||||
assert client.pipelines[0].commands == [("MGET", "user-1", "team_id:t1"), ("MGET", "spend:key:k1")]
|
||||
assert values == {"user-1": {"spend": 1.0}, "team_id:t1": {"spend": 1.0}}
|
||||
assert redis_cache.alone == []
|
||||
assert active_request_redis_batches() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_armed_update_cache_read_is_declared_under_the_auth_objects_family():
|
||||
"""The user, team and tag rows the accounting reads are auth objects, so the pipeline that carries
|
||||
the armed read renders ``redis.pipeline auth_objects``, not a bare ``redis.pipeline``."""
|
||||
from litellm.proxy.proxy_server import _read_update_cache_values, arm_update_cache_read
|
||||
|
||||
client = FakeClient(_ok_replies)
|
||||
redis_cache = PostCallFakeRedisCache(client)
|
||||
cache = DualCache()
|
||||
cache.attach_redis_cache(redis_cache)
|
||||
pipeline_targets: list[str | None] = [] # mutable-ok: filled by the recording hook
|
||||
|
||||
async def record(**kwargs: object) -> None:
|
||||
pipeline_targets.append(current_service_target())
|
||||
|
||||
redis_cache.service_logger_obj.async_service_success_hook = record # pyright: ignore[reportAttributeAccessIssue] # fake, records the hook call
|
||||
|
||||
with request_redis_batch_scope():
|
||||
await arm_update_cache_read(["user-1", "team_id:t1"], cache=cache)
|
||||
await _read_update_cache_values(["user-1", "team_id:t1"], None, cache=cache)
|
||||
await asyncio.gather(*(t for t in asyncio.all_tasks() if t is not asyncio.current_task()))
|
||||
|
||||
assert pipeline_targets == [AUTH_OBJECTS_TARGET]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_update_cache_read_armed_for_other_keys_is_ignored_and_the_read_happens_as_before():
|
||||
from litellm.proxy.proxy_server import _read_update_cache_values, arm_update_cache_read
|
||||
|
||||
redis_cache = PostCallFakeRedisCache(FakeClient(_ok_replies))
|
||||
redis_cache.store["team_id:t1"] = {"spend": 2.0}
|
||||
cache = DualCache()
|
||||
cache.attach_redis_cache(redis_cache)
|
||||
|
||||
with request_redis_batch_scope():
|
||||
await arm_update_cache_read(["user-1"], cache=cache)
|
||||
values = await _read_update_cache_values(["team_id:t1"], None, cache=cache)
|
||||
|
||||
assert values == {"team_id:t1": {"spend": 2.0}}
|
||||
assert ("MGET", ("team_id:t1",)) in redis_cache.alone
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_update_cache_read_sees_a_cached_spend_written_while_the_spend_was_persisted(monkeypatch):
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import _update_database_and_spend_counters
|
||||
|
||||
cached_user_spend = {"user-1": 1.0}
|
||||
|
||||
def replies(command: tuple[object, ...]) -> object:
|
||||
if command[0] == "MGET":
|
||||
return [
|
||||
json.dumps({"spend": cached_user_spend[key]}) if key in cached_user_spend else b"0.5"
|
||||
for key in command[1:]
|
||||
]
|
||||
return _ok_replies(command)
|
||||
|
||||
client = FakeClient(replies)
|
||||
redis_cache = PostCallFakeRedisCache(client)
|
||||
spend_cache = DualCache()
|
||||
spend_cache.attach_redis_cache(redis_cache)
|
||||
user_cache = DualCache()
|
||||
user_cache.attach_redis_cache(redis_cache)
|
||||
monkeypatch.setattr(proxy_server, "spend_counter_cache", spend_cache)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", user_cache)
|
||||
|
||||
async def _read_on_the_request_pipeline_then_a_concurrent_callback_writes_the_user(**kwargs: object) -> bool:
|
||||
request = active_request_redis_batches()
|
||||
assert request is not None
|
||||
await request.batch(redis_cache).mget(["key-object"])
|
||||
cached_user_spend["user-1"] = 5.0
|
||||
return True
|
||||
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.db_spend_update_writer.update_database = AsyncMock(
|
||||
side_effect=_read_on_the_request_pipeline_then_a_concurrent_callback_writes_the_user
|
||||
)
|
||||
reservation = {
|
||||
"reserved_cost": 0.5,
|
||||
"entries": [
|
||||
{
|
||||
"counter_key": "spend:key:k1",
|
||||
"entity_type": "Key",
|
||||
"entity_id": "k1",
|
||||
"reserved_cost": 0.5,
|
||||
"applied_adjustment": 0.0,
|
||||
}
|
||||
],
|
||||
"finalized": False,
|
||||
}
|
||||
|
||||
with request_redis_batch_scope():
|
||||
charged = await _update_database_and_spend_counters(
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
increment_spend_counters=proxy_server.increment_spend_counters,
|
||||
user_api_key="k1",
|
||||
user_id="user-1",
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
org_id=None,
|
||||
kwargs={},
|
||||
completion_response=None,
|
||||
start_time=datetime.datetime.now(),
|
||||
end_time=datetime.datetime.now(),
|
||||
response_cost=0.2,
|
||||
budget_reservation=reservation,
|
||||
update_cache_read_keys=("user-1",),
|
||||
)
|
||||
values = await proxy_server._read_update_cache_values(("user-1",), None)
|
||||
|
||||
assert charged is True
|
||||
assert values == {"user-1": {"spend": 5.0}}, client.pipelines
|
||||
|
|
|
|||
|
|
@ -1928,6 +1928,7 @@ def test_service_span_is_named_by_purpose_when_the_producer_declares_a_target():
|
|||
("async_batch_get_cache", "redis.mget auth_objects", "redis.mget"),
|
||||
("async_set_cache_pipeline_with_ttls", "redis.set auth_objects", "redis.set"),
|
||||
("async_increment_pipeline", "redis.incr auth_objects", "redis.incr"),
|
||||
("async_increment_if_present", "redis.incr auth_objects", "redis.incr"),
|
||||
("async_delete_cache", "redis.delete auth_objects", "redis.delete"),
|
||||
("async_scan_iter", "redis.scan auth_objects", "redis.scan"),
|
||||
("request_redis_batch", "redis.pipeline auth_objects", "redis.pipeline"),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from litellm.llms.bedrock.count_tokens.handler import BedrockCountTokensHandler
|
|||
from litellm.llms.bedrock.count_tokens.transformation import BedrockCountTokensConfig
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
from tests.unit.llms.bedrock.event_loop_probe import host_in_loop_probing_vault
|
||||
|
||||
|
||||
class _ProbedCountTokensHandler(BedrockCountTokensHandler):
|
||||
|
|
@ -56,6 +57,34 @@ async def test_handle_count_tokens_request_signs_off_the_event_loop(monkeypatch)
|
|||
assert probe.served_during_refresh is True
|
||||
|
||||
|
||||
def _count_tokens_in_us_west_2(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.host != "bedrock-runtime.us-west-2.amazonaws.com":
|
||||
return httpx.Response(404, request=request)
|
||||
return httpx.Response(200, json={"inputTokens": 7}, request=request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_count_tokens_request_looks_up_its_region_off_the_event_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIATESTACCESSKEY")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
|
||||
vault: Final = host_in_loop_probing_vault(monkeypatch, "AWS_REGION_NAME", "AWS_BEDROCK_RUNTIME_ENDPOINT")
|
||||
|
||||
result = await BedrockCountTokensHandler().handle_count_tokens_request(
|
||||
request_data={"model": "us.anthropic.claude-sonnet-5-5", "messages": [{"role": "user", "content": "hi"}]},
|
||||
litellm_params={},
|
||||
resolved_model="us.anthropic.claude-sonnet-5-5",
|
||||
client=AsyncHTTPHandler(transport=httpx.MockTransport(_count_tokens_in_us_west_2)),
|
||||
)
|
||||
|
||||
assert vault.read.is_set()
|
||||
assert not vault.stalled_the_loop.is_set()
|
||||
assert result == {"input_tokens": 7}
|
||||
|
||||
|
||||
class TestBedrockCountTokensEndpoint:
|
||||
def _make_handler(self) -> BedrockCountTokensConfig:
|
||||
return BedrockCountTokensConfig()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.llms.bedrock.count_tokens.mantle_handler import BedrockMantleCountTokensHandler
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from tests.unit.llms.bedrock.event_loop_probe import host_in_loop_probing_vault
|
||||
|
||||
MANTLE_COUNT_URL: Final = "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages/count_tokens"
|
||||
LITELLM_PARAMS: Final = {
|
||||
|
|
@ -107,3 +108,22 @@ async def test_non_200_answers_raise_bedrock_error_with_mantle_status() -> None:
|
|||
|
||||
assert raised.value.status_code == 404
|
||||
assert "does not exist" in raised.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_region_and_endpoint_lookups_leave_the_event_loop_free(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "us-east-1")
|
||||
vault: Final = host_in_loop_probing_vault(monkeypatch, "AWS_REGION_NAME", "BEDROCK_MANTLE_API_BASE")
|
||||
mantle: Final = _MantleEndpoint(200, {"input_tokens": 2177})
|
||||
|
||||
result = await BedrockMantleCountTokensHandler().handle_count_tokens_request(
|
||||
request_data={**REQUEST, "model": "global.anthropic.claude-opus-5-5"},
|
||||
litellm_params={key: value for key, value in LITELLM_PARAMS.items() if key != "aws_region_name"},
|
||||
resolved_model="anthropic.claude-opus-5-5",
|
||||
client=_client(mantle),
|
||||
)
|
||||
|
||||
assert vault.read.is_set()
|
||||
assert not vault.stalled_the_loop.is_set()
|
||||
assert result == {"input_tokens": 2177}
|
||||
assert str(mantle.only_request().url) == MANTLE_COUNT_URL
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Refreshable credentials whose refresh only completes while the event loop keeps serving."""
|
||||
"""Credential refreshes and Vault reads that only complete while the event loop keeps serving."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -8,10 +8,15 @@ import time
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from botocore.credentials import RefreshableCredentials
|
||||
|
||||
import litellm
|
||||
from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem
|
||||
|
||||
REFRESH_RELEASE_TIMEOUT_SECONDS: Final = 2.0
|
||||
REFRESH_START_TIMEOUT_SECONDS: Final = 10.0
|
||||
LOOP_SERVE_TIMEOUT_SECONDS: Final = 2.0
|
||||
|
||||
|
||||
class EventLoopProbe:
|
||||
|
|
@ -55,3 +60,34 @@ class EventLoopProbe:
|
|||
raise TimeoutError("signing finished without ever starting a credential refresh")
|
||||
await asyncio.sleep(0.005)
|
||||
self.loop_served.set()
|
||||
|
||||
|
||||
class LoopProbingVault:
|
||||
"""A Vault holding none of its hosted secrets, whose read waits for the event loop to run a callback.
|
||||
|
||||
A read made on the loop thread can never see that callback, so `stalled_the_loop` is set only then.
|
||||
"""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop: Final = loop
|
||||
self.read: Final = threading.Event()
|
||||
self.stalled_the_loop: Final = threading.Event()
|
||||
|
||||
def sync_read_secret(self, secret_name: str) -> None:
|
||||
self.read.set()
|
||||
served: Final = threading.Event()
|
||||
self._loop.call_soon_threadsafe(served.set)
|
||||
if not served.wait(timeout=LOOP_SERVE_TIMEOUT_SECONDS):
|
||||
self.stalled_the_loop.set()
|
||||
|
||||
|
||||
def host_in_loop_probing_vault(monkeypatch: pytest.MonkeyPatch, *secret_names: str) -> LoopProbingVault:
|
||||
vault: Final = LoopProbingVault(asyncio.get_running_loop())
|
||||
monkeypatch.setattr(litellm, "secret_manager_client", vault)
|
||||
monkeypatch.setattr(litellm, "_key_management_system", KeyManagementSystem.HASHICORP_VAULT)
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"_key_management_settings",
|
||||
KeyManagementSettings(access_mode="read_only", hosted_keys=list(secret_names)),
|
||||
)
|
||||
return vault
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.llms.bedrock.common_utils import BedrockError
|
|||
from litellm.llms.bedrock.realtime.handler import BedrockRealtime
|
||||
from litellm.llms.bedrock.realtime.transformation import BedrockRealtimeConfig
|
||||
from litellm.realtime_api import main as realtime_main
|
||||
from tests.unit.llms.bedrock.event_loop_probe import host_in_loop_probing_vault
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -625,6 +626,27 @@ class TestBedrockRealtimeSessionLifecycle:
|
|||
assert first_event["session"]["id"] == "trace-nova-sonic"
|
||||
assert first_event["session"]["model"] == "amazon.nova-sonic-v1:0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_region_lookup_on_connect_leaves_the_event_loop_free(
|
||||
self, stub_aws_sdk_client, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
|
||||
vault: Final = host_in_loop_probing_vault(monkeypatch, "AWS_REGION_NAME")
|
||||
websocket: Final = RealtimeClientWS()
|
||||
|
||||
_ = await BedrockRealtime().async_realtime(
|
||||
model="amazon.nova-2-sonic-v1:0",
|
||||
websocket=websocket,
|
||||
logging_obj=FakeLogging(),
|
||||
aws_access_key_id="k",
|
||||
aws_secret_access_key="s",
|
||||
)
|
||||
|
||||
assert vault.read.is_set()
|
||||
assert not vault.stalled_the_loop.is_set()
|
||||
assert stub_aws_sdk_client["config_kwargs"]["region"] == "us-west-2"
|
||||
assert stub_aws_sdk_client["config_kwargs"]["endpoint_uri"] == "https://bedrock-runtime.us-west-2.amazonaws.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_update_is_acked_with_session_updated(self, stub_aws_models):
|
||||
handler = BedrockRealtime()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from litellm.llms.bedrock.base_aws_llm import (
|
|||
BaseAWSLLM,
|
||||
Boto3CredentialsInfo,
|
||||
run_aws_signing,
|
||||
sign_request_off_loop_if_aws,
|
||||
run_off_loop_if_aws,
|
||||
)
|
||||
from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
from litellm.llms.bedrock.common_utils import BedrockModelInfo
|
||||
|
|
@ -3443,7 +3443,7 @@ class TestGetRequestHeadersResign:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sign_request_off_loop_if_aws_keeps_the_loop_serving_while_credentials_refresh():
|
||||
async def test_run_off_loop_if_aws_keeps_the_loop_serving_while_credentials_refresh() -> None:
|
||||
"""Regression for issue #40165: an AWS provider's signing (and the botocore credential refresh
|
||||
inside it) must run off the event loop, so other requests keep being served meanwhile."""
|
||||
probe = EventLoopProbe()
|
||||
|
|
@ -3456,7 +3456,7 @@ async def test_sign_request_off_loop_if_aws_keeps_the_loop_serving_while_credent
|
|||
return dict(request.headers)
|
||||
|
||||
release = asyncio.create_task(probe.release_refresh_from_the_loop())
|
||||
signed = await sign_request_off_loop_if_aws(BaseAWSLLM(), sign, headers={"Content-Type": "application/json"})
|
||||
signed = await run_off_loop_if_aws(BaseAWSLLM(), sign, headers={"Content-Type": "application/json"})
|
||||
await release
|
||||
|
||||
assert "Authorization" in signed
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, ValidationError
|
|||
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from tests.unit.llms.bedrock.event_loop_probe import host_in_loop_probing_vault
|
||||
|
||||
WORKSPACE_ALIASES: Final = ("workspace_id", "aws_workspace_id", "anthropic_workspace_id", "anthropic-workspace-id")
|
||||
|
||||
|
|
@ -683,6 +684,32 @@ async def test_anthropic_messages_claude_platform_sends_exact_body_through_stric
|
|||
_assert_gateway_accepted(requests[0], auth_kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_messages_claude_platform_workspace_and_region_lookups_leave_the_event_loop_free(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import litellm
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_AWS_WORKSPACE_ID", "wrkspc_test")
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
|
||||
vault: Final = host_in_loop_probing_vault(monkeypatch, "ANTHROPIC_AWS_WORKSPACE_ID", "AWS_REGION_NAME")
|
||||
sigv4_without_region: Final = {key: value for key, value in SIGV4_KWARGS.items() if key != "aws_region_name"}
|
||||
requests: list[dict] = []
|
||||
|
||||
response = await litellm.anthropic_messages(
|
||||
model="bedrock/claude_platform/claude-sonnet-5-5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
max_tokens=10,
|
||||
client=_async_gateway_client(requests),
|
||||
**sigv4_without_region,
|
||||
)
|
||||
|
||||
assert vault.read.is_set()
|
||||
assert not vault.stalled_the_loop.is_set()
|
||||
assert response["content"][0]["text"] == "ok"
|
||||
_assert_gateway_accepted(requests[0], sigv4_without_region)
|
||||
|
||||
|
||||
def test_chat_completion_claude_platform_drops_context_management_and_gateway_accepts():
|
||||
import litellm
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from botocore.awsrequest import AWSRequest
|
|||
import litellm
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.llms.bedrock_mantle.chat.transformation import BedrockMantleChatConfig
|
||||
from litellm.llms.bedrock.base_aws_llm import sign_request_off_loop_if_aws
|
||||
from litellm.llms.bedrock.base_aws_llm import run_off_loop_if_aws
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
from litellm.types.utils import LlmProviders
|
||||
from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
|
|
@ -1234,9 +1234,7 @@ async def test_mantle_signing_runs_off_the_event_loop():
|
|||
return dict(request.headers)
|
||||
|
||||
release = asyncio.create_task(probe.release_refresh_from_the_loop())
|
||||
signed = await sign_request_off_loop_if_aws(
|
||||
BedrockMantleChatConfig(), sign, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
signed = await run_off_loop_if_aws(BedrockMantleChatConfig(), sign, headers={"Content-Type": "application/json"})
|
||||
await release
|
||||
|
||||
assert "Authorization" in signed
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import json
|
|||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from botocore.credentials import RefreshableCredentials
|
||||
|
||||
import litellm
|
||||
|
|
@ -66,6 +67,7 @@ from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIRe
|
|||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import ImageObject, ImageResponse, ModelResponse, TranscriptionResponse
|
||||
from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
from tests.unit.llms.bedrock.event_loop_probe import host_in_loop_probing_vault
|
||||
|
||||
_ACTIVE_KEY = "_code_interpreter_interception_active"
|
||||
_SANDBOX_KEY = "_code_interpreter_interception_sandbox_key"
|
||||
|
|
@ -1245,6 +1247,155 @@ async def test_async_anthropic_messages_handler_signs_bedrock_off_the_event_loop
|
|||
assert probe.served_during_refresh is True
|
||||
|
||||
|
||||
_BEDROCK_MESSAGE: Final = {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5-5",
|
||||
"content": [{"type": "text", "text": "hi"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
}
|
||||
_BEDROCK_RESPONSE: Final = {
|
||||
"id": "resp_1",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": "completed",
|
||||
"model": "openai.gpt-5.6-sol",
|
||||
"output": [],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
_EMPTY_S3_LISTING: Final = (
|
||||
b'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
b'<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
|
||||
b"<Name>batch-inputs</Name><KeyCount>0</KeyCount><IsTruncated>false</IsTruncated></ListBucketResult>"
|
||||
)
|
||||
_BATCH_LINE: Final = json.dumps(
|
||||
{
|
||||
"custom_id": "1",
|
||||
"method": "POST",
|
||||
"url": "/v1/chat/completions",
|
||||
"body": {"model": "us.anthropic.claude-sonnet-5-5", "messages": [{"role": "user", "content": "hi"}]},
|
||||
}
|
||||
).encode()
|
||||
_BATCH_FILE_ID: Final = "s3://batch-inputs/litellm-bedrock-files/batch.jsonl"
|
||||
_BEDROCK_ENTRY_POINTS: Final[tuple[tuple[str, Callable[[], Awaitable[object]], httpx.Response], ...]] = (
|
||||
(
|
||||
"anthropic_messages",
|
||||
lambda: litellm.anthropic.messages.acreate(
|
||||
model="bedrock/invoke/us.anthropic.claude-sonnet-5-5",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=16,
|
||||
),
|
||||
httpx.Response(200, json=_BEDROCK_MESSAGE),
|
||||
),
|
||||
(
|
||||
"responses",
|
||||
lambda: litellm.aresponses(model="bedrock/us.openai.gpt-5.6-sol", input="hi"),
|
||||
httpx.Response(200, json=_BEDROCK_RESPONSE),
|
||||
),
|
||||
(
|
||||
"compact_responses",
|
||||
lambda: litellm.acompact_responses(model="bedrock/us.openai.gpt-5.6-sol", input="hi"),
|
||||
httpx.Response(200, json=_BEDROCK_RESPONSE),
|
||||
),
|
||||
(
|
||||
"search",
|
||||
lambda: litellm.asearch(
|
||||
query="hi",
|
||||
search_provider="agentcore",
|
||||
api_base="https://gw.gateway.bedrock-agentcore.us-west-2.amazonaws.com/mcp",
|
||||
),
|
||||
httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"content": []}}),
|
||||
),
|
||||
(
|
||||
"create_file",
|
||||
lambda: litellm.acreate_file(
|
||||
file=("batch.jsonl", _BATCH_LINE, "application/jsonl"),
|
||||
purpose="batch",
|
||||
custom_llm_provider="bedrock",
|
||||
model="bedrock/us.anthropic.claude-sonnet-5-5",
|
||||
),
|
||||
httpx.Response(200),
|
||||
),
|
||||
(
|
||||
"retrieve_file",
|
||||
lambda: litellm.afile_retrieve(file_id=_BATCH_FILE_ID, custom_llm_provider="bedrock"),
|
||||
httpx.Response(200, content=_BATCH_LINE),
|
||||
),
|
||||
(
|
||||
"delete_file",
|
||||
lambda: litellm.afile_delete(file_id=_BATCH_FILE_ID, custom_llm_provider="bedrock"),
|
||||
httpx.Response(204),
|
||||
),
|
||||
(
|
||||
"list_files",
|
||||
lambda: litellm.afile_list(custom_llm_provider="bedrock"),
|
||||
httpx.Response(200, content=_EMPTY_S3_LISTING),
|
||||
),
|
||||
(
|
||||
"file_content",
|
||||
lambda: litellm.afile_content(file_id=_BATCH_FILE_ID, custom_llm_provider="bedrock"),
|
||||
httpx.Response(200, content=_BATCH_LINE),
|
||||
),
|
||||
(
|
||||
"create_batch",
|
||||
lambda: litellm.acreate_batch(
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id=_BATCH_FILE_ID,
|
||||
custom_llm_provider="bedrock",
|
||||
model="bedrock/us.anthropic.claude-sonnet-5-5",
|
||||
aws_batch_role_arn="arn:aws:iam::123456789012:role/batch",
|
||||
s3_output_bucket_name="batch-outputs",
|
||||
),
|
||||
httpx.Response(200, json={"jobArn": "arn:aws:bedrock:us-west-2:123456789012:model-invocation-job/batch"}),
|
||||
),
|
||||
(
|
||||
"vector_store_search",
|
||||
lambda: litellm.vector_stores.asearch(vector_store_id="KBID12345", query="hi", custom_llm_provider="bedrock"),
|
||||
httpx.Response(200, json={"retrievalResults": []}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("call", "upstream_answer"),
|
||||
[pytest.param(call, answer, id=name) for name, call, answer in _BEDROCK_ENTRY_POINTS],
|
||||
)
|
||||
async def test_bedrock_secret_lookups_leave_the_event_loop_free(
|
||||
call: Callable[[], Awaitable[object]],
|
||||
upstream_answer: httpx.Response,
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIATESTTESTTESTTEST")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
|
||||
monkeypatch.setenv("AWS_S3_BUCKET_NAME", "batch-inputs")
|
||||
monkeypatch.setenv("AWS_S3_OUTPUT_BUCKET_NAME", "batch-outputs")
|
||||
vault: Final = host_in_loop_probing_vault(
|
||||
monkeypatch,
|
||||
"AWS_REGION_NAME",
|
||||
"AWS_BEDROCK_RUNTIME_ENDPOINT",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"AWS_S3_ENCRYPTION_KEY_ID",
|
||||
"AWS_S3_BUCKET_OWNER",
|
||||
"AGENTCORE_MCP_PROTOCOL_VERSION",
|
||||
"AGENTCORE_SEARCH_TOOL_NAME",
|
||||
"AGENTCORE_GATEWAY_TOKEN",
|
||||
)
|
||||
upstream: Final = respx_mock.route().mock(return_value=upstream_answer)
|
||||
|
||||
_ = await call()
|
||||
|
||||
assert vault.read.is_set()
|
||||
assert not vault.stalled_the_loop.is_set()
|
||||
assert ".us-west-2." in upstream.calls.last.request.url.host
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_anthropic_messages_handler_passes_litellm_metadata():
|
||||
"""Ensure litellm_metadata from kwargs is forwarded via update_from_kwargs.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
|||
)
|
||||
from litellm.types.utils import CallTypes, ModelResponse
|
||||
from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
from tests.unit.llms.bedrock.event_loop_probe import host_in_loop_probing_vault
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -8063,3 +8064,44 @@ async def test_during_call_masking_rewrites_only_pii_block_in_content_list(
|
|||
]
|
||||
assert messages[1]["content"] == "Hello, how can I help you today?"
|
||||
assert messages[2]["content"] == "who is the president of the united states?"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("guardrail_config", "upstream_answer"),
|
||||
[
|
||||
pytest.param(
|
||||
{"guardrailIdentifier": "wf0hkdb5x07f", "guardrailVersion": "DRAFT"},
|
||||
{"action": "NONE", "outputs": [], "assessments": []},
|
||||
id="apply_guardrail",
|
||||
),
|
||||
pytest.param(
|
||||
{"checks": {"contentFilter": {"categories": [{"category": "VIOLENCE"}]}}, "content_filter_threshold": 0.5},
|
||||
{"results": {"contentFilter": {"results": [{"category": "VIOLENCE", "severityScore": 0.1}]}}},
|
||||
id="invoke_guardrail_checks",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_guardrail_secret_lookups_leave_the_event_loop_free(
|
||||
guardrail_config: dict[str, object],
|
||||
upstream_answer: dict[str, object],
|
||||
respx_mock: respx.MockRouter,
|
||||
httpx_transport: None,
|
||||
aws_test_credentials: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
|
||||
vault: Final = host_in_loop_probing_vault(monkeypatch, "AWS_BEARER_TOKEN_BEDROCK", "AWS_REGION_NAME")
|
||||
guardrail: Final = BedrockGuardrail(**guardrail_config)
|
||||
upstream: Final = respx_mock.route().mock(return_value=httpx.Response(200, json=upstream_answer))
|
||||
|
||||
_ = await guardrail.make_bedrock_api_request(
|
||||
source="INPUT",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
request_data={"messages": []},
|
||||
)
|
||||
|
||||
assert vault.read.is_set()
|
||||
assert not vault.stalled_the_loop.is_set()
|
||||
assert upstream.calls.last.request.url.host == "bedrock-runtime.us-west-2.amazonaws.com"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from litellm.proxy.utils import ProxyLogging
|
|||
from litellm.passthrough.main import raise_for_provider_status
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
from tests.unit.llms.bedrock.event_loop_probe import host_in_loop_probing_vault
|
||||
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
|
||||
BaseOpenAIPassThroughHandler,
|
||||
|
|
@ -2519,6 +2520,48 @@ class TestBedrockAgentRuntimePassthroughVirtualKeyLeak:
|
|||
assert upstream["x-request-id"] == "trace-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bedrock_agent_runtime_region_lookup_leaves_the_event_loop_free(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", SHARED_MASTER_KEY)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
for ambient in TestBedrockAgentRuntimePassthroughVirtualKeyLeak.AMBIENT_AWS_ENV:
|
||||
monkeypatch.delenv(ambient, raising=False)
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "ak")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "sk")
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
|
||||
vault: Final = host_in_loop_probing_vault(monkeypatch, "AWS_REGION_NAME")
|
||||
|
||||
async def receive() -> dict[str, object]:
|
||||
return {"type": "http.request", "body": b'{"retrievalQuery": {"text": "hi"}}', "more_body": False}
|
||||
|
||||
request: Final = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/bedrock/knowledgebases/KB1234567/retrieve",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"query_string": b"",
|
||||
},
|
||||
receive=receive,
|
||||
)
|
||||
with respx.mock(assert_all_called=True) as upstream:
|
||||
retrieve: Final = upstream.post(
|
||||
"https://bedrock-agent-runtime.us-west-2.amazonaws.com/knowledgebases/KB1234567/retrieve"
|
||||
).mock(return_value=httpx.Response(200, json={"retrievalResults": []}))
|
||||
_ = await bedrock_proxy_route(
|
||||
endpoint="knowledgebases/KB1234567/retrieve",
|
||||
request=request,
|
||||
fastapi_response=Response(),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-litellm-caller"),
|
||||
)
|
||||
|
||||
assert vault.read.is_set()
|
||||
assert not vault.stalled_the_loop.is_set()
|
||||
assert retrieve.calls.last.request.headers["authorization"].startswith("AWS4-HMAC-SHA256")
|
||||
|
||||
|
||||
class TestLLMPassthroughFactoryProxyRoute:
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_passthrough_factory_proxy_route_success(self):
|
||||
|
|
|
|||
|
|
@ -24,15 +24,31 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime
|
||||
from typing import Final, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from typing import Final, Literal, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, call
|
||||
|
||||
import pytest
|
||||
from fakeredis import FakeAsyncRedis, FakeServer
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from opentelemetry.trace import StatusCode
|
||||
|
||||
import litellm
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm._service_logger import ServiceLogging
|
||||
from litellm._internal_context import current_service_target
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.caching.redis_cache import RedisCache, RedisCircuitBreaker
|
||||
from litellm.constants import GLOBAL_PROXY_SPEND_CACHE_KEY
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
||||
from litellm.models.end_user import LiteLLM_EndUserTable
|
||||
from litellm.models.tag import LiteLLM_TagTable
|
||||
from litellm.models.team import LiteLLM_TeamTable, LiteLLM_TeamTableCachedObj
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_local
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache, end_user_cache_key, tag_cache_key
|
||||
from litellm.proxy.management_endpoints.team_endpoints import refresh_cached_team
|
||||
|
||||
from .conftest import normalize
|
||||
|
|
@ -1559,31 +1575,6 @@ async def test_invalidate_spend_counter_swallows_redis_failure_no_raise(monkeypa
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cache_no_cached_entities_schedules_pipeline_flush(monkeypatch):
|
||||
fake_user_cache = _make_user_api_key_cache(get_value=None)
|
||||
monkeypatch.setattr(ps, "user_api_key_cache", fake_user_cache)
|
||||
|
||||
await ps.update_cache(
|
||||
token=None,
|
||||
user_id="u1",
|
||||
end_user_id="eu1",
|
||||
team_id="t1",
|
||||
response_cost=1.0,
|
||||
parent_otel_span=None,
|
||||
tags=["x"],
|
||||
)
|
||||
|
||||
assert fake_user_cache.async_get_cache.await_count == 0
|
||||
fake_user_cache.async_batch_get_cache.assert_awaited_once()
|
||||
assert fake_user_cache.async_batch_get_cache.await_args.kwargs["keys"] == [
|
||||
"u1",
|
||||
f"{ps.litellm_proxy_admin_name}:spend",
|
||||
"end_user_id:eu1",
|
||||
"tag:x",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cache_user_cache_failure_invalid_state_is_swallowed(monkeypatch):
|
||||
"""An inner _update_user_cache raising must not propagate — update_cache
|
||||
|
|
@ -1671,3 +1662,354 @@ async def test_update_cache_never_restores_a_team_a_management_write_replaced(
|
|||
|
||||
seen_by_auth: Final = cache.get_cache(key=f"team_id:{team_id}", model_type=LiteLLM_TeamTableCachedObj)
|
||||
assert (seen_by_auth.blocked if seen_by_auth is not None else None) == expected_blocked
|
||||
|
||||
|
||||
_CachedSpendObject = LiteLLM_UserTable | LiteLLM_EndUserTable | LiteLLM_TeamTableCachedObj | LiteLLM_TagTable
|
||||
|
||||
|
||||
def _cached_spend_object(
|
||||
kind: Literal["user", "end_user", "team", "tag"], models: list[str]
|
||||
) -> tuple[str, _CachedSpendObject]:
|
||||
match kind:
|
||||
case "user":
|
||||
return "race-user", LiteLLM_UserTable(user_id="race-user", models=models)
|
||||
case "end_user":
|
||||
return end_user_cache_key("race-end-user"), LiteLLM_EndUserTable(
|
||||
user_id="race-end-user", blocked=False, models=models
|
||||
)
|
||||
case "team":
|
||||
return "team_id:race-team", LiteLLM_TeamTableCachedObj(team_id="race-team", models=models)
|
||||
case "tag":
|
||||
return tag_cache_key("race-tag"), LiteLLM_TagTable(tag_name="race-tag", models=models)
|
||||
|
||||
|
||||
class _RunsOnKeyRead(InMemoryCache):
|
||||
def __init__(self, on_read: Callable[[], Awaitable[None]]) -> None:
|
||||
super().__init__()
|
||||
self._on_read: Final = on_read
|
||||
|
||||
async def async_get_cache(self, key: str, **kwargs: object) -> object:
|
||||
await self._on_read()
|
||||
return await super().async_get_cache(key, **kwargs)
|
||||
|
||||
|
||||
def _cache_that_runs_on_key_read(on_read: Callable[[], Awaitable[None]]) -> UserApiKeyCache:
|
||||
return UserApiKeyCache(in_memory_cache=InMemoryCache(), key_object_in_memory_cache=_RunsOnKeyRead(on_read))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_max_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "max_budget", 100.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["user", "end_user", "team", "tag"])
|
||||
@pytest.mark.parametrize(
|
||||
("management_write", "expected"),
|
||||
[("none", (["gpt-a"], 0.25)), ("evict", None), ("refresh", (["gpt-b"], 0.25))],
|
||||
)
|
||||
async def test_update_cache_adds_spend_to_the_entry_a_management_write_left_mid_flight(
|
||||
kind: Literal["user", "end_user", "team", "tag"],
|
||||
management_write: Literal["none", "evict", "refresh"],
|
||||
expected: tuple[list[str], float] | None,
|
||||
) -> None:
|
||||
key, seeded = _cached_spend_object(kind, models=["gpt-a"])
|
||||
_, refreshed = _cached_spend_object(kind, models=["gpt-b"])
|
||||
|
||||
async def _write() -> None:
|
||||
if management_write == "none":
|
||||
return
|
||||
evict_local(cache_keys=(key,), user_api_key_cache=cache)
|
||||
if management_write == "refresh":
|
||||
cache.set_cache(key=key, value=refreshed, local_only=True)
|
||||
|
||||
cache: Final = _cache_that_runs_on_key_read(on_read=_write)
|
||||
cache.set_cache(key=key, value=seeded, local_only=True)
|
||||
|
||||
await ps.update_cache(
|
||||
token="sk-race",
|
||||
user_id="race-user",
|
||||
end_user_id="race-end-user",
|
||||
team_id="race-team",
|
||||
response_cost=0.25,
|
||||
parent_otel_span=None,
|
||||
tags=["race-tag"],
|
||||
cache=cache,
|
||||
)
|
||||
await _drain_spend_cache_writes()
|
||||
|
||||
stored: Final = cache.get_cache(key=key, local_only=True, model_type=type(seeded))
|
||||
assert ((stored.models, stored.spend) if stored is not None else None) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("proxy_max_budget")
|
||||
@pytest.mark.parametrize("user", ["cached_on_this_worker", "not_cached_on_this_worker", "none"])
|
||||
async def test_update_cache_adds_spend_to_the_global_proxy_spend_for_every_request(
|
||||
user: Literal["cached_on_this_worker", "not_cached_on_this_worker", "none"],
|
||||
) -> None:
|
||||
cache: Final = UserApiKeyCache()
|
||||
cache.set_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, value=10.0)
|
||||
if user == "cached_on_this_worker":
|
||||
cache.set_cache(key="spender", value=LiteLLM_UserTable(user_id="spender", spend=1.0))
|
||||
|
||||
await ps.update_cache(
|
||||
token=None,
|
||||
user_id=None if user == "none" else "spender",
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=0.25,
|
||||
parent_otel_span=None,
|
||||
cache=cache,
|
||||
)
|
||||
await _drain_spend_cache_writes()
|
||||
|
||||
assert cache.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY) == 10.25
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("proxy_max_budget")
|
||||
async def test_update_cache_never_brings_back_a_global_proxy_spend_a_budget_reset_cleared() -> None:
|
||||
async def _budget_reset() -> None:
|
||||
await cache.async_delete_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY)
|
||||
|
||||
cache: Final = _cache_that_runs_on_key_read(on_read=_budget_reset)
|
||||
cache.set_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, value=10.0)
|
||||
|
||||
await ps.update_cache(
|
||||
token="sk-spender",
|
||||
user_id="spender",
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=0.25,
|
||||
parent_otel_span=None,
|
||||
cache=cache,
|
||||
)
|
||||
await _drain_spend_cache_writes()
|
||||
|
||||
assert cache.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY) is None
|
||||
|
||||
|
||||
class _FakeRedisRunningIncrementIfPresent(FakeAsyncRedis):
|
||||
def __init__(self, server: FakeServer, after_increment: Callable[[], object] = lambda: None) -> None:
|
||||
super().__init__(server=server)
|
||||
self._after_increment: Final = after_increment
|
||||
|
||||
async def eval(self, script: str, numkeys: int, *keys_and_args: str | bytes | float) -> bytes | None:
|
||||
key, increment = keys_and_args
|
||||
if not await self.exists(key):
|
||||
return None
|
||||
total: Final = await self.incrbyfloat(key, increment)
|
||||
self._after_increment()
|
||||
return str(total).encode()
|
||||
|
||||
|
||||
class _RedisCacheOnClient(RedisCache):
|
||||
def __init__(self, client: FakeAsyncRedis) -> None:
|
||||
self._injected_client: Final = client
|
||||
self.namespace = None
|
||||
self._circuit_breaker = RedisCircuitBreaker(failure_threshold=5, recovery_timeout=30)
|
||||
self.service_logger_obj = ServiceLogging()
|
||||
self.default_ttl = None
|
||||
|
||||
def init_async_client(self) -> FakeAsyncRedis:
|
||||
return self._injected_client
|
||||
|
||||
|
||||
class _FakeRedisHoldingOneReply(FakeAsyncRedis):
|
||||
def __init__(self, server: FakeServer, held_increment: float, applied: asyncio.Event, released: asyncio.Event) -> None:
|
||||
super().__init__(server=server)
|
||||
self._held_increment: Final = held_increment
|
||||
self._applied: Final = applied
|
||||
self._released: Final = released
|
||||
|
||||
async def eval(self, script: str, numkeys: int, *keys_and_args: str | bytes | float) -> bytes | None:
|
||||
key, increment = keys_and_args
|
||||
total: Final = await self.incrbyfloat(key, increment)
|
||||
if float(increment) == self._held_increment:
|
||||
self._applied.set()
|
||||
await self._released.wait()
|
||||
return str(total).encode()
|
||||
|
||||
|
||||
_SharedGlobalSpendOutcome = Literal["peers_added_spend", "reset_elsewhere", "reset_while_redis_answers", "redis_down"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("proxy_max_budget")
|
||||
@pytest.mark.parametrize(
|
||||
("outcome", "shared_total", "expected"),
|
||||
[
|
||||
("peers_added_spend", 11.75, 12.0),
|
||||
("reset_elsewhere", None, 10.25),
|
||||
("reset_while_redis_answers", 9.75, None),
|
||||
("redis_down", 10.0, 10.25),
|
||||
],
|
||||
)
|
||||
async def test_update_cache_moves_this_workers_global_proxy_spend_to_the_shared_total(
|
||||
outcome: _SharedGlobalSpendOutcome, shared_total: float | None, expected: float | None
|
||||
) -> None:
|
||||
def _reset_this_worker() -> None:
|
||||
if outcome == "reset_while_redis_answers":
|
||||
cache.in_memory_cache.delete_cache(GLOBAL_PROXY_SPEND_CACHE_KEY)
|
||||
|
||||
server: Final = FakeServer()
|
||||
redis: Final = _FakeRedisRunningIncrementIfPresent(server, after_increment=_reset_this_worker)
|
||||
if shared_total is not None:
|
||||
await redis.set(GLOBAL_PROXY_SPEND_CACHE_KEY, shared_total)
|
||||
server.connected = outcome != "redis_down"
|
||||
cache: Final = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=_RedisCacheOnClient(redis))
|
||||
cache.set_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, value=10.0, local_only=True)
|
||||
|
||||
await ps.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=0.25,
|
||||
parent_otel_span=None,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
assert cache.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, local_only=True) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("proxy_max_budget")
|
||||
async def test_update_cache_keeps_the_newest_global_proxy_spend_when_redis_replies_out_of_order() -> None:
|
||||
held_increment_applied: Final = asyncio.Event()
|
||||
held_reply_released: Final = asyncio.Event()
|
||||
server: Final = FakeServer()
|
||||
redis: Final = _FakeRedisHoldingOneReply(
|
||||
server, held_increment=0.25, applied=held_increment_applied, released=held_reply_released
|
||||
)
|
||||
await redis.set(GLOBAL_PROXY_SPEND_CACHE_KEY, 10.0)
|
||||
cache: Final = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=_RedisCacheOnClient(redis))
|
||||
cache.set_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, value=10.0, local_only=True)
|
||||
|
||||
async def _request(response_cost: float) -> None:
|
||||
await ps.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=response_cost,
|
||||
parent_otel_span=None,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
earlier: Final = asyncio.create_task(_request(0.25))
|
||||
await held_increment_applied.wait()
|
||||
await _request(0.5)
|
||||
held_reply_released.set()
|
||||
await earlier
|
||||
|
||||
assert cache.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, local_only=True) == 10.75
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("proxy_max_budget")
|
||||
async def test_update_cache_adds_the_global_spend_scalar_in_redis_as_spend_counters() -> None:
|
||||
target_during_increment: Final = MagicMock()
|
||||
redis: Final = _FakeRedisRunningIncrementIfPresent(
|
||||
FakeServer(), after_increment=lambda: target_during_increment(current_service_target())
|
||||
)
|
||||
await redis.set(GLOBAL_PROXY_SPEND_CACHE_KEY, 10.0)
|
||||
cache: Final = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=_RedisCacheOnClient(redis))
|
||||
|
||||
await ps.update_cache(
|
||||
token=None,
|
||||
user_id="u1",
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=2.0,
|
||||
parent_otel_span=None,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
assert target_during_increment.call_args_list == [call("spend_counters")]
|
||||
assert float(await redis.get(GLOBAL_PROXY_SPEND_CACHE_KEY)) == 12.0
|
||||
assert current_service_target() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("proxy_max_budget")
|
||||
@pytest.mark.parametrize(("redis_up", "expected_status"), [(True, StatusCode.OK), (False, StatusCode.ERROR)])
|
||||
async def test_update_cache_traces_the_global_spend_redis_call_under_the_request_span(
|
||||
monkeypatch: pytest.MonkeyPatch, redis_up: bool, expected_status: StatusCode
|
||||
) -> None:
|
||||
exporter: Final = InMemorySpanExporter()
|
||||
provider: Final = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"service_callback",
|
||||
[OpenTelemetry(config=OpenTelemetryConfig(exporter=exporter), tracer_provider=provider)],
|
||||
)
|
||||
server: Final = FakeServer()
|
||||
redis: Final = _FakeRedisRunningIncrementIfPresent(server)
|
||||
await redis.set(GLOBAL_PROXY_SPEND_CACHE_KEY, 10.0)
|
||||
server.connected = redis_up
|
||||
cache: Final = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=_RedisCacheOnClient(redis))
|
||||
request_span: Final = provider.get_tracer("test").start_span("request")
|
||||
|
||||
await ps.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=2.0,
|
||||
parent_otel_span=request_span,
|
||||
cache=cache,
|
||||
)
|
||||
await _drain_spend_cache_writes()
|
||||
request_span.end()
|
||||
|
||||
redis_spans: Final = tuple(
|
||||
(span.attributes.get("call_type"), span.status.status_code, span.parent.span_id)
|
||||
for span in exporter.get_finished_spans()
|
||||
if span.name == "redis" and span.attributes is not None and span.parent is not None
|
||||
)
|
||||
assert redis_spans == (
|
||||
("async_increment_if_present", expected_status, request_span.get_span_context().span_id),
|
||||
)
|
||||
|
||||
|
||||
class _UnwritableKeyUserApiKeyCache(UserApiKeyCache):
|
||||
def __init__(self, unwritable_key: str) -> None:
|
||||
super().__init__(in_memory_cache=InMemoryCache())
|
||||
self._unwritable_key: Final = unwritable_key
|
||||
|
||||
def set_cache(self, key: str | None, value: object, local_only: bool = False, **kwargs: object):
|
||||
if key == self._unwritable_key:
|
||||
raise RuntimeError("cache write failed")
|
||||
return super().set_cache(key, value, local_only=local_only, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("proxy_max_budget")
|
||||
async def test_update_cache_still_adds_spend_to_the_others_when_one_cached_object_fails() -> None:
|
||||
cache: Final = _UnwritableKeyUserApiKeyCache(unwritable_key="broken-user")
|
||||
cache.in_memory_cache.set_cache(
|
||||
key="broken-user",
|
||||
value=CacheCodec.serialize(LiteLLM_UserTable(user_id="broken-user", spend=1.0), model_type=LiteLLM_UserTable),
|
||||
)
|
||||
cache.set_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY, value=10.0)
|
||||
cache.set_cache(key="team_id:still-billed", value=LiteLLM_TeamTableCachedObj(team_id="still-billed", spend=1.0))
|
||||
|
||||
await ps.update_cache(
|
||||
token=None,
|
||||
user_id="broken-user",
|
||||
end_user_id=None,
|
||||
team_id="still-billed",
|
||||
response_cost=0.25,
|
||||
parent_otel_span=None,
|
||||
cache=cache,
|
||||
)
|
||||
await _drain_spend_cache_writes()
|
||||
|
||||
team: Final = cache.get_cache(key="team_id:still-billed", model_type=LiteLLM_TeamTableCachedObj)
|
||||
assert (team.spend if team is not None else None, cache.get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY)) == (
|
||||
1.25,
|
||||
10.25,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -737,22 +737,3 @@ async def test_a_resized_reservation_is_carried_at_its_resized_cost_to_the_count
|
|||
assert reservation["reserved_cost"] == pytest.approx(0.1)
|
||||
assert redis.store["spend:key:hashed"] == pytest.approx(9.9)
|
||||
assert redis.store["spend:team:team"] == pytest.approx(2.1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cache_reads_an_object_redis_gained_right_after_a_batch_read_missed_it(monkeypatch):
|
||||
"""DualCache throttles repeated batch reads of a key that just missed; the per-object GET update_cache used to
|
||||
issue never did, so its batched read must not either."""
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
|
||||
redis = CountingRedis()
|
||||
cache = DualCache(redis_cache=redis)
|
||||
monkeypatch.setattr(ps, "user_api_key_cache", cache)
|
||||
assert await cache.async_batch_get_cache(keys=["team_id:team"]) == [None]
|
||||
redis.store["team_id:team"] = {"spend": 1.0}
|
||||
assert await cache.async_batch_get_cache(keys=["team_id:team"]) == [None]
|
||||
|
||||
assert await ps._read_update_cache_values(keys=["team_id:team"], parent_otel_span=None) == {
|
||||
"team_id:team": {"spend": 1.0}
|
||||
}
|
||||
assert redis.commands.count("MGET team_id:team") == 2
|
||||
|
|
|
|||
|
|
@ -6189,53 +6189,24 @@ async def test_tag_cache_update_called():
|
|||
"""
|
||||
Test that update_cache updates tag cache when tags are provided.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
cache = DualCache()
|
||||
cache = UserApiKeyCache(in_memory_cache=InMemoryCache())
|
||||
cache.in_memory_cache.set_cache("tag:test-tag", {"tag_name": "test-tag", "spend": 10.0})
|
||||
|
||||
setattr(
|
||||
litellm.proxy.proxy_server,
|
||||
"user_api_key_cache",
|
||||
cache,
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=5.0,
|
||||
parent_otel_span=None,
|
||||
tags=["test-tag"],
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
mock_tag_obj = {
|
||||
"tag_name": "test-tag",
|
||||
"spend": 10.0,
|
||||
}
|
||||
|
||||
set_cache_called: Final = asyncio.Event()
|
||||
|
||||
async def _record_set_cache(*args: object, **kwargs: object) -> None:
|
||||
set_cache_called.set()
|
||||
|
||||
with patch.object(cache, "async_batch_get_cache", new=AsyncMock(return_value=[mock_tag_obj])) as mock_get_cache:
|
||||
with patch.object(
|
||||
cache, "async_set_cache_pipeline", new=AsyncMock(side_effect=_record_set_cache)
|
||||
) as mock_set_cache:
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=5.0,
|
||||
parent_otel_span=None,
|
||||
tags=["test-tag"],
|
||||
)
|
||||
|
||||
assert await asyncio.wait_for(set_cache_called.wait(), timeout=5)
|
||||
|
||||
mock_get_cache.assert_awaited_once_with(keys=["tag:test-tag"], parent_otel_span=None, throttle_redis=False)
|
||||
mock_set_cache.assert_awaited_once()
|
||||
|
||||
call_args = mock_set_cache.call_args
|
||||
cache_list = call_args.kwargs["cache_list"]
|
||||
|
||||
assert len(cache_list) == 1
|
||||
cache_key, cache_value = cache_list[0]
|
||||
assert cache_key == "tag:test-tag"
|
||||
assert cache_value["spend"] == 15.0
|
||||
assert cache.in_memory_cache.get_cache("tag:test-tag")["spend"] == 15.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -6243,59 +6214,26 @@ async def test_tag_cache_update_multiple_tags():
|
|||
"""
|
||||
Test that multiple tags are updated in cache.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
cache = DualCache()
|
||||
cache = UserApiKeyCache(in_memory_cache=InMemoryCache())
|
||||
cache.in_memory_cache.set_cache("tag:tag1", {"tag_name": "tag1", "spend": 10.0})
|
||||
cache.in_memory_cache.set_cache("tag:tag2", {"tag_name": "tag2", "spend": 20.0})
|
||||
|
||||
setattr(
|
||||
litellm.proxy.proxy_server,
|
||||
"user_api_key_cache",
|
||||
cache,
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=5.0,
|
||||
parent_otel_span=None,
|
||||
tags=["tag1", "tag2"],
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
mock_tag1_obj = {"tag_name": "tag1", "spend": 10.0}
|
||||
mock_tag2_obj = {"tag_name": "tag2", "spend": 20.0}
|
||||
|
||||
async def mock_get_cache_side_effect(keys, **kwargs):
|
||||
return [{"tag:tag1": mock_tag1_obj, "tag:tag2": mock_tag2_obj}.get(key) for key in keys]
|
||||
|
||||
set_cache_called: Final = asyncio.Event()
|
||||
|
||||
async def _record_set_cache(*args: object, **kwargs: object) -> None:
|
||||
set_cache_called.set()
|
||||
|
||||
with patch.object(
|
||||
cache, "async_batch_get_cache", new=AsyncMock(side_effect=mock_get_cache_side_effect)
|
||||
) as mock_get_cache:
|
||||
with patch.object(
|
||||
cache, "async_set_cache_pipeline", new=AsyncMock(side_effect=_record_set_cache)
|
||||
) as mock_set_cache:
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=5.0,
|
||||
parent_otel_span=None,
|
||||
tags=["tag1", "tag2"],
|
||||
)
|
||||
|
||||
assert await asyncio.wait_for(set_cache_called.wait(), timeout=5)
|
||||
|
||||
mock_get_cache.assert_awaited_once_with(keys=["tag:tag1", "tag:tag2"], parent_otel_span=None, throttle_redis=False)
|
||||
mock_set_cache.assert_awaited_once()
|
||||
|
||||
call_args = mock_set_cache.call_args
|
||||
cache_list = call_args.kwargs["cache_list"]
|
||||
|
||||
assert len(cache_list) == 2
|
||||
|
||||
tag_updates = {cache_key: cache_value for cache_key, cache_value in cache_list}
|
||||
assert "tag:tag1" in tag_updates
|
||||
assert "tag:tag2" in tag_updates
|
||||
assert tag_updates["tag:tag1"]["spend"] == 15.0
|
||||
assert tag_updates["tag:tag2"]["spend"] == 25.0
|
||||
assert cache.in_memory_cache.get_cache("tag:tag1")["spend"] == 15.0
|
||||
assert cache.in_memory_cache.get_cache("tag:tag2")["spend"] == 25.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -6306,41 +6244,25 @@ async def test_update_cache_pipeline_honors_user_api_key_cache_ttl():
|
|||
a hardcoded 60s, otherwise every priced request resets an active key's cache
|
||||
entry back to 60s and the configured TTL is never observed.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
original_cache = litellm.proxy.proxy_server.user_api_key_cache
|
||||
cache = DualCache(default_in_memory_ttl=300)
|
||||
setattr(litellm.proxy.proxy_server, "user_api_key_cache", cache)
|
||||
set_cache_called: Final = asyncio.Event()
|
||||
cache = UserApiKeyCache(in_memory_cache=InMemoryCache(clock=lambda: 1000.0), default_in_memory_ttl=300)
|
||||
cache.in_memory_cache.cache_dict["tag:active-tag"] = {"tag_name": "active-tag", "spend": 1.0}
|
||||
|
||||
async def _record_set_cache(*args: object, **kwargs: object) -> None:
|
||||
set_cache_called.set()
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=5.0,
|
||||
parent_otel_span=None,
|
||||
tags=["active-tag"],
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
try:
|
||||
with patch.object(
|
||||
cache,
|
||||
"async_batch_get_cache",
|
||||
new=AsyncMock(return_value=[{"tag_name": "active-tag", "spend": 1.0}]),
|
||||
):
|
||||
with patch.object(
|
||||
cache, "async_set_cache_pipeline", new=AsyncMock(side_effect=_record_set_cache)
|
||||
) as mock_set_cache:
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id=None,
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=5.0,
|
||||
parent_otel_span=None,
|
||||
tags=["active-tag"],
|
||||
)
|
||||
|
||||
assert await asyncio.wait_for(set_cache_called.wait(), timeout=5)
|
||||
|
||||
mock_set_cache.assert_awaited_once()
|
||||
assert mock_set_cache.call_args.kwargs["ttl"] == 300
|
||||
finally:
|
||||
setattr(litellm.proxy.proxy_server, "user_api_key_cache", original_cache)
|
||||
assert cache.in_memory_cache.ttl_dict["tag:active-tag"] == 1300.0
|
||||
assert cache.in_memory_cache.get_cache("tag:active-tag")["spend"] == 6.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -6393,63 +6315,6 @@ async def test_spend_tracking_never_writes_the_auth_object_back():
|
|||
setattr(litellm.proxy.proxy_server, "user_api_key_cache", original_cache)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cache_global_proxy_spend_scalar_stays_shared():
|
||||
"""
|
||||
The proxy-wide spend estimate must keep flowing to Redis when the spend
|
||||
writeback goes per-pod: the global max_budget check reads the
|
||||
``{litellm_proxy_admin_name}:spend`` cache entry between authoritative DB
|
||||
reloads, so keeping it pod-local would let traffic spread across replicas
|
||||
exceed the proxy budget by roughly a factor of the replica count within a
|
||||
cache TTL. Sharing this scalar is safe because it carries no limits or
|
||||
permissions, so it cannot resurrect an invalidated auth blob.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
|
||||
admin_name = litellm.proxy.proxy_server.litellm_proxy_admin_name
|
||||
global_key = "{}:spend".format(admin_name)
|
||||
|
||||
def fake_get(key):
|
||||
if key == "user-lit":
|
||||
return {"user_id": "user-lit", "spend": 1.0}
|
||||
if key == global_key:
|
||||
return 10.0
|
||||
return None
|
||||
|
||||
async def fake_batch_get(keys, **kwargs):
|
||||
return [fake_get(key) for key in keys]
|
||||
|
||||
original_cache = litellm.proxy.proxy_server.user_api_key_cache
|
||||
cache = DualCache(default_in_memory_ttl=300)
|
||||
setattr(litellm.proxy.proxy_server, "user_api_key_cache", cache)
|
||||
try:
|
||||
with patch.object(cache, "async_batch_get_cache", new=AsyncMock(side_effect=fake_batch_get)):
|
||||
with patch.object(cache, "async_set_cache_pipeline", new=AsyncMock()) as mock_set_cache:
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id="user-lit",
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=5.0,
|
||||
parent_otel_span=None,
|
||||
)
|
||||
|
||||
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=5)
|
||||
|
||||
calls = mock_set_cache.await_args_list
|
||||
local_keys = [k for c in calls if c.kwargs.get("local_only") is True for k, _ in c.kwargs["cache_list"]]
|
||||
shared_keys = [
|
||||
k for c in calls if c.kwargs.get("local_only") is not True for k, _ in c.kwargs["cache_list"]
|
||||
]
|
||||
assert "user-lit" in local_keys
|
||||
assert global_key not in local_keys
|
||||
assert shared_keys == [global_key]
|
||||
finally:
|
||||
setattr(litellm.proxy.proxy_server, "user_api_key_cache", original_cache)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_sso_settings_in_db():
|
||||
"""
|
||||
|
|
@ -15720,46 +15585,3 @@ async def test_spend_capture_rate_check_job_clears_the_gauge_once_the_setting_is
|
|||
call(api_provider="openai", capture_rate=None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cache_reads_and_writes_declare_the_auth_objects_key_family():
|
||||
"""The post-call spend write-back reads and rewrites the cached auth objects, so its
|
||||
Redis spans must read ``redis.mget auth_objects`` / ``redis.set auth_objects`` (the key
|
||||
family the auth phase declares) and the global spend scalar ``redis.set spend_counters``,
|
||||
never a bare ``redis.mget`` with no owner."""
|
||||
from litellm.caching.caching import DualCache
|
||||
|
||||
original_cache = litellm.proxy.proxy_server.user_api_key_cache
|
||||
cache = DualCache()
|
||||
setattr(litellm.proxy.proxy_server, "user_api_key_cache", cache)
|
||||
seen: list[tuple[str, str | None]] = []
|
||||
|
||||
async def _mget(keys, **_kwargs):
|
||||
seen.append(("mget", current_service_target()))
|
||||
return [{"user_id": "u1", "spend": 1.0} for _ in keys]
|
||||
|
||||
async def _set_pipeline(**_kwargs):
|
||||
seen.append(("set", current_service_target()))
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(cache, "async_batch_get_cache", new=AsyncMock(side_effect=_mget)),
|
||||
patch.object(cache, "async_set_cache_pipeline", new=AsyncMock(side_effect=_set_pipeline)),
|
||||
):
|
||||
await litellm.proxy.proxy_server.update_cache(
|
||||
token=None,
|
||||
user_id="u1",
|
||||
end_user_id=None,
|
||||
team_id=None,
|
||||
response_cost=2.0,
|
||||
parent_otel_span=None,
|
||||
)
|
||||
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=5)
|
||||
finally:
|
||||
setattr(litellm.proxy.proxy_server, "user_api_key_cache", original_cache)
|
||||
|
||||
assert seen, "update_cache must touch the cache for a priced user request"
|
||||
assert {target for _, target in seen} == {"auth_objects"}
|
||||
assert current_service_target() is None
|
||||
|
|
|
|||
|
|
@ -87,10 +87,10 @@ _IN_MEMORY_ONLY_CALLERS: Final = frozenset(
|
|||
)
|
||||
|
||||
_CACHE_CALL: Final = re.compile(
|
||||
r"\.(?:async_)?(?:get_cache|set_cache|batch_get_cache|batch_get_cache_shared|increment_cache|increment"
|
||||
r"\.(?:async_)?(?:get_cache|set_cache|batch_get_cache|batch_get_cache_shared|increment_cache|increment_if_present|increment"
|
||||
r"|set_cache_pipeline|set_cache_pipeline_with_ttls|set_cache_sadd|delete_cache|batch_set_cache|increment_pipeline"
|
||||
r"|rpush|lpop|scan_iter|get_ttl|mget)\("
|
||||
r"|\b(?:reserve_redis_batch_reads|declare_batch_get|_prepare_batch_get)\("
|
||||
r"|\b(?:reserve_redis_batch_reads|_prepare_batch_get)\("
|
||||
r"|\bbatch\.(?:set|delete|script|increment)\("
|
||||
)
|
||||
_DECLARES_TARGET: Final = re.compile(r"\b(?:with_service_target|service_target|response_cache_phase)\(")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import respx
|
|||
from openai import APITimeoutError, AsyncOpenAI
|
||||
from openai._legacy_response import LegacyAPIResponse
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm import (
|
||||
|
|
@ -3937,6 +3938,100 @@ async def test_acompletion_forwards_store_and_prompt_cache_key_to_openai():
|
|||
assert request_body["prompt_cache_key"] == "test-cache-key"
|
||||
|
||||
|
||||
_TOOL: Final[Mapping[str, object]] = {"type": "function", "function": {"name": "f", "parameters": {"type": "object", "properties": {}}}}
|
||||
|
||||
# Client settings ride in OPENAI_CHAT_COMPLETION_PARAMS but configure the HTTP client, never the body.
|
||||
_OPENAI_CLIENT_SETTINGS: Final = frozenset(
|
||||
{
|
||||
"api_base",
|
||||
"api_key",
|
||||
"api_version",
|
||||
"base_url",
|
||||
"default_headers",
|
||||
"deployment_id",
|
||||
"extra_headers",
|
||||
"max_retries",
|
||||
"organization",
|
||||
"request_timeout",
|
||||
"timeout",
|
||||
}
|
||||
)
|
||||
|
||||
# Accepted by completion() for other providers, never sent to OpenAI's /chat/completions body.
|
||||
_NOT_OPENAI_BODY_PARAMS: Final = frozenset({"include_server_side_tool_invocations", "thinking"})
|
||||
|
||||
# param -> (model that accepts it, the call kwargs that carry it)
|
||||
_OPENAI_BODY_PARAM_CASES: Final[Mapping[str, tuple[str, Mapping[str, object]]]] = {
|
||||
"audio": ("openai/gpt-4o", {"audio": {"voice": "alloy", "format": "wav"}}),
|
||||
"frequency_penalty": ("openai/gpt-4o", {"frequency_penalty": 0.5}),
|
||||
"function_call": ("openai/gpt-4o", {"function_call": "auto"}),
|
||||
"functions": ("openai/gpt-4o", {"functions": [_TOOL["function"]]}),
|
||||
"logit_bias": ("openai/gpt-4o", {"logit_bias": {"1": 1}}),
|
||||
"logprobs": ("openai/gpt-4o", {"logprobs": True}),
|
||||
"max_completion_tokens": ("openai/gpt-4o", {"max_completion_tokens": 7}),
|
||||
"max_tokens": ("openai/gpt-4o", {"max_tokens": 7}),
|
||||
"modalities": ("openai/gpt-4o", {"modalities": ["text"]}),
|
||||
"n": ("openai/gpt-4o", {"n": 2}),
|
||||
"parallel_tool_calls": ("openai/gpt-4o", {"tools": [_TOOL], "parallel_tool_calls": False}),
|
||||
"prediction": ("openai/gpt-4o", {"prediction": {"type": "content", "content": "x"}}),
|
||||
"presence_penalty": ("openai/gpt-4o", {"presence_penalty": 0.5}),
|
||||
"prompt_cache_key": ("openai/gpt-4o", {"prompt_cache_key": "k"}),
|
||||
"prompt_cache_retention": ("openai/gpt-5.5", {"prompt_cache_retention": "24h"}),
|
||||
"reasoning_effort": ("openai/gpt-5.5", {"reasoning_effort": "low"}),
|
||||
"response_format": ("openai/gpt-4o", {"response_format": {"type": "json_object"}}),
|
||||
"safety_identifier": ("openai/gpt-4o", {"safety_identifier": "s"}),
|
||||
"seed": ("openai/gpt-4o", {"seed": 3}),
|
||||
"service_tier": ("openai/gpt-4o", {"service_tier": "priority"}),
|
||||
"stop": ("openai/gpt-4o", {"stop": ["x"]}),
|
||||
"store": ("openai/gpt-4o", {"store": False}),
|
||||
"stream": ("openai/gpt-4o", {"stream": True}),
|
||||
"stream_options": ("openai/gpt-4o", {"stream": True, "stream_options": {"include_usage": True}}),
|
||||
"temperature": ("openai/gpt-4o", {"temperature": 0.5}),
|
||||
"tool_choice": ("openai/gpt-4o", {"tools": [_TOOL], "tool_choice": "auto"}),
|
||||
"tools": ("openai/gpt-4o", {"tools": [_TOOL]}),
|
||||
"top_logprobs": ("openai/gpt-4o", {"logprobs": True, "top_logprobs": 2}),
|
||||
"top_p": ("openai/gpt-4o", {"top_p": 0.5}),
|
||||
"user": ("openai/gpt-4o", {"user": "u1"}),
|
||||
"verbosity": ("openai/gpt-5.5", {"verbosity": "low"}),
|
||||
"web_search_options": ("openai/gpt-4o", {"web_search_options": {"search_context_size": "low"}}),
|
||||
}
|
||||
|
||||
|
||||
def test_every_openai_body_param_has_a_forwarding_case():
|
||||
body_params: Final = frozenset(litellm.OPENAI_CHAT_COMPLETION_PARAMS) - _OPENAI_CLIENT_SETTINGS - _NOT_OPENAI_BODY_PARAMS
|
||||
assert frozenset(_OPENAI_BODY_PARAM_CASES) == body_params, (
|
||||
"a param added to OPENAI_CHAT_COMPLETION_PARAMS needs a case in _OPENAI_BODY_PARAM_CASES proving "
|
||||
"completion() sends it to OpenAI; prompt_cache_retention (LIT-9758) and store/prompt_cache_key "
|
||||
"(#33184) were listed as supported but silently dropped"
|
||||
)
|
||||
|
||||
|
||||
_REQUEST_BODY: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("param", sorted(_OPENAI_BODY_PARAM_CASES))
|
||||
def test_completion_sends_openai_body_param_to_openai(
|
||||
param: str, respx_mock: respx.MockRouter, openai_api_response: Mapping[str, object]
|
||||
):
|
||||
api_base: Final = "http://localhost:12346/v1"
|
||||
route: Final = respx_mock.post(f"{api_base}/chat/completions").mock(
|
||||
return_value=httpx.Response(status_code=200, json=openai_api_response)
|
||||
)
|
||||
model, call_kwargs = _OPENAI_BODY_PARAM_CASES[param]
|
||||
|
||||
litellm.completion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base=api_base,
|
||||
api_key="fake_openai_api_key",
|
||||
**call_kwargs,
|
||||
)
|
||||
|
||||
assert route.call_count == 1
|
||||
sent: Final = _REQUEST_BODY.validate_json(route.calls.last.request.content)
|
||||
assert sent.get(param) == call_kwargs[param], f"{param} did not reach the OpenAI request body: {sent}"
|
||||
|
||||
|
||||
def test_completion_omits_store_and_prompt_cache_key_when_not_passed():
|
||||
"""
|
||||
When store and prompt_cache_key are not passed, they must not appear in the
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue