mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge branch 'litellm_internal_staging' into litellm_/search-tools-sync-issue-e522a2
This commit is contained in:
commit
cbefebbd9f
206 changed files with 7235 additions and 1387 deletions
|
|
@ -66,7 +66,7 @@ Commit and push your work when you're done without asking
|
|||
|
||||
When referencing or running models (coding, QA'ing, writing docs, writing tests, etc.), use the latest model in that model family unless otherwise specified; treat your training knowledge, memories, configs, and tests as stale, and determine the family's latest with model_prices_and_context_window.json or the web
|
||||
|
||||
Always pull before starting any work. The checkout or worktree may be sitting on a stale branch, and work built on a stale base lands on top of code that has already moved. Run `git fetch origin` first, then update the branch you're on with `git pull --no-rebase`, which fast-forwards when the branch hasn't diverged and merges the remote tip in when it has. When working a feature branch, bring it up to date with a freshly fetched `origin/litellm_internal_staging` before touching it: rebase onto it while the branch is still unpushed, merge it in once it has been pushed, and never rewrite pushed history
|
||||
Always pull before starting any work. The checkout or worktree may be sitting on a stale branch
|
||||
|
||||
If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names
|
||||
|
||||
|
|
|
|||
|
|
@ -99,19 +99,19 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 44530
|
||||
"limit": 44528
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 109
|
||||
},
|
||||
"reportUnknownMemberType": {
|
||||
"limit": 38808
|
||||
"limit": 38804
|
||||
},
|
||||
"reportUnknownParameterType": {
|
||||
"limit": 19829
|
||||
},
|
||||
"reportUnknownVariableType": {
|
||||
"limit": 30356
|
||||
"limit": 30355
|
||||
},
|
||||
"reportUnnecessaryCast": {
|
||||
"limit": 117
|
||||
|
|
@ -138,9 +138,9 @@
|
|||
"limit": 139
|
||||
},
|
||||
"reportUnusedImport": {
|
||||
"limit": 545
|
||||
"limit": 544
|
||||
},
|
||||
"reportUnusedVariable": {
|
||||
"limit": 146
|
||||
"limit": 145
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ COST_DESCRIPTIONS: dict[str, str] = {
|
|||
"input_cost_per_token": "USD per prompt token.",
|
||||
"output_cost_per_token": "USD per generated token.",
|
||||
"output_cost_per_reasoning_token": "USD per reasoning/thinking token, when billed separately.",
|
||||
"google_maps_grounding_cost_per_query": (
|
||||
"USD per Grounding with Google Maps request; billed per query or per prompt per web_search_billing_unit."
|
||||
),
|
||||
"cache_creation_input_token_cost": "USD per token written to the provider's prompt cache.",
|
||||
"cache_read_input_token_cost": "USD per prompt token served from the provider's prompt cache.",
|
||||
"input_cost_per_token_batches": "USD per prompt token via the provider's batch API.",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import asyncio
|
|||
import datetime
|
||||
import inspect
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Mapping
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator, Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -27,6 +27,7 @@ import litellm
|
|||
from litellm._logging import print_verbose, verbose_logger
|
||||
from litellm.caching import InMemoryCache
|
||||
from litellm.caching.caching import S3Cache
|
||||
from litellm.constants import CACHE_WRITE_SHUTDOWN_FLUSH_TIMEOUT_SECONDS
|
||||
from litellm.litellm_core_utils.llm_response_utils.response_metadata import (
|
||||
update_response_metadata,
|
||||
)
|
||||
|
|
@ -124,6 +125,29 @@ def _prompt_tokens_details_as_mapping(details: "PromptTokensDetailsWrapper") ->
|
|||
return details.model_dump(exclude_none=True) if hasattr(details, "model_dump") else {}
|
||||
|
||||
|
||||
_PENDING_CACHE_WRITES: Final[set["asyncio.Task[None]"]] = set() # mutable-ok: strong refs to pending write tasks
|
||||
|
||||
|
||||
async def _complete_cache_write_despite_cancellation(write_factory: Callable[[], Awaitable[None]]) -> None:
|
||||
try:
|
||||
await write_factory()
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
await asyncio.wait_for(write_factory(), timeout=CACHE_WRITE_SHUTDOWN_FLUSH_TIMEOUT_SECONDS)
|
||||
except Exception as flush_error: # noqa: BLE001 # shutdown flush failures are logged, never raised
|
||||
verbose_logger.warning(
|
||||
"LiteLLM Cache: pending cache write failed during event loop shutdown: %s", flush_error
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def create_cache_write_task(write_factory: Callable[[], Awaitable[None]]) -> "asyncio.Task[None]":
|
||||
task: Final = asyncio.create_task(_complete_cache_write_despite_cancellation(write_factory))
|
||||
_PENDING_CACHE_WRITES.add(task)
|
||||
task.add_done_callback(_PENDING_CACHE_WRITES.discard)
|
||||
return task
|
||||
|
||||
|
||||
def _request_cache_key(request_kwargs: Mapping[str, Any]) -> str | None:
|
||||
"""Read the caller-supplied ``cache_key`` off the request kwargs."""
|
||||
return request_kwargs.get("cache_key", None)
|
||||
|
|
@ -983,6 +1007,7 @@ class LLMCachingHandler:
|
|||
|
||||
if litellm.cache is None:
|
||||
return
|
||||
cache: Final = litellm.cache
|
||||
|
||||
new_kwargs: Final = kwargs.copy()
|
||||
new_kwargs.update(
|
||||
|
|
@ -1004,24 +1029,24 @@ class LLMCachingHandler:
|
|||
):
|
||||
if (
|
||||
isinstance(result, EmbeddingResponse)
|
||||
and litellm.cache is not None
|
||||
and not isinstance(litellm.cache.cache, S3Cache) # s3 doesn't support bulk writing. Exclude.
|
||||
and not isinstance(cache.cache, S3Cache) # s3 doesn't support bulk writing. Exclude.
|
||||
):
|
||||
asyncio.create_task(
|
||||
litellm.cache.async_add_cache_pipeline(
|
||||
create_cache_write_task(
|
||||
lambda: cache.async_add_cache_pipeline(
|
||||
result, dynamic_cache_object=self.dual_cache, **new_kwargs
|
||||
)
|
||||
)
|
||||
else:
|
||||
asyncio.create_task(
|
||||
litellm.cache.async_add_cache(
|
||||
result.model_dump_json(),
|
||||
result_json: Final = result.model_dump_json()
|
||||
create_cache_write_task(
|
||||
lambda: cache.async_add_cache(
|
||||
result_json,
|
||||
dynamic_cache_object=self.dual_cache,
|
||||
**new_kwargs,
|
||||
)
|
||||
)
|
||||
else:
|
||||
asyncio.create_task(litellm.cache.async_add_cache(result, **new_kwargs))
|
||||
create_cache_write_task(lambda: cache.async_add_cache(result, **new_kwargs))
|
||||
|
||||
def sync_set_cache(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -435,7 +435,7 @@ class RedisCache(BaseCache):
|
|||
"""
|
||||
if key is None:
|
||||
return key
|
||||
if self.namespace is not None and not key.startswith(self.namespace):
|
||||
if self.namespace and not key.startswith(self.namespace + ":"):
|
||||
key = self.namespace + ":" + key
|
||||
|
||||
return key
|
||||
|
|
|
|||
|
|
@ -381,6 +381,7 @@ AZURE_OPERATION_POLLING_TIMEOUT: Final = int(os.getenv("AZURE_OPERATION_POLLING_
|
|||
AZURE_DOCUMENT_INTELLIGENCE_API_VERSION: Final = str(os.getenv("AZURE_DOCUMENT_INTELLIGENCE_API_VERSION", "2024-11-30"))
|
||||
AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI: Final = int(os.getenv("AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI", 96))
|
||||
REDIS_SOCKET_TIMEOUT: Final = float(os.getenv("REDIS_SOCKET_TIMEOUT", 0.1))
|
||||
CACHE_WRITE_SHUTDOWN_FLUSH_TIMEOUT_SECONDS: Final[float] = 5.0
|
||||
REDIS_CONNECTION_POOL_TIMEOUT: Final = int(os.getenv("REDIS_CONNECTION_POOL_TIMEOUT", 5))
|
||||
REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD: Final = int(os.getenv("REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD", 5))
|
||||
REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT: Final = int(os.getenv("REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT", 60))
|
||||
|
|
|
|||
|
|
@ -591,6 +591,7 @@ def cost_per_token(
|
|||
prompt_characters=prompt_characters,
|
||||
completion_characters=completion_characters,
|
||||
usage=usage_block,
|
||||
service_tier=service_tier,
|
||||
vertex_location=vertex_location,
|
||||
)
|
||||
elif cost_router == "cost_per_token":
|
||||
|
|
@ -794,14 +795,27 @@ def _select_model_name_for_cost_calc(
|
|||
and custom_llm_provider is not None
|
||||
and not _model_contains_known_llm_provider(return_model)
|
||||
): # add provider prefix if not already present, to match model_cost
|
||||
if region_name is not None:
|
||||
return_model = f"{custom_llm_provider}/{region_name}/{return_model}"
|
||||
else:
|
||||
return_model = f"{custom_llm_provider}/{return_model}"
|
||||
provider_prefix: Final = custom_llm_provider if region_name is None else f"{custom_llm_provider}/{region_name}"
|
||||
return_model = _strip_unregistered_leading_segments(f"{provider_prefix}/{return_model}", region_name)
|
||||
|
||||
return return_model
|
||||
|
||||
|
||||
def _strip_unregistered_leading_segments(model: str, region_name: str | None) -> str:
|
||||
"""Resolve a provider-prefixed slash alias like "vertex_ai/vertex/claude-opus-5" to the
|
||||
registered cost key ("vertex_ai/claude-opus-5"), keeping the model unchanged when it already
|
||||
resolves downstream (custom-priced router ids) or no stripped candidate is registered (#38069)."""
|
||||
segments: Final = model.split("/")
|
||||
if "/".join(segments[1:]) in litellm.model_cost:
|
||||
return model
|
||||
head_len: Final = 2 if region_name is not None and len(segments) > 2 and segments[1] == region_name else 1
|
||||
head: Final = "/".join(segments[:head_len])
|
||||
tail: Final = segments[head_len:]
|
||||
strippable: Final = next((index for index, segment in enumerate(tail) if segment in LlmProvidersSet), len(tail))
|
||||
candidates: Final = (f"{head}/{'/'.join(tail[start:])}" for start in range(min(strippable, len(tail) - 1) + 1))
|
||||
return next((candidate for candidate in candidates if candidate in litellm.model_cost), model)
|
||||
|
||||
|
||||
@lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)
|
||||
def _model_contains_known_llm_provider(model: str) -> bool:
|
||||
"""
|
||||
|
|
@ -832,9 +846,11 @@ def _get_response_model(completion_response: object) -> str | None:
|
|||
_GEMINI_TRAFFIC_TYPE_TO_SERVICE_TIER: Final[dict] = {
|
||||
# ON_DEMAND_PRIORITY maps to "priority" — selects input_cost_per_token_priority, etc.
|
||||
"ON_DEMAND_PRIORITY": "priority",
|
||||
# FLEX / BATCH maps to "flex" — selects input_cost_per_token_flex, etc.
|
||||
# FLEX / BATCH / ON_DEMAND_FLEX maps to "flex" — selects input_cost_per_token_flex, etc.
|
||||
# Vertex AI reports flex/shared-capacity traffic as ON_DEMAND_FLEX, not FLEX.
|
||||
"FLEX": "flex",
|
||||
"BATCH": "flex",
|
||||
"ON_DEMAND_FLEX": "flex",
|
||||
# ON_DEMAND is standard pricing — no service_tier suffix applied
|
||||
"ON_DEMAND": None,
|
||||
}
|
||||
|
|
@ -849,9 +865,9 @@ def _map_traffic_type_to_service_tier(traffic_type: str | None) -> str | None:
|
|||
|
||||
trafficType values seen in practice
|
||||
------------------------------------
|
||||
ON_DEMAND -> standard pricing (service_tier = None)
|
||||
ON_DEMAND_PRIORITY -> priority pricing (service_tier = "priority")
|
||||
FLEX / BATCH -> batch/flex pricing (service_tier = "flex")
|
||||
ON_DEMAND -> standard pricing (service_tier = None)
|
||||
ON_DEMAND_PRIORITY -> priority pricing (service_tier = "priority")
|
||||
FLEX / BATCH / ON_DEMAND_FLEX -> batch/flex pricing (service_tier = "flex")
|
||||
"""
|
||||
if traffic_type is None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ if TYPE_CHECKING:
|
|||
from litellm.types.utils import ModelResponse
|
||||
|
||||
|
||||
def _completion_response_cost(model_response: "ModelResponse") -> float | None:
|
||||
hidden_params: Final = getattr(model_response, "_hidden_params", None)
|
||||
if not isinstance(hidden_params, dict):
|
||||
return None
|
||||
response_cost: Final = hidden_params.get("response_cost")
|
||||
return response_cost if isinstance(response_cost, float) else None
|
||||
|
||||
|
||||
class SpeechToCompletionBridgeTransformationHandler:
|
||||
def transform_request(
|
||||
self,
|
||||
|
|
@ -123,4 +131,6 @@ class SpeechToCompletionBridgeTransformationHandler:
|
|||
|
||||
# Create an httpx.Response object
|
||||
response: Final = httpx.Response(status_code=200, content=binary_data, headers=headers)
|
||||
return HttpxBinaryResponseContent(response)
|
||||
binary_response: Final = HttpxBinaryResponseContent(response)
|
||||
binary_response.set_response_cost(_completion_response_cost(model_response))
|
||||
return binary_response
|
||||
|
|
|
|||
|
|
@ -104,6 +104,13 @@ def _accepts_prompt_cache_breakpoint(block: object) -> bool:
|
|||
return isinstance(block, dict) and block.get("type") in OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES
|
||||
|
||||
|
||||
# Set by a caller whose message list is not the one that goes upstream -- today the
|
||||
# Responses API layer, whose `instructions` only becomes a system message further down.
|
||||
# Tells this hook to hand role-targeted points to the pass holding the final messages
|
||||
# rather than spending them on a list that is still missing some of their targets.
|
||||
CARRY_UNMATCHED_MESSAGE_POINTS: Final = "_litellm_carry_unmatched_cache_control_points"
|
||||
|
||||
|
||||
class AnthropicCacheControlHook(CustomPromptManagement):
|
||||
def get_chat_completion_prompt(
|
||||
self,
|
||||
|
|
@ -128,6 +135,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
- non_default_params: dict - params with any global cache controls
|
||||
"""
|
||||
# Extract cache control injection points
|
||||
carry_unmatched: Final = bool(non_default_params.pop(CARRY_UNMATCHED_MESSAGE_POINTS, False))
|
||||
injection_points: Final[list[CacheControlInjectionPoint]] = non_default_params.pop(
|
||||
"cache_control_injection_points", []
|
||||
)
|
||||
|
|
@ -161,12 +169,25 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
non_default_params.get("prompt_cache_options"),
|
||||
)
|
||||
)
|
||||
# A provisional message list defers every role-targeted point to the pass holding
|
||||
# the final one: a role with no message here may have one there, and settling all
|
||||
# of them in one pass is what lets config order decide the shared breakpoint
|
||||
# budget. An ordinal names a different message once a later layer builds its own
|
||||
# list, so it is placed here or not at all.
|
||||
carried_message_points: Final[Sequence[CacheControlMessageInjectionPoint]] = (
|
||||
tuple(point for point in message_points if point.get("index") is None) if carry_unmatched else ()
|
||||
)
|
||||
applied_message_points: Final[Sequence[CacheControlMessageInjectionPoint]] = (
|
||||
tuple(point for point in message_points if point.get("index") is not None)
|
||||
if carry_unmatched
|
||||
else tuple(message_points)
|
||||
)
|
||||
reserved_blocks: Final = (
|
||||
1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0
|
||||
)
|
||||
breakpoints_before: Final = AnthropicCacheControlHook._count_request_cache_breakpoints(processed_messages)
|
||||
processed_messages = self._apply_message_injections(
|
||||
points=message_points,
|
||||
points=applied_message_points,
|
||||
messages=processed_messages,
|
||||
max_blocks=MAX_CACHE_CONTROL_BLOCKS - reserved_blocks,
|
||||
openai_dialect=openai_dialect,
|
||||
|
|
@ -177,10 +198,15 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
):
|
||||
non_default_params.setdefault("prompt_cache_options", PromptCacheOptions(mode="explicit"))
|
||||
|
||||
# Pass through non-message injection points for provider-specific handling
|
||||
if remaining_points:
|
||||
# Points this pass did not place: non-message ones for the provider transform, and
|
||||
# the deferred role-targeted ones. Deferring is what reaches the Responses API's
|
||||
# `instructions`, which is only a system message once the bridge builds one. The
|
||||
# judged stamp is what makes it safe: the next pass must not re-judge points
|
||||
# against messages this pass already marked (see `_should_stand_down`).
|
||||
carried_points: Final[Sequence[CacheControlInjectionPoint]] = (*remaining_points, *carried_message_points)
|
||||
if carried_points:
|
||||
non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged(
|
||||
remaining_points
|
||||
carried_points
|
||||
)
|
||||
|
||||
return model, processed_messages, non_default_params
|
||||
|
|
@ -218,7 +244,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
|
||||
@staticmethod
|
||||
def _apply_message_injections(
|
||||
points: list[CacheControlMessageInjectionPoint],
|
||||
points: Sequence[CacheControlMessageInjectionPoint],
|
||||
messages: list[AllMessageValues],
|
||||
max_blocks: int,
|
||||
openai_dialect: bool = False,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,12 @@
|
|||
"ui_name": "Host URL",
|
||||
"description": "Langfuse host URL (default: https://cloud.langfuse.com)",
|
||||
"required": false
|
||||
},
|
||||
"langfuse_environment": {
|
||||
"type": "text",
|
||||
"ui_name": "Tracing Environment",
|
||||
"description": "Langfuse tracing environment (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"description": "Langfuse v2 Logging Integration"
|
||||
|
|
@ -247,6 +253,12 @@
|
|||
"ui_name": "Host URL",
|
||||
"description": "Langfuse host URL (default: https://cloud.langfuse.com)",
|
||||
"required": false
|
||||
},
|
||||
"langfuse_environment": {
|
||||
"type": "text",
|
||||
"ui_name": "Tracing Environment",
|
||||
"description": "Langfuse tracing environment (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"description": "Langfuse v3 OTEL Logging Integration"
|
||||
|
|
|
|||
|
|
@ -62,12 +62,16 @@ def prompt_initializer(litellm_params: "PromptLiteLLMParams", prompt_spec: "Prom
|
|||
if dotprompt_content and not prompt_data and not prompt_file:
|
||||
prompt_data = _get_prompt_data_from_dotprompt_content(dotprompt_content)
|
||||
|
||||
from .prompt_manager import strip_version_suffix
|
||||
|
||||
registration_prompt_id: Final = prompt_id or strip_version_suffix(prompt_spec.prompt_id) or prompt_spec.prompt_id
|
||||
|
||||
try:
|
||||
dot_prompt_manager: Final = DotpromptManager(
|
||||
prompt_directory=prompt_directory,
|
||||
prompt_data=prompt_data,
|
||||
prompt_file=prompt_file,
|
||||
prompt_id=prompt_id,
|
||||
prompt_id=registration_prompt_id,
|
||||
)
|
||||
|
||||
return dot_prompt_manager
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class DotpromptManager(CustomPromptManagement):
|
|||
if prompt_id is None:
|
||||
return False
|
||||
try:
|
||||
return prompt_id in self.prompt_manager.list_prompts()
|
||||
return self.prompt_manager.get_prompt(prompt_id) is not None
|
||||
except Exception:
|
||||
# If there's any error accessing prompts, don't run prompt management
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ from jinja2 import DictLoader, select_autoescape
|
|||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||
|
||||
|
||||
def strip_version_suffix(prompt_id: str) -> str | None:
|
||||
base, separator, version = prompt_id.rpartition(".v")
|
||||
if separator and base and version.isdigit():
|
||||
return base
|
||||
return None
|
||||
|
||||
|
||||
class PromptTemplate:
|
||||
"""Represents a single prompt template with metadata and content."""
|
||||
|
||||
|
|
@ -124,11 +131,13 @@ class PromptManager:
|
|||
"content": "template content",
|
||||
"metadata": {"model": "gpt-4", "temperature": 0.7, ...}
|
||||
} + prompt_id
|
||||
"""
|
||||
if prompt_id:
|
||||
prompt_data = {prompt_id: prompt_data}
|
||||
|
||||
for prompt_id, prompt_info in prompt_data.items():
|
||||
A dict carrying a "content" key is a single flat template registered under
|
||||
prompt_id; anything else is treated as already keyed by template ID.
|
||||
"""
|
||||
keyed_prompts: Final = {prompt_id: prompt_data} if prompt_id and "content" in prompt_data else prompt_data
|
||||
|
||||
for template_id, prompt_info in keyed_prompts.items():
|
||||
try:
|
||||
content = prompt_info.get("content", "")
|
||||
metadata = prompt_info.get("metadata", {})
|
||||
|
|
@ -136,11 +145,11 @@ class PromptManager:
|
|||
template = PromptTemplate(
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
template_id=prompt_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
self.prompts[prompt_id] = template
|
||||
self.prompts[template_id] = template
|
||||
except Exception:
|
||||
# Optional: print(f"Error loading prompt from JSON: {prompt_id}")
|
||||
# Optional: print(f"Error loading prompt from JSON: {template_id}")
|
||||
pass
|
||||
|
||||
def _load_prompt_file(self, file_path: str | Path, prompt_id: str) -> PromptTemplate:
|
||||
|
|
@ -272,8 +281,12 @@ class PromptManager:
|
|||
if versioned_id in self.prompts:
|
||||
return self.prompts[versioned_id]
|
||||
|
||||
# Fall back to base prompt_id
|
||||
return self.prompts.get(prompt_id)
|
||||
direct_match: Final = self.prompts.get(prompt_id)
|
||||
if direct_match is not None:
|
||||
return direct_match
|
||||
|
||||
base_prompt_id: Final = strip_version_suffix(prompt_id)
|
||||
return self.prompts.get(base_prompt_id) if base_prompt_id else None
|
||||
|
||||
def list_prompts(self) -> list[str]:
|
||||
"""Get a list of all available prompt IDs."""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#### What this does ####
|
||||
# On success, logs events to Langfuse
|
||||
import inspect
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
|
|
@ -21,6 +22,9 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
reconstruct_model_name,
|
||||
safe_deep_copy,
|
||||
)
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
validate_langfuse_environment_value,
|
||||
)
|
||||
from litellm.litellm_core_utils.redact_messages import redact_user_api_key_info
|
||||
from litellm.llms.custom_httpx.http_handler import _get_httpx_client
|
||||
from litellm.secret_managers.main import str_to_bool
|
||||
|
|
@ -140,6 +144,7 @@ class LangFuseLogger:
|
|||
langfuse_public_key=None,
|
||||
langfuse_secret=None,
|
||||
langfuse_host=None,
|
||||
langfuse_environment: str | None = None,
|
||||
flush_interval=1,
|
||||
allow_env_credentials: bool = True,
|
||||
):
|
||||
|
|
@ -159,6 +164,10 @@ class LangFuseLogger:
|
|||
if not (self.langfuse_host.startswith("http://") or self.langfuse_host.startswith("https://")):
|
||||
# add http:// if unset, assume communicating over private network - e.g. render
|
||||
self.langfuse_host = "http://" + self.langfuse_host
|
||||
_env_override: Final = str(langfuse_environment).strip() if langfuse_environment is not None else None
|
||||
self.langfuse_environment = _env_override or os.getenv("LANGFUSE_TRACING_ENVIRONMENT")
|
||||
if self.langfuse_environment:
|
||||
validate_langfuse_environment_value(self.langfuse_environment)
|
||||
self.langfuse_release = os.getenv("LANGFUSE_RELEASE")
|
||||
self.langfuse_debug = os.getenv("LANGFUSE_DEBUG")
|
||||
self.langfuse_flush_interval = LangFuseLogger._get_langfuse_flush_interval(flush_interval)
|
||||
|
|
@ -182,6 +191,8 @@ class LangFuseLogger:
|
|||
}
|
||||
self.langfuse_sdk_version: str = langfuse.version.__version__
|
||||
|
||||
if "environment" in inspect.signature(Langfuse.__init__).parameters:
|
||||
parameters["environment"] = self.langfuse_environment
|
||||
if Version(self.langfuse_sdk_version) >= Version("2.6.0"):
|
||||
parameters["sdk_integration"] = "litellm"
|
||||
self.Langfuse: Langfuse = self.safe_init_langfuse_client(parameters)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import os
|
||||
|
||||
"""
|
||||
This file contains the LangFuseHandler class
|
||||
|
||||
|
|
@ -108,6 +110,7 @@ class LangFuseHandler:
|
|||
langfuse_public_key=credentials.get("langfuse_public_key"),
|
||||
langfuse_secret=credentials.get("langfuse_secret") or credentials.get("langfuse_secret_key"),
|
||||
langfuse_host=credentials.get("langfuse_host"),
|
||||
langfuse_environment=credentials.get("langfuse_environment"),
|
||||
allow_env_credentials=credentials.get("langfuse_host") is None,
|
||||
)
|
||||
in_memory_dynamic_logger_cache.set_cache(
|
||||
|
|
@ -135,8 +138,29 @@ class LangFuseHandler:
|
|||
or standard_callback_dynamic_params.get("langfuse_secret_key"),
|
||||
langfuse_public_key=standard_callback_dynamic_params.get("langfuse_public_key"),
|
||||
langfuse_host=standard_callback_dynamic_params.get("langfuse_host"),
|
||||
langfuse_environment=LangFuseHandler._meaningful_dynamic_environment(standard_callback_dynamic_params),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _meaningful_dynamic_environment(
|
||||
standard_callback_dynamic_params: StandardCallbackDynamicParams,
|
||||
) -> str | None:
|
||||
"""Return the per-request environment only when it changes behavior.
|
||||
|
||||
Empty/whitespace values and values equal to the deployment-wide
|
||||
LANGFUSE_TRACING_ENVIRONMENT fallback are treated as absent so an
|
||||
environment-only override that matches the default does not mint a
|
||||
duplicate SDK client (each client costs threads and counts against
|
||||
MAX_LANGFUSE_INITIALIZED_CLIENTS).
|
||||
"""
|
||||
raw = standard_callback_dynamic_params.get("langfuse_environment")
|
||||
if raw is None:
|
||||
return None
|
||||
value = str(raw).strip()
|
||||
if not value or value == os.getenv("LANGFUSE_TRACING_ENVIRONMENT"):
|
||||
return None
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_langfuse_credentials_are_passed(
|
||||
standard_callback_dynamic_params: StandardCallbackDynamicParams,
|
||||
|
|
@ -153,6 +177,7 @@ class LangFuseHandler:
|
|||
or standard_callback_dynamic_params.get("langfuse_public_key") is not None
|
||||
or standard_callback_dynamic_params.get("langfuse_secret") is not None
|
||||
or standard_callback_dynamic_params.get("langfuse_secret_key") is not None
|
||||
or LangFuseHandler._meaningful_dynamic_environment(standard_callback_dynamic_params) is not None
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -231,7 +231,10 @@ class LangfuseOtelLogger(OpenTelemetry):
|
|||
from litellm.integrations.arize._utils import safe_set_attribute
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
||||
langfuse_environment: Final = os.environ.get("LANGFUSE_TRACING_ENVIRONMENT")
|
||||
dynamic_params: Final = kwargs.get("standard_callback_dynamic_params")
|
||||
langfuse_environment: Final = (
|
||||
dynamic_params.get("langfuse_environment") if dynamic_params else None
|
||||
) or os.environ.get("LANGFUSE_TRACING_ENVIRONMENT")
|
||||
if langfuse_environment:
|
||||
safe_set_attribute(
|
||||
span,
|
||||
|
|
|
|||
|
|
@ -3,19 +3,25 @@ Helper functions for health check calls.
|
|||
"""
|
||||
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
# Minimal PDF for health checks - base64 encoded 1-page PDF with just "test"
|
||||
TEST_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y="
|
||||
|
||||
# Minimal image for health checks - base64 encoded 512x512 solid-gray PNG
|
||||
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAFlklEQVR42u3VMQEAAAzCMKQjHQ97l0jo0xSAlyIBgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAUgAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAADcDrctaAb6XeXAAAAAASUVORK5CYII="
|
||||
# Minimal image for health checks - base64 encoded 512x512 blue circle on a white background PNG
|
||||
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAJk0lEQVR42u3VQREAIRADwVWCOmTjBVzwSLorCri6nbkAVBpPACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAZdY+HgEBgIRr/meeGgGA8EMvDAgAuPh6gACAi68HCAA4+mKAAICjLwYIALj7SoAAgLuvBAgA7r4pAQKAu29KgADg7psSIAA4/SYDCADuvikBAoDTbzKAAOD0mwwgADj9JgMIAE6/yQACgNNvMoAA4PSbDCAAOP0mAwgATr/JAAKA668BIAA4/TKAAOD0mwwgALj+pgEIAE6/yQACgOtvGoAA4PSbDCAAuP6mAQgATr/JAAKA628agADg9JsMIAC4/qYBCACuv2kAAoDrbxqAAOD0mwwgALj+pgEIAK6/aQACgOtvGoAA4PqbBiAArr+ZBiAATr+ZDCAArr+ZBiAArr+ZBiAArr+ZBiAArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggAAmAmAAKA62+mAQKA62+mAQKA62/m1xYAXH/TAAQA1980AAHA9TcNQAAEwEwAEADX30wDEADX30wDEADX30wDEADX30wDEAABMBMABMD1N9MABMD1N9MABEAAzAQAAXD9zTQAAXD9zTRAABAAMwEQAFx/Mw0QAFx/Mw0QAATATAAEANffTAMEANffTAMEAAEwEwABwPU30wABQADMBEAAXH8z0wABcP3NTAMEQADMTAAEwPU3Mw0QAAEwMwEQANffTAMQAAEwEwAEwPU30wAEQADMBAABcP3NNAABEAAzAUAAXH8zDUAABMBMABAA199MAwQAATATAAHA9TfTAAFAAMwEQABw/c00QAAQADMBEAAEwEwABMD1NzMNEAABMDMBEADX38w0QAAEwMwEQAAEwMwEQABcfzPTAAEQADMTAAEQADMTAAFw/c1MAwRAAMxMAARAAMxMAATA9TczDRAAATATAARAAMwEAAFw/c00AAEQADMBQAAEwEwAEADX30wDBAABMBMAAUAAzARAABAAMwEQAFx/Mw0QAAEwMwEQAAEwMwEQAAEwMwEQANffzDRAAATAzARAAATAzARAAATAzARAAATAzARAAFx/M9MAARAAMxMAARAAMxMAARAAMxMAARAAMxMAAXD9zUwDBEAAzEwABEAAzAQAARAAMwFAAATATAAQAAEwEwABQADMBEAAEAAzARAAXH8zDRAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAA19/MNEAANMDM9UcABMBMABAAATATAAHwBAJgJgACgACYCYAAIABmAiAA+IvMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAANMDMXH8BEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzAUAABMBMABAADTBz/REAATATAAFAAMwEQAAQADMBEAAEwEwABAANMHP9BUAAzEwABEAAzEwABEAAzEwABEAAzEwABEADzMz1FwABMDMBEAABMDMBEAABMDMBEAANMDPXXwAEwMwEQAAEwMwEQAAEwMwEQAA0wMxcfwEQADMTAAEQADMBQAA0wMz1RwAEwEwAEAABMBMABEADzFx/AUAAzARAABAAMwEQADTAzPUXAATATAAEAAEwEwABQAPMXH8BEAAzEwABEAAzEwAB0AAzc/0FQADMTAAEQAPMzPUXAAEwMwEQAAEwMwEQAA0wM9dfAATAzARAADTAzFx/ARAAMwFAADTAzPVHAATATAAQAA0wc/0RAAEwEwAEQAPMXH8EQADMBAAB0AAz118AEAAzARAANMDM9RcABMBMAAQADTBz/QUAATATAAFAA8xcfwFAA8xcfwFAAMwEQADQADPXXwAEwMwEQAA0wMxcfwHQADNz/QVAAMxMAARAA8xcfwRAA8xcfwRAAMwEAAHQADPXHwHQADPXHwEQADMBQAA0wMz1RwA0wMz1RwAEwEwAEAANMHP9BQANMHP9BQANMHP9BQANMHP9BQABMBMAAUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzPVHABAAEwAEAA0w1x8BQAPM9UcA0ABz/REANMBcfwQADTDXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwGQATOnHwHQADPXHwHQADPXXwDQADPXXwDQADPXXwDQADPXXwCQAXP6EQA0wFx/BAANMNcfAUADzPVHAJABc/oRADTAXH8EABkwpx8BQAPM9UcAkAFz+hEANMBcfwQAGTCnHwFAA8z1RwCQAXP6EQBkwJx+BAANMNcfAUAGzOlHAJABc/oRAGTA6QcBQAacfhAAZMDpRwBABpx+BABkwOlHAEAGnH4EAJTA3UcAQAacfgQAlMDdRwBACdx9BACUwN1HAEAJ3H0EAJTA3UcAQAwcfQQAmmLgsyIA0NIDHw4BgJYe+DQIAISHwVMjAJDQDI+AAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACACAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAIAABNHpialFcmLajuAAAAAElFTkSuQmCC"
|
||||
|
||||
|
||||
IMAGE_EDIT_HEALTH_CHECK_PROMPT: Final = (
|
||||
"Add a small yellow star in the top right corner of this simple drawing of a blue circle on a white background"
|
||||
)
|
||||
|
||||
|
||||
def get_image_file_for_health_check() -> bytes:
|
||||
|
|
@ -121,6 +127,17 @@ class HealthCheckHelpers:
|
|||
else:
|
||||
return await litellm.acompletion(**model_params)
|
||||
|
||||
@staticmethod
|
||||
async def _image_edit_health_check(edit_request: Callable[[], Awaitable["ImageResponse"]]) -> "ImageResponse":
|
||||
import litellm
|
||||
|
||||
try:
|
||||
return await edit_request()
|
||||
except litellm.BadRequestError as e:
|
||||
if isinstance(e, litellm.ContentPolicyViolationError) or "moderation_blocked" in str(e):
|
||||
return litellm.ImageResponse()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_mode_handlers(
|
||||
model: str,
|
||||
|
|
@ -195,10 +212,12 @@ class HealthCheckHelpers:
|
|||
**_filter_model_params(model_params=model_params),
|
||||
prompt=prompt,
|
||||
),
|
||||
"image_edit": lambda: litellm.aimage_edit(
|
||||
**_filter_model_params(model_params=model_params),
|
||||
image=get_image_file_for_health_check(),
|
||||
prompt=prompt or "test",
|
||||
"image_edit": lambda: HealthCheckHelpers._image_edit_health_check(
|
||||
edit_request=lambda: litellm.aimage_edit(
|
||||
**_filter_model_params(model_params=model_params),
|
||||
image=get_image_file_for_health_check(),
|
||||
prompt=IMAGE_EDIT_HEALTH_CHECK_PROMPT,
|
||||
),
|
||||
),
|
||||
"video_generation": lambda: litellm.avideo_generation(
|
||||
**_filter_model_params(model_params=model_params),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import re
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
|
|
@ -45,12 +46,29 @@ def validate_no_callback_env_reference(param: str, value: object, *, source: str
|
|||
_raise_env_reference_error(param, source=source)
|
||||
|
||||
|
||||
# Langfuse rejects events whose environment does not match this pattern
|
||||
# (lowercase alphanumerics, hyphens, underscores; no "langfuse" prefix).
|
||||
# Validating here fails fast at config/init time instead of silently
|
||||
# dropping every trace server-side.
|
||||
LANGFUSE_ENVIRONMENT_PATTERN: Final = r"^(?!langfuse)[a-z0-9-_]+$"
|
||||
|
||||
|
||||
def validate_langfuse_environment_value(value: str) -> None:
|
||||
if not re.match(LANGFUSE_ENVIRONMENT_PATTERN, value):
|
||||
raise ValueError(
|
||||
f"Invalid langfuse_environment {value!r}: must be lowercase "
|
||||
"alphanumerics/hyphens/underscores and must not start with "
|
||||
f"'langfuse' (pattern {LANGFUSE_ENVIRONMENT_PATTERN})"
|
||||
)
|
||||
|
||||
|
||||
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
|
||||
_supported_callback_params: Final[tuple[str, ...]] = (
|
||||
"langfuse_public_key",
|
||||
"langfuse_secret",
|
||||
"langfuse_secret_key",
|
||||
"langfuse_host",
|
||||
"langfuse_environment",
|
||||
"langfuse_prompt_version",
|
||||
"langsmith_api_key",
|
||||
"langsmith_project",
|
||||
|
|
|
|||
|
|
@ -1590,7 +1590,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
if transformed_result is not None:
|
||||
result = transformed_result
|
||||
|
||||
if isinstance(result, BaseModel) and hasattr(result, "_hidden_params"):
|
||||
if isinstance(result, (BaseModel, HttpxBinaryResponseContent)) and hasattr(result, "_hidden_params"):
|
||||
hidden_params: Final = getattr(result, "_hidden_params", {})
|
||||
if (
|
||||
"response_cost" in hidden_params and hidden_params["response_cost"] is not None
|
||||
|
|
|
|||
|
|
@ -64,11 +64,17 @@ class StandardBuiltInToolCostTracking:
|
|||
"""
|
||||
standard_built_in_tools_params = standard_built_in_tools_params or {}
|
||||
|
||||
google_maps_grounding_cost: Final = StandardBuiltInToolCostTracking._handle_google_maps_grounding_cost(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Handle web search
|
||||
if StandardBuiltInToolCostTracking.response_object_includes_web_search_call(
|
||||
response_object=response_object, usage=usage
|
||||
):
|
||||
return StandardBuiltInToolCostTracking._handle_web_search_cost(
|
||||
return google_maps_grounding_cost + StandardBuiltInToolCostTracking._handle_web_search_cost(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
usage=usage,
|
||||
|
|
@ -78,19 +84,56 @@ class StandardBuiltInToolCostTracking:
|
|||
|
||||
# Handle file search
|
||||
if StandardBuiltInToolCostTracking.response_object_includes_file_search_call(response_object=response_object):
|
||||
return StandardBuiltInToolCostTracking._handle_file_search_cost(
|
||||
return google_maps_grounding_cost + StandardBuiltInToolCostTracking._handle_file_search_cost(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
standard_built_in_tools_params=standard_built_in_tools_params,
|
||||
)
|
||||
|
||||
# Handle Azure assistant features
|
||||
return StandardBuiltInToolCostTracking._handle_azure_assistant_costs(
|
||||
return google_maps_grounding_cost + StandardBuiltInToolCostTracking._handle_azure_assistant_costs(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
standard_built_in_tools_params=standard_built_in_tools_params,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_model_info(model: str, custom_llm_provider: str | None) -> tuple[ModelInfo | None, str | None]:
|
||||
direct: Final = StandardBuiltInToolCostTracking._safe_get_model_info(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
if direct is not None:
|
||||
return direct, custom_llm_provider or direct["litellm_provider"]
|
||||
if "/" not in model:
|
||||
return None, custom_llm_provider
|
||||
by_prefix: Final = StandardBuiltInToolCostTracking._safe_get_model_info(model=model)
|
||||
if by_prefix is None:
|
||||
return None, custom_llm_provider
|
||||
return by_prefix, by_prefix["litellm_provider"]
|
||||
|
||||
@staticmethod
|
||||
def _handle_google_maps_grounding_cost(
|
||||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
usage: Usage | None,
|
||||
) -> float:
|
||||
from litellm.llms import get_cost_for_google_maps_grounding_request
|
||||
from litellm.llms.gemini.cost_calculator import google_maps_grounding_requests
|
||||
|
||||
if usage is None or google_maps_grounding_requests(usage) is None:
|
||||
return 0.0
|
||||
model_info, resolved_provider = StandardBuiltInToolCostTracking._resolve_model_info(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
if model_info is None or resolved_provider is None:
|
||||
return 0.0
|
||||
return (
|
||||
get_cost_for_google_maps_grounding_request(
|
||||
custom_llm_provider=resolved_provider, usage=usage, model_info=model_info
|
||||
)
|
||||
or 0.0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _handle_web_search_cost(
|
||||
model: str,
|
||||
|
|
@ -102,29 +145,21 @@ class StandardBuiltInToolCostTracking:
|
|||
"""Handle web search cost calculation."""
|
||||
from litellm.llms import get_cost_for_web_search_request
|
||||
|
||||
model_info = StandardBuiltInToolCostTracking._safe_get_model_info(
|
||||
# A provider-prefixed model (e.g. gemini/gemini-3.1-flash-lite) may not map under the
|
||||
# request's custom_llm_provider. _resolve_model_info re-resolves from the prefix and adopts
|
||||
# that provider so the cost is routed and priced with the model_info that was actually
|
||||
# resolved, instead of feeding a re-resolved model into the original provider's calculator.
|
||||
model_info, resolved_provider = StandardBuiltInToolCostTracking._resolve_model_info(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
|
||||
# A provider-prefixed model (e.g. gemini/gemini-3.1-flash-lite) may not map under the
|
||||
# request's custom_llm_provider. Re-resolve from the prefix and adopt that provider so the
|
||||
# cost is routed and priced with the model_info that was actually resolved, instead of
|
||||
# feeding a re-resolved model into the original provider's calculator.
|
||||
if model_info is None and "/" in model:
|
||||
model_info = StandardBuiltInToolCostTracking._safe_get_model_info(model=model)
|
||||
if model_info is not None:
|
||||
custom_llm_provider = model_info["litellm_provider"]
|
||||
|
||||
if custom_llm_provider is None and model_info is not None:
|
||||
custom_llm_provider = model_info["litellm_provider"]
|
||||
|
||||
resolved_usage: Final = StandardBuiltInToolCostTracking._usage_with_anthropic_web_search(
|
||||
usage=usage, response_object=response_object
|
||||
)
|
||||
|
||||
if model_info is not None and resolved_usage is not None and custom_llm_provider is not None:
|
||||
if model_info is not None and resolved_usage is not None and resolved_provider is not None:
|
||||
result: Final = get_cost_for_web_search_request(
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
custom_llm_provider=resolved_provider,
|
||||
usage=resolved_usage,
|
||||
model_info=model_info,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import asyncio
|
||||
import atexit
|
||||
import contextvars
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Coroutine, Iterator
|
||||
from typing import Final
|
||||
|
|
@ -53,6 +54,7 @@ class LoggingWorker:
|
|||
self._queue: asyncio.Queue[LoggingTask] | None = None
|
||||
self._worker_task: asyncio.Task | None = None
|
||||
self._running_tasks: set[asyncio.Task] = set()
|
||||
self._dequeued_tasks: dict[int, LoggingTask] = {} # mutable-ok: refs so flush can rescue never-started tasks
|
||||
self._sem: asyncio.Semaphore | None = None
|
||||
self._bound_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._last_aggressive_clear_time: float = 0.0
|
||||
|
|
@ -61,6 +63,38 @@ class LoggingWorker:
|
|||
# Register cleanup handler to flush remaining events on exit
|
||||
atexit.register(self._flush_on_exit)
|
||||
|
||||
def _track_dequeued(self, task: LoggingTask) -> None:
|
||||
self._dequeued_tasks[id(task)] = task
|
||||
|
||||
def _untrack_dequeued(self, task: LoggingTask) -> None:
|
||||
self._dequeued_tasks.pop(id(task), None)
|
||||
|
||||
def _unstarted_dequeued_tasks(self) -> tuple[LoggingTask, ...]:
|
||||
return tuple(
|
||||
task
|
||||
for task in self._dequeued_tasks.values()
|
||||
if inspect.getcoroutinestate(task["coroutine"]) == inspect.CORO_CREATED
|
||||
)
|
||||
|
||||
def _requeue_unstarted_dequeued(self, new_queue: "asyncio.Queue[LoggingTask]") -> int:
|
||||
revived: Final = self._unstarted_dequeued_tasks()
|
||||
self._dequeued_tasks.clear()
|
||||
for index, revived_task in enumerate(revived):
|
||||
try:
|
||||
new_queue.put_nowait(revived_task)
|
||||
except asyncio.QueueFull:
|
||||
for leftover in revived[index:]:
|
||||
self._track_dequeued(leftover)
|
||||
return index
|
||||
return len(revived)
|
||||
|
||||
def _run_coroutine_silently(self, loop: asyncio.AbstractEventLoop, coroutine: Coroutine) -> bool:
|
||||
try:
|
||||
loop.run_until_complete(asyncio.wait_for(coroutine, timeout=self.timeout))
|
||||
except (Exception, asyncio.CancelledError): # noqa: BLE001 # atexit flush must never break the user's program
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _drain_pending(queue: "asyncio.Queue[LoggingTask]") -> tuple[LoggingTask, ...]:
|
||||
"""Pop every task still queued, without awaiting them, so they can be moved to another queue."""
|
||||
|
|
@ -90,10 +124,12 @@ class LoggingWorker:
|
|||
new_queue: Final[asyncio.Queue[LoggingTask]] = asyncio.Queue(maxsize=self.max_queue_size)
|
||||
for carried_task in carried_over:
|
||||
new_queue.put_nowait(carried_task)
|
||||
if carried_over:
|
||||
revived_count: Final = self._requeue_unstarted_dequeued(new_queue)
|
||||
if carried_over or revived_count:
|
||||
verbose_logger.warning(
|
||||
"LoggingWorker: event loop changed; carried %d pending logging task(s) onto the new loop",
|
||||
"LoggingWorker: event loop changed; carried %d pending and revived %d dequeued logging task(s) onto the new loop",
|
||||
len(carried_over),
|
||||
revived_count,
|
||||
)
|
||||
else:
|
||||
verbose_logger.debug("LoggingWorker: Event loop changed, reinitializing queue and worker")
|
||||
|
|
@ -129,6 +165,7 @@ class LoggingWorker:
|
|||
except Exception as e:
|
||||
verbose_logger.exception("LoggingWorker error: %s", e)
|
||||
finally:
|
||||
self._untrack_dequeued(task)
|
||||
self._queue.task_done()
|
||||
finally:
|
||||
# Always release semaphore, even if queue is None
|
||||
|
|
@ -146,6 +183,7 @@ class LoggingWorker:
|
|||
await self._sem.acquire()
|
||||
try:
|
||||
task = await self._queue.get()
|
||||
self._track_dequeued(task)
|
||||
# Track each spawned coroutine so we can cancel on shutdown.
|
||||
processing_task = asyncio.create_task(self._process_log_task(task, self._sem))
|
||||
self._running_tasks.add(processing_task)
|
||||
|
|
@ -298,9 +336,10 @@ class LoggingWorker:
|
|||
extracted_tasks: Final = []
|
||||
for _ in range(items_to_extract):
|
||||
try:
|
||||
extracted_tasks.append(self._queue.get_nowait())
|
||||
extracted_tasks.append(extracted := self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
self._track_dequeued(extracted)
|
||||
|
||||
return extracted_tasks
|
||||
|
||||
|
|
@ -318,6 +357,7 @@ class LoggingWorker:
|
|||
|
||||
# Add new task to extracted tasks to process directly
|
||||
if new_task is not None:
|
||||
self._track_dequeued(new_task)
|
||||
extracted_tasks.append(new_task)
|
||||
|
||||
# Process extracted tasks directly
|
||||
|
|
@ -343,6 +383,7 @@ class LoggingWorker:
|
|||
# Suppress errors during processing to ensure we keep going
|
||||
pass
|
||||
finally:
|
||||
self._untrack_dequeued(task)
|
||||
self._queue.task_done()
|
||||
|
||||
async def _process_extracted_tasks(self, tasks: list[LoggingTask]) -> None:
|
||||
|
|
@ -486,11 +527,12 @@ class LoggingWorker:
|
|||
self._safe_log("debug", "[LoggingWorker] atexit: No queue initialized")
|
||||
return
|
||||
|
||||
if self._queue.empty():
|
||||
unstarted_dequeued: Final = self._unstarted_dequeued_tasks()
|
||||
if self._queue.empty() and not unstarted_dequeued:
|
||||
self._safe_log("debug", "[LoggingWorker] atexit: Queue is empty")
|
||||
return
|
||||
|
||||
queue_size: Final = self._queue.qsize()
|
||||
queue_size: Final = self._queue.qsize() + len(unstarted_dequeued)
|
||||
self._safe_log("info", f"[LoggingWorker] atexit: Flushing {queue_size} remaining events...")
|
||||
|
||||
# Create a new event loop since the original is closed
|
||||
|
|
@ -509,6 +551,16 @@ class LoggingWorker:
|
|||
previous_raise_exceptions: Final = logging.raiseExceptions
|
||||
logging.raiseExceptions = False
|
||||
try:
|
||||
for pending in unstarted_dequeued:
|
||||
if (
|
||||
processed >= MAX_ITERATIONS_TO_CLEAR_QUEUE
|
||||
or loop.time() - start_time >= MAX_TIME_TO_CLEAR_QUEUE
|
||||
):
|
||||
break
|
||||
if self._run_coroutine_silently(loop, pending["coroutine"]):
|
||||
processed += 1
|
||||
self._untrack_dequeued(pending)
|
||||
|
||||
while not self._queue.empty() and processed < MAX_ITERATIONS_TO_CLEAR_QUEUE:
|
||||
if loop.time() - start_time >= MAX_TIME_TO_CLEAR_QUEUE:
|
||||
self._safe_log(
|
||||
|
|
@ -526,11 +578,8 @@ class LoggingWorker:
|
|||
# Note: We run the coroutine directly, not via create_task,
|
||||
# since we're in a new event loop context
|
||||
try:
|
||||
loop.run_until_complete(task["coroutine"])
|
||||
processed += 1
|
||||
except Exception:
|
||||
# Silent failure to not break user's program
|
||||
pass
|
||||
if self._run_coroutine_silently(loop, task["coroutine"]):
|
||||
processed += 1
|
||||
finally:
|
||||
# Clear reference to prevent memory leaks
|
||||
task = None
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ PTU_ZEROED_PRICING_FIELDS: Final = tuple(f for f in MirroredPricingParams.model_
|
|||
"cache_creation_input_token_cost_above_1hr",
|
||||
"cache_creation_input_token_cost_above_200k_tokens",
|
||||
"cache_read_input_token_cost_above_200k_tokens",
|
||||
"google_maps_grounding_cost_per_query",
|
||||
)
|
||||
# tiered_pricing is emptied rather than zeroed: its tiers outrank the zeros written beside
|
||||
# them, so a zero here would leave the cost map's tiers billing the traffic the reserved
|
||||
|
|
|
|||
|
|
@ -173,6 +173,27 @@ def attach_cache_creation_token_details(
|
|||
return prompt_tokens_details.model_copy(update={"cache_creation_token_details": cache_creation_token_details})
|
||||
|
||||
|
||||
def apply_grounding_request_counts(
|
||||
prompt_tokens_details: PromptTokensDetailsWrapper | None,
|
||||
web_search_requests: int | None,
|
||||
google_maps_grounding_requests: int | None,
|
||||
) -> PromptTokensDetailsWrapper | None:
|
||||
updates: Final = MappingProxyType(
|
||||
{
|
||||
field: value
|
||||
for field, value in (
|
||||
("web_search_requests", web_search_requests),
|
||||
("google_maps_grounding_requests", google_maps_grounding_requests),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
if not updates:
|
||||
return prompt_tokens_details
|
||||
counted: Final = prompt_tokens_details if prompt_tokens_details is not None else PromptTokensDetailsWrapper()
|
||||
return counted.model_copy(update=updates)
|
||||
|
||||
|
||||
class ChunkProcessor:
|
||||
def __init__(self, chunks: list, messages: list | None = None):
|
||||
self.chunks = self._sort_chunks(chunks)
|
||||
|
|
@ -778,6 +799,7 @@ class ChunkProcessor:
|
|||
|
||||
server_tool_use: ServerToolUse | None = None
|
||||
web_search_requests: int | None = None
|
||||
google_maps_grounding_requests: int | None = None
|
||||
completion_tokens_details: CompletionTokensDetails | None = None
|
||||
prompt_tokens_details: PromptTokensDetailsWrapper | None = None
|
||||
# Anthropic emits the cache-creation TTL breakdown (5m/1h split) only on
|
||||
|
|
@ -827,6 +849,13 @@ class ChunkProcessor:
|
|||
)
|
||||
if chunk_web_search_requests is not None:
|
||||
web_search_requests = chunk_web_search_requests
|
||||
chunk_google_maps_grounding_requests: int | None = getattr(
|
||||
usage_chunk_dict["prompt_tokens_details"],
|
||||
"google_maps_grounding_requests",
|
||||
None,
|
||||
)
|
||||
if chunk_google_maps_grounding_requests is not None:
|
||||
google_maps_grounding_requests = chunk_google_maps_grounding_requests
|
||||
|
||||
prompt_tokens_details = usage_chunk_dict["prompt_tokens_details"] or prompt_tokens_details
|
||||
|
||||
|
|
@ -852,6 +881,7 @@ class ChunkProcessor:
|
|||
cache_read_input_tokens=cache_read_input_tokens,
|
||||
server_tool_use=server_tool_use,
|
||||
web_search_requests=web_search_requests,
|
||||
google_maps_grounding_requests=google_maps_grounding_requests,
|
||||
completion_tokens_details=completion_tokens_details,
|
||||
prompt_tokens_details=prompt_tokens_details,
|
||||
cost=cost,
|
||||
|
|
@ -939,6 +969,7 @@ class ChunkProcessor:
|
|||
|
||||
server_tool_use: Final[ServerToolUse | None] = calculated_usage_per_chunk["server_tool_use"]
|
||||
web_search_requests: Final[int | None] = calculated_usage_per_chunk["web_search_requests"]
|
||||
google_maps_grounding_requests: Final[int | None] = calculated_usage_per_chunk["google_maps_grounding_requests"]
|
||||
completion_tokens_details: Final[CompletionTokensDetails | None] = calculated_usage_per_chunk[
|
||||
"completion_tokens_details"
|
||||
]
|
||||
|
|
@ -998,13 +1029,11 @@ class ChunkProcessor:
|
|||
|
||||
if server_tool_use is not None:
|
||||
returned_usage.server_tool_use = server_tool_use
|
||||
if web_search_requests is not None:
|
||||
if returned_usage.prompt_tokens_details is None:
|
||||
returned_usage.prompt_tokens_details = PromptTokensDetailsWrapper(
|
||||
web_search_requests=web_search_requests
|
||||
)
|
||||
else:
|
||||
returned_usage.prompt_tokens_details.web_search_requests = web_search_requests
|
||||
returned_usage.prompt_tokens_details = apply_grounding_request_counts(
|
||||
returned_usage.prompt_tokens_details,
|
||||
web_search_requests,
|
||||
google_maps_grounding_requests,
|
||||
)
|
||||
|
||||
if cost is not None:
|
||||
setattr(returned_usage, "cost", cost)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,21 @@ if TYPE_CHECKING:
|
|||
from litellm.types.utils import ModelInfo, Usage
|
||||
|
||||
|
||||
def get_cost_for_google_maps_grounding_request(
|
||||
custom_llm_provider: str, usage: "Usage", model_info: "ModelInfo"
|
||||
) -> float | None:
|
||||
"""
|
||||
Get the cost of Grounding with Google Maps for a given model. Only Gemini models on the
|
||||
Gemini API and Vertex AI can populate the Maps grounding counter, so every other provider
|
||||
returns None.
|
||||
"""
|
||||
if custom_llm_provider != "gemini" and not custom_llm_provider.startswith("vertex_ai"):
|
||||
return None
|
||||
from .gemini.cost_calculator import cost_per_google_maps_grounding_request
|
||||
|
||||
return cost_per_google_maps_grounding_request(usage=usage, model_info=model_info)
|
||||
|
||||
|
||||
def get_cost_for_web_search_request(custom_llm_provider: str, usage: "Usage", model_info: "ModelInfo") -> float | None:
|
||||
"""
|
||||
Get the cost for a web search request for a given model.
|
||||
|
|
|
|||
|
|
@ -712,11 +712,14 @@ class ModelResponseIterator:
|
|||
|
||||
def _handle_usage(self, anthropic_usage_chunk: dict | UsageDelta) -> Usage:
|
||||
reasoning_content: Final = "".join(self.reasoning_content_chunks) if self.reasoning_content_chunks else None
|
||||
return AnthropicConfig().calculate_usage(
|
||||
usage: Final = AnthropicConfig().calculate_usage(
|
||||
usage_object=cast(dict, anthropic_usage_chunk),
|
||||
reasoning_content=reasoning_content,
|
||||
speed=self.speed,
|
||||
)
|
||||
if usage.speed is not None:
|
||||
self.speed = usage.speed
|
||||
return usage
|
||||
|
||||
def _content_block_delta_helper(
|
||||
self, chunk: dict
|
||||
|
|
|
|||
|
|
@ -2279,6 +2279,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
str | None,
|
||||
_usage.get("service_tier"),
|
||||
)
|
||||
raw_speed: Final = _usage.get("speed")
|
||||
resolved_speed: Final = raw_speed if isinstance(raw_speed, str) else speed
|
||||
|
||||
iterations: Final[list[Any] | None] = _usage.get("iterations")
|
||||
if iterations:
|
||||
|
|
@ -2353,7 +2355,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
else None
|
||||
),
|
||||
inference_geo=inference_geo,
|
||||
speed=speed,
|
||||
speed=resolved_speed,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
return usage
|
||||
|
|
|
|||
|
|
@ -8,12 +8,9 @@ from typing import TYPE_CHECKING, Final, Optional
|
|||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
_get_token_base_cost,
|
||||
_get_web_search_requests,
|
||||
calculate_cache_writing_cost,
|
||||
generic_cost_per_token,
|
||||
get_provider_specific_geo_multiplier,
|
||||
parse_prompt_tokens_details,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -21,43 +18,6 @@ if TYPE_CHECKING:
|
|||
import litellm
|
||||
|
||||
|
||||
def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage", service_tier: str | None = None) -> float:
|
||||
"""
|
||||
Return only the cache-related portion of the prompt cost (cache read + cache write).
|
||||
|
||||
These costs must NOT be scaled by the ``fast`` speed multiplier because the old
|
||||
explicit ``fast/`` model entries carried unchanged cache rates while
|
||||
multiplying only the regular input/output token costs. Regional pricing, by
|
||||
contrast, uplifts every token type, so the geo multiplier does scale them.
|
||||
"""
|
||||
if usage.prompt_tokens_details is None:
|
||||
return 0.0
|
||||
|
||||
prompt_tokens_details: Final = parse_prompt_tokens_details(usage)
|
||||
(
|
||||
_,
|
||||
_,
|
||||
cache_creation_cost,
|
||||
cache_creation_cost_above_1hr,
|
||||
cache_read_cost,
|
||||
) = _get_token_base_cost(model_info=model_info, usage=usage, service_tier=service_tier)
|
||||
|
||||
cache_cost = float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost
|
||||
|
||||
if (
|
||||
prompt_tokens_details["cache_creation_tokens"]
|
||||
or prompt_tokens_details["cache_creation_token_details"] is not None
|
||||
):
|
||||
cache_cost += calculate_cache_writing_cost(
|
||||
cache_creation_tokens=prompt_tokens_details["cache_creation_tokens"],
|
||||
cache_creation_token_details=prompt_tokens_details["cache_creation_token_details"],
|
||||
cache_creation_cost_above_1hr=cache_creation_cost_above_1hr,
|
||||
cache_creation_cost=cache_creation_cost,
|
||||
)
|
||||
|
||||
return cache_cost
|
||||
|
||||
|
||||
def cost_per_token(model: str, usage: "Usage", service_tier: str | None = None) -> tuple[float, float]:
|
||||
"""
|
||||
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
|
||||
|
|
@ -89,8 +49,7 @@ def cost_per_token(model: str, usage: "Usage", service_tier: str | None = None)
|
|||
)
|
||||
|
||||
if speed_multiplier != 1.0:
|
||||
cache_cost: Final = _compute_cache_only_cost(model_info=model_info, usage=usage, service_tier=service_tier)
|
||||
prompt_cost = (prompt_cost - cache_cost) * speed_multiplier + cache_cost
|
||||
prompt_cost *= speed_multiplier
|
||||
completion_cost *= speed_multiplier
|
||||
|
||||
if geo_multiplier != 1.0:
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ from litellm.types.llms.openai import (
|
|||
OpenAIMessageContentListBlock,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
CacheCreationTokenDetails,
|
||||
ChatCompletionMessageToolCall,
|
||||
CompletionTokensDetailsWrapper,
|
||||
Function,
|
||||
|
|
@ -1807,6 +1808,26 @@ class AmazonConverseConfig(BaseConfig):
|
|||
thinking_blocks_list.append(_redacted_block)
|
||||
return thinking_blocks_list
|
||||
|
||||
@staticmethod
|
||||
def _parse_cache_details(usage: ConverseTokenUsageBlock) -> "CacheCreationTokenDetails | None":
|
||||
"""Split ``cacheDetails`` into 5m/1h buckets, or ``None`` unless the split fully
|
||||
accounts for ``cacheWriteInputTokens``, since a partial or unrecognized-ttl
|
||||
breakdown would understate the cache-write cost.
|
||||
|
||||
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CacheDetail.html
|
||||
"""
|
||||
cache_details: Final = usage.get("cacheDetails")
|
||||
if not cache_details:
|
||||
return None
|
||||
tokens_5m: Final = sum(d["inputTokens"] for d in cache_details if d.get("ttl") == "5m")
|
||||
tokens_1h: Final = sum(d["inputTokens"] for d in cache_details if d.get("ttl") == "1h")
|
||||
if tokens_5m + tokens_1h != usage.get("cacheWriteInputTokens", 0):
|
||||
return None
|
||||
return CacheCreationTokenDetails(
|
||||
ephemeral_5m_input_tokens=tokens_5m,
|
||||
ephemeral_1h_input_tokens=tokens_1h,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def thinking_tokens_from_additional_fields(additional_fields: object) -> int | None:
|
||||
"""Converse omits thinking tokens from its usage block; they only arrive under
|
||||
|
|
@ -1878,6 +1899,7 @@ class AmazonConverseConfig(BaseConfig):
|
|||
prompt_tokens_details: Final = PromptTokensDetailsWrapper(
|
||||
cached_tokens=cache_read_input_tokens,
|
||||
cache_creation_tokens=cache_creation_input_tokens,
|
||||
cache_creation_token_details=self._parse_cache_details(usage),
|
||||
text_tokens=raw_input_tokens,
|
||||
)
|
||||
estimated_reasoning_tokens: Final = (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import ssl
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
AsyncHTTPHandler,
|
||||
HTTPHandler,
|
||||
_get_httpx_client,
|
||||
get_ssl_configuration,
|
||||
)
|
||||
from litellm.types.llms.openai import FileTypes
|
||||
from litellm.types.utils import HttpHandlerRequestFields, ImageResponse, LlmProviders
|
||||
|
|
@ -56,7 +58,11 @@ class BaseLLMAIOHTTPHandler:
|
|||
|
||||
# Create a transport using AsyncHTTPHandler's logic
|
||||
try:
|
||||
self.transport = AsyncHTTPHandler._create_aiohttp_transport()
|
||||
ssl_config: Final = get_ssl_configuration()
|
||||
self.transport = AsyncHTTPHandler._create_aiohttp_transport(
|
||||
ssl_verify=ssl_config if isinstance(ssl_config, bool) else None,
|
||||
ssl_context=ssl_config if isinstance(ssl_config, ssl.SSLContext) else None,
|
||||
)
|
||||
self._owns_transport = True
|
||||
return self.transport
|
||||
except Exception:
|
||||
|
|
@ -79,20 +85,19 @@ class BaseLLMAIOHTTPHandler:
|
|||
|
||||
def _create_client_session_with_transport(self) -> ClientSession:
|
||||
"""Create a new client session using transport or connector configuration."""
|
||||
connector: Final = self._get_connector()
|
||||
if self.transport is None:
|
||||
connector: Final = self._get_connector()
|
||||
if connector:
|
||||
return aiohttp.ClientSession(connector=connector)
|
||||
|
||||
if self.transport and hasattr(self.transport, "_get_valid_client_session"):
|
||||
# Use transport's session creation if available
|
||||
session = self.transport._get_valid_client_session()
|
||||
return session
|
||||
elif connector:
|
||||
# Use provided connector
|
||||
session = aiohttp.ClientSession(connector=connector)
|
||||
return session
|
||||
else:
|
||||
# Default session creation
|
||||
session = aiohttp.ClientSession()
|
||||
return session
|
||||
transport: Final = self.transport or self._get_or_create_transport()
|
||||
if transport is not None and hasattr(transport, "_get_valid_client_session"):
|
||||
try:
|
||||
return transport._get_valid_client_session()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
return aiohttp.ClientSession()
|
||||
|
||||
def _get_async_client_session(self, dynamic_client_session: ClientSession | None = None) -> ClientSession:
|
||||
if dynamic_client_session:
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@
|
|||
Translates from OpenAI's `/v1/chat/completions` to DeepSeek's `/v1/chat/completions`
|
||||
"""
|
||||
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Coroutine, Mapping, Sequence
|
||||
from typing import Any, Final, Literal, cast, overload
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
handle_messages_with_content_list_to_str_conversion,
|
||||
convert_content_list_to_str,
|
||||
extract_search_results_text,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.utils import supports_reasoning
|
||||
from litellm.utils import supports_reasoning, supports_vision
|
||||
|
||||
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
|
||||
|
||||
|
|
@ -117,13 +118,98 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
|
|||
self, messages: list[AllMessageValues], model: str, is_async: bool = False
|
||||
) -> list[AllMessageValues] | Coroutine[Any, Any, list[AllMessageValues]]:
|
||||
"""
|
||||
DeepSeek does not support content in list format.
|
||||
DeepSeek vision models accept image_url content blocks in user
|
||||
messages (https://api-docs.deepseek.com/guides/vision), so those
|
||||
content lists are forwarded as-is, with any search_results text
|
||||
appended as a trailing text block. Every other message keeps the
|
||||
historical string collapse (which also folds search_results text
|
||||
into string content); a list with no extractable text stays
|
||||
unchanged, matching what DeepSeek historically received.
|
||||
"""
|
||||
messages = handle_messages_with_content_list_to_str_conversion(messages)
|
||||
forward_images: Final = any(
|
||||
isinstance(message.get("content"), list) for message in messages
|
||||
) and supports_vision(model=model, custom_llm_provider="deepseek")
|
||||
transformed: Final = [ # mutable-ok: provider messages must stay JSON-array lists the base transform mutates
|
||||
self._forward_or_collapse_content(message=message, forward_images=forward_images) for message in messages
|
||||
]
|
||||
|
||||
if is_async:
|
||||
return super()._transform_messages(messages=messages, model=model, is_async=True)
|
||||
return super()._transform_messages(messages=transformed, model=model, is_async=True)
|
||||
else:
|
||||
return super()._transform_messages(messages=messages, model=model, is_async=False)
|
||||
return super()._transform_messages(messages=transformed, model=model, is_async=False)
|
||||
|
||||
def _forward_or_collapse_content(self, message: AllMessageValues, forward_images: bool) -> AllMessageValues:
|
||||
"""
|
||||
Returns the vision-forwardable message with any search_results text
|
||||
appended as a text block; every other message keeps the historical
|
||||
string collapse, which extracts the text from a content list and
|
||||
folds search_results text into string content.
|
||||
"""
|
||||
content: Final = message.get("content")
|
||||
if (
|
||||
forward_images
|
||||
and isinstance(content, list)
|
||||
and self._is_vision_forwardable_content(message=message, content=content)
|
||||
):
|
||||
return self._with_search_results_text_block(message=message, content=content)
|
||||
collapsed: Final = convert_content_list_to_str(message=message)
|
||||
if not collapsed or collapsed == content:
|
||||
return message
|
||||
collapsed_message: Final = {**message, "content": collapsed} # mutable-ok: wire messages are plain JSON dicts
|
||||
return cast(AllMessageValues, collapsed_message) # cast-ok: TypedDict spread narrows to dict
|
||||
|
||||
def _is_vision_forwardable_content(self, message: AllMessageValues, content: Sequence[object]) -> bool:
|
||||
"""
|
||||
True only for a user message whose content list holds well-formed
|
||||
text and image_url blocks with at least one image; a block missing
|
||||
its payload falls back to the string collapse instead of crashing
|
||||
or reaching the wire malformed. The model capability gate lives in
|
||||
the caller.
|
||||
"""
|
||||
if message.get("role") != "user":
|
||||
return False
|
||||
if not all(self._is_forwardable_block(block) for block in content):
|
||||
return False
|
||||
return any(isinstance(block, dict) and block.get("type") == "image_url" for block in content)
|
||||
|
||||
@staticmethod
|
||||
def _is_forwardable_block(block: object) -> bool:
|
||||
"""A dict block typed text or image_url that carries its payload."""
|
||||
if not isinstance(block, dict):
|
||||
return False
|
||||
block_type: Final = block.get("type")
|
||||
if block_type == "image_url":
|
||||
return DeepSeekChatConfig._is_image_url_payload(block.get("image_url"))
|
||||
if block_type == "text":
|
||||
return isinstance(block.get("text"), str)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_image_url_payload(payload: object) -> bool:
|
||||
"""A url string or an object carrying one, per the OpenAI image_url shape."""
|
||||
if isinstance(payload, str):
|
||||
return bool(payload)
|
||||
if not isinstance(payload, Mapping):
|
||||
return False
|
||||
url: Final = payload.get("url")
|
||||
return isinstance(url, str) and bool(url)
|
||||
|
||||
def _with_search_results_text_block(self, message: AllMessageValues, content: Sequence[object]) -> AllMessageValues:
|
||||
"""
|
||||
Appends the message's search_results text as a trailing text block,
|
||||
keeping the context that the string collapse used to fold in, and
|
||||
drops the non-OpenAI search_results key from the wire message.
|
||||
"""
|
||||
message_fields: Final = cast(Mapping[str, object], message) # cast-ok: search_results is not on the TypedDicts
|
||||
search_text: Final = extract_search_results_text(message_fields.get("search_results"))
|
||||
if not search_text:
|
||||
return message
|
||||
forwarded_content: Final = [*content, {"type": "text", "text": search_text}] # mutable-ok: JSON-array content
|
||||
forwarded: Final = { # mutable-ok: wire messages are plain JSON dicts
|
||||
**{key: value for key, value in message_fields.items() if key != "search_results"},
|
||||
"content": forwarded_content,
|
||||
}
|
||||
return cast(AllMessageValues, forwarded) # cast-ok: TypedDict spread narrows to dict
|
||||
|
||||
def _thinking_mode_active(self, model: str, optional_params: dict) -> bool:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ class FireworksAIException(BaseLLMException):
|
|||
|
||||
|
||||
def get_fireworks_session_id(litellm_params: dict) -> str | None:
|
||||
"""
|
||||
Session id to send as `x-session-affinity`, or None when the caller gave none.
|
||||
|
||||
Deliberately does not fall back to `litellm_trace_id`: that is generated per
|
||||
request (`str(uuid.uuid4())` when absent), so using it pins every request to a
|
||||
different Fireworks node and prompt caching never hits.
|
||||
"""
|
||||
params: Final = litellm_params
|
||||
for key in ("litellm_session_id", "session_id"):
|
||||
value = params.get(key)
|
||||
|
|
@ -23,9 +30,6 @@ def get_fireworks_session_id(litellm_params: dict) -> str | None:
|
|||
value = metadata.get("session_id")
|
||||
if value:
|
||||
return str(value)
|
||||
value = params.get("litellm_trace_id")
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -61,3 +61,42 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
|
|||
number_of_web_search_requests = 1
|
||||
|
||||
return _cost * number_of_web_search_requests
|
||||
|
||||
|
||||
GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_QUERY: Final = 14e-3
|
||||
GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_PROMPT: Final = 25e-3
|
||||
|
||||
|
||||
def google_maps_grounding_requests(usage: "Usage | None") -> int | None:
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper
|
||||
|
||||
details: Final = usage.prompt_tokens_details if usage is not None else None
|
||||
if not isinstance(details, PromptTokensDetailsWrapper) or not hasattr(details, "google_maps_grounding_requests"):
|
||||
return None
|
||||
return details.google_maps_grounding_requests
|
||||
|
||||
|
||||
def cost_per_google_maps_grounding_request(usage: "Usage", model_info: "ModelInfo") -> float:
|
||||
"""
|
||||
Calculates the cost of Grounding with Google Maps.
|
||||
|
||||
Billing follows ``web_search_billing_unit`` in model_info the same way Google Search grounding
|
||||
does: ``"per_query"`` (Gemini 3.x) multiplies the executed Maps queries, ``"per_prompt"``
|
||||
(default, Gemini 2.x) charges one flat fee per grounded prompt.
|
||||
|
||||
The rate comes from ``google_maps_grounding_cost_per_query`` in ``model_info``, falling back
|
||||
to Google's list price for that billing unit when the pricing JSON has no entry yet.
|
||||
"""
|
||||
requests: Final = google_maps_grounding_requests(usage)
|
||||
if not requests or requests <= 0:
|
||||
return 0.0
|
||||
billing_mode: Final = model_info.get("web_search_billing_unit") or "per_prompt"
|
||||
default_cost: Final = (
|
||||
GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_QUERY
|
||||
if billing_mode == "per_query"
|
||||
else GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_PROMPT
|
||||
)
|
||||
configured_cost: Final = model_info.get("google_maps_grounding_cost_per_query")
|
||||
cost: Final = default_cost if configured_cost is None else configured_cost
|
||||
billed_requests: Final = requests if billing_mode == "per_query" else 1
|
||||
return cost * billed_requests
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ This file contains the transformation logic for the Gemini realtime API.
|
|||
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import litellm
|
||||
|
|
@ -72,6 +73,28 @@ MAP_GEMINI_FIELD_TO_OPENAI_EVENT: Final[dict[str, OpenAIRealtimeEventTypes | Res
|
|||
_KNOWN_GEMINI_TOP_LEVEL_KEYS: Final[set] = {map_key.split(".", 1)[0] for map_key in MAP_GEMINI_FIELD_TO_OPENAI_EVENT}
|
||||
|
||||
|
||||
OPENAI_STOCK_REALTIME_VOICES: Final[frozenset[str]] = frozenset(
|
||||
{"alloy", "ash", "ballad", "cedar", "coral", "echo", "marin", "sage", "shimmer", "verse"}
|
||||
)
|
||||
|
||||
|
||||
def _gemini_live_speech_config(voice: object) -> Mapping[str, object] | None:
|
||||
"""Build the Gemini Live speechConfig for a client-requested voice.
|
||||
|
||||
OpenAI stock voice names have no Gemini equivalent and Gemini Live closes
|
||||
the session on an unknown voice, so they are dropped with a warning and
|
||||
the model keeps its default voice. Every other name is forwarded verbatim.
|
||||
"""
|
||||
if isinstance(voice, str) and voice.lower() in OPENAI_STOCK_REALTIME_VOICES:
|
||||
verbose_logger.warning(
|
||||
"Gemini Realtime: voice %s is an OpenAI voice with no Gemini equivalent; "
|
||||
"dropping it so the session keeps the model's default voice.",
|
||||
voice,
|
||||
)
|
||||
return None
|
||||
return VertexGeminiConfig()._map_audio_params({"voice": voice})
|
||||
|
||||
|
||||
class GeminiRealtimeConfig(BaseRealtimeConfig):
|
||||
_TOOL_CALL_ID_TO_NAME_MAX = 256 # LRU cap for call_id→name mapping
|
||||
|
||||
|
|
@ -282,12 +305,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
automaticActivityDetection=transformed_audio_activity_config
|
||||
)
|
||||
elif key == "voice":
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
||||
vertex_gemini_config = VertexGeminiConfig()
|
||||
speech_config = vertex_gemini_config._map_audio_params({"voice": value})
|
||||
speech_config = _gemini_live_speech_config(value)
|
||||
if speech_config:
|
||||
optional_params["generationConfig"]["speechConfig"] = speech_config
|
||||
if len(optional_params["generationConfig"]) == 0:
|
||||
|
|
@ -365,10 +383,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
entry: Final = GeminiRealtimeConfig._model_cost_entry(model)
|
||||
return bool(entry.get("gemini_native_audio") or entry.get("gemini_audio_only_live"))
|
||||
|
||||
@staticmethod
|
||||
def _is_native_audio_model(model: str) -> bool:
|
||||
return bool(GeminiRealtimeConfig._model_cost_entry(model).get("gemini_native_audio"))
|
||||
|
||||
@staticmethod
|
||||
def _coerce_response_modalities(model: str, modalities: list[Any]) -> list[str]:
|
||||
"""Map unsupported TEXT responseModalities to AUDIO for audio-only Live models."""
|
||||
|
|
@ -384,7 +398,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
|
||||
@staticmethod
|
||||
def _finalize_gemini_live_setup(model: str, setup: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drop fields Gemini Live native-audio rejects on ``setup``."""
|
||||
generation_config: Final = setup.get("generationConfig")
|
||||
if isinstance(generation_config, dict):
|
||||
modalities: Final = generation_config.get("responseModalities")
|
||||
|
|
@ -392,8 +405,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
generation_config["responseModalities"] = GeminiRealtimeConfig._coerce_response_modalities(
|
||||
model, modalities
|
||||
)
|
||||
if GeminiRealtimeConfig._is_native_audio_model(model):
|
||||
generation_config.pop("speechConfig", None)
|
||||
return setup
|
||||
|
||||
def _handle_session_update(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
MiniMax Anthropic transformation config - extends AnthropicConfig for MiniMax's Anthropic-compatible API
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
from typing import Any, Final # noqa: TID251 # override below must mirror the legacy base signature
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
|
||||
|
|
@ -49,6 +49,26 @@ class MinimaxMessagesConfig(AnthropicMessagesConfig):
|
|||
"""
|
||||
return api_base or get_secret_str("MINIMAX_API_BASE") or "https://api.minimax.io/anthropic/v1/messages"
|
||||
|
||||
def validate_anthropic_messages_environment(
|
||||
self,
|
||||
headers: dict, # mutable-ok: mirrors the legacy base override signature
|
||||
model: str,
|
||||
messages: list[Any], # mutable-ok: mirrors the legacy base override signature
|
||||
optional_params: dict, # mutable-ok: mirrors the legacy base override signature
|
||||
litellm_params: dict, # mutable-ok: mirrors the legacy base override signature
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> tuple[dict, str | None]: # mutable-ok: mirrors the legacy base override signature
|
||||
return super().validate_anthropic_messages_environment(
|
||||
headers=headers,
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
api_key=self.get_api_key(api_key=api_key),
|
||||
api_base=api_base,
|
||||
)
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ def cost_per_character(
|
|||
usage: Usage,
|
||||
prompt_characters: float | None = None,
|
||||
completion_characters: float | None = None,
|
||||
service_tier: str | None = None,
|
||||
vertex_location: str | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
|
|
@ -74,6 +75,8 @@ def cost_per_character(
|
|||
- custom_llm_provider: str, "vertex_ai-*"
|
||||
- prompt_characters: float, the number of input characters
|
||||
- completion_characters: float, the number of output characters
|
||||
- service_tier: optional tier derived from Gemini trafficType
|
||||
("priority" for ON_DEMAND_PRIORITY, "flex" for FLEX/batch).
|
||||
- vertex_location: the Vertex AI location serving the request; non-global
|
||||
locations apply the model's regional-endpoint uplift multiplier
|
||||
|
||||
|
|
@ -92,6 +95,7 @@ def cost_per_character(
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
usage=usage,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
@ -123,6 +127,7 @@ def cost_per_character(
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
usage=usage,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
|
||||
## CALCULATE OUTPUT COST
|
||||
|
|
@ -131,6 +136,7 @@ def cost_per_character(
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
usage=usage,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
else:
|
||||
completion_tokens: Final = usage.completion_tokens
|
||||
|
|
@ -162,6 +168,7 @@ def cost_per_character(
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
usage=usage,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
|
||||
vertex_uplift: Final = get_vertex_regional_endpoint_uplift(model_info, vertex_location)
|
||||
|
|
|
|||
56
litellm/llms/vertex_ai/gemini/grounding_requests.py
Normal file
56
litellm/llms/vertex_ai/gemini/grounding_requests.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroundingRequests:
|
||||
web_search_requests: int | None
|
||||
google_maps_grounding_requests: int | None
|
||||
|
||||
def has_billable_grounding(self) -> bool:
|
||||
return bool(self.web_search_requests or self.google_maps_grounding_requests)
|
||||
|
||||
|
||||
def _chunk_kinds(item: Mapping[str, object]) -> frozenset[str]:
|
||||
chunks: Final = item.get("groundingChunks")
|
||||
if not isinstance(chunks, list):
|
||||
return frozenset()
|
||||
return frozenset(kind for chunk in chunks if isinstance(chunk, Mapping) for kind in chunk)
|
||||
|
||||
|
||||
def _queries(item: Mapping[str, object]) -> frozenset[str]:
|
||||
queries: Final = item.get("webSearchQueries")
|
||||
if not isinstance(queries, list):
|
||||
return frozenset()
|
||||
return frozenset(query for query in queries if isinstance(query, str) and query)
|
||||
|
||||
|
||||
def _is_maps_item(item: Mapping[str, object]) -> bool:
|
||||
return "maps" in _chunk_kinds(item) or bool(item.get("googleMapsWidgetContextToken"))
|
||||
|
||||
|
||||
def _attributes_queries_to_maps(item: Mapping[str, object]) -> bool:
|
||||
return _is_maps_item(item) and "web" not in _chunk_kinds(item)
|
||||
|
||||
|
||||
def calculate_grounding_requests(grounding_metadata: Sequence[Mapping[str, object]]) -> GroundingRequests:
|
||||
"""Billable grounding requests across candidates, counting each distinct query once.
|
||||
|
||||
Duplicate queries within and across grounding metadata items collapse to the
|
||||
distinct-query count (#36377), and empty strings are ignored. Maps grounding is
|
||||
floored at one request whenever a candidate carries maps chunks or a widget token,
|
||||
since per-prompt billing charges the prompt even when no query is reported.
|
||||
"""
|
||||
items: Final = tuple(item for item in grounding_metadata if isinstance(item, Mapping))
|
||||
web_queries: Final = frozenset(
|
||||
query for item in items if not _attributes_queries_to_maps(item) for query in _queries(item)
|
||||
)
|
||||
maps_queries: Final = frozenset(
|
||||
query for item in items if _attributes_queries_to_maps(item) for query in _queries(item)
|
||||
)
|
||||
has_maps: Final = any(_is_maps_item(item) for item in items)
|
||||
return GroundingRequests(
|
||||
web_search_requests=len(web_queries) or None,
|
||||
google_maps_grounding_requests=max(len(maps_queries), 1) if has_maps else None,
|
||||
)
|
||||
|
|
@ -89,6 +89,7 @@ from ..common_utils import (
|
|||
supports_response_json_schema,
|
||||
)
|
||||
from ..vertex_llm_base import VertexBase
|
||||
from .grounding_requests import calculate_grounding_requests
|
||||
from .transformation import (
|
||||
_gemini_convert_messages_with_history,
|
||||
async_transform_request_body,
|
||||
|
|
@ -1717,14 +1718,15 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
completion_response: GenerateContentResponseBody | BidiGenerateContentServerMessage,
|
||||
) -> bool:
|
||||
"""
|
||||
Whether the response used Grounding with Google Search, detected via
|
||||
groundingMetadata.webSearchQueries (an actual web search was performed).
|
||||
Whether the response used Grounding with Google Search or Grounding with Google Maps,
|
||||
detected via groundingMetadata.webSearchQueries (an actual web search was performed) or
|
||||
groundingMetadata.groundingChunks[].maps (a Maps lookup was performed).
|
||||
|
||||
Google bills grounding-with-Google-Search retrieved tokens separately (a per-request /
|
||||
per-query search fee) and excludes them from input token billing, unlike URL context /
|
||||
File Search / code execution whose tool-use tokens are charged at the input token rate.
|
||||
URL context also emits groundingMetadata (with groundingChunks but no webSearchQueries),
|
||||
so presence of groundingMetadata alone is not a sufficient signal.
|
||||
Google bills both groundings separately (a per-request / per-query fee) and excludes their
|
||||
retrieved tokens from input token billing, unlike URL context / File Search / code execution
|
||||
whose tool-use tokens are charged at the input token rate. URL context also emits
|
||||
groundingMetadata (with web groundingChunks but no webSearchQueries), so presence of
|
||||
groundingMetadata alone is not a sufficient signal.
|
||||
See https://ai.google.dev/gemini-api/docs/pricing and
|
||||
https://github.com/BerriAI/litellm/discussions/33198
|
||||
"""
|
||||
|
|
@ -1732,7 +1734,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
return False
|
||||
for candidate in completion_response["candidates"] or []:
|
||||
grounding_metadata, _, _, _ = VertexGeminiConfig._extract_candidate_metadata(candidate)
|
||||
if VertexGeminiConfig._calculate_web_search_requests(grounding_metadata):
|
||||
if calculate_grounding_requests(grounding_metadata).has_billable_grounding():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -1979,16 +1981,16 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
|
||||
@staticmethod
|
||||
def _calculate_web_search_requests(grounding_metadata: list[dict]) -> int | None:
|
||||
web_search_requests: int | None = None
|
||||
return calculate_grounding_requests(grounding_metadata).web_search_requests
|
||||
|
||||
if grounding_metadata and isinstance(grounding_metadata, list) and len(grounding_metadata) > 0:
|
||||
for grounding_metadata_item in grounding_metadata:
|
||||
web_search_queries = grounding_metadata_item.get("webSearchQueries")
|
||||
if web_search_queries and web_search_requests:
|
||||
web_search_requests += len([q for q in web_search_queries if q])
|
||||
elif web_search_queries:
|
||||
web_search_requests = len([q for q in web_search_queries if q])
|
||||
return web_search_requests
|
||||
@staticmethod
|
||||
def _set_grounding_usage_counters(usage: Usage, grounding_metadata: Sequence[Mapping[str, object]]) -> None:
|
||||
grounding_requests: Final = calculate_grounding_requests(grounding_metadata)
|
||||
details: Final = cast(PromptTokensDetailsWrapper, usage.prompt_tokens_details)
|
||||
if grounding_requests.web_search_requests is not None:
|
||||
details.web_search_requests = grounding_requests.web_search_requests
|
||||
if grounding_requests.google_maps_grounding_requests is not None:
|
||||
details.google_maps_grounding_requests = grounding_requests.google_maps_grounding_requests
|
||||
|
||||
@staticmethod
|
||||
def _create_streaming_choice(
|
||||
|
|
@ -2454,9 +2456,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
|
||||
usage: Final = VertexGeminiConfig._calculate_usage(completion_response=completion_response)
|
||||
|
||||
web_search_requests: Final = VertexGeminiConfig._calculate_web_search_requests(grounding_metadata)
|
||||
if web_search_requests is not None:
|
||||
cast(PromptTokensDetailsWrapper, usage.prompt_tokens_details).web_search_requests = web_search_requests
|
||||
VertexGeminiConfig._set_grounding_usage_counters(usage, grounding_metadata)
|
||||
|
||||
setattr(model_response, "usage", usage)
|
||||
|
||||
|
|
@ -3221,9 +3221,7 @@ class ModelResponseIterator:
|
|||
completion_response=processed_chunk,
|
||||
)
|
||||
|
||||
web_search_requests: Final = VertexGeminiConfig._calculate_web_search_requests(grounding_metadata)
|
||||
if web_search_requests is not None:
|
||||
cast(PromptTokensDetailsWrapper, usage.prompt_tokens_details).web_search_requests = web_search_requests
|
||||
VertexGeminiConfig._set_grounding_usage_counters(usage, grounding_metadata)
|
||||
|
||||
traffic_type: Final = processed_chunk.get("usageMetadata", {}).get("trafficType")
|
||||
if traffic_type:
|
||||
|
|
|
|||
|
|
@ -8013,7 +8013,7 @@ def speech(
|
|||
|
||||
if max_retries is None:
|
||||
max_retries = litellm.num_retries or openai.DEFAULT_MAX_RETRIES
|
||||
litellm_params_dict: Final = get_litellm_params(**kwargs)
|
||||
litellm_params_dict: Final = get_litellm_params(metadata=metadata, api_key=api_key or dynamic_api_key, **kwargs)
|
||||
|
||||
# Get provider-specific text-to-speech config and map parameters
|
||||
text_to_speech_provider_config = ProviderConfigManager.get_provider_text_to_speech_config(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,8 +8,8 @@ omits each feature's routes until the feature is warmed.
|
|||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
|
|
@ -397,11 +397,27 @@ def _make_warmup_router(app: "FastAPI") -> "APIRouter":
|
|||
return router
|
||||
|
||||
|
||||
def inject_lazy_stubs(schema: dict) -> dict:
|
||||
"""Inject openapi entries for unloaded features. Uses the snapshot file
|
||||
when available (full route info), otherwise falls back to a single
|
||||
placeholder per feature. Any failure logs and returns the schema unchanged
|
||||
so /openapi.json never 500s on a cosmetic injection bug."""
|
||||
def loaded_lazy_modules(app: "FastAPI") -> frozenset[str]:
|
||||
"""The set of lazy feature modules whose routers are actually registered
|
||||
on this app (tracked by _force_load), empty before the middleware ever ran.
|
||||
sys.modules is the wrong signal: boot code imports several feature modules
|
||||
(mcp_management, cloudzero, vantage, config_overrides) without mounting
|
||||
their routers, and their stubs must still be injected."""
|
||||
loaded: Final = getattr(app.state, "lazy_loaded", None)
|
||||
if not isinstance(loaded, set):
|
||||
return frozenset()
|
||||
return frozenset(m for m in loaded if isinstance(m, str))
|
||||
|
||||
|
||||
def inject_lazy_stubs(
|
||||
schema: dict,
|
||||
loaded_modules: AbstractSet[str],
|
||||
features: tuple[LazyFeature, ...] = LAZY_FEATURES,
|
||||
) -> dict:
|
||||
"""Inject openapi entries for features not in loaded_modules. Uses the
|
||||
snapshot file when available (full route info), otherwise falls back to a
|
||||
single placeholder per feature. Any failure logs and returns the schema
|
||||
unchanged so /openapi.json never 500s on a cosmetic injection bug."""
|
||||
try:
|
||||
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
|
||||
|
||||
|
|
@ -409,8 +425,8 @@ def inject_lazy_stubs(schema: dict) -> dict:
|
|||
paths: Final = schema.setdefault("paths", {})
|
||||
schemas: Final = schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
|
||||
for feat in LAZY_FEATURES:
|
||||
if feat.module_path in sys.modules and not feat.persistent_swagger_stub:
|
||||
for feat in features:
|
||||
if feat.module_path in loaded_modules and not feat.persistent_swagger_stub:
|
||||
continue
|
||||
|
||||
fragment = (snapshot or {}).get(feat.name)
|
||||
|
|
|
|||
|
|
@ -23538,7 +23538,7 @@
|
|||
"paths": {
|
||||
"/prompts": {
|
||||
"post": {
|
||||
"description": "Create a new prompt\n\n\ud83d\udc49 [Prompt docs](https://docs.litellm.ai/docs/proxy/prompt_management)\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/prompts\" \\\n -H \"Authorization: Bearer <your_api_key>\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"prompt_id\": \"my_prompt\",\n \"litellm_params\": {\n \"prompt_id\": \"json_prompt\",\n \"prompt_integration\": \"dotprompt\",\n ### EITHER prompt_directory OR prompt_data MUST BE PROVIDED\n \"prompt_directory\": \"/path/to/dotprompt/folder\",\n \"prompt_data\": {\"json_prompt\": {\"content\": \"This is a prompt\", \"metadata\": {\"model\": \"gpt-4\"}}}\n },\n \"prompt_info\": {\n \"prompt_type\": \"config\"\n }\n }'\n```",
|
||||
"description": "Create a new prompt\n\n\ud83d\udc49 [Prompt docs](https://docs.litellm.ai/docs/proxy/prompt_management)\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/prompts\" \\\n -H \"Authorization: Bearer <your_api_key>\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"prompt_id\": \"my_prompt\",\n \"litellm_params\": {\n \"prompt_id\": \"my_prompt\",\n \"prompt_integration\": \"dotprompt\",\n \"prompt_data\": {\"content\": \"This is a prompt\", \"metadata\": {\"model\": \"gpt-4\"}}\n },\n \"prompt_info\": {\n \"prompt_type\": \"config\"\n }\n }'\n```",
|
||||
"operationId": "create_prompt_prompts_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from typing_extensions import NotRequired, ReadOnly, Required, TypedDict
|
|||
from litellm._uuid import uuid
|
||||
from litellm.constants import DEFAULT_STAGGER_WINDOW_SECONDS, MCP_STDIO_ALLOWED_COMMANDS
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
validate_langfuse_environment_value,
|
||||
validate_no_callback_env_reference,
|
||||
)
|
||||
from litellm.types.integrations.compression_interception import (
|
||||
|
|
@ -2027,6 +2028,8 @@ class AddTeamCallback(LiteLLMPydanticObjectBase):
|
|||
raise ValueError(f"Invalid callback variable: {key}. Must be one of {valid_keys}")
|
||||
callback_vars[key] = str(value)
|
||||
validate_no_callback_env_reference(key, callback_vars[key], source="key/team callback metadata")
|
||||
if key == "langfuse_environment":
|
||||
validate_langfuse_environment_value(callback_vars[key])
|
||||
return values
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2588,13 +2588,29 @@ async def _delete_cache_key_object(
|
|||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
):
|
||||
"""
|
||||
Evict one key object, best-effort, matching `delete_cache_team_object` and
|
||||
`delete_cache_key_objects`.
|
||||
|
||||
Every caller runs this after its own write has already committed, and the in-memory entry is
|
||||
dropped before the Redis round trip. Letting a cache-backend error raise here therefore reports
|
||||
failure for work that succeeded without making the cache any less stale; the leftover Redis
|
||||
entry expires at its TTL either way.
|
||||
"""
|
||||
key: Final = hashed_token
|
||||
|
||||
user_api_key_cache.delete_cache(key=key)
|
||||
try:
|
||||
user_api_key_cache.delete_cache(key=key)
|
||||
|
||||
## UPDATE REDIS CACHE ##
|
||||
if proxy_logging_obj is not None:
|
||||
await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key)
|
||||
## UPDATE REDIS CACHE ##
|
||||
if proxy_logging_obj is not None:
|
||||
await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key)
|
||||
except Exception as e: # noqa: BLE001 # best-effort: a cache error must not fail a committed write
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to invalidate cached key entry %s; a stale key object may be served until its TTL expires: %s",
|
||||
key,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
async def delete_cache_key_objects(
|
||||
|
|
|
|||
|
|
@ -14,11 +14,36 @@ _NEWRELIC_VAR_PREFIX: Final = "newrelic_"
|
|||
|
||||
|
||||
def callback_config_error(callback_name: str | None, callback_vars: Mapping[str, str] | None) -> str | None:
|
||||
if callback_name != _NEWRELIC_CALLBACK or not callback_vars:
|
||||
if not callback_vars:
|
||||
return None
|
||||
env_error: Final = _langfuse_environment_error(callback_vars)
|
||||
if env_error is not None:
|
||||
return env_error
|
||||
if callback_name != _NEWRELIC_CALLBACK:
|
||||
return None
|
||||
return _newrelic_config_error(callback_vars)
|
||||
|
||||
|
||||
def _langfuse_environment_error(callback_vars: Mapping[str, str]) -> str | None:
|
||||
"""Reject langfuse_environment values Langfuse ingestion would drop.
|
||||
|
||||
Accepting an invalid value here would 200 the config write and then
|
||||
silently lose every trace for that key/team at request time.
|
||||
"""
|
||||
value: Final = callback_vars.get("langfuse_environment")
|
||||
if value is None:
|
||||
return None
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
validate_langfuse_environment_value,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_langfuse_environment_value(value)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
return None
|
||||
|
||||
|
||||
def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str | None:
|
||||
"""Validate every ``logging`` entry of a team/key metadata payload."""
|
||||
if not metadata:
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@ import random
|
|||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import litellm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
logger: Final = logging.getLogger(__name__)
|
||||
from litellm.constants import (
|
||||
BACKGROUND_HEALTH_CHECK_MAX_TOKENS,
|
||||
|
|
@ -18,16 +22,29 @@ from litellm.constants import (
|
|||
DEFAULT_HEALTH_CHECK_PROMPT,
|
||||
HEALTH_CHECK_TIMEOUT_SECONDS,
|
||||
)
|
||||
from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model
|
||||
from litellm.router_utils.auto_router_model_naming import (
|
||||
StrategyRouterDependency,
|
||||
classify_strategy_router_model,
|
||||
strategy_router_dependencies,
|
||||
)
|
||||
|
||||
ILLEGAL_DISPLAY_PARAMS: Final = [
|
||||
"messages",
|
||||
"api_key",
|
||||
"prompt",
|
||||
"input",
|
||||
"client_secret",
|
||||
"azure_ad_token",
|
||||
"azure_username",
|
||||
"azure_password",
|
||||
"vertex_credentials",
|
||||
"vertex_ai_credentials",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
"aws_web_identity_token",
|
||||
"extra_headers",
|
||||
"headers",
|
||||
"exception", # internal; not JSON-serializable, never for display
|
||||
"litellm_metadata", # internal tracking metadata with auth objects; not for display
|
||||
]
|
||||
|
|
@ -151,7 +168,7 @@ def health_check_filter_kwargs_from_general_settings(
|
|||
|
||||
|
||||
def filter_deployments_by_id(
|
||||
model_list: list,
|
||||
model_list: Sequence[Mapping[str, object]],
|
||||
) -> list:
|
||||
seen_ids: Final = set()
|
||||
filtered_deployments: Final = []
|
||||
|
|
@ -183,12 +200,240 @@ async def run_with_timeout(task, timeout):
|
|||
return {"error": "Timeout exceeded", "exception": timeout_exception}
|
||||
|
||||
|
||||
def _skips_health_checks(deployment: Mapping[str, object]) -> bool:
|
||||
info: Final = deployment.get("model_info")
|
||||
return bool(info.get("disable_background_health_check", False)) if isinstance(info, Mapping) else False
|
||||
|
||||
|
||||
def _health_check_eligible(
|
||||
model_list: Sequence[Mapping[str, object]], skip_disabled: bool
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""Deployments this run is allowed to contact.
|
||||
|
||||
The one eligibility gate, applied to the requested set and to the pool a router's
|
||||
dependencies are drawn from alike, so an opted-out deployment cannot re-enter through a
|
||||
router that depends on it.
|
||||
"""
|
||||
return tuple(x for x in model_list if not (skip_disabled and _skips_health_checks(x)))
|
||||
|
||||
|
||||
def _deployment_model(deployment: Mapping[str, object]) -> str | None:
|
||||
params: Final = deployment.get("litellm_params")
|
||||
return params.get("model") if isinstance(params, Mapping) else None
|
||||
|
||||
|
||||
def _narrow_to_target(
|
||||
model_list: Sequence[Mapping[str, object]], model: str | None, model_id: str | None
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""Narrow to the requested deployment. An id matching nothing keeps the whole list."""
|
||||
if model_id is not None:
|
||||
by_id: Final = tuple(x for x in model_list if _deployment_id(x) == model_id)
|
||||
return by_id or tuple(model_list)
|
||||
if model is None:
|
||||
return tuple(model_list)
|
||||
by_param: Final = tuple(x for x in model_list if _deployment_model(x) == model)
|
||||
return by_param or tuple(x for x in model_list if x.get("model_name") == model)
|
||||
|
||||
|
||||
def _is_strategy_router_deployment(litellm_params: Mapping[str, object]) -> bool:
|
||||
"""True for strategy-router deployments."""
|
||||
model: Final[object] = litellm_params.get("model", "")
|
||||
return isinstance(model, str) and classify_strategy_router_model(model) is not None
|
||||
|
||||
|
||||
def _is_marker(deployment: Mapping[str, object]) -> bool:
|
||||
params: Final = deployment.get("litellm_params")
|
||||
return isinstance(params, Mapping) and _is_strategy_router_deployment(params)
|
||||
|
||||
|
||||
def _deployment_id(deployment: Mapping[str, object]) -> str | None:
|
||||
info: Final = deployment.get("model_info")
|
||||
ident: Final = info.get("id") if isinstance(info, Mapping) else None
|
||||
return str(ident) if ident else None
|
||||
|
||||
|
||||
def _resolved_deployment_ids(router: "Router", model_name: str) -> frozenset[str] | None:
|
||||
"""Deployment ids backing `model_name`, or None when the name resolves to nothing.
|
||||
|
||||
`get_model_list` composes every channel the request path itself uses (exact name,
|
||||
model_group_alias, routing groups, wildcards); a mirror of any one channel would call a
|
||||
working tier broken. An alias whose target is gone resolves to nothing, which fails a
|
||||
request exactly like an unknown name.
|
||||
"""
|
||||
resolved: Final = router.get_model_list(model_name=model_name)
|
||||
if not resolved:
|
||||
return None
|
||||
return frozenset(ident for entry in resolved if (ident := _deployment_id(entry)))
|
||||
|
||||
|
||||
def _dependency_failure(
|
||||
dependency: StrategyRouterDependency,
|
||||
router: "Router",
|
||||
unhealthy_ids: frozenset[str],
|
||||
) -> str | None:
|
||||
"""Why this dependency makes its router unable to serve, or None when it does not.
|
||||
|
||||
A name reds its router only when *every* deployment behind it is known unhealthy. One
|
||||
replica this run never judged, hidden from the caller or opted out of health checks, can
|
||||
still serve what the dead one drops, so partial evidence leaves the verdict green.
|
||||
"""
|
||||
resolved: Final = _resolved_deployment_ids(router, dependency.model_name)
|
||||
if resolved is None:
|
||||
return f"{dependency.role} model '{dependency.model_name}' matches no deployment on this proxy"
|
||||
if not resolved or not resolved <= unhealthy_ids:
|
||||
return None
|
||||
return f"{dependency.role} model '{dependency.model_name}' has no healthy deployment"
|
||||
|
||||
|
||||
def _strategy_router_dependency_error(
|
||||
deployment: Mapping[str, object],
|
||||
router: "Router",
|
||||
unhealthy_ids: frozenset[str],
|
||||
) -> str | None:
|
||||
"""The first dependency fault that makes this router unable to serve, if any."""
|
||||
params: Final = deployment.get("litellm_params")
|
||||
if not isinstance(params, Mapping):
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
failure
|
||||
for dependency in strategy_router_dependencies(params)
|
||||
if (failure := _dependency_failure(dependency, router, unhealthy_ids))
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _deployments_by_id(
|
||||
universe: Sequence[Mapping[str, object]], ids: frozenset[str]
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""The deployments for `ids`, one row per id.
|
||||
|
||||
Reuses the requested set's own dedupe rule, so an alias that duplicates a row cannot get
|
||||
it probed twice or split a single id's verdict across two disagreeing results.
|
||||
"""
|
||||
matched: Final = tuple(d for d in universe if (uid := _deployment_id(d)) and uid in ids)
|
||||
return tuple(filter_deployments_by_id(model_list=matched))
|
||||
|
||||
|
||||
def _dependency_deployments_to_probe(
|
||||
checked: Sequence[Mapping[str, object]],
|
||||
universe: Sequence[Mapping[str, object]],
|
||||
router: "Router",
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""Deployments backing the checked routers' dependencies that are not already checked.
|
||||
|
||||
Empty on a full-list run, which therefore gains no probe; it is the targeted
|
||||
`/health?model_id=<router>` call the dashboard makes per deployment that needs them,
|
||||
since a router's verdict is a statement about models the request never named. Drawn from
|
||||
`universe`, the caller's access-filtered list, so no deployment is probed that the caller
|
||||
was not already granted. Expansion follows routers through routers, one hop per round,
|
||||
because a child router's own models must be probed for the parent to fail; stopping when
|
||||
a round adds nothing is what makes a router cycle terminate.
|
||||
"""
|
||||
checked_ids: Final = frozenset(cid for d in checked if (cid := _deployment_id(d)))
|
||||
reached = checked_ids # rebind-ok: the sweep's cursor, one hop wider per round
|
||||
frontier = tuple(checked) # rebind-ok: the routers whose dependencies the next round expands
|
||||
for _ in range(len(universe)):
|
||||
names = frozenset(
|
||||
dependency.model_name
|
||||
for deployment in frontier
|
||||
if isinstance(params := deployment.get("litellm_params"), Mapping)
|
||||
for dependency in strategy_router_dependencies(params)
|
||||
)
|
||||
fresh_ids = (
|
||||
frozenset(ident for name in names for ident in (_resolved_deployment_ids(router, name) or ())) - reached
|
||||
)
|
||||
if not fresh_ids:
|
||||
break
|
||||
frontier = _deployments_by_id(universe, fresh_ids)
|
||||
reached = reached | fresh_ids
|
||||
return _deployments_by_id(universe, reached - checked_ids)
|
||||
|
||||
|
||||
def _strategy_router_verdicts(
|
||||
healthy_endpoints: Sequence[Mapping[str, object]],
|
||||
unhealthy_endpoints: Sequence[Mapping[str, object]],
|
||||
checked: Sequence[Mapping[str, object]],
|
||||
router: "Router",
|
||||
) -> Mapping[str, str]:
|
||||
"""The dependency fault, per model id, for every strategy router that cannot serve.
|
||||
|
||||
A marker is filed healthy by `_run_model_health_check` returning `{}`, which says only
|
||||
that nothing was probed. This is where that placeholder becomes a verdict, derived from
|
||||
this run's own results rather than a re-probe or a cache that is empty unless
|
||||
`enable_health_check_routing` is on. A marker never fails a probe of its own, so verdicts
|
||||
settle over rounds, each feeding the last round's reds back in as unhealthy; without that
|
||||
the parent of a red child would stay green. Bounded by the marker count, which is what
|
||||
makes a router cycle terminate green rather than spin.
|
||||
"""
|
||||
by_id: Final = MappingProxyType({i: d for d in checked if (i := _deployment_id(d))})
|
||||
markers: Final = MappingProxyType(
|
||||
{
|
||||
marker_id: by_id[marker_id]
|
||||
for endpoint in healthy_endpoints
|
||||
if isinstance(marker_id := endpoint.get("model_id"), str) and marker_id in by_id
|
||||
if _is_marker(by_id[marker_id])
|
||||
}
|
||||
)
|
||||
probe_failures: Final = frozenset(
|
||||
ident for endpoint in unhealthy_endpoints if isinstance(ident := endpoint.get("model_id"), str)
|
||||
)
|
||||
settled: Mapping[str, str] = MappingProxyType({}) # rebind-ok: the fixed point, a round's verdicts at a time
|
||||
for _ in range(len(markers)):
|
||||
fresh = MappingProxyType(
|
||||
{
|
||||
marker_id: error
|
||||
for marker_id, deployment in markers.items()
|
||||
if marker_id not in settled
|
||||
if (error := _strategy_router_dependency_error(deployment, router, probe_failures | frozenset(settled)))
|
||||
}
|
||||
)
|
||||
if not fresh:
|
||||
break
|
||||
settled = MappingProxyType({**settled, **fresh})
|
||||
return settled
|
||||
|
||||
|
||||
def _finalize_strategy_router_endpoints(
|
||||
healthy_endpoints: Sequence[Mapping[str, object]],
|
||||
unhealthy_endpoints: Sequence[Mapping[str, object]],
|
||||
checked: Sequence[Mapping[str, object]],
|
||||
router: "Router | None",
|
||||
dependency_probes: Sequence[Mapping[str, object]],
|
||||
) -> tuple[Sequence[Mapping[str, object]], Sequence[Mapping[str, object]]]:
|
||||
"""Apply router verdicts, then drop the deployments probed only to reach them.
|
||||
|
||||
The probes exist to judge the routers that depend on them; reporting them would answer a
|
||||
targeted request with deployments the caller never asked about.
|
||||
"""
|
||||
verdicts: Final = (
|
||||
_strategy_router_verdicts(healthy_endpoints, unhealthy_endpoints, checked, router)
|
||||
if router is not None
|
||||
else MappingProxyType({})
|
||||
)
|
||||
dropped: Final = frozenset(i for d in dependency_probes if (i := _deployment_id(d)))
|
||||
|
||||
def keep(endpoint: Mapping[str, object]) -> bool:
|
||||
model_id: Final = endpoint.get("model_id")
|
||||
return not (isinstance(model_id, str) and model_id in dropped)
|
||||
|
||||
def verdict_for(endpoint: Mapping[str, object]) -> str | None:
|
||||
model_id: Final = endpoint.get("model_id")
|
||||
return verdicts.get(model_id) if isinstance(model_id, str) else None
|
||||
|
||||
kept_healthy: Final = tuple(e for e in healthy_endpoints if keep(e))
|
||||
return (
|
||||
tuple(e for e in kept_healthy if verdict_for(e) is None),
|
||||
tuple(e for e in unhealthy_endpoints if keep(e))
|
||||
+ tuple(
|
||||
dict(e, error=error) # mutable-ok: the /health payload must stay a plain JSON-serializable dict
|
||||
for e in kept_healthy
|
||||
if (error := verdict_for(e)) is not None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _run_model_health_check(model: dict):
|
||||
litellm_params = model["litellm_params"]
|
||||
model_info: Final = model.get("model_info", {})
|
||||
|
|
@ -531,6 +776,7 @@ async def perform_health_check(
|
|||
max_concurrency: int | None = None,
|
||||
instrumentation_context: dict | None = None,
|
||||
health_check_skip_disabled_background_models: bool = False,
|
||||
router: "Router | None" = None,
|
||||
):
|
||||
"""
|
||||
Perform a health check on the system.
|
||||
|
|
@ -567,23 +813,9 @@ async def perform_health_check(
|
|||
|
||||
cycle_start_time: Final = time.monotonic()
|
||||
requested_model_count: Final = len(model_list)
|
||||
|
||||
# Filter by model_id first so a single deployment is checked when id is specified
|
||||
if model_id is not None:
|
||||
_by_id: Final = [x for x in model_list if (x.get("model_info") or {}).get("id") == model_id]
|
||||
if _by_id:
|
||||
model_list = _by_id
|
||||
elif model is not None:
|
||||
_new_model_list = [x for x in model_list if x["litellm_params"]["model"] == model]
|
||||
if _new_model_list == []:
|
||||
_new_model_list = [x for x in model_list if x["model_name"] == model]
|
||||
model_list = _new_model_list
|
||||
|
||||
if health_check_skip_disabled_background_models:
|
||||
model_list = [
|
||||
x for x in model_list if not (x.get("model_info") or {}).get("disable_background_health_check", False)
|
||||
]
|
||||
if not model_list:
|
||||
skip_disabled: Final = health_check_skip_disabled_background_models
|
||||
narrowed: Final = _health_check_eligible(_narrow_to_target(model_list, model, model_id), skip_disabled)
|
||||
if not narrowed:
|
||||
if instrumentation_enabled:
|
||||
logger.debug(
|
||||
"health_check_cycle_skipped source=%s cycle_id=%s reason=no_models_after_filter",
|
||||
|
|
@ -592,11 +824,16 @@ async def perform_health_check(
|
|||
)
|
||||
return [], [], {}
|
||||
|
||||
post_filter_model_count: Final = len(model_list)
|
||||
model_list = filter_deployments_by_id(
|
||||
model_list=model_list
|
||||
) # filter duplicate deployments (e.g. when model alias'es are used)
|
||||
deduped_model_count: Final = len(model_list)
|
||||
post_filter_model_count: Final = len(narrowed)
|
||||
requested: Final = filter_deployments_by_id(model_list=narrowed)
|
||||
deduped_model_count: Final = len(requested)
|
||||
|
||||
dependency_probes: Final = (
|
||||
_dependency_deployments_to_probe(requested, _health_check_eligible(model_list, skip_disabled), router)
|
||||
if router is not None
|
||||
else ()
|
||||
)
|
||||
checked: Final = requested + list(dependency_probes) # mutable-ok: _perform_health_check takes a list
|
||||
|
||||
if instrumentation_enabled:
|
||||
logger.debug(
|
||||
|
|
@ -613,15 +850,20 @@ async def perform_health_check(
|
|||
|
||||
try:
|
||||
(
|
||||
healthy_endpoints,
|
||||
unhealthy_endpoints,
|
||||
probed_healthy,
|
||||
probed_unhealthy,
|
||||
exceptions_by_model_id,
|
||||
) = await _perform_health_check(
|
||||
model_list,
|
||||
checked,
|
||||
details,
|
||||
max_concurrency=max_concurrency,
|
||||
instrumentation_context=instrumentation_context,
|
||||
)
|
||||
graded_healthy, graded_unhealthy = _finalize_strategy_router_endpoints(
|
||||
probed_healthy, probed_unhealthy, checked, router, dependency_probes
|
||||
)
|
||||
healthy_endpoints: Final = list(graded_healthy)
|
||||
unhealthy_endpoints: Final = list(graded_unhealthy)
|
||||
except Exception:
|
||||
if instrumentation_enabled:
|
||||
logger.exception(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Final
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.redis_cache import RedisCache
|
||||
|
|
@ -12,6 +12,9 @@ from litellm.constants import (
|
|||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.proxy.health_check import perform_health_check
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
|
||||
class SharedHealthCheckManager:
|
||||
"""
|
||||
|
|
@ -185,6 +188,7 @@ class SharedHealthCheckManager:
|
|||
details: bool = True,
|
||||
max_concurrency: int | None = None,
|
||||
health_check_skip_disabled_background_models: bool = False,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
|
||||
"""
|
||||
Perform health check with shared state coordination.
|
||||
|
|
@ -235,6 +239,7 @@ class SharedHealthCheckManager:
|
|||
details=details,
|
||||
max_concurrency=max_concurrency,
|
||||
health_check_skip_disabled_background_models=health_check_skip_disabled_background_models,
|
||||
router=router,
|
||||
)
|
||||
|
||||
# Cache the results
|
||||
|
|
@ -254,6 +259,7 @@ class SharedHealthCheckManager:
|
|||
details=details,
|
||||
max_concurrency=max_concurrency,
|
||||
health_check_skip_disabled_background_models=health_check_skip_disabled_background_models,
|
||||
router=router,
|
||||
)
|
||||
|
||||
# Lock not acquired — poll for cached results until the lock
|
||||
|
|
@ -309,6 +315,7 @@ class SharedHealthCheckManager:
|
|||
details=details,
|
||||
max_concurrency=max_concurrency,
|
||||
health_check_skip_disabled_background_models=health_check_skip_disabled_background_models,
|
||||
router=router,
|
||||
)
|
||||
|
||||
async def is_health_check_in_progress(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -1113,6 +1113,7 @@ async def health_endpoint(
|
|||
user_id=user_api_key_dict.user_id,
|
||||
model_id=model_id,
|
||||
max_concurrency=health_check_concurrency,
|
||||
router=llm_router,
|
||||
**_hc_filter,
|
||||
)
|
||||
return _post_process(router_result)
|
||||
|
|
|
|||
|
|
@ -252,6 +252,38 @@ def _raise_on_strategy_router_write_violation(
|
|||
)
|
||||
|
||||
|
||||
ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING: Final = "enforce_rpm_tpm_on_model_add"
|
||||
_REQUIRED_RATE_LIMIT_FIELDS: Final = ("rpm", "tpm")
|
||||
|
||||
|
||||
def _raise_if_rate_limits_required_but_missing(*, litellm_params: GenericLiteLLMParams, enforced: bool) -> None:
|
||||
"""Require both rpm and tpm (each a positive value) when the operator opts in via config.yaml.
|
||||
|
||||
Off by default, so deployments keep adding models without limits. When
|
||||
``enforce_rpm_tpm_on_model_add: true`` is set under general_settings, a model added
|
||||
without both rpm and tpm set to a positive value is rejected rather than stored
|
||||
unbounded (or effectively excluded from routing by a zero/negative limit).
|
||||
"""
|
||||
if not enforced:
|
||||
return
|
||||
missing: Final = tuple(
|
||||
field
|
||||
for field in _REQUIRED_RATE_LIMIT_FIELDS
|
||||
if (value := getattr(litellm_params, field)) is None or value <= 0
|
||||
)
|
||||
if not missing:
|
||||
return
|
||||
raise ProxyException(
|
||||
message=(
|
||||
f"{' and '.join(missing)} must be set to a positive value when "
|
||||
f"'{ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING}' is enabled in general_settings"
|
||||
),
|
||||
type=ProxyErrorTypes.validation_error.value,
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
param=f"litellm_params.{missing[0]}",
|
||||
)
|
||||
|
||||
|
||||
_PTU_PRICED_PAIR: Final = frozenset({"ptu_count", "cost_per_ptu_per_hour"})
|
||||
|
||||
|
||||
|
|
@ -326,9 +358,10 @@ def _validate_ptu_model_info(model_info: Mapping[str, object]) -> None:
|
|||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
|
||||
# The mirrored per-token pricing fields plus the three remaining fields
|
||||
# Router._inherit_builtin_cache_pricing back-fills from the public cost map. An unset field is
|
||||
# what that back-fill targets, so a field left out here is one a PTU deployment still bills.
|
||||
# The mirrored per-token pricing fields plus the remaining rates the public cost map or a
|
||||
# provider default would otherwise supply (the cache back-fills, the Maps grounding rate). An
|
||||
# unset field falls back to those sources, so a field left out here is one a PTU deployment
|
||||
# still bills.
|
||||
# tiered_pricing is the one mirrored field that is a table of ranges, not a rate, so it is stored
|
||||
# empty (see _PTU_EMPTIED_PRICING_FIELDS): its tiers outrank the zeros written beside them, so
|
||||
# dropping it would leave the cost map's tiers billing the traffic the reserved capacity covers.
|
||||
|
|
@ -1748,6 +1781,11 @@ async def add_new_model(
|
|||
existing_params=None,
|
||||
)
|
||||
|
||||
_raise_if_rate_limits_required_but_missing(
|
||||
litellm_params=model_params.litellm_params,
|
||||
enforced=bool(general_settings.get(ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING, False)),
|
||||
)
|
||||
|
||||
model_response: prisma_models.LiteLLM_ProxyModelTable | LiteLLM_ProxyModelTable | None = None
|
||||
# update DB
|
||||
incoming_model_info: Final = model_params.model_info.model_dump(exclude_none=True)
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ async def add_team_callbacks(
|
|||
- langfuse_secret_key: The secret key for the Langfuse callback
|
||||
- langfuse_secret: The secret for the Langfuse callback
|
||||
- langfuse_host: The host for the Langfuse callback
|
||||
- langfuse_environment: The tracing environment for the Langfuse callback (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)
|
||||
- gcs_bucket_name: The name of the GCS bucket
|
||||
- gcs_path_service_account: The path to the GCS service account
|
||||
- langsmith_api_key: The API key for the Langsmith callback
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import json
|
|||
import math
|
||||
import traceback
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast
|
||||
|
|
@ -494,8 +495,13 @@ class TeamMemberBudgetHandler:
|
|||
team_member_rpm_limit: int | None = None,
|
||||
team_member_tpm_limit: int | None = None,
|
||||
team_member_budget_duration: str | None = None,
|
||||
explicitly_set_fields: AbstractSet[str] = frozenset(),
|
||||
) -> dict:
|
||||
"""Create team member budget table with provided limits"""
|
||||
"""Create team member budget table with provided limits.
|
||||
|
||||
The team's own reset period is only inherited when the caller left the
|
||||
member duration out, so an explicit null means "never resets".
|
||||
"""
|
||||
from litellm.proxy._types import BudgetNewRequest
|
||||
from litellm.proxy.management_endpoints.budget_management_endpoints import (
|
||||
new_budget,
|
||||
|
|
@ -509,7 +515,11 @@ class TeamMemberBudgetHandler:
|
|||
# Create budget request with all provided limits
|
||||
budget_request: Final = BudgetNewRequest(
|
||||
budget_id=budget_id,
|
||||
budget_duration=data.budget_duration or team_member_budget_duration,
|
||||
budget_duration=(
|
||||
team_member_budget_duration
|
||||
if "team_member_budget_duration" in explicitly_set_fields
|
||||
else data.budget_duration or team_member_budget_duration
|
||||
),
|
||||
)
|
||||
|
||||
if team_member_budget is not None:
|
||||
|
|
@ -545,8 +555,13 @@ class TeamMemberBudgetHandler:
|
|||
team_member_rpm_limit: int | None = None,
|
||||
team_member_tpm_limit: int | None = None,
|
||||
team_member_budget_duration: str | None = None,
|
||||
explicitly_set_fields: AbstractSet[str] = frozenset(),
|
||||
) -> dict:
|
||||
"""Upsert team member budget table with provided limits"""
|
||||
"""Upsert team member budget table with provided limits.
|
||||
|
||||
A field the caller explicitly sent as null is written as null, so a
|
||||
team can keep a member budget while dropping its reset period.
|
||||
"""
|
||||
from litellm.proxy._types import BudgetNewRequest
|
||||
from litellm.proxy.management_endpoints.budget_management_endpoints import (
|
||||
update_budget,
|
||||
|
|
@ -560,14 +575,16 @@ class TeamMemberBudgetHandler:
|
|||
# Budget exists - create update request with only provided values
|
||||
budget_request: Final = BudgetNewRequest(budget_id=team_member_budget_id)
|
||||
|
||||
if team_member_budget is not None:
|
||||
if team_member_budget is not None or "team_member_budget" in explicitly_set_fields:
|
||||
budget_request.max_budget = team_member_budget
|
||||
if team_member_rpm_limit is not None:
|
||||
if team_member_rpm_limit is not None or "team_member_rpm_limit" in explicitly_set_fields:
|
||||
budget_request.rpm_limit = team_member_rpm_limit
|
||||
if team_member_tpm_limit is not None:
|
||||
if team_member_tpm_limit is not None or "team_member_tpm_limit" in explicitly_set_fields:
|
||||
budget_request.tpm_limit = team_member_tpm_limit
|
||||
if team_member_budget_duration is not None:
|
||||
if team_member_budget_duration is not None or "team_member_budget_duration" in explicitly_set_fields:
|
||||
budget_request.budget_duration = team_member_budget_duration
|
||||
if team_member_budget_duration is None:
|
||||
budget_request.budget_reset_at = None
|
||||
|
||||
budget_row: Final = await _as_budget_write(update_budget)(
|
||||
budget_obj=budget_request,
|
||||
|
|
@ -593,6 +610,7 @@ class TeamMemberBudgetHandler:
|
|||
team_member_rpm_limit=team_member_rpm_limit,
|
||||
team_member_tpm_limit=team_member_tpm_limit,
|
||||
team_member_budget_duration=team_member_budget_duration,
|
||||
explicitly_set_fields=explicitly_set_fields,
|
||||
)
|
||||
|
||||
# Remove team member fields from updated_kv
|
||||
|
|
@ -1479,6 +1497,7 @@ async def new_team(
|
|||
team_member_rpm_limit=data.team_member_rpm_limit,
|
||||
team_member_tpm_limit=data.team_member_tpm_limit,
|
||||
team_member_budget_duration=data.team_member_budget_duration,
|
||||
explicitly_set_fields=data.model_fields_set,
|
||||
)
|
||||
|
||||
## ADD TO TEAM TABLE
|
||||
|
|
@ -2184,6 +2203,7 @@ async def update_team(
|
|||
team_member_rpm_limit=data.team_member_rpm_limit,
|
||||
team_member_tpm_limit=data.team_member_tpm_limit,
|
||||
team_member_budget_duration=data.team_member_budget_duration,
|
||||
explicitly_set_fields=_team_member_fields_in_request,
|
||||
)
|
||||
# Backfill team_memberships for members who joined before the
|
||||
# budget was configured — they won't have a membership row yet.
|
||||
|
|
|
|||
|
|
@ -117,8 +117,10 @@ class AnthropicPassthroughLoggingHandler:
|
|||
@staticmethod
|
||||
def _cost_relevant_speed(request_body: Mapping[str, object] | None) -> str | None:
|
||||
"""
|
||||
Anthropic's ``speed=fast`` multiplies non-cache token cost, and only the request
|
||||
carries it, so it has to reach the usage-building paths for spend to be right.
|
||||
Anthropic's ``speed=fast`` multiplies token cost. The response usage carries the
|
||||
served ``speed`` when the request asked for one, and ``calculate_usage`` prefers
|
||||
that served value; this request-side value is the fallback when the response
|
||||
omits it, so it still has to reach the usage-building paths.
|
||||
"""
|
||||
speed: Final = (request_body or {}).get("speed")
|
||||
return speed if isinstance(speed, str) else None
|
||||
|
|
@ -702,6 +704,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
web_search_requests: int | None = None
|
||||
tool_search_requests: int | None = None
|
||||
inference_geo: str | None = None
|
||||
speed_from_stream: str | None = None
|
||||
stop_reason: str | None = None
|
||||
found_usage = False
|
||||
resolved_model = model
|
||||
|
|
@ -725,6 +728,8 @@ class AnthropicPassthroughLoggingHandler:
|
|||
cache_creation_1h = _cc.get("ephemeral_1h_input_tokens")
|
||||
if usage.get("inference_geo") is not None:
|
||||
inference_geo = usage.get("inference_geo")
|
||||
if isinstance(usage.get("speed"), str):
|
||||
speed_from_stream = usage.get("speed")
|
||||
if usage.get("output_tokens") is not None:
|
||||
output_tokens = usage.get("output_tokens")
|
||||
found_usage = True
|
||||
|
|
@ -745,6 +750,8 @@ class AnthropicPassthroughLoggingHandler:
|
|||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if usage.get("inference_geo") is not None:
|
||||
inference_geo = usage.get("inference_geo")
|
||||
if isinstance(usage.get("speed"), str):
|
||||
speed_from_stream = usage.get("speed")
|
||||
found_usage = True
|
||||
if not found_usage:
|
||||
return None
|
||||
|
|
@ -776,6 +783,8 @@ class AnthropicPassthroughLoggingHandler:
|
|||
usage_object["server_tool_use"] = _server_tool_use
|
||||
if inference_geo is not None:
|
||||
usage_object["inference_geo"] = inference_geo
|
||||
if speed_from_stream is not None:
|
||||
usage_object["speed"] = speed_from_stream
|
||||
usage_obj: Final = AnthropicConfig().calculate_usage(
|
||||
usage_object=usage_object, reasoning_content=None, speed=speed
|
||||
)
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ def create_versioned_prompt_spec(db_prompt: _PromptRow) -> PromptSpec:
|
|||
prompt_info=prompt_info,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
version=row.version,
|
||||
environment=row.environment,
|
||||
created_by=row.created_by,
|
||||
)
|
||||
|
|
@ -334,6 +335,21 @@ class Prompt(BaseModel):
|
|||
prompt_info: PromptInfo | None = None
|
||||
|
||||
|
||||
AMBIGUOUS_PROMPT_DATA_ERROR: Final = (
|
||||
"litellm_params.prompt_id cannot be combined with prompt_data keyed by template name. "
|
||||
'Send a flat template, prompt_data={"content": "...", "metadata": {...}}, together with litellm_params.prompt_id, '
|
||||
'or send prompt_data={"<template_id>": {"content": "...", "metadata": {...}}} without litellm_params.prompt_id.'
|
||||
)
|
||||
|
||||
|
||||
def is_ambiguous_keyed_prompt_data(litellm_params: PromptLiteLLMParams) -> bool:
|
||||
extra_fields: Final = litellm_params.model_extra or {}
|
||||
prompt_data: Final = extra_fields.get("prompt_data")
|
||||
if not litellm_params.prompt_id or not isinstance(prompt_data, dict):
|
||||
return False
|
||||
return bool(prompt_data) and "content" not in prompt_data
|
||||
|
||||
|
||||
class PatchPromptRequest(BaseModel):
|
||||
litellm_params: PromptLiteLLMParams | None = None
|
||||
prompt_info: PromptInfo | None = None
|
||||
|
|
@ -737,11 +753,9 @@ async def create_prompt(
|
|||
-d '{
|
||||
"prompt_id": "my_prompt",
|
||||
"litellm_params": {
|
||||
"prompt_id": "json_prompt",
|
||||
"prompt_id": "my_prompt",
|
||||
"prompt_integration": "dotprompt",
|
||||
### EITHER prompt_directory OR prompt_data MUST BE PROVIDED
|
||||
"prompt_directory": "/path/to/dotprompt/folder",
|
||||
"prompt_data": {"json_prompt": {"content": "This is a prompt", "metadata": {"model": "gpt-4"}}}
|
||||
"prompt_data": {"content": "This is a prompt", "metadata": {"model": "gpt-4"}}
|
||||
},
|
||||
"prompt_info": {
|
||||
"prompt_type": "config"
|
||||
|
|
@ -763,6 +777,9 @@ async def create_prompt(
|
|||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
|
||||
if is_ambiguous_keyed_prompt_data(request.litellm_params):
|
||||
raise HTTPException(status_code=400, detail=AMBIGUOUS_PROMPT_DATA_ERROR)
|
||||
|
||||
try:
|
||||
# Extract environment from request
|
||||
environment: Final = (
|
||||
|
|
@ -857,6 +874,9 @@ async def update_prompt(
|
|||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
|
||||
if is_ambiguous_keyed_prompt_data(request.litellm_params):
|
||||
raise HTTPException(status_code=400, detail=AMBIGUOUS_PROMPT_DATA_ERROR)
|
||||
|
||||
try:
|
||||
# Strip version suffix from prompt_id if present (e.g., "jack_success.v1" -> "jack_success")
|
||||
base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id)
|
||||
|
|
@ -1086,6 +1106,9 @@ async def patch_prompt(
|
|||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
|
||||
if request.litellm_params is not None and is_ambiguous_keyed_prompt_data(request.litellm_params):
|
||||
raise HTTPException(status_code=400, detail=AMBIGUOUS_PROMPT_DATA_ERROR)
|
||||
|
||||
try:
|
||||
# Resolve the target row: find the latest version in the given environment
|
||||
base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id)
|
||||
|
|
|
|||
|
|
@ -147,6 +147,9 @@ class InMemoryPromptRegistry:
|
|||
prompt_info=prompt.prompt_info or PromptInfo(prompt_type="config"),
|
||||
created_at=prompt.created_at,
|
||||
updated_at=prompt.updated_at,
|
||||
version=prompt.version,
|
||||
environment=prompt.environment,
|
||||
created_by=prompt.created_by,
|
||||
)
|
||||
|
||||
# store references to the prompt in memory
|
||||
|
|
|
|||
|
|
@ -1502,9 +1502,9 @@ def get_openapi_schema():
|
|||
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
|
||||
|
||||
# Stub unloaded lazy features so they appear as Swagger sections.
|
||||
from litellm.proxy._lazy_features import inject_lazy_stubs
|
||||
from litellm.proxy._lazy_features import inject_lazy_stubs, loaded_lazy_modules
|
||||
|
||||
openapi_schema = inject_lazy_stubs(openapi_schema)
|
||||
openapi_schema = inject_lazy_stubs(openapi_schema, loaded_lazy_modules(app))
|
||||
openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema)
|
||||
|
||||
# Fix Swagger UI execute path error when server_root_path is set
|
||||
|
|
@ -1534,9 +1534,9 @@ def custom_openapi():
|
|||
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
|
||||
|
||||
# Stub unloaded lazy features so they appear as Swagger sections.
|
||||
from litellm.proxy._lazy_features import inject_lazy_stubs
|
||||
from litellm.proxy._lazy_features import inject_lazy_stubs, loaded_lazy_modules
|
||||
|
||||
openapi_schema = inject_lazy_stubs(openapi_schema)
|
||||
openapi_schema = inject_lazy_stubs(openapi_schema, loaded_lazy_modules(app))
|
||||
openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema)
|
||||
|
||||
# Fix Swagger UI execute path error when server_root_path is set
|
||||
|
|
@ -3393,9 +3393,7 @@ def _rss_mb_for_log() -> str:
|
|||
return f"{rss_mb:.2f}"
|
||||
|
||||
|
||||
def _is_unexpected_keyword_argument_type_error(exc: BaseException) -> bool:
|
||||
"""True when ``exc`` is a TypeError from passing a kwarg the callee does not accept."""
|
||||
return isinstance(exc, TypeError) and ("unexpected keyword argument" in str(exc).lower())
|
||||
_UNEXPECTED_KWARG: Final = re.compile(r"unexpected keyword argument '(?P<name>[^']+)'")
|
||||
|
||||
|
||||
async def _run_direct_health_check_with_instrumentation(
|
||||
|
|
@ -3404,31 +3402,33 @@ async def _run_direct_health_check_with_instrumentation(
|
|||
max_concurrency: int | None,
|
||||
instrumentation_context: dict,
|
||||
):
|
||||
"""Call ``perform_health_check``, retrying with fewer kwargs on unexpected-kw TypeErrors."""
|
||||
_hc_filter: Final = health_check_filter_kwargs_from_general_settings(general_settings)
|
||||
last_type_error: TypeError | None = None
|
||||
for extra_kwargs in (
|
||||
"""Call ``perform_health_check``, dropping exactly the optional kwarg each TypeError names.
|
||||
|
||||
A callee that predates an argument rejects it by name, so only that one is dropped. A
|
||||
hand-written ladder of combinations would drop working options alongside it, and would
|
||||
need a new rung every time an argument is added.
|
||||
"""
|
||||
optional: Mapping[str, object] = MappingProxyType( # rebind-ok: loses the kwarg the callee rejected
|
||||
{
|
||||
"router": llm_router,
|
||||
"instrumentation_context": instrumentation_context,
|
||||
**_hc_filter,
|
||||
},
|
||||
{"instrumentation_context": instrumentation_context},
|
||||
dict(_hc_filter),
|
||||
{},
|
||||
):
|
||||
**health_check_filter_kwargs_from_general_settings(general_settings),
|
||||
}
|
||||
)
|
||||
for _ in range(len(optional) + 1):
|
||||
try:
|
||||
return await perform_health_check(
|
||||
model_list=model_list,
|
||||
details=details,
|
||||
max_concurrency=max_concurrency,
|
||||
**extra_kwargs,
|
||||
**optional,
|
||||
)
|
||||
except TypeError as e:
|
||||
if not _is_unexpected_keyword_argument_type_error(e):
|
||||
rejected = _UNEXPECTED_KWARG.search(str(e))
|
||||
if rejected is None or rejected["name"] not in optional:
|
||||
raise
|
||||
last_type_error = e
|
||||
assert last_type_error is not None
|
||||
raise last_type_error
|
||||
optional = MappingProxyType({k: v for k, v in optional.items() if k != rejected["name"]})
|
||||
raise AssertionError("perform_health_check rejected every optional argument")
|
||||
|
||||
|
||||
def _schedule_background_health_check_db_save(
|
||||
|
|
@ -3683,6 +3683,7 @@ async def _run_background_health_check():
|
|||
model_list=_llm_model_list,
|
||||
details=details_bool,
|
||||
max_concurrency=health_check_concurrency,
|
||||
router=llm_router,
|
||||
**_hc_filter,
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -6268,7 +6269,14 @@ class ProxyConfig:
|
|||
):
|
||||
from litellm.utils import _update_dictionary
|
||||
|
||||
combined_router_settings = _update_dictionary(config_router_settings, db_router_settings.param_value)
|
||||
db_overlay_deferring_empty_lists_to_config: Final = {
|
||||
k: v
|
||||
for k, v in db_router_settings.param_value.items()
|
||||
if not (k in config_router_settings and isinstance(v, list) and len(v) == 0)
|
||||
}
|
||||
combined_router_settings = _update_dictionary(
|
||||
config_router_settings, db_overlay_deferring_empty_lists_to_config
|
||||
)
|
||||
elif config_router_settings is not None and isinstance(config_router_settings, dict):
|
||||
combined_router_settings = config_router_settings
|
||||
elif db_router_settings is not None and isinstance(db_router_settings.param_value, dict):
|
||||
|
|
|
|||
|
|
@ -557,6 +557,34 @@ async def _arealtime(
|
|||
raise ValueError(f"Unsupported model: {model}")
|
||||
|
||||
|
||||
def _is_transcription_only_realtime_model(model: str, custom_llm_provider: str) -> bool:
|
||||
try:
|
||||
model_info: Final = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
|
||||
except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models
|
||||
return False
|
||||
if model_info.get("mode") == "audio_transcription":
|
||||
return True
|
||||
return "/v1/realtime/transcription_sessions" in (model_info.get("supported_endpoints") or ())
|
||||
|
||||
|
||||
_TRANSCRIPTION_QUERY_PARAMS: Final[RealtimeQueryParams] = {"intent": "transcription"}
|
||||
|
||||
|
||||
def _azure_realtime_health_protocol(
|
||||
model: str, realtime_protocol: str | None, model_params: Mapping[str, Any]
|
||||
) -> tuple[str, RealtimeQueryParams | None]:
|
||||
query_params: Final = _TRANSCRIPTION_QUERY_PARAMS if _is_transcription_only_realtime_model(model, "azure") else None
|
||||
configured_raw: Final = (
|
||||
realtime_protocol or model_params.get("realtime_protocol") or os.environ.get("LITELLM_AZURE_REALTIME_PROTOCOL")
|
||||
)
|
||||
configured: Final = configured_raw if isinstance(configured_raw, str) else None
|
||||
if configured is not None:
|
||||
return configured, query_params
|
||||
if query_params is not None:
|
||||
return "GA", query_params
|
||||
return "beta", None
|
||||
|
||||
|
||||
def _realtime_health_check_auth_headers(
|
||||
custom_llm_provider: str, api_key: str | None, model_params: Mapping[str, Any]
|
||||
) -> Mapping[str, str | None]:
|
||||
|
|
@ -586,7 +614,9 @@ async def _realtime_health_check(
|
|||
api_version: Optional[str] - api version
|
||||
api_key: str - api key
|
||||
custom_llm_provider: str - custom llm provider
|
||||
realtime_protocol: Optional[str] - protocol version ("GA"/"v1" for GA path, "beta"/None for beta path)
|
||||
realtime_protocol: Optional[str] - protocol version ("GA"/"v1" for GA path, "beta" for beta path);
|
||||
None resolves it for Azure from model_params/env, with transcription-only models probing GA
|
||||
plus intent=transcription the way real calls do
|
||||
|
||||
Returns:
|
||||
bool - True if connection is successful, False otherwise
|
||||
|
|
@ -602,11 +632,17 @@ async def _realtime_health_check(
|
|||
model_params=model_params or _EMPTY_MODEL_PARAMS,
|
||||
)
|
||||
if custom_llm_provider == "azure":
|
||||
resolved_protocol, azure_query_params = _azure_realtime_health_protocol(
|
||||
model=model,
|
||||
realtime_protocol=realtime_protocol,
|
||||
model_params=model_params or _EMPTY_MODEL_PARAMS,
|
||||
)
|
||||
url = azure_realtime._construct_url(
|
||||
api_base=api_base or "",
|
||||
model=model,
|
||||
api_version=api_version or "2024-10-01-preview",
|
||||
realtime_protocol=realtime_protocol,
|
||||
realtime_protocol=resolved_protocol,
|
||||
query_params=azure_query_params,
|
||||
)
|
||||
elif custom_llm_provider == "openai":
|
||||
url = openai_realtime._construct_url(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import contextvars
|
||||
from collections.abc import Coroutine, Iterable, Mapping
|
||||
from collections.abc import Coroutine, Generator, Iterable, Mapping
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ from litellm.completion_extras.litellm_responses_transformation.transformation i
|
|||
LiteLLMResponsesTransformationHandler,
|
||||
)
|
||||
from litellm.constants import request_timeout
|
||||
from litellm.integrations.anthropic_cache_control_hook import CARRY_UNMATCHED_MESSAGE_POINTS
|
||||
from litellm.litellm_core_utils.asyncify import run_async_function
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
|
|
@ -390,6 +392,60 @@ async def aresponses_api_with_mcp(
|
|||
return response
|
||||
|
||||
|
||||
def _bridges_to_chat_completions(
|
||||
responses_api_provider_config: BaseResponsesAPIConfig | None, use_chat_completions_api: bool
|
||||
) -> bool:
|
||||
"""Whether the request reaches its provider as a chat completion, not a Responses call."""
|
||||
return responses_api_provider_config is None or use_chat_completions_api is True
|
||||
|
||||
|
||||
def _will_bridge_to_chat_completions(
|
||||
model: str, custom_llm_provider: str | None, use_chat_completions_api: bool
|
||||
) -> bool:
|
||||
"""``_bridges_to_chat_completions`` for callers running before the provider config is resolved.
|
||||
|
||||
Resolving the config is a pure lookup, so this asks the same question the dispatch
|
||||
asks rather than restating its condition. Both callers resolve the provider before
|
||||
this runs, so the only way to be wrong is a prompt manager that moves the model
|
||||
across the bridge boundary, which would leave the deferred points to a pass that
|
||||
never comes.
|
||||
"""
|
||||
normalized_model: Final = _normalize_openai_chat_completions_responses_model(model)
|
||||
if custom_llm_provider is None:
|
||||
return True
|
||||
return _bridges_to_chat_completions(
|
||||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=normalized_model[0], provider=custom_llm_provider
|
||||
),
|
||||
use_chat_completions_api or normalized_model[1],
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _prompt_management_sees_a_provisional_message_list(
|
||||
kwargs: dict[str, Any], # mutable-ok: the signal is read and popped out of the caller's own kwargs
|
||||
bridged: bool,
|
||||
) -> Generator[None, None]:
|
||||
"""Tell the cache-control hook that this layer's messages are not the ones sent upstream.
|
||||
|
||||
A Responses request keeps its system prompt in ``instructions``, which only becomes a
|
||||
system message when the chat-completion bridge builds one, so a role-targeted point
|
||||
is placed by the bridge's pass rather than this one.
|
||||
|
||||
Only raised for a request that will be bridged. A provider serving Responses natively
|
||||
gets no second pass, so this layer is the last one that can place anything and handing
|
||||
a point forward there drops it.
|
||||
"""
|
||||
if not bridged:
|
||||
yield
|
||||
return
|
||||
kwargs[CARRY_UNMATCHED_MESSAGE_POINTS] = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
kwargs.pop(CARRY_UNMATCHED_MESSAGE_POINTS, None)
|
||||
|
||||
|
||||
@client
|
||||
async def aresponses(
|
||||
input: str | ResponseInputParam,
|
||||
|
|
@ -467,19 +523,25 @@ async def aresponses(
|
|||
client_input: list[AllMessageValues] = [{"role": "user", "content": input}]
|
||||
else:
|
||||
client_input = [item for item in input if isinstance(item, dict) and "role" in item]
|
||||
(
|
||||
model,
|
||||
merged_input,
|
||||
merged_optional_params,
|
||||
) = await litellm_logging_obj.async_get_chat_completion_prompt(
|
||||
model=model,
|
||||
messages=client_input,
|
||||
non_default_params=kwargs,
|
||||
prompt_id=prompt_id,
|
||||
prompt_variables=prompt_variables,
|
||||
prompt_label=kwargs.get("prompt_label", None),
|
||||
prompt_version=kwargs.get("prompt_version", None),
|
||||
)
|
||||
with _prompt_management_sees_a_provisional_message_list(
|
||||
kwargs,
|
||||
bridged=_will_bridge_to_chat_completions(
|
||||
model, custom_llm_provider, bool(kwargs.get("use_chat_completions_api"))
|
||||
),
|
||||
):
|
||||
(
|
||||
model,
|
||||
merged_input,
|
||||
merged_optional_params,
|
||||
) = await litellm_logging_obj.async_get_chat_completion_prompt(
|
||||
model=model,
|
||||
messages=client_input,
|
||||
non_default_params=kwargs,
|
||||
prompt_id=prompt_id,
|
||||
prompt_variables=prompt_variables,
|
||||
prompt_label=kwargs.get("prompt_label", None),
|
||||
prompt_version=kwargs.get("prompt_version", None),
|
||||
)
|
||||
input = cast(
|
||||
str | ResponseInputParam,
|
||||
ResponsesAPIRequestUtils.merge_prompt_management_input(
|
||||
|
|
@ -566,6 +628,7 @@ def _apply_prompt_management_to_responses_call(
|
|||
litellm_logging_obj: LiteLLMLoggingObj | None,
|
||||
kwargs: dict[str, Any],
|
||||
local_vars: dict[str, object],
|
||||
use_chat_completions_api: bool,
|
||||
) -> tuple[str | ResponseInputParam, str, str | None]:
|
||||
async_merged: Final[Mapping[str, object] | None] = kwargs.pop("_async_prompt_merged_params", None)
|
||||
if async_merged is not None:
|
||||
|
|
@ -585,19 +648,23 @@ def _apply_prompt_management_to_responses_call(
|
|||
if isinstance(litellm_logging_obj, LiteLLMLoggingObj) and litellm_logging_obj.should_run_prompt_management_hooks(
|
||||
prompt_id=prompt_id, non_default_params=kwargs
|
||||
):
|
||||
(
|
||||
model,
|
||||
merged_input,
|
||||
merged_optional_params,
|
||||
) = litellm_logging_obj.get_chat_completion_prompt(
|
||||
model=model,
|
||||
messages=client_input,
|
||||
non_default_params=kwargs,
|
||||
prompt_id=prompt_id,
|
||||
prompt_variables=prompt_variables,
|
||||
prompt_label=kwargs.get("prompt_label", None),
|
||||
prompt_version=kwargs.get("prompt_version", None),
|
||||
)
|
||||
with _prompt_management_sees_a_provisional_message_list(
|
||||
kwargs,
|
||||
bridged=_will_bridge_to_chat_completions(model, custom_llm_provider, use_chat_completions_api),
|
||||
):
|
||||
(
|
||||
model,
|
||||
merged_input,
|
||||
merged_optional_params,
|
||||
) = litellm_logging_obj.get_chat_completion_prompt(
|
||||
model=model,
|
||||
messages=client_input,
|
||||
non_default_params=kwargs,
|
||||
prompt_id=prompt_id,
|
||||
prompt_variables=prompt_variables,
|
||||
prompt_label=kwargs.get("prompt_label", None),
|
||||
prompt_version=kwargs.get("prompt_version", None),
|
||||
)
|
||||
input = cast(
|
||||
str | ResponseInputParam,
|
||||
ResponsesAPIRequestUtils.merge_prompt_management_input(
|
||||
|
|
@ -961,6 +1028,7 @@ def responses(
|
|||
litellm_logging_obj=litellm_logging_obj,
|
||||
kwargs=kwargs,
|
||||
local_vars=local_vars,
|
||||
use_chat_completions_api=use_chat_completions_api,
|
||||
)
|
||||
|
||||
#########################################################
|
||||
|
|
@ -1063,7 +1131,7 @@ def responses(
|
|||
if _file_search_dispatch is not None:
|
||||
return _file_search_dispatch
|
||||
|
||||
if responses_api_provider_config is None or use_chat_completions_api is True:
|
||||
if _bridges_to_chat_completions(responses_api_provider_config, use_chat_completions_api):
|
||||
return litellm_completion_transformation_handler.response_api_handler(
|
||||
model=model,
|
||||
input=input,
|
||||
|
|
|
|||
|
|
@ -573,13 +573,16 @@ class BaseResponsesAPIStreamingIterator:
|
|||
):
|
||||
return
|
||||
|
||||
if litellm.cache is None:
|
||||
cache: Final = litellm.cache
|
||||
if cache is None:
|
||||
return
|
||||
|
||||
cached_response: Final = response_obj.model_dump_json()
|
||||
if is_async:
|
||||
cache_write_task: Final = asyncio.create_task(
|
||||
litellm.cache.async_add_cache(
|
||||
from litellm.caching.caching_handler import create_cache_write_task
|
||||
|
||||
cache_write_task: Final = create_cache_write_task(
|
||||
lambda: cache.async_add_cache(
|
||||
cached_response,
|
||||
dynamic_cache_object=getattr(caching_handler, "dual_cache", None),
|
||||
**request_kwargs,
|
||||
|
|
@ -592,7 +595,7 @@ class BaseResponsesAPIStreamingIterator:
|
|||
)
|
||||
)
|
||||
else:
|
||||
litellm.cache.add_cache(
|
||||
cache.add_cache(
|
||||
cached_response,
|
||||
dynamic_cache_object=getattr(caching_handler, "dual_cache", None),
|
||||
**request_kwargs,
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ from litellm.types.router import (
|
|||
PreRoutingStrategy,
|
||||
RetryPolicy,
|
||||
RouterCacheEnum,
|
||||
RouterErrors,
|
||||
RouterGeneralSettings,
|
||||
RouterModelGroupAliasItem,
|
||||
RouterRateLimitError,
|
||||
|
|
@ -11520,10 +11521,8 @@ class Router:
|
|||
|
||||
# If still no deployments after checking for fallbacks, raise an error
|
||||
if len(healthy_deployments) == 0:
|
||||
message: Final = f"You passed in model={model}. There are no healthy deployments for this model"
|
||||
|
||||
raise litellm.BadRequestError(
|
||||
message=message,
|
||||
message=f"You passed in model={model}. {RouterErrors.no_healthy_deployments.value}",
|
||||
model=model,
|
||||
llm_provider="",
|
||||
)
|
||||
|
|
@ -11534,11 +11533,18 @@ class Router:
|
|||
] # update the model to the actual value if an alias has been passed in
|
||||
|
||||
marker_flags: Final = tuple(self._is_strategy_marker_deployment(d) for d in healthy_deployments)
|
||||
if all(marker_flags) or not any(marker_flags):
|
||||
if not any(marker_flags):
|
||||
return model, healthy_deployments
|
||||
return model, [ # mutable-ok: matches this function's list contract expected by downstream filters
|
||||
selectable: Final = [ # mutable-ok: matches this function's list contract expected by downstream filters
|
||||
d for d, is_marker in zip(healthy_deployments, marker_flags, strict=True) if not is_marker
|
||||
]
|
||||
if not selectable:
|
||||
raise litellm.BadRequestError(
|
||||
message=f"You passed in model={model}. {RouterErrors.only_strategy_marker_deployments.value}",
|
||||
model=model,
|
||||
llm_provider="",
|
||||
)
|
||||
return model, selectable
|
||||
|
||||
def _filter_deployments_by_model_access_groups(
|
||||
self,
|
||||
|
|
@ -12127,7 +12133,15 @@ class Router:
|
|||
This hook is called before the routing decision is made.
|
||||
|
||||
Used for the litellm auto-router to modify the request before the routing decision is made.
|
||||
|
||||
`model` is whatever the caller asked for, which may be a `model_group_alias` key, while the
|
||||
strategy registries and the marker deployment are keyed by the marker's own `model_name`, so
|
||||
every lookup below resolves the alias first. Only the lookups: the caller-facing name stays
|
||||
the alias, since spend metadata is stamped before routing and the response carries the tier
|
||||
group the strategy picked.
|
||||
"""
|
||||
registered_model_name: Final = self._get_model_from_alias(model=model) or model
|
||||
|
||||
#########################################################
|
||||
# Run the routing-plugin pipeline, if any plugins are configured.
|
||||
# Plugins narrow the candidate deployment pool (consumed later by
|
||||
|
|
@ -12135,9 +12149,13 @@ class Router:
|
|||
# downstream strategies (auto-router, complexity-router, ...) to read.
|
||||
#########################################################
|
||||
if self.routing_plugins:
|
||||
await self._run_routing_plugins(model=model, request_kwargs=request_kwargs, messages=messages)
|
||||
await self._run_routing_plugins(
|
||||
model=registered_model_name, request_kwargs=request_kwargs, messages=messages
|
||||
)
|
||||
|
||||
selected_strategy: Final = self._select_pre_routing_strategy(model=model, request_kwargs=request_kwargs)
|
||||
selected_strategy: Final = self._select_pre_routing_strategy(
|
||||
model=registered_model_name, request_kwargs=request_kwargs
|
||||
)
|
||||
if selected_strategy is None:
|
||||
self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
|
||||
self._stamp_or_clear_metadata_key(
|
||||
|
|
@ -12152,7 +12170,7 @@ class Router:
|
|||
return None
|
||||
|
||||
pre_routing_hook_response: Final = await selected_strategy.strategy.async_pre_routing_hook(
|
||||
model=model,
|
||||
model=registered_model_name,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
|
|
@ -12204,7 +12222,7 @@ class Router:
|
|||
# Per-tier `litellm_params` on the hook response are deliberate overrides
|
||||
# the caller applies on top, so those keys are never forwarded here.
|
||||
marker_params: Final = (
|
||||
self._forwardable_alias_marker_params(model=model, strategy_tags=selected_strategy.tags)
|
||||
self._forwardable_alias_marker_params(model=registered_model_name, strategy_tags=selected_strategy.tags)
|
||||
if pre_routing_hook_response is not None
|
||||
else ()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,13 +10,26 @@ the router silently dropping the deployment at load time under
|
|||
``ignore_invalid_deployments``.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final, Literal
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
AUTO_ROUTER_MODEL_PREFIX: Final = "auto_router/"
|
||||
|
||||
StrategyRouterKind = Literal["semantic", "complexity", "adaptive", "quality"]
|
||||
|
||||
StrategyRouterDependencyRole: TypeAlias = Literal["tier", "default", "classifier", "embedding"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StrategyRouterDependency:
|
||||
"""A model name a strategy router must be able to reach to do its job."""
|
||||
|
||||
model_name: str
|
||||
role: StrategyRouterDependencyRole
|
||||
|
||||
|
||||
STRATEGY_ROUTER_PARAM_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"auto_router_config",
|
||||
|
|
@ -63,6 +76,84 @@ def classify_strategy_router_model(model: str) -> StrategyRouterKind | None:
|
|||
return "semantic"
|
||||
|
||||
|
||||
def _named(value: object, role: StrategyRouterDependencyRole) -> tuple[StrategyRouterDependency, ...]:
|
||||
"""One dependency from a scalar field, or none when it is absent or not a name."""
|
||||
return (StrategyRouterDependency(value, role),) if isinstance(value, str) and value else ()
|
||||
|
||||
|
||||
def _pool(value: object, role: StrategyRouterDependencyRole) -> tuple[StrategyRouterDependency, ...]:
|
||||
"""Dependencies from a field holding either a single name or a pool of them."""
|
||||
if isinstance(value, str):
|
||||
return _named(value, role)
|
||||
if isinstance(value, Sequence):
|
||||
return tuple(dep for entry in value for dep in _named(entry, role))
|
||||
return ()
|
||||
|
||||
|
||||
_NO_CONFIG: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
|
||||
def _mapping(value: object) -> Mapping[str, object]:
|
||||
return value if isinstance(value, Mapping) else _NO_CONFIG
|
||||
|
||||
|
||||
def strategy_router_dependencies(
|
||||
litellm_params: Mapping[str, object],
|
||||
) -> tuple[StrategyRouterDependency, ...]:
|
||||
"""The model names a strategy-router deployment must reach, in no particular order.
|
||||
|
||||
A field is a dependency only under the condition the runtime itself reads it: the
|
||||
classifier model needs `classifier_type: llm`, and the complexity embedding model needs
|
||||
`semantic_keyword_matching`. Listing one the router never calls reds a working deployment.
|
||||
|
||||
The two default-model spellings are not symmetric. A quality router falls back to its
|
||||
config's `default_model`, so both are read. A complexity router ignores that field and
|
||||
derives its default from the tiers instead (`fallback_tier`, then MEDIUM, then SIMPLE),
|
||||
overwriting the config value at init, so only the `litellm_params` spelling is a
|
||||
dependency here; the derived one is already covered as a tier.
|
||||
|
||||
Returns empty for a regular deployment, and for any name this module cannot reach from
|
||||
the deployment dict alone: a semantic router's routes live in an `auto_router_config`
|
||||
JSON string or an `auto_router_config_path` file, so only its default and embedding
|
||||
models are enumerable here. Every field is read defensively, since a caller may hold a
|
||||
config the router itself would refuse, and a health check must not raise on one.
|
||||
"""
|
||||
kind: Final = classify_strategy_router_model(str(litellm_params.get("model", "")))
|
||||
if kind is None:
|
||||
return ()
|
||||
if kind == "semantic":
|
||||
return _named(litellm_params.get("auto_router_default_model"), "default") + _named(
|
||||
litellm_params.get("auto_router_embedding_model"), "embedding"
|
||||
)
|
||||
if kind == "adaptive":
|
||||
return _pool(_mapping(litellm_params.get("adaptive_router_config")).get("available_models"), "tier")
|
||||
if kind == "quality":
|
||||
quality: Final = _mapping(litellm_params.get("quality_router_config"))
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
_pool(quality.get("available_models"), "tier")
|
||||
+ _named(
|
||||
litellm_params.get("quality_router_default_model") or quality.get("default_model"),
|
||||
"default",
|
||||
)
|
||||
)
|
||||
)
|
||||
complexity: Final = _mapping(litellm_params.get("complexity_router_config"))
|
||||
classifier: Final = _mapping(complexity.get("classifier_llm_config"))
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
tuple(dep for tier in _mapping(complexity.get("tiers")).values() for dep in _pool(tier, "tier"))
|
||||
+ _named(litellm_params.get("complexity_router_default_model"), "default")
|
||||
+ (_named(classifier.get("model"), "classifier") if complexity.get("classifier_type") == "llm" else ())
|
||||
+ (
|
||||
_named(complexity.get("embedding_model"), "embedding")
|
||||
if complexity.get("semantic_keyword_matching")
|
||||
else ()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_complexity_router_config_write(complexity_router_config: Mapping[str, object] | None) -> str | None:
|
||||
"""Reject a complexity config the router would refuse to build a deployment from.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from typing_extensions import TypedDict
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
|
||||
class LangfuseLoggingConfig(TypedDict):
|
||||
langfuse_secret: str | None
|
||||
langfuse_public_key: str | None
|
||||
langfuse_host: str | None
|
||||
langfuse_environment: ReadOnly[str | None]
|
||||
|
||||
|
||||
class LangfuseUsageDetails(TypedDict):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing_extensions import TypedDict
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from ..utils import CompletionTokensDetails, PromptTokensDetailsWrapper, ServerToolUse
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ class UsagePerChunk(TypedDict):
|
|||
cache_read_input_tokens: int | None
|
||||
server_tool_use: ServerToolUse | None
|
||||
web_search_requests: int | None
|
||||
google_maps_grounding_requests: ReadOnly[int | None]
|
||||
completion_tokens_details: CompletionTokensDetails | None
|
||||
prompt_tokens_details: PromptTokensDetailsWrapper | None
|
||||
cost: float | None
|
||||
|
|
|
|||
|
|
@ -221,14 +221,22 @@ class ConverseResponseOutputBlock(TypedDict):
|
|||
message: MessageBlock | None
|
||||
|
||||
|
||||
class ConverseTokenUsageBlock(TypedDict):
|
||||
inputTokens: int
|
||||
outputTokens: int
|
||||
totalTokens: int
|
||||
cacheReadInputTokenCount: int
|
||||
cacheReadInputTokens: int
|
||||
cacheWriteInputTokenCount: int
|
||||
cacheWriteInputTokens: int
|
||||
class CacheDetailBlock(TypedDict):
|
||||
"""Per-TTL cache-write breakdown, read-only AWS response data. https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CacheDetail.html"""
|
||||
|
||||
inputTokens: ReadOnly[int]
|
||||
ttl: ReadOnly[Literal["5m", "1h"]]
|
||||
|
||||
|
||||
class ConverseTokenUsageBlock(TypedDict, total=False):
|
||||
inputTokens: Required[ReadOnly[int]]
|
||||
outputTokens: Required[ReadOnly[int]]
|
||||
totalTokens: Required[ReadOnly[int]]
|
||||
cacheReadInputTokenCount: ReadOnly[int]
|
||||
cacheReadInputTokens: ReadOnly[int]
|
||||
cacheWriteInputTokenCount: ReadOnly[int]
|
||||
cacheWriteInputTokens: ReadOnly[int]
|
||||
cacheDetails: ReadOnly[list[CacheDetailBlock]] # mutable-ok: AWS response array, never mutated after parsing
|
||||
|
||||
|
||||
class ServiceTierBlock(TypedDict):
|
||||
|
|
|
|||
|
|
@ -107,7 +107,17 @@ EmbeddingInput = str | list[str]
|
|||
|
||||
|
||||
class HttpxBinaryResponseContent(_HttpxBinaryResponseContent):
|
||||
_hidden_params: dict = {}
|
||||
_hidden_params: dict
|
||||
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
super().__init__(response)
|
||||
self._hidden_params = {} # mutable-ok: mutable-dict contract shared with ModelResponse logging consumers
|
||||
|
||||
def set_response_cost(self, response_cost: float | None) -> None:
|
||||
if response_cost is None:
|
||||
self._hidden_params.pop("response_cost", None)
|
||||
return
|
||||
self._hidden_params["response_cost"] = response_cost
|
||||
|
||||
|
||||
class NotGiven:
|
||||
|
|
|
|||
|
|
@ -576,6 +576,11 @@ class RouterErrors(enum.Enum):
|
|||
no_deployments_available = "No deployments available for selected model"
|
||||
no_deployments_with_tag_routing = "Not allowed to access model due to tags configuration"
|
||||
no_deployments_with_provider_budget_routing = "No deployments available - crossed budget"
|
||||
no_healthy_deployments = "There are no healthy deployments for this model"
|
||||
only_strategy_marker_deployments = (
|
||||
"Every deployment for it is a strategy router marker (auto_router/...), which is not a callable "
|
||||
"model, and no pre-routing strategy selected a deployment for this request"
|
||||
)
|
||||
|
||||
|
||||
class AllowedFailsPolicy(BaseModel):
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
web_search_billing_unit: (
|
||||
Literal["per_query", "per_prompt"] | None
|
||||
) # "per_query" (Gemini 3.x) or "per_prompt" (Gemini 2.x)
|
||||
google_maps_grounding_cost_per_query: ReadOnly[float | None]
|
||||
citation_cost_per_token: float | None # Cost per citation token for Perplexity
|
||||
tiered_pricing: list[dict[str, Any]] | None # Tiered pricing structure for models like Dashscope
|
||||
litellm_provider: Required[str]
|
||||
|
|
@ -1613,6 +1614,9 @@ class PromptTokensDetailsWrapper(
|
|||
web_search_requests: int | None = None
|
||||
"""Number of web search requests made by the tool call. Used for Anthropic to calculate web search cost."""
|
||||
|
||||
google_maps_grounding_requests: int | None = None
|
||||
"""Number of Grounding with Google Maps requests made by the tool call. Used for Gemini to calculate Maps cost."""
|
||||
|
||||
tool_use_tokens: int | None = None
|
||||
"""Prompt tokens consumed by server-side tool use (e.g. Gemini grounding via googleSearch)."""
|
||||
|
||||
|
|
@ -1671,6 +1675,8 @@ class PromptTokensDetailsWrapper(
|
|||
del self.audio_length_seconds
|
||||
if self.web_search_requests is None:
|
||||
del self.web_search_requests
|
||||
if self.google_maps_grounding_requests is None:
|
||||
del self.google_maps_grounding_requests
|
||||
if self.tool_use_tokens is None:
|
||||
del self.tool_use_tokens
|
||||
if self.cache_write_tokens is None:
|
||||
|
|
@ -3272,6 +3278,7 @@ class StandardCallbackDynamicParams(TypedDict, total=False):
|
|||
langfuse_secret: str | None
|
||||
langfuse_secret_key: str | None
|
||||
langfuse_host: str | None
|
||||
langfuse_environment: ReadOnly[str | None]
|
||||
|
||||
# Langfuse prompt version
|
||||
langfuse_prompt_version: int | None
|
||||
|
|
@ -3405,6 +3412,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
output_cost_per_video_per_second: float | None = None
|
||||
output_cost_per_audio_per_second: float | None = None
|
||||
search_context_cost_per_query: dict[str, Any] | None = None
|
||||
google_maps_grounding_cost_per_query: float | None = None
|
||||
citation_cost_per_token: float | None = None
|
||||
cache_read_input_token_cost_above_272k_tokens: float | None = None
|
||||
cache_read_input_token_cost_above_512k_tokens: float | None = None
|
||||
|
|
|
|||
|
|
@ -5845,6 +5845,7 @@ def _get_model_info_helper(
|
|||
tiered_pricing=_model_info.get("tiered_pricing", None),
|
||||
litellm_provider=_model_info.get("litellm_provider", custom_llm_provider),
|
||||
mode=_model_info.get("mode"),
|
||||
supported_endpoints=_model_info.get("supported_endpoints", None),
|
||||
supports_system_messages=_model_info.get("supports_system_messages", None),
|
||||
supports_response_schema=_model_info.get("supports_response_schema", None),
|
||||
supports_vision=_model_info.get("supports_vision", None),
|
||||
|
|
@ -5877,6 +5878,7 @@ def _get_model_info_helper(
|
|||
supports_computer_use=_model_info.get("supports_computer_use", None),
|
||||
search_context_cost_per_query=_model_info.get("search_context_cost_per_query", None),
|
||||
web_search_billing_unit=_model_info.get("web_search_billing_unit", None),
|
||||
google_maps_grounding_cost_per_query=_model_info.get("google_maps_grounding_cost_per_query", None),
|
||||
tpm=_model_info.get("tpm", None),
|
||||
rpm=_model_info.get("rpm", None),
|
||||
ocr_cost_per_page=_model_info.get("ocr_cost_per_page", None),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -104,6 +104,11 @@
|
|||
"minimum": 0,
|
||||
"description": "Flex service-tier rate for the same-named base field."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Priority service-tier rate for the same-named base field."
|
||||
},
|
||||
"cache_creation_input_token_cost_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -186,6 +191,11 @@
|
|||
"gemini_native_audio": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"google_maps_grounding_cost_per_query": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "USD per Grounding with Google Maps request; billed per query or per prompt per web_search_billing_unit."
|
||||
},
|
||||
"guardrail_cost_per_unit": {
|
||||
"type": "object",
|
||||
"description": "USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).",
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
"limit": 3
|
||||
},
|
||||
"BLE001": {
|
||||
"limit": 2918
|
||||
"limit": 2917
|
||||
},
|
||||
"C401": {
|
||||
"limit": 8
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
"limit": 3
|
||||
},
|
||||
"F401": {
|
||||
"limit": 14
|
||||
"limit": 13
|
||||
},
|
||||
"LOG015": {
|
||||
"limit": 5
|
||||
|
|
@ -171,7 +171,7 @@
|
|||
"limit": 175
|
||||
},
|
||||
"RUF012": {
|
||||
"limit": 240
|
||||
"limit": 239
|
||||
},
|
||||
"RUF015": {
|
||||
"limit": 8
|
||||
|
|
@ -183,13 +183,13 @@
|
|||
"limit": 4
|
||||
},
|
||||
"RUF059": {
|
||||
"limit": 67
|
||||
"limit": 66
|
||||
},
|
||||
"RUF100": {
|
||||
"limit": 0
|
||||
},
|
||||
"S110": {
|
||||
"limit": 218
|
||||
"limit": 217
|
||||
},
|
||||
"S112": {
|
||||
"limit": 22
|
||||
|
|
|
|||
|
|
@ -55,6 +55,40 @@
|
|||
# never fires and a subprocess still reads the real keys the test believes it
|
||||
# cleared. The manual restore underneath is skipped whenever the body raises,
|
||||
# so every later test in that worker inherits a plain dict for an environment
|
||||
# PGH005 an assertion on a mock attribute the library never defines. `assert
|
||||
# m.called_once` and a bare `m.assert_called_once` both read as checks and
|
||||
# neither is one: a Mock invents whatever attribute it is asked for, so the
|
||||
# first is always truthy and the second is an attribute nobody calls
|
||||
# F631 `assert (cond, "message")` asserts a two-element tuple, which is always
|
||||
# truthy. The message meant to explain the failure is what stops the assertion
|
||||
# from ever having one
|
||||
# F634 `if (a, b):` branches on a tuple, so the branch is always taken and the
|
||||
# condition it was written to test is never evaluated
|
||||
# PT010 `pytest.raises()` with no exception type accepts anything the block raises,
|
||||
# including the TypeError a refactor introduced
|
||||
# PT030 the `pytest.warns` twin of PT011. `Warning` or `UserWarning` with no `match=`
|
||||
# passes on any warning that broad
|
||||
# PT031 the `pytest.warns` twin of PT012. Everything after the warning call is dead,
|
||||
# so an `assert` sitting there is never checked
|
||||
# B012 a `return`, `break` or `continue` inside `finally` discards whatever exception
|
||||
# was in flight, so the AssertionError the test just raised is thrown away and
|
||||
# the test reports green
|
||||
# B013 a one-element tuple where the exception class was meant, which reads as a
|
||||
# wider handler than it is
|
||||
# B014 an exception named twice in one handler, or a subclass beside its parent. The
|
||||
# second name does nothing, and it is usually the one someone meant to change
|
||||
# B016 `raise "message"` raises a str, so the failure the test set up is replaced by
|
||||
# a TypeError from the raise itself
|
||||
# B022 `contextlib.suppress()` with no arguments suppresses nothing, so the call it
|
||||
# wraps still raises
|
||||
# B029 `except ():` catches nothing, so the recovery or skip written in that handler
|
||||
# never happens
|
||||
# B030 an `except` naming something that is not an exception class raises TypeError
|
||||
# while unwinding, replacing the error under test
|
||||
# F707 a bare `except:` ahead of another handler makes every handler below it
|
||||
# unreachable
|
||||
# PLE0704 a bare `raise` outside an except block raises RuntimeError instead of
|
||||
# re-raising anything
|
||||
#
|
||||
# No target-version here on purpose: it resolves from requires-python (>=3.10), so
|
||||
# 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that
|
||||
|
|
@ -83,4 +117,19 @@ lint.select = [
|
|||
"B025",
|
||||
"F632",
|
||||
"B003",
|
||||
"PGH005",
|
||||
"F631",
|
||||
"F634",
|
||||
"PT010",
|
||||
"PT030",
|
||||
"PT031",
|
||||
"B012",
|
||||
"B013",
|
||||
"B014",
|
||||
"B016",
|
||||
"B022",
|
||||
"B029",
|
||||
"B030",
|
||||
"F707",
|
||||
"PLE0704",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2655,6 +2655,35 @@ async def test_run_direct_health_check_with_instrumentation_accepts_filter_only(
|
|||
assert seen[0] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_direct_health_check_drops_only_the_rejected_kwarg(monkeypatch):
|
||||
"""A callee that predates `router` must still get the skip-disabled filter: dropping the
|
||||
rejected argument alongside working ones would probe deployments the operator opted out."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
seen: list = []
|
||||
|
||||
async def fake_perform_health_check(
|
||||
model_list,
|
||||
details,
|
||||
max_concurrency=None,
|
||||
instrumentation_context=None,
|
||||
health_check_skip_disabled_background_models=False,
|
||||
):
|
||||
seen.append((instrumentation_context, health_check_skip_disabled_background_models))
|
||||
return ([], [], {})
|
||||
|
||||
monkeypatch.setattr(proxy_server, "perform_health_check", fake_perform_health_check)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"general_settings",
|
||||
{"health_check_skip_disabled_background_models": True},
|
||||
)
|
||||
await proxy_server._run_direct_health_check_with_instrumentation([], True, 1, {"cycle_id": "c3"})
|
||||
|
||||
assert seen == [({"cycle_id": "c3"}, True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_direct_health_check_with_instrumentation_non_kw_typeerror_reraises(
|
||||
monkeypatch,
|
||||
|
|
|
|||
|
|
@ -617,3 +617,44 @@ def test_request_kwargs_does_not_retain_logging_obj():
|
|||
assert "litellm_logging_obj" not in handler.request_kwargs
|
||||
assert handler.request_kwargs["messages"] == kwargs["messages"]
|
||||
assert handler.request_kwargs["model"] == "gpt-4o"
|
||||
|
||||
|
||||
def test_async_cache_write_completes_when_asyncio_run_closes_the_loop(monkeypatch):
|
||||
"""
|
||||
Regression test for the SDK losing async cache writes in short-lived scripts:
|
||||
async_set_cache dispatched the write as a bare fire-and-forget task, so
|
||||
asyncio.run cancelled it at loop close before the write landed (LIT-6184,
|
||||
deterministic with hiredis installed). The write must survive loop shutdown.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
writes = []
|
||||
|
||||
class _SlowWriteCache:
|
||||
supported_call_types = ["acompletion"]
|
||||
cache = None
|
||||
|
||||
async def async_add_cache(self, result, dynamic_cache_object=None, **kwargs):
|
||||
await asyncio.sleep(0.2)
|
||||
writes.append(result)
|
||||
|
||||
async def acompletion(**kwargs):
|
||||
return None
|
||||
|
||||
handler = LLMCachingHandler(
|
||||
original_function=acompletion,
|
||||
request_kwargs={},
|
||||
start_time=datetime.now(),
|
||||
)
|
||||
monkeypatch.setattr(litellm, "cache", _SlowWriteCache())
|
||||
|
||||
async def _short_lived_script():
|
||||
await handler.async_set_cache(
|
||||
result=litellm.ModelResponse(),
|
||||
original_function=acompletion,
|
||||
kwargs={},
|
||||
)
|
||||
|
||||
asyncio.run(_short_lived_script())
|
||||
|
||||
assert len(writes) == 1
|
||||
|
|
|
|||
|
|
@ -17,6 +17,31 @@ def redis_no_ping():
|
|||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("namespace", "key", "expected"),
|
||||
[
|
||||
("litellm", "litellm_spend_update_buffer", "litellm:litellm_spend_update_buffer"),
|
||||
("litellm", "litellm_config:param:general_settings", "litellm:litellm_config:param:general_settings"),
|
||||
("litellm", "litellm:3997c4abcdef", "litellm:3997c4abcdef"),
|
||||
("litellm", "spend:key:3997c4abcdef", "litellm:spend:key:3997c4abcdef"),
|
||||
(None, "litellm_spend_update_buffer", "litellm_spend_update_buffer"),
|
||||
("", "litellm_spend_update_buffer", "litellm_spend_update_buffer"),
|
||||
],
|
||||
)
|
||||
def test_check_and_fix_namespace_prefixes_keys_sharing_the_namespace_prefix(
|
||||
namespace, key, expected, monkeypatch, redis_no_ping
|
||||
):
|
||||
"""A key whose name merely begins with the namespace string (e.g.
|
||||
litellm_spend_update_buffer under namespace "litellm") is not namespaced
|
||||
yet and must still get the "namespace:" prefix; only a key already carrying
|
||||
the delimited prefix is left alone. Without this, spend update buffers and
|
||||
litellm_config:param:* keys reach Redis unprefixed and NOPERM under an ACL
|
||||
scoped to the namespace pattern."""
|
||||
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
||||
redis_cache = RedisCache(namespace=namespace)
|
||||
assert redis_cache.check_and_fix_namespace(key=key) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", [None, "litellm"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_delete_cache_applies_namespace(
|
||||
|
|
|
|||
|
|
@ -577,3 +577,83 @@ async def test_dotprompt_with_prompt_version():
|
|||
)
|
||||
assert "Version 2:" in v2_rendered
|
||||
assert "Test v2" in v2_rendered
|
||||
|
||||
|
||||
def test_keyed_prompt_data_with_prompt_id_keeps_real_content():
|
||||
prompt_data = {
|
||||
"json_prompt": {
|
||||
"content": "You are a pirate. Begin every reply with AHOY.",
|
||||
"metadata": {"model": "gpt-4o-mini"},
|
||||
}
|
||||
}
|
||||
|
||||
manager = PromptManager(prompt_data=prompt_data, prompt_id="agent-prompt")
|
||||
|
||||
template = manager.get_prompt("json_prompt")
|
||||
assert template is not None
|
||||
assert template.content == "You are a pirate. Begin every reply with AHOY."
|
||||
assert template.model == "gpt-4o-mini"
|
||||
assert "agent-prompt" not in manager.prompts
|
||||
|
||||
|
||||
def test_flat_prompt_data_with_prompt_id_registers_under_prompt_id():
|
||||
manager = PromptManager(
|
||||
prompt_data={"content": "Hello {{name}}", "metadata": {"model": "gpt-4o-mini"}},
|
||||
prompt_id="flat-prompt",
|
||||
)
|
||||
|
||||
template = manager.get_prompt("flat-prompt")
|
||||
assert template is not None
|
||||
assert template.content == "Hello {{name}}"
|
||||
assert manager.render("flat-prompt", {"name": "world"}) == "Hello world"
|
||||
|
||||
|
||||
def test_get_prompt_falls_back_to_base_id_for_versioned_id():
|
||||
manager = PromptManager(
|
||||
prompt_data={"content": "Hi", "metadata": {}},
|
||||
prompt_id="my-prompt",
|
||||
)
|
||||
|
||||
assert manager.get_prompt("my-prompt.v1") is not None
|
||||
assert manager.get_prompt("my-prompt.v12") is not None
|
||||
assert manager.get_prompt("my-prompt.vx") is None
|
||||
assert manager.get_prompt("other-prompt.v1") is None
|
||||
|
||||
|
||||
def test_should_run_prompt_management_accepts_versioned_id():
|
||||
from litellm.integrations.dotprompt import DotpromptManager
|
||||
|
||||
dotprompt_manager = DotpromptManager(
|
||||
prompt_data={"content": "Hi", "metadata": {}},
|
||||
prompt_id="versioned-prompt",
|
||||
)
|
||||
|
||||
assert dotprompt_manager.should_run_prompt_management("versioned-prompt", None, {}) is True
|
||||
assert dotprompt_manager.should_run_prompt_management("versioned-prompt.v1", None, {}) is True
|
||||
assert dotprompt_manager.should_run_prompt_management("missing-prompt", None, {}) is False
|
||||
|
||||
|
||||
def test_prompt_initializer_registers_flat_db_prompt_under_base_id():
|
||||
from litellm.integrations.dotprompt import DotpromptManager, prompt_initializer
|
||||
from litellm.types.prompts.init_prompts import (
|
||||
PromptInfo,
|
||||
PromptLiteLLMParams,
|
||||
PromptSpec,
|
||||
)
|
||||
|
||||
litellm_params = PromptLiteLLMParams(
|
||||
prompt_integration="dotprompt",
|
||||
prompt_data={"content": "AHOY {{name}}", "metadata": {"model": "gpt-4o-mini"}},
|
||||
)
|
||||
prompt_spec = PromptSpec(
|
||||
prompt_id="agent-prompt.v1",
|
||||
litellm_params=litellm_params,
|
||||
prompt_info=PromptInfo(prompt_type="db"),
|
||||
)
|
||||
|
||||
dotprompt_manager = prompt_initializer(litellm_params, prompt_spec)
|
||||
|
||||
assert isinstance(dotprompt_manager, DotpromptManager)
|
||||
template = dotprompt_manager.prompt_manager.get_prompt("agent-prompt")
|
||||
assert template is not None
|
||||
assert template.content == "AHOY {{name}}"
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,14 @@ def test_max_langfuse_clients_limit():
|
|||
class _RecordingLangfuse:
|
||||
last_parameters: Optional[dict] = None
|
||||
|
||||
def __init__(self, environment=None, **parameters):
|
||||
type(self).last_parameters = {"environment": environment, **parameters}
|
||||
self.client = MagicMock()
|
||||
|
||||
|
||||
class _RecordingLangfuseWithoutEnvironment:
|
||||
last_parameters: Optional[dict] = None
|
||||
|
||||
def __init__(self, **parameters):
|
||||
type(self).last_parameters = parameters
|
||||
self.client = MagicMock()
|
||||
|
|
@ -1195,6 +1203,62 @@ def _build_langfuse_logger(monkeypatch) -> LangFuseLogger:
|
|||
)
|
||||
|
||||
|
||||
def test_langfuse_environment_is_passed_to_sdk_client(monkeypatch):
|
||||
monkeypatch.setenv("LANGFUSE_MOCK", "false")
|
||||
monkeypatch.delenv("LANGFUSE_TRACING_ENVIRONMENT", raising=False)
|
||||
monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0)
|
||||
with patch("langfuse.Langfuse", _RecordingLangfuse):
|
||||
logger = LangFuseLogger(
|
||||
langfuse_public_key="pk-env",
|
||||
langfuse_secret="sk-env",
|
||||
langfuse_host="https://test.langfuse.com",
|
||||
langfuse_environment="staging",
|
||||
)
|
||||
assert logger.langfuse_environment == "staging"
|
||||
assert _RecordingLangfuse.last_parameters["environment"] == "staging"
|
||||
|
||||
|
||||
def test_langfuse_environment_falls_back_to_deployment_env_var(monkeypatch):
|
||||
monkeypatch.setenv("LANGFUSE_MOCK", "false")
|
||||
monkeypatch.setenv("LANGFUSE_TRACING_ENVIRONMENT", "deployment-wide")
|
||||
monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0)
|
||||
with patch("langfuse.Langfuse", _RecordingLangfuse):
|
||||
logger = LangFuseLogger(
|
||||
langfuse_public_key="pk-env",
|
||||
langfuse_secret="sk-env",
|
||||
langfuse_host="https://test.langfuse.com",
|
||||
)
|
||||
assert logger.langfuse_environment == "deployment-wide"
|
||||
assert _RecordingLangfuse.last_parameters["environment"] == "deployment-wide"
|
||||
|
||||
|
||||
def test_langfuse_environment_omitted_for_old_sdk_versions(monkeypatch):
|
||||
monkeypatch.setenv("LANGFUSE_MOCK", "false")
|
||||
monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0)
|
||||
with patch("langfuse.Langfuse", _RecordingLangfuseWithoutEnvironment):
|
||||
LangFuseLogger(
|
||||
langfuse_public_key="pk-env",
|
||||
langfuse_secret="sk-env",
|
||||
langfuse_host="https://test.langfuse.com",
|
||||
langfuse_environment="staging",
|
||||
)
|
||||
assert "environment" not in _RecordingLangfuseWithoutEnvironment.last_parameters
|
||||
|
||||
|
||||
def test_dynamic_langfuse_environment_triggers_dynamic_logger():
|
||||
from litellm.integrations.langfuse.langfuse_handler import LangFuseHandler
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
params = StandardCallbackDynamicParams(langfuse_environment="team-a-env")
|
||||
|
||||
assert LangFuseHandler._dynamic_langfuse_credentials_are_passed(params) is True
|
||||
|
||||
config = LangFuseHandler.get_dynamic_langfuse_logging_config(
|
||||
standard_callback_dynamic_params=params
|
||||
)
|
||||
assert config["langfuse_environment"] == "team-a-env"
|
||||
|
||||
|
||||
def test_langfuse_sdk_client_survives_httpx_cache_eviction(monkeypatch):
|
||||
import gc
|
||||
import weakref
|
||||
|
|
@ -1408,3 +1472,52 @@ def test_update_trace_keys_matches_whole_keys_not_substrings():
|
|||
)
|
||||
|
||||
assert "input" not in trace_params
|
||||
|
||||
|
||||
def test_langfuse_environment_is_coerced_and_validated(monkeypatch):
|
||||
monkeypatch.setenv("LANGFUSE_MOCK", "false")
|
||||
monkeypatch.delenv("LANGFUSE_TRACING_ENVIRONMENT", raising=False)
|
||||
monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0)
|
||||
with patch("langfuse.Langfuse", _RecordingLangfuse):
|
||||
logger = LangFuseLogger(
|
||||
langfuse_public_key="pk-env",
|
||||
langfuse_secret="sk-env",
|
||||
langfuse_host="https://test.langfuse.com",
|
||||
langfuse_environment=123, # non-string: must coerce, not crash
|
||||
)
|
||||
assert logger.langfuse_environment == "123"
|
||||
|
||||
with pytest.raises(ValueError, match="langfuse_environment"):
|
||||
LangFuseLogger(
|
||||
langfuse_public_key="pk-env",
|
||||
langfuse_secret="sk-env",
|
||||
langfuse_host="https://test.langfuse.com",
|
||||
langfuse_environment="Production",
|
||||
)
|
||||
|
||||
|
||||
def test_langfuse_empty_environment_falls_back_and_is_not_dynamic(monkeypatch):
|
||||
from litellm.integrations.langfuse.langfuse_handler import LangFuseHandler
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
monkeypatch.setenv("LANGFUSE_TRACING_ENVIRONMENT", "production")
|
||||
|
||||
# '' falls back to the deployment env var at init
|
||||
monkeypatch.setenv("LANGFUSE_MOCK", "false")
|
||||
monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0)
|
||||
with patch("langfuse.Langfuse", _RecordingLangfuse):
|
||||
logger = LangFuseLogger(
|
||||
langfuse_public_key="pk-env",
|
||||
langfuse_secret="sk-env",
|
||||
langfuse_host="https://test.langfuse.com",
|
||||
langfuse_environment="",
|
||||
)
|
||||
assert logger.langfuse_environment == "production"
|
||||
|
||||
# env-only params that add nothing do not select a dynamic logger
|
||||
for redundant in ["", " ", "production"]:
|
||||
params = StandardCallbackDynamicParams(langfuse_environment=redundant)
|
||||
assert LangFuseHandler._dynamic_langfuse_credentials_are_passed(params) is False
|
||||
|
||||
params = StandardCallbackDynamicParams(langfuse_environment="team-a-prod")
|
||||
assert LangFuseHandler._dynamic_langfuse_credentials_are_passed(params) is True
|
||||
|
|
|
|||
|
|
@ -137,6 +137,32 @@ class TestLangfuseOtelIntegration:
|
|||
mock_span, "langfuse.environment", test_env
|
||||
)
|
||||
|
||||
def test_set_langfuse_environment_attribute_prefers_dynamic_param(self):
|
||||
"""Per-key/team langfuse_environment beats the deployment env var."""
|
||||
|
||||
class _RecordingSpan:
|
||||
def __init__(self):
|
||||
self.attributes = {}
|
||||
|
||||
def set_attribute(self, key, value):
|
||||
self.attributes[key] = value
|
||||
|
||||
span = _RecordingSpan()
|
||||
mock_kwargs = {
|
||||
"standard_callback_dynamic_params": {
|
||||
"langfuse_environment": "team-a-env"
|
||||
}
|
||||
}
|
||||
|
||||
with patch.dict(
|
||||
os.environ, {"LANGFUSE_TRACING_ENVIRONMENT": "deployment-wide"}
|
||||
):
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
span, mock_kwargs, {}
|
||||
)
|
||||
|
||||
assert span.attributes["langfuse.environment"] == "team-a-env"
|
||||
|
||||
def test_extract_langfuse_metadata_basic(self):
|
||||
"""Ensure metadata is correctly pulled from litellm_params."""
|
||||
metadata_in = {"generation_name": "my-gen", "custom": "data"}
|
||||
|
|
|
|||
|
|
@ -512,6 +512,95 @@ def test_gemini_3x_web_search_billed_per_query(model, local_model_cost_map):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,custom_llm_provider",
|
||||
[
|
||||
("gemini/gemini-2.5-flash", "gemini"),
|
||||
("vertex_ai/gemini-2.5-flash", "vertex_ai"),
|
||||
],
|
||||
)
|
||||
def test_gemini_2x_maps_grounding_billed_at_maps_rate(model, custom_llm_provider, local_model_cost_map):
|
||||
"""
|
||||
Grounding with Google Maps is its own SKU: a Maps-only grounded prompt on Gemini 2.x bills the
|
||||
$0.025 Maps per-prompt fee, not the $0.035 Google Search fee it was previously conflated with,
|
||||
and not $0 as on Vertex AI where webSearchQueries is never populated for Maps.
|
||||
Regression for https://github.com/BerriAI/litellm/issues/35906
|
||||
"""
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
|
||||
model_info = litellm.get_model_info(model)
|
||||
expected_cost = model_info["google_maps_grounding_cost_per_query"]
|
||||
assert expected_cost == pytest.approx(0.025)
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=15,
|
||||
completion_tokens=100,
|
||||
total_tokens=115,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=15, google_maps_grounding_requests=1),
|
||||
)
|
||||
cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
|
||||
model=model,
|
||||
usage=usage,
|
||||
response_object=None,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
standard_built_in_tools_params=None,
|
||||
)
|
||||
assert cost == pytest.approx(expected_cost)
|
||||
|
||||
|
||||
def test_gemini_3x_maps_grounding_billed_per_query(local_model_cost_map):
|
||||
"""Gemini 3.x bills Maps grounding per executed query: N queries cost N * $0.014."""
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
|
||||
model = "vertex_ai/gemini-3.5-flash"
|
||||
model_info = litellm.get_model_info(model)
|
||||
assert model_info["web_search_billing_unit"] == "per_query"
|
||||
expected_cost = model_info["google_maps_grounding_cost_per_query"] * 2
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=15,
|
||||
completion_tokens=100,
|
||||
total_tokens=115,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=15, google_maps_grounding_requests=2),
|
||||
)
|
||||
cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
|
||||
model=model,
|
||||
usage=usage,
|
||||
response_object=None,
|
||||
custom_llm_provider="vertex_ai",
|
||||
standard_built_in_tools_params=None,
|
||||
)
|
||||
assert cost == pytest.approx(expected_cost)
|
||||
assert cost == pytest.approx(0.028)
|
||||
|
||||
|
||||
def test_gemini_combined_search_and_maps_costs_are_additive(local_model_cost_map):
|
||||
"""A prompt grounded with both Google Search and Google Maps pays both fees."""
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
|
||||
model = "gemini/gemini-3.5-flash"
|
||||
model_info = litellm.get_model_info(model)
|
||||
search_rate = model_info["search_context_cost_per_query"]["search_context_size_medium"]
|
||||
maps_rate = model_info["google_maps_grounding_cost_per_query"]
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=15,
|
||||
completion_tokens=100,
|
||||
total_tokens=115,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=15, web_search_requests=2, google_maps_grounding_requests=1
|
||||
),
|
||||
)
|
||||
cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
|
||||
model=model,
|
||||
usage=usage,
|
||||
response_object=None,
|
||||
custom_llm_provider="gemini",
|
||||
standard_built_in_tools_params=None,
|
||||
)
|
||||
assert cost == pytest.approx(search_rate * 2 + maps_rate)
|
||||
|
||||
|
||||
def test_gemini_2x_web_search_still_billed_per_prompt(local_model_cost_map):
|
||||
"""
|
||||
Gemini 2.x bills web search per grounded prompt: multiple internal queries are one flat
|
||||
|
|
|
|||
|
|
@ -1,19 +1,41 @@
|
|||
"""Test health check helper functions"""
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
|
||||
from litellm.litellm_core_utils.health_check_helpers import HealthCheckHelpers
|
||||
from litellm.litellm_core_utils.health_check_helpers import (
|
||||
IMAGE_EDIT_HEALTH_CHECK_PROMPT,
|
||||
HealthCheckHelpers,
|
||||
)
|
||||
from litellm.main import ahealth_check
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS
|
||||
|
||||
|
||||
def _png_chunks(png: bytes, offset: int = 8) -> tuple[tuple[bytes, bytes], ...]:
|
||||
if offset >= len(png):
|
||||
return ()
|
||||
(length,) = struct.unpack(">I", png[offset : offset + 4])
|
||||
chunk = (png[offset + 4 : offset + 8], png[offset + 8 : offset + 8 + length])
|
||||
return (chunk, *_png_chunks(png, offset + 12 + length))
|
||||
|
||||
|
||||
def _distinct_rgb_colors(png: bytes) -> set[bytes]:
|
||||
width = int.from_bytes(png[16:20], "big")
|
||||
raw = zlib.decompress(b"".join(data for tag, data in _png_chunks(png) if tag == b"IDAT"))
|
||||
row_size = 1 + width * 3
|
||||
rows = tuple(raw[i : i + row_size] for i in range(0, len(raw), row_size))
|
||||
assert all(row[0] == 0 for row in rows)
|
||||
return {bytes(row[i : i + 3]) for row in rows for i in range(1, row_size, 3)}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_edit_health_check_handler_uses_png_and_prompt():
|
||||
async def test_image_edit_health_check_handler_uses_descriptive_prompt_and_multicolor_png():
|
||||
model_params = {"model": "openai/gpt-image-1", "api_key": "sk-test"}
|
||||
mode_handlers = HealthCheckHelpers.get_mode_handlers(
|
||||
model="gpt-image-1",
|
||||
|
|
@ -31,20 +53,76 @@ async def test_image_edit_health_check_handler_uses_png_and_prompt():
|
|||
model="gpt-image-1",
|
||||
custom_llm_provider="openai",
|
||||
model_params=model_params,
|
||||
prompt="edit this image",
|
||||
prompt="test from litellm",
|
||||
)["image_edit"]()
|
||||
|
||||
assert mock_aimage_edit.call_count == 2
|
||||
default_call = mock_aimage_edit.call_args_list[0].kwargs
|
||||
explicit_call = mock_aimage_edit.call_args_list[1].kwargs
|
||||
assert default_call["model"] == "openai/gpt-image-1"
|
||||
assert default_call["prompt"] == "test"
|
||||
assert explicit_call["prompt"] == "edit this image"
|
||||
image = default_call["image"]
|
||||
for handler_call in mock_aimage_edit.call_args_list:
|
||||
assert handler_call.kwargs["model"] == "openai/gpt-image-1"
|
||||
assert handler_call.kwargs["prompt"] == IMAGE_EDIT_HEALTH_CHECK_PROMPT
|
||||
image = mock_aimage_edit.call_args_list[0].kwargs["image"]
|
||||
assert isinstance(image, bytes)
|
||||
assert image.startswith(b"\x89PNG")
|
||||
assert int.from_bytes(image[16:20], "big") == 512
|
||||
assert int.from_bytes(image[20:24], "big") == 512
|
||||
assert len(_distinct_rgb_colors(image)) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahealth_check_image_edit_treats_content_policy_violation_as_healthy():
|
||||
moderation_error = litellm.ContentPolicyViolationError(
|
||||
message="Your request was rejected as a result of our safety system.",
|
||||
model="gpt-image-1",
|
||||
llm_provider="openai",
|
||||
)
|
||||
with patch( # test-quality-ok: the public health-check path has no dependency injection seam
|
||||
"litellm.aimage_edit", new_callable=AsyncMock, side_effect=moderation_error
|
||||
):
|
||||
result = await ahealth_check(
|
||||
{"model": "gpt-image-1", "api_key": "sk-test"},
|
||||
mode="image_edit",
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahealth_check_image_edit_treats_moderation_blocked_code_as_healthy():
|
||||
moderation_blocked = litellm.BadRequestError(
|
||||
message=(
|
||||
'{"error": {"code": "moderation_blocked", "message": "Your request was blocked", '
|
||||
'"moderation_stage": "output", "type": "invalid_request_error"}}'
|
||||
),
|
||||
model="gpt-image-1",
|
||||
llm_provider="openai",
|
||||
)
|
||||
with patch( # test-quality-ok: the public health-check path has no dependency injection seam
|
||||
"litellm.aimage_edit", new_callable=AsyncMock, side_effect=moderation_blocked
|
||||
):
|
||||
result = await ahealth_check(
|
||||
{"model": "gpt-image-1", "api_key": "sk-test"},
|
||||
mode="image_edit",
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahealth_check_image_edit_still_fails_on_non_moderation_errors():
|
||||
auth_error = litellm.AuthenticationError(
|
||||
message="Incorrect API key provided",
|
||||
llm_provider="openai",
|
||||
model="gpt-image-1",
|
||||
)
|
||||
with patch( # test-quality-ok: the public health-check path has no dependency injection seam
|
||||
"litellm.aimage_edit", new_callable=AsyncMock, side_effect=auth_error
|
||||
):
|
||||
result = await ahealth_check(
|
||||
{"model": "gpt-image-1", "api_key": "sk-bad"},
|
||||
mode="image_edit",
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -88,9 +166,7 @@ def test_update_model_params_with_health_check_tracking_information():
|
|||
|
||||
# Verify that litellm_metadata was added
|
||||
assert "litellm_metadata" in result
|
||||
assert result["litellm_metadata"]["tags"] == [
|
||||
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
|
||||
]
|
||||
assert result["litellm_metadata"]["tags"] == [LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME]
|
||||
|
||||
# Verify the auth setup was called
|
||||
mock_add_auth.assert_called_once()
|
||||
|
|
@ -169,16 +245,12 @@ async def test_ahealth_check_failure_masks_raw_request_headers():
|
|||
if "Authorization" in headers:
|
||||
auth_header = headers["Authorization"]
|
||||
# Should be masked (e.g., "Be****90" or similar)
|
||||
assert (
|
||||
auth_header != f"Bearer {test_api_key}"
|
||||
), "Authorization header must be masked"
|
||||
assert (
|
||||
auth_header != test_api_key
|
||||
), "API key must not appear in Authorization header"
|
||||
assert auth_header != f"Bearer {test_api_key}", "Authorization header must be masked"
|
||||
assert auth_header != test_api_key, "API key must not appear in Authorization header"
|
||||
# Masked headers typically have asterisks or are truncated
|
||||
assert "*" in auth_header or len(auth_header) < len(
|
||||
f"Bearer {test_api_key}"
|
||||
), f"Authorization header should be masked but got: {auth_header}"
|
||||
assert "*" in auth_header or len(auth_header) < len(f"Bearer {test_api_key}"), (
|
||||
f"Authorization header should be masked but got: {auth_header}"
|
||||
)
|
||||
|
||||
# Content-Type should remain unmasked (not sensitive)
|
||||
if "Content-Type" in headers:
|
||||
|
|
@ -257,9 +329,7 @@ async def test_batch_health_check_skips_bridge_when_no_logging_obj():
|
|||
"litellm_metadata": litellm_metadata,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.alist_batches", new_callable=AsyncMock, return_value={}
|
||||
) as mock_alist:
|
||||
with patch("litellm.alist_batches", new_callable=AsyncMock, return_value={}) as mock_alist:
|
||||
await HealthCheckHelpers._batch_health_check(
|
||||
custom_llm_provider="openai",
|
||||
model_params={"model": "openai/gpt-4"},
|
||||
|
|
@ -283,9 +353,7 @@ async def test_batch_health_check_uses_alist_batches_for_supported_providers():
|
|||
"litellm_metadata": litellm_metadata,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.alist_batches", new_callable=AsyncMock, return_value={}
|
||||
) as mock_alist:
|
||||
with patch("litellm.alist_batches", new_callable=AsyncMock, return_value={}) as mock_alist:
|
||||
await HealthCheckHelpers._batch_health_check(
|
||||
custom_llm_provider=provider,
|
||||
model_params={"model": f"{provider}/some-model"},
|
||||
|
|
@ -344,9 +412,7 @@ async def test_realtime_health_check_uses_model_level_vertex_params():
|
|||
|
||||
fake_vertex_base = MagicMock()
|
||||
fake_vertex_base.get_vertex_region = MagicMock(return_value="us-central1")
|
||||
fake_vertex_base._ensure_access_token_async = AsyncMock(
|
||||
return_value=("model-level-token", "model-level-project")
|
||||
)
|
||||
fake_vertex_base._ensure_access_token_async = AsyncMock(return_value=("model-level-token", "model-level-project"))
|
||||
connect_calls = []
|
||||
|
||||
with (
|
||||
|
|
@ -381,8 +447,7 @@ async def test_realtime_health_check_uses_model_level_vertex_params():
|
|||
custom_llm_provider="vertex_ai",
|
||||
)
|
||||
assert connect_calls[0]["url"] == (
|
||||
"wss://us-central1-aiplatform.googleapis.com/ws/"
|
||||
"google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent"
|
||||
"wss://us-central1-aiplatform.googleapis.com/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent"
|
||||
)
|
||||
assert connect_calls[0]["additional_headers"] == {
|
||||
"Authorization": "Bearer model-level-token",
|
||||
|
|
|
|||
|
|
@ -233,3 +233,18 @@ def test_trusted_vars_overlay_uses_shared_parser_semantics():
|
|||
)
|
||||
|
||||
assert params.get("newrelic_api_key") == "12345"
|
||||
|
||||
|
||||
def test_validate_langfuse_environment_value():
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
validate_langfuse_environment_value,
|
||||
)
|
||||
|
||||
validate_langfuse_environment_value("team-a-prod")
|
||||
validate_langfuse_environment_value("staging_2")
|
||||
|
||||
for bad in ["Production", "langfuse-eu", "", "team a"]:
|
||||
with pytest.raises(ValueError, match="langfuse_environment"):
|
||||
validate_langfuse_environment_value(bad)
|
||||
|
|
|
|||
|
|
@ -97,6 +97,32 @@ class TestLoggingWorker:
|
|||
logging.raiseExceptions = previous_raise_exceptions
|
||||
logger.removeHandler(handler)
|
||||
|
||||
def test_flush_on_exit_rescues_dequeued_coroutine_never_started(self):
|
||||
"""
|
||||
Regression test for cache-hit success callbacks lost in short-lived SDK scripts:
|
||||
the worker loop dequeues the task, then ``asyncio.run`` cancels the processing
|
||||
task before it ever runs, so the coroutine leaves the queue without being
|
||||
awaited and the atexit flush used to find an empty queue and rescue nothing.
|
||||
"""
|
||||
worker = LoggingWorker(timeout=1.0, max_queue_size=10)
|
||||
fired = []
|
||||
|
||||
async def marker():
|
||||
fired.append(True)
|
||||
|
||||
async def short_lived_script():
|
||||
worker.ensure_initialized_and_enqueue(marker())
|
||||
|
||||
asyncio.run(short_lived_script())
|
||||
|
||||
assert worker._queue is not None
|
||||
assert worker._queue.qsize() == 0, "precondition: the worker loop dequeued the task before loop close"
|
||||
assert fired == [], "precondition: the callback never ran before loop close"
|
||||
|
||||
worker._flush_on_exit()
|
||||
|
||||
assert fired == [True]
|
||||
|
||||
def test_flush_on_exit_swallows_errors_and_drains_remaining(self):
|
||||
"""A failing queued coroutine must not abort the atexit drain of later events."""
|
||||
worker = LoggingWorker(timeout=1.0, max_queue_size=10)
|
||||
|
|
@ -118,6 +144,53 @@ class TestLoggingWorker:
|
|||
assert processed == ["ran"]
|
||||
assert worker._queue.empty()
|
||||
|
||||
def test_loop_change_revives_dequeued_coroutine_on_new_loop(self):
|
||||
"""
|
||||
A callback dequeued but never started before its loop closed must run on the
|
||||
next event loop's worker instead of staying stranded until process exit.
|
||||
"""
|
||||
worker = LoggingWorker(timeout=1.0, max_queue_size=10)
|
||||
fired = []
|
||||
|
||||
async def marker(name):
|
||||
fired.append(name)
|
||||
|
||||
async def first_script():
|
||||
worker.ensure_initialized_and_enqueue(marker("first"))
|
||||
|
||||
asyncio.run(first_script())
|
||||
assert fired == [], "precondition: the callback was dequeued but never ran before loop close"
|
||||
|
||||
async def second_script():
|
||||
worker.ensure_initialized_and_enqueue(marker("second"))
|
||||
assert worker._queue is not None
|
||||
await asyncio.wait_for(worker._queue.join(), timeout=5)
|
||||
|
||||
asyncio.run(second_script())
|
||||
|
||||
assert sorted(fired) == ["first", "second"]
|
||||
|
||||
def test_flush_on_exit_swallows_cancellation_and_drains_remaining(self):
|
||||
"""A callback raising CancelledError must not abort the atexit flush of later events."""
|
||||
worker = LoggingWorker(timeout=1.0, max_queue_size=10)
|
||||
worker._queue = asyncio.Queue(maxsize=10)
|
||||
|
||||
processed = []
|
||||
|
||||
async def cancels_during_flush():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
async def records_during_flush():
|
||||
processed.append("ran")
|
||||
|
||||
worker.enqueue(cancels_during_flush())
|
||||
worker.enqueue(records_during_flush())
|
||||
|
||||
worker._flush_on_exit()
|
||||
|
||||
assert processed == ["ran"]
|
||||
assert worker._queue.empty()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_handles_cancellation_gracefully(self, logging_worker):
|
||||
"""Test that the worker handles cancellation without throwing exceptions."""
|
||||
|
|
|
|||
|
|
@ -134,6 +134,15 @@ def test_the_search_context_table_is_zeroed_in_place_on_every_deployment():
|
|||
assert dict(override[field]) == dict.fromkeys(SEARCH_CONTEXT_SIZES, 0.0)
|
||||
|
||||
|
||||
def test_the_maps_grounding_rate_is_zeroed_on_every_deployment():
|
||||
"""An absent rate falls back to the Maps default rather than free, so it is written
|
||||
even when the deployment never declared one."""
|
||||
override = _with_flag(_VALID)
|
||||
|
||||
assert override is not None
|
||||
assert override["google_maps_grounding_cost_per_query"] == 0.0
|
||||
|
||||
|
||||
def test_a_declared_table_does_not_become_a_scalar():
|
||||
"""Zeroing it as a plain 0.0 would leave the provider's reader without a table to
|
||||
consult, which is the same as absent."""
|
||||
|
|
|
|||
|
|
@ -711,6 +711,66 @@ def test_stream_chunk_builder_anthropic_web_search():
|
|||
assert usage.server_tool_use.web_search_requests == 2
|
||||
|
||||
|
||||
def test_calculate_usage_carries_google_maps_grounding_requests():
|
||||
"""
|
||||
The Maps grounding counter set on a streamed usage chunk must survive the stream rebuild even
|
||||
when a later chunk carries its own prompt_tokens_details, or Maps grounding on streaming
|
||||
requests silently bills $0.
|
||||
"""
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper
|
||||
|
||||
chunk1 = ModelResponseStream(
|
||||
id="chatcmpl-maps-usage-0",
|
||||
created=1745513207,
|
||||
model="gemini-2.5-flash",
|
||||
object="chat.completion.chunk",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason=None,
|
||||
index=0,
|
||||
delta=Delta(content="Here"),
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
stream_options={"include_usage": True},
|
||||
usage=Usage(
|
||||
completion_tokens=0,
|
||||
prompt_tokens=15,
|
||||
total_tokens=15,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(google_maps_grounding_requests=1),
|
||||
),
|
||||
)
|
||||
|
||||
chunk2 = ModelResponseStream(
|
||||
id="chatcmpl-maps-usage-0",
|
||||
created=1745513207,
|
||||
model="gemini-2.5-flash",
|
||||
object="chat.completion.chunk",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
delta=Delta(content=None),
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
stream_options={"include_usage": True},
|
||||
usage=Usage(
|
||||
completion_tokens=27,
|
||||
prompt_tokens=0,
|
||||
total_tokens=27,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=0),
|
||||
),
|
||||
)
|
||||
|
||||
chunks = [chunk1, chunk2]
|
||||
processor = ChunkProcessor(chunks=chunks)
|
||||
|
||||
usage = processor.calculate_usage(chunks=chunks, model="gemini-2.5-flash", completion_output="")
|
||||
|
||||
assert usage.prompt_tokens_details.google_maps_grounding_requests == 1
|
||||
|
||||
|
||||
def test_sort_chunks_handles_dict_hidden_params_created_at():
|
||||
chunks = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -100,6 +100,48 @@ def test_calculate_usage():
|
|||
assert usage._cache_read_input_tokens == 0
|
||||
|
||||
|
||||
def test_calculate_usage_prefers_served_speed_from_response_usage():
|
||||
"""
|
||||
Anthropic reports the speed a request was actually served at in the response
|
||||
usage (a fast request on a model without fast mode comes back
|
||||
``"speed": "standard"``), so the served value must beat the requested one or
|
||||
spend gets multiplied for fast service that never happened.
|
||||
"""
|
||||
config = AnthropicConfig()
|
||||
|
||||
served_standard = config.calculate_usage(
|
||||
usage_object={"input_tokens": 12, "output_tokens": 1, "speed": "standard"},
|
||||
reasoning_content=None,
|
||||
speed="fast",
|
||||
)
|
||||
assert served_standard.speed == "standard"
|
||||
|
||||
no_response_speed = config.calculate_usage(
|
||||
usage_object={"input_tokens": 12, "output_tokens": 1},
|
||||
reasoning_content=None,
|
||||
speed="fast",
|
||||
)
|
||||
assert no_response_speed.speed == "fast"
|
||||
|
||||
|
||||
def test_streaming_iterator_persists_served_speed_across_usage_chunks():
|
||||
"""
|
||||
Only ``message_start`` usage carries the served speed; the final
|
||||
``message_delta`` usage does not. The iterator must remember the served
|
||||
value so the last usage chunk, which wins in the stream chunk builder, does
|
||||
not fall back to the requested speed.
|
||||
"""
|
||||
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
|
||||
|
||||
iterator = ModelResponseIterator(None, sync_stream=True, speed="fast")
|
||||
|
||||
start_usage = iterator._handle_usage({"input_tokens": 12, "output_tokens": 1, "speed": "standard"})
|
||||
delta_usage = iterator._handle_usage({"output_tokens": 5})
|
||||
|
||||
assert start_usage.speed == "standard"
|
||||
assert delta_usage.speed == "standard"
|
||||
|
||||
|
||||
def test_calculate_usage_aggregates_cache_creation_split_across_iterations():
|
||||
"""
|
||||
In the iterations path each iteration can carry the 5m/1h cache_creation
|
||||
|
|
|
|||
|
|
@ -47,6 +47,97 @@ def test_transform_usage():
|
|||
assert openai_usage.completion_tokens_details.text_tokens == usage["outputTokens"]
|
||||
|
||||
|
||||
def test_transform_usage_with_cache_details():
|
||||
"""cacheDetails should split cacheWriteInputTokens into the 5m/1h TTL breakdown
|
||||
so cost calc can bill the 1h portion at its own (higher) rate instead of
|
||||
defaulting the whole write to the 5m rate. See issue #36760."""
|
||||
usage = ConverseTokenUsageBlock(
|
||||
**{
|
||||
"inputTokens": 76,
|
||||
"outputTokens": 259,
|
||||
"totalTokens": 335,
|
||||
"cacheWriteInputTokens": 362,
|
||||
"cacheDetails": [
|
||||
{"inputTokens": 74, "ttl": "1h"},
|
||||
{"inputTokens": 288, "ttl": "5m"},
|
||||
],
|
||||
}
|
||||
)
|
||||
config = AmazonConverseConfig()
|
||||
openai_usage = config.transform_usage(usage)
|
||||
details = openai_usage.prompt_tokens_details.cache_creation_token_details
|
||||
assert details is not None
|
||||
assert details.ephemeral_1h_input_tokens == 74
|
||||
assert details.ephemeral_5m_input_tokens == 288
|
||||
|
||||
|
||||
def test_transform_usage_with_mismatched_cache_details_falls_back():
|
||||
"""An unrecognized ttl or partial breakdown must not silently understate
|
||||
cache-write cost, so the split is only used when it fully accounts for
|
||||
cacheWriteInputTokens."""
|
||||
usage = ConverseTokenUsageBlock(
|
||||
**{
|
||||
"inputTokens": 76,
|
||||
"outputTokens": 259,
|
||||
"totalTokens": 335,
|
||||
"cacheWriteInputTokens": 362,
|
||||
"cacheDetails": [{"inputTokens": 74, "ttl": "1h"}], # missing the 5m entry
|
||||
}
|
||||
)
|
||||
config = AmazonConverseConfig()
|
||||
openai_usage = config.transform_usage(usage)
|
||||
assert (
|
||||
getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_transform_usage_without_cache_details_stays_none():
|
||||
"""No cacheDetails in the response (older models/regions) should leave
|
||||
cache_creation_token_details unset, same as before this field existed."""
|
||||
usage = ConverseTokenUsageBlock(
|
||||
**{
|
||||
"inputTokens": 3,
|
||||
"outputTokens": 401,
|
||||
"totalTokens": 2193,
|
||||
"cacheWriteInputTokens": 1789,
|
||||
}
|
||||
)
|
||||
config = AmazonConverseConfig()
|
||||
openai_usage = config.transform_usage(usage)
|
||||
assert (
|
||||
getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_bedrock_converse_1h_cache_write_billed_at_1h_rate(monkeypatch):
|
||||
"""Regression for issue #36760: without the cacheDetails split, the whole
|
||||
write is billed at the (cheaper) 5m rate."""
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
usage = ConverseTokenUsageBlock(
|
||||
**{
|
||||
"inputTokens": 16,
|
||||
"outputTokens": 4,
|
||||
"totalTokens": 11652,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheWriteInputTokens": 11632,
|
||||
"cacheDetails": [{"inputTokens": 11632, "ttl": "1h"}],
|
||||
}
|
||||
)
|
||||
openai_usage = AmazonConverseConfig().transform_usage(usage)
|
||||
model = "bedrock/converse/global.anthropic.claude-opus-4-8"
|
||||
prompt_cost, completion_cost = litellm.cost_calculator.cost_per_token(model=model, usage_object=openai_usage)
|
||||
model_info = litellm.get_model_info(model=model)
|
||||
expected_prompt_cost = (
|
||||
16 * model_info["input_cost_per_token"] + 11632 * model_info["cache_creation_input_token_cost_above_1hr"]
|
||||
)
|
||||
assert prompt_cost == pytest.approx(expected_prompt_cost)
|
||||
assert prompt_cost > 16 * model_info["input_cost_per_token"] + 11632 * model_info["cache_creation_input_token_cost"]
|
||||
assert completion_cost == pytest.approx(4 * model_info["output_cost_per_token"])
|
||||
|
||||
|
||||
def test_transform_usage_with_reasoning_content():
|
||||
"""Test that completion_tokens_details correctly tracks reasoning vs text tokens."""
|
||||
usage = ConverseTokenUsageBlock(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||
import aiohttp
|
||||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.aiohttp_handler import BaseLLMAIOHTTPHandler
|
||||
from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport
|
||||
|
||||
|
|
@ -318,19 +318,47 @@ class TestBaseLLMAIOHTTPHandler:
|
|||
mock_client_session.assert_called_once_with(connector=mock_connector)
|
||||
assert result is mock_session_instance
|
||||
|
||||
@patch("aiohttp.ClientSession")
|
||||
def test_create_client_session_default(self, mock_client_session):
|
||||
"""Test default session creation when no transport/connector provided"""
|
||||
mock_session_instance = Mock()
|
||||
mock_client_session.return_value = mock_session_instance
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_client_session_default_honors_global_ssl_verify_false(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Regression test for LIT-3369: `litellm.ssl_verify = False` (set via
|
||||
`litellm_settings.ssl_verify: false`) must reach the default session's
|
||||
connector instead of being ignored by a bare `aiohttp.ClientSession()`."""
|
||||
monkeypatch.setattr(litellm, "ssl_verify", False)
|
||||
|
||||
handler = BaseLLMAIOHTTPHandler()
|
||||
session = handler._create_client_session_with_transport()
|
||||
try:
|
||||
assert isinstance(session.connector, aiohttp.TCPConnector)
|
||||
assert session.connector._ssl is False
|
||||
finally:
|
||||
await session.close()
|
||||
await handler.close()
|
||||
|
||||
result = handler._create_client_session_with_transport()
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_client_session_default_keeps_ssl_verification(self):
|
||||
"""Default `ssl_verify=True` must not collapse to `ssl=False`."""
|
||||
handler = BaseLLMAIOHTTPHandler()
|
||||
session = handler._create_client_session_with_transport()
|
||||
try:
|
||||
assert isinstance(session.connector, aiohttp.TCPConnector)
|
||||
assert session.connector._ssl is not False
|
||||
finally:
|
||||
await session.close()
|
||||
await handler.close()
|
||||
|
||||
# Should create default session
|
||||
mock_client_session.assert_called_once_with()
|
||||
assert result is mock_session_instance
|
||||
def test_get_or_create_transport_resolves_global_ssl_verify(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""The lazily created transport must carry the resolved global ssl config."""
|
||||
monkeypatch.setattr(litellm, "ssl_verify", False)
|
||||
|
||||
handler = BaseLLMAIOHTTPHandler()
|
||||
transport = handler._get_or_create_transport()
|
||||
|
||||
assert transport is not None
|
||||
assert transport._ssl_verify is False
|
||||
|
||||
def test_get_or_create_transport(self):
|
||||
"""Test that _get_or_create_transport creates or returns a transport.
|
||||
|
|
|
|||
|
|
@ -2757,3 +2757,176 @@ def test_video_generation_with_input_reference_keeps_file_multipart():
|
|||
"seconds": "4",
|
||||
}
|
||||
assert result.status == "queued"
|
||||
|
||||
|
||||
AZURE_AI_BASE = "https://myfoundry.services.ai.azure.com"
|
||||
AZURE_AI_CHAT_COMPLETIONS_URL = f"{AZURE_AI_BASE}/models/chat/completions"
|
||||
|
||||
def _a_tool_with_an_unsupported_field() -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {}}},
|
||||
"strict": True,
|
||||
}
|
||||
|
||||
A_COMPLETION = {
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "grok-3",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
TOOL_LEVEL_REJECTION = "Extra inputs are not permitted: tools[0].strict"
|
||||
UNRELATED_REJECTION = "Extra inputs are not permitted: temperature"
|
||||
A_REJECTION_THE_PROVIDER_CANNOT_FIX = "The model is not available in this region"
|
||||
|
||||
|
||||
class _RecordedAzureAI:
|
||||
def __init__(self, responses: list[httpx.Response]) -> None:
|
||||
self._responses = responses
|
||||
self.bodies: list[dict] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.bodies.append(json.loads(request.content))
|
||||
return self._responses[min(len(self.bodies) - 1, len(self._responses) - 1)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def httpx_transport(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
|
||||
|
||||
def _rejection(message: str) -> httpx.Response:
|
||||
return httpx.Response(422, json={"error": {"message": message}})
|
||||
|
||||
|
||||
def _call_azure_ai(recorder: _RecordedAzureAI, **overrides):
|
||||
import respx
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder)
|
||||
return litellm.completion(
|
||||
model="azure_ai/grok-3",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=[_a_tool_with_an_unsupported_field()],
|
||||
api_base=AZURE_AI_BASE,
|
||||
api_key="fake-key",
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
|
||||
response = _call_azure_ai(recorder)
|
||||
|
||||
assert len(recorder.bodies) == 2
|
||||
assert recorder.bodies[0]["tools"][0]["strict"] is True
|
||||
assert "strict" not in recorder.bodies[1]["tools"][0]
|
||||
assert response.choices[0].message.content == "sent"
|
||||
|
||||
|
||||
def test_the_retry_changes_only_the_field_the_provider_named():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
|
||||
_call_azure_ai(recorder)
|
||||
|
||||
first, second = recorder.bodies
|
||||
assert second["messages"] == first["messages"]
|
||||
assert second["model"] == first["model"]
|
||||
assert second["tools"][0]["function"] == first["tools"][0]["function"]
|
||||
|
||||
|
||||
def test_a_provider_that_keeps_rejecting_is_not_retried_forever():
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION)])
|
||||
|
||||
with pytest.raises(litellm.BadRequestError) as raised:
|
||||
_call_azure_ai(recorder)
|
||||
|
||||
assert len(recorder.bodies) == 2
|
||||
assert raised.value.status_code == 422
|
||||
|
||||
|
||||
def test_a_rejection_the_provider_cannot_fix_is_not_retried_at_all():
|
||||
recorder = _RecordedAzureAI([_rejection(A_REJECTION_THE_PROVIDER_CANNOT_FIX)])
|
||||
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
_call_azure_ai(recorder)
|
||||
|
||||
assert len(recorder.bodies) == 1
|
||||
|
||||
|
||||
def test_an_extra_input_outside_a_tool_is_not_retried_unless_dropping_params_was_asked_for():
|
||||
recorder = _RecordedAzureAI([_rejection(UNRELATED_REJECTION)])
|
||||
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
_call_azure_ai(recorder)
|
||||
|
||||
assert len(recorder.bodies) == 1
|
||||
|
||||
|
||||
def test_an_extra_input_outside_a_tool_is_retried_when_dropping_params_was_asked_for():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
|
||||
response = _call_azure_ai(recorder, drop_params=True)
|
||||
|
||||
assert len(recorder.bodies) == 2
|
||||
assert response.choices[0].message.content == "sent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_tool_field_the_provider_rejects_is_dropped_and_retried_on_the_async_path(
|
||||
httpx_transport,
|
||||
):
|
||||
import respx
|
||||
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder)
|
||||
response = await litellm.acompletion(
|
||||
model="azure_ai/grok-3",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=[_a_tool_with_an_unsupported_field()],
|
||||
api_base=AZURE_AI_BASE,
|
||||
api_key="fake-key",
|
||||
)
|
||||
|
||||
assert len(recorder.bodies) == 2
|
||||
assert recorder.bodies[0]["tools"][0]["strict"] is True
|
||||
assert "strict" not in recorder.bodies[1]["tools"][0]
|
||||
assert response.choices[0].message.content == "sent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_provider_that_keeps_rejecting_is_not_retried_forever_on_the_async_path(
|
||||
httpx_transport,
|
||||
):
|
||||
import respx
|
||||
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION)])
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder)
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
await litellm.acompletion(
|
||||
model="azure_ai/grok-3",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=[_a_tool_with_an_unsupported_field()],
|
||||
api_base=AZURE_AI_BASE,
|
||||
api_key="fake-key",
|
||||
)
|
||||
|
||||
assert len(recorder.bodies) == 2
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import litellm
|
||||
from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig
|
||||
|
||||
|
||||
|
|
@ -108,6 +109,284 @@ def test_thinking_mode_active_bool_thinking_returns_false_without_crashing():
|
|||
assert config._thinking_mode_active(model="deepseek-reasoner", optional_params={"thinking": True}) is False
|
||||
|
||||
|
||||
class TestDeepSeekVisionMultimodalContent:
|
||||
"""Image content lists are forwarded only for user messages on vision models."""
|
||||
|
||||
VISION_MODEL = "deepseek/deepseek-v4-flash-vision-exp"
|
||||
NON_VISION_MODEL = "deepseek/deepseek-chat"
|
||||
|
||||
def setup_method(self):
|
||||
self.config = DeepSeekChatConfig()
|
||||
prior_entry = litellm.model_cost.get(self.VISION_MODEL)
|
||||
self._prior_registry_entry = dict(prior_entry) if prior_entry is not None else None
|
||||
litellm.register_model(
|
||||
{
|
||||
"deepseek/deepseek-v4-flash-vision-exp": {
|
||||
"litellm_provider": "deepseek",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 4.4e-07,
|
||||
"output_cost_per_token": 1.32e-06,
|
||||
"supports_vision": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def teardown_method(self):
|
||||
if self._prior_registry_entry is None:
|
||||
litellm.model_cost.pop(self.VISION_MODEL, None)
|
||||
else:
|
||||
litellm.model_cost[self.VISION_MODEL] = self._prior_registry_entry
|
||||
|
||||
@staticmethod
|
||||
def _image_message(role="user"):
|
||||
return {
|
||||
"role": role,
|
||||
"content": [
|
||||
{"type": "text", "text": "what is in this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/image.jpg", "detail": "auto"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def test_user_image_list_forwarded_on_vision_model(self):
|
||||
result = self.config._transform_messages([self._image_message()], model=self.VISION_MODEL)
|
||||
|
||||
assert isinstance(result[0]["content"], list)
|
||||
assert result[0]["content"][0]["type"] == "text"
|
||||
assert result[0]["content"][1]["type"] == "image_url"
|
||||
assert result[0]["content"][1]["image_url"]["url"] == "https://example.com/image.jpg"
|
||||
|
||||
def test_image_list_collapsed_on_non_vision_model(self):
|
||||
result = self.config._transform_messages([self._image_message()], model=self.NON_VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "what is in this image?"
|
||||
|
||||
def test_image_list_collapsed_on_non_user_roles_even_on_vision_model(self):
|
||||
for role in ("assistant", "system"):
|
||||
result = self.config._transform_messages([self._image_message(role=role)], model=self.VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "what is in this image?"
|
||||
|
||||
def test_audio_block_collapsed_even_on_vision_model(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "transcribe this"},
|
||||
{"type": "input_audio", "input_audio": {"data": "UklGRg==", "format": "wav"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "transcribe this"
|
||||
|
||||
def test_typeless_image_block_collapses(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what is this"},
|
||||
{"image_url": {"url": "https://example.com/image.jpg"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "what is this"
|
||||
|
||||
def test_text_only_content_list_collapses(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert isinstance(result[0]["content"], str)
|
||||
assert result[0]["content"] == "Hello world"
|
||||
|
||||
def test_search_results_text_appended_on_forwarded_message(self):
|
||||
message = self._image_message()
|
||||
message["search_results"] = [{"source": "kb", "content": [{"text": "article body"}]}]
|
||||
|
||||
result = self.config._transform_messages([message], model=self.VISION_MODEL)
|
||||
|
||||
content = result[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert content[-1] == {"type": "text", "text": "kbarticle body"}
|
||||
assert any(block.get("type") == "image_url" for block in content)
|
||||
assert "search_results" not in result[0]
|
||||
|
||||
def test_search_results_text_kept_on_collapse(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "context: "}],
|
||||
"search_results": [{"source": "kb", "content": [{"text": "article body"}]}],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "context: kbarticle body"
|
||||
|
||||
def test_responses_shape_blocks_collapse_even_on_vision_model(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "what is this?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "what is this?"
|
||||
|
||||
def test_image_block_missing_payload_collapses(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hi"}, {"type": "image_url"}],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "hi"
|
||||
|
||||
def test_image_block_empty_payload_object_collapses(self):
|
||||
for payload in ({}, {"url": ""}, {"detail": "auto"}, None, 42):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hi"}, {"type": "image_url", "image_url": payload}],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "hi"
|
||||
|
||||
def test_image_block_string_payload_forwarded(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what is this?"},
|
||||
{"type": "image_url", "image_url": "https://example.com/image.jpg"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
content = result[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert content[1]["image_url"] == {"url": "https://example.com/image.jpg"}
|
||||
|
||||
def test_text_block_missing_text_field_collapses(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"},
|
||||
{"type": "text"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "hi"
|
||||
|
||||
def test_string_content_search_results_folded_into_string(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "summarize the docs",
|
||||
"search_results": [{"source": "kb", "content": [{"text": "article body"}]}],
|
||||
}
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == "summarize the docskbarticle body"
|
||||
|
||||
def test_plain_string_content_message_unchanged(self):
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert result[0] is messages[0]
|
||||
|
||||
def test_empty_content_list_untouched(self):
|
||||
messages = [{"role": "user", "content": []}]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL)
|
||||
|
||||
assert result[0]["content"] == []
|
||||
|
||||
def test_later_messages_still_collapsed_after_forwarded_one(self):
|
||||
messages = [
|
||||
self._image_message(),
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "and "},
|
||||
{"type": "text", "text": "then?"},
|
||||
],
|
||||
},
|
||||
self._image_message(),
|
||||
]
|
||||
|
||||
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
|
||||
|
||||
assert isinstance(result[0]["content"], list)
|
||||
assert result[1]["content"] == "and then?"
|
||||
assert isinstance(result[2]["content"], list)
|
||||
|
||||
def test_transform_request_preserves_image_url_block(self):
|
||||
body = self.config.transform_request(
|
||||
model=self.VISION_MODEL,
|
||||
messages=[self._image_message()],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
content = body["messages"][0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert any(block.get("type") == "image_url" for block in content)
|
||||
|
||||
async def test_async_transform_request_preserves_image_url_block(self):
|
||||
body = await self.config.async_transform_request(
|
||||
model=self.VISION_MODEL,
|
||||
messages=[self._image_message()],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
content = body["messages"][0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert any(block.get("type") == "image_url" for block in content)
|
||||
|
||||
|
||||
class TestDeepSeekThinkingParams:
|
||||
"""Test thinking and reasoning_effort parameter handling for DeepSeek."""
|
||||
|
||||
|
|
@ -282,8 +561,6 @@ class TestDeepSeekThinkingParams:
|
|||
|
||||
result = self.config._drop_unsupported_tools(optional_params)
|
||||
|
||||
assert result["tools"] == [
|
||||
{"type": "function", "function": {"name": "get_weather"}}
|
||||
]
|
||||
assert result["tools"] == [{"type": "function", "function": {"name": "get_weather"}}]
|
||||
assert "tool_choice" not in result
|
||||
assert result["parallel_tool_calls"] is True
|
||||
|
|
|
|||
|
|
@ -73,7 +73,13 @@ def test_validate_environment_sets_session_affinity_from_session_id():
|
|||
assert headers["x-session-affinity"] == "session-id-123"
|
||||
|
||||
|
||||
def test_validate_environment_sets_session_affinity_from_trace_id():
|
||||
def test_validate_environment_ignores_trace_id_for_session_affinity():
|
||||
"""A trace id must not become the session id.
|
||||
|
||||
litellm_trace_id defaults to a fresh uuid4 per request, so pinning
|
||||
x-session-affinity to it sent every request to a different Fireworks node and
|
||||
prompt caching never hit (cached_tokens stayed 0 across identical prompts).
|
||||
"""
|
||||
config = FireworksAIConfig()
|
||||
|
||||
headers = config.validate_environment(
|
||||
|
|
@ -85,7 +91,25 @@ def test_validate_environment_sets_session_affinity_from_trace_id():
|
|||
api_key="test-key",
|
||||
)
|
||||
|
||||
assert headers["x-session-affinity"] == "trace-id-123"
|
||||
assert "x-session-affinity" not in headers
|
||||
|
||||
|
||||
def test_validate_environment_prefers_session_id_over_trace_id():
|
||||
config = FireworksAIConfig()
|
||||
|
||||
headers = config.validate_environment(
|
||||
headers={},
|
||||
model="accounts/fireworks/models/test-model",
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={
|
||||
"litellm_session_id": "session-123",
|
||||
"litellm_trace_id": "trace-id-123",
|
||||
},
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
assert headers["x-session-affinity"] == "session-123"
|
||||
|
||||
|
||||
def test_validate_environment_does_not_set_session_affinity_without_session_id():
|
||||
|
|
|
|||
|
|
@ -1298,8 +1298,7 @@ def test_gemini_realtime_pipecat_ga_session_voice_and_tools(patch_gemini_audio_c
|
|||
assert len(messages) == 1
|
||||
setup = json.loads(messages[0])["setup"]
|
||||
assert setup["generationConfig"]["responseModalities"] == ["AUDIO"]
|
||||
# Native-audio Live rejects speechConfig on setup (see _finalize_gemini_live_setup).
|
||||
assert "speechConfig" not in setup.get("generationConfig", {})
|
||||
assert setup["generationConfig"]["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
|
||||
assert setup["tools"][0]["function_declarations"][0]["name"] == "terminate_call"
|
||||
assert setup["realtimeInputConfig"]["automaticActivityDetection"]["disabled"] is False
|
||||
|
||||
|
|
@ -1843,20 +1842,6 @@ def test_is_audio_only_live_model_uses_cost_map(model, expected, patch_gemini_au
|
|||
assert GeminiRealtimeConfig._is_audio_only_live_model(model) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected",
|
||||
[
|
||||
("gemini-2.5-flash-native-audio-latest", True),
|
||||
("gemini/gemini-2.5-flash-native-audio-latest", True),
|
||||
("gemini-3.1-flash-live-preview", False),
|
||||
("gemini/gemini-3.1-flash-live-preview", False),
|
||||
("gemini-2.0-flash", False),
|
||||
],
|
||||
)
|
||||
def test_is_native_audio_model_uses_cost_map(model, expected, patch_gemini_audio_cost_map_entries):
|
||||
assert GeminiRealtimeConfig._is_native_audio_model(model) == expected
|
||||
|
||||
|
||||
def test_is_setup_message_and_is_content_message():
|
||||
config = GeminiRealtimeConfig()
|
||||
assert config.is_setup_message({"setup": {}}) is True
|
||||
|
|
@ -1865,3 +1850,17 @@ def test_is_setup_message_and_is_content_message():
|
|||
assert config.is_content_message({"clientContent": {}}) is True
|
||||
assert config.is_content_message({"toolResponse": {}}) is True
|
||||
assert config.is_content_message({"setup": {}}) is False
|
||||
|
||||
|
||||
def test_map_openai_params_drops_stock_voice_case_insensitively():
|
||||
"""Regression: OpenAI stock voices are dropped regardless of casing so Gemini Live keeps its default voice.
|
||||
|
||||
Non-OpenAI names pass through verbatim.
|
||||
"""
|
||||
cfg = GeminiRealtimeConfig()
|
||||
|
||||
dropped = cfg.map_openai_params(optional_params={}, non_default_params={"voice": "Alloy"})
|
||||
assert "speechConfig" not in dropped.get("generationConfig", {})
|
||||
|
||||
passthrough = cfg.map_openai_params(optional_params={}, non_default_params={"voice": "Kore"})
|
||||
assert passthrough["generationConfig"]["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import os
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.gemini.cost_calculator import cost_per_web_search_request
|
||||
from litellm.llms.gemini.cost_calculator import (
|
||||
cost_per_google_maps_grounding_request,
|
||||
cost_per_web_search_request,
|
||||
)
|
||||
from litellm.llms.gemini.image_edit.cost_calculator import (
|
||||
cost_calculator as gemini_image_edit_cost_calculator,
|
||||
)
|
||||
|
|
@ -81,6 +84,63 @@ def test_no_usage_details():
|
|||
assert cost == 0.0
|
||||
|
||||
|
||||
def _make_maps_usage(google_maps_grounding_requests: int) -> Usage:
|
||||
return Usage(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
total_tokens=150,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
google_maps_grounding_requests=google_maps_grounding_requests,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_maps_per_query_billing():
|
||||
"""web_search_billing_unit=per_query charges per Maps query."""
|
||||
model_info = {
|
||||
"key": "gemini/gemini-3.5-flash",
|
||||
"web_search_billing_unit": "per_query",
|
||||
"google_maps_grounding_cost_per_query": 0.014,
|
||||
}
|
||||
cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(3), model_info=model_info)
|
||||
assert cost == pytest.approx(0.014 * 3)
|
||||
|
||||
|
||||
def test_maps_per_prompt_billing_clamps_to_one():
|
||||
"""Without web_search_billing_unit, Maps grounding is one flat fee per grounded prompt."""
|
||||
model_info = {
|
||||
"key": "gemini/gemini-2.5-flash",
|
||||
"google_maps_grounding_cost_per_query": 0.025,
|
||||
}
|
||||
cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(3), model_info=model_info)
|
||||
assert cost == pytest.approx(0.025)
|
||||
|
||||
|
||||
def test_maps_default_rate_per_query():
|
||||
"""A per_query model missing the pricing key falls back to Google's $14/1K queries."""
|
||||
model_info = {"key": "gemini/gemini-3.9-flash", "web_search_billing_unit": "per_query"}
|
||||
cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(2), model_info=model_info)
|
||||
assert cost == pytest.approx(0.014 * 2)
|
||||
|
||||
|
||||
def test_maps_default_rate_per_prompt():
|
||||
"""A per_prompt model missing the pricing key falls back to Google's $25/1K grounded prompts."""
|
||||
model_info = {"key": "gemini/gemini-2.6-flash"}
|
||||
cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(2), model_info=model_info)
|
||||
assert cost == pytest.approx(0.025)
|
||||
|
||||
|
||||
def test_maps_zero_requests():
|
||||
model_info = {"key": "gemini/gemini-3.5-flash", "web_search_billing_unit": "per_query"}
|
||||
assert cost_per_google_maps_grounding_request(usage=_make_maps_usage(0), model_info=model_info) == 0.0
|
||||
|
||||
|
||||
def test_maps_no_usage_details():
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
|
||||
model_info = {"key": "gemini/gemini-3.5-flash"}
|
||||
assert cost_per_google_maps_grounding_request(usage=usage, model_info=model_info) == 0.0
|
||||
|
||||
|
||||
def test_gemini_image_edit_cost_prefers_token_usage_metadata(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
|
@ -301,3 +361,33 @@ def test_gemini_image_generation_cost_no_web_search_when_absent(monkeypatch):
|
|||
)
|
||||
|
||||
assert cost_zero == cost_none
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"traffic_type, expected_service_tier",
|
||||
[
|
||||
("ON_DEMAND", None),
|
||||
("ON_DEMAND_PRIORITY", "priority"),
|
||||
("FLEX", "flex"),
|
||||
("BATCH", "flex"),
|
||||
# Vertex AI reports flex/shared-capacity traffic as ON_DEMAND_FLEX.
|
||||
("ON_DEMAND_FLEX", "flex"),
|
||||
# trafficType is matched case-insensitively.
|
||||
("on_demand_flex", "flex"),
|
||||
(None, None),
|
||||
("SOMETHING_UNKNOWN", None),
|
||||
],
|
||||
)
|
||||
def test_map_traffic_type_to_service_tier(
|
||||
traffic_type: str | None, expected_service_tier: str | None
|
||||
):
|
||||
"""
|
||||
Gemini/Vertex usageMetadata.trafficType maps to the LiteLLM service_tier
|
||||
that selects flex/priority cost keys. ON_DEMAND_FLEX (Vertex's flex opt-in
|
||||
value) must map to "flex" so flex-tier requests are not billed as standard.
|
||||
"""
|
||||
from litellm.cost_calculator import _map_traffic_type_to_service_tier
|
||||
|
||||
assert (
|
||||
_map_traffic_type_to_service_tier(traffic_type) == expected_service_tier
|
||||
)
|
||||
|
|
|
|||
|
|
@ -436,10 +436,9 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
catalog entries that lack ``mode``).
|
||||
|
||||
Exercises the real ``_cached_get_model_info_helper`` plumbing via
|
||||
``register_model`` (no mock). ``supported_endpoints`` is not carried on
|
||||
the normalized ``ModelInfoBase`` the helper returns, so the gate must
|
||||
read it from the raw ``litellm.model_cost`` entry; a mock-based test
|
||||
would mask that.
|
||||
``register_model`` (no mock). The gate reads ``supported_endpoints``
|
||||
from the raw ``litellm.model_cost`` entry; a mock-based test would
|
||||
mask that.
|
||||
"""
|
||||
litellm.register_model(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -142,3 +142,33 @@ if __name__ == "__main__":
|
|||
print("✓ Provider config manager test passed")
|
||||
|
||||
print("\n✅ All basic tests passed!")
|
||||
|
||||
|
||||
def test_minimax_messages_env_key_attached(monkeypatch):
|
||||
"""Regression: an env-only MINIMAX_API_KEY must be attached on /v1/messages validation"""
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-env-key")
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
|
||||
config = MinimaxMessagesConfig()
|
||||
headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
model="MiniMax-M2.1",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert headers["x-api-key"] == "test-minimax-env-key"
|
||||
|
||||
|
||||
def test_minimax_messages_explicit_key_wins_over_env(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "env-key")
|
||||
config = MinimaxMessagesConfig()
|
||||
headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
model="MiniMax-M2.1",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
api_key="param-key",
|
||||
)
|
||||
assert headers["x-api-key"] == "param-key"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
from litellm.llms.vertex_ai.gemini.grounding_requests import (
|
||||
GroundingRequests,
|
||||
calculate_grounding_requests,
|
||||
)
|
||||
|
||||
|
||||
def test_search_only_counts_non_empty_queries_as_web_requests():
|
||||
result = calculate_grounding_requests(
|
||||
[
|
||||
{
|
||||
"webSearchQueries": ["", "capital of France", "France capital"],
|
||||
"groundingChunks": [{"web": {"uri": "https://example.com", "title": "Example"}}],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert result == GroundingRequests(web_search_requests=2, google_maps_grounding_requests=None)
|
||||
|
||||
|
||||
def test_gemini_api_maps_only_counts_queries_as_maps_requests():
|
||||
result = calculate_grounding_requests(
|
||||
[
|
||||
{
|
||||
"webSearchQueries": ["coffee shops near the Louvre"],
|
||||
"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1)
|
||||
|
||||
|
||||
def test_vertex_maps_only_without_queries_counts_one_maps_request():
|
||||
result = calculate_grounding_requests(
|
||||
[
|
||||
{
|
||||
"groundingChunks": [
|
||||
{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}},
|
||||
{"maps": {"uri": "https://maps.google.com/?cid=2", "placeId": "p2"}},
|
||||
],
|
||||
"groundingSupports": [],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1)
|
||||
|
||||
|
||||
def test_widget_context_token_alone_counts_one_maps_request():
|
||||
result = calculate_grounding_requests([{"googleMapsWidgetContextToken": "widget-token"}])
|
||||
assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1)
|
||||
|
||||
|
||||
def test_combined_web_and_maps_chunks_split_between_both_counters():
|
||||
result = calculate_grounding_requests(
|
||||
[
|
||||
{
|
||||
"webSearchQueries": ["q1", "q2"],
|
||||
"groundingChunks": [
|
||||
{"web": {"uri": "https://example.com"}},
|
||||
{"maps": {"uri": "https://maps.google.com/?cid=1"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert result == GroundingRequests(web_search_requests=2, google_maps_grounding_requests=1)
|
||||
|
||||
|
||||
def test_url_context_grounding_chunks_without_queries_count_nothing():
|
||||
result = calculate_grounding_requests([{"groundingChunks": [{"web": {"uri": "https://example.com"}}]}])
|
||||
assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=None)
|
||||
|
||||
|
||||
def test_counters_count_distinct_queries_across_candidates():
|
||||
result = calculate_grounding_requests(
|
||||
[
|
||||
{"webSearchQueries": ["a"]},
|
||||
{"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1"}}]},
|
||||
{"webSearchQueries": ["b", "c"], "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=2"}}]},
|
||||
]
|
||||
)
|
||||
assert result == GroundingRequests(web_search_requests=1, google_maps_grounding_requests=2)
|
||||
|
||||
|
||||
def test_duplicate_queries_across_candidates_collapse_per_bucket():
|
||||
result = calculate_grounding_requests(
|
||||
[
|
||||
{"webSearchQueries": ["shared", "web only"], "groundingChunks": [{"web": {"uri": "https://e.com"}}]},
|
||||
{"webSearchQueries": ["shared"], "groundingChunks": [{"web": {"uri": "https://e.com"}}]},
|
||||
{"webSearchQueries": ["maps q", "maps q"], "groundingChunks": [{"maps": {"uri": "https://m.com"}}]},
|
||||
{"webSearchQueries": ["maps q"], "groundingChunks": [{"maps": {"uri": "https://m.com"}}]},
|
||||
]
|
||||
)
|
||||
assert result == GroundingRequests(web_search_requests=2, google_maps_grounding_requests=1)
|
||||
|
||||
|
||||
def test_empty_metadata_counts_nothing():
|
||||
result = calculate_grounding_requests([])
|
||||
assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=None)
|
||||
|
||||
|
||||
def test_has_billable_grounding():
|
||||
assert GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1).has_billable_grounding()
|
||||
assert GroundingRequests(web_search_requests=1, google_maps_grounding_requests=None).has_billable_grounding()
|
||||
assert not GroundingRequests(web_search_requests=None, google_maps_grounding_requests=None).has_billable_grounding()
|
||||
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
import json
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import List, cast
|
||||
from typing import Final, List, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -549,9 +549,10 @@ def test_vertex_ai_non_grounded_usage_omits_tool_use_tokens():
|
|||
|
||||
def test_response_has_search_grounding_detection():
|
||||
"""
|
||||
Only groundingMetadata.webSearchQueries signals an actual Google Search. URL context also
|
||||
emits groundingMetadata (groundingChunks but no webSearchQueries) and must not be treated
|
||||
as search grounding.
|
||||
groundingMetadata.webSearchQueries signals an actual Google Search and
|
||||
groundingMetadata.groundingChunks[].maps signals a Google Maps lookup. URL context also
|
||||
emits groundingMetadata (web groundingChunks but no webSearchQueries) and must not be
|
||||
treated as billable grounding.
|
||||
"""
|
||||
assert (
|
||||
VertexGeminiConfig._response_has_search_grounding(
|
||||
|
|
@ -580,6 +581,101 @@ def test_response_has_search_grounding_detection():
|
|||
)
|
||||
assert VertexGeminiConfig._response_has_search_grounding({"candidates": []}) is False
|
||||
assert VertexGeminiConfig._response_has_search_grounding({}) is False
|
||||
assert (
|
||||
VertexGeminiConfig._response_has_search_grounding(
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"groundingMetadata": {
|
||||
"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_vertex_ai_maps_grounding_tool_use_tokens_excluded_from_prompt_tokens():
|
||||
"""
|
||||
Grounding with Google Maps retrieved tokens are billed like Google Search grounding: a
|
||||
separate per-request / per-query fee, with toolUsePromptTokenCount surfaced on
|
||||
prompt_tokens_details.tool_use_tokens but excluded from prompt_tokens. Before Maps detection
|
||||
existed, a Vertex AI Maps-only response folded the 120 tool-use tokens into prompt_tokens.
|
||||
Regression for https://github.com/BerriAI/litellm/issues/35906
|
||||
"""
|
||||
v = VertexGeminiConfig()
|
||||
completion_response = {
|
||||
"candidates": [
|
||||
{
|
||||
"groundingMetadata": {
|
||||
"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}]
|
||||
}
|
||||
}
|
||||
],
|
||||
"usageMetadata": UsageMetadata(
|
||||
promptTokenCount=15,
|
||||
candidatesTokenCount=100,
|
||||
toolUsePromptTokenCount=120,
|
||||
totalTokenCount=235,
|
||||
),
|
||||
}
|
||||
|
||||
usage = v._calculate_usage(completion_response=completion_response)
|
||||
|
||||
assert usage.prompt_tokens == 15
|
||||
assert usage.completion_tokens == 100
|
||||
assert usage.total_tokens == 235
|
||||
assert usage.prompt_tokens_details.tool_use_tokens == 120
|
||||
|
||||
|
||||
def test_vertex_ai_maps_grounding_sets_google_maps_grounding_requests_non_streaming():
|
||||
"""
|
||||
A Vertex AI Maps-only response (groundingChunks[].maps, no webSearchQueries) must set
|
||||
google_maps_grounding_requests and leave web_search_requests unset, so the Maps fee is
|
||||
billed instead of nothing (Vertex) or the Google Search fee (Gemini API).
|
||||
"""
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
||||
completion_response = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Here are some coffee shops"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
"groundingMetadata": {
|
||||
"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}],
|
||||
"groundingSupports": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 15,
|
||||
"candidatesTokenCount": 100,
|
||||
"totalTokenCount": 115,
|
||||
},
|
||||
}
|
||||
|
||||
raw_response = MagicMock()
|
||||
raw_response.json.return_value = completion_response
|
||||
|
||||
result = VertexGeminiConfig().transform_response(
|
||||
model="gemini-2.5-flash",
|
||||
raw_response=raw_response,
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=MagicMock(),
|
||||
request_data={},
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
usage = result.usage
|
||||
assert usage.prompt_tokens_details.google_maps_grounding_requests == 1
|
||||
assert not hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
|
||||
|
||||
def test_vertex_ai_search_grounding_tool_use_tokens_excluded_from_prompt_tokens():
|
||||
|
|
@ -1292,6 +1388,66 @@ def test_vertex_ai_streaming_usage_web_search_calculation():
|
|||
assert usage.prompt_tokens_details.web_search_requests == 2
|
||||
|
||||
|
||||
def test_vertex_ai_maps_grounding_chunk_parser_sets_maps_requests():
|
||||
"""A Vertex-shaped Maps-only streaming chunk sets the Maps counter and not the Search one."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
ModelResponseIterator,
|
||||
)
|
||||
|
||||
chunk = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Here"}]},
|
||||
"groundingMetadata": {
|
||||
"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}],
|
||||
"groundingSupports": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usageMetadata": {"promptTokenCount": 15, "candidatesTokenCount": 10, "totalTokenCount": 25},
|
||||
}
|
||||
|
||||
iterator = ModelResponseIterator(streaming_response=[], sync_stream=True, logging_obj=MagicMock())
|
||||
completed_response = iterator.chunk_parser(chunk)
|
||||
|
||||
usage = completed_response.usage
|
||||
assert usage.prompt_tokens_details.google_maps_grounding_requests == 1
|
||||
assert not hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
|
||||
|
||||
def test_gemini_api_maps_grounding_chunk_parser_counts_queries_as_maps_requests():
|
||||
"""A Gemini-API-shaped Maps chunk (webSearchQueries plus maps chunks) bills Maps, not Search."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
ModelResponseIterator,
|
||||
)
|
||||
|
||||
chunk = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Here"}]},
|
||||
"groundingMetadata": [
|
||||
{
|
||||
"webSearchQueries": ["coffee shops near the Louvre"],
|
||||
"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"usageMetadata": {"promptTokenCount": 15, "candidatesTokenCount": 10, "totalTokenCount": 25},
|
||||
}
|
||||
|
||||
iterator = ModelResponseIterator(streaming_response=[], sync_stream=True, logging_obj=MagicMock())
|
||||
completed_response = iterator.chunk_parser(chunk)
|
||||
|
||||
usage = completed_response.usage
|
||||
assert usage.prompt_tokens_details.google_maps_grounding_requests == 1
|
||||
assert not hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
|
||||
|
||||
def test_vertex_ai_transform_parts():
|
||||
"""
|
||||
Test the _transform_parts method for converting Vertex AI function calls
|
||||
|
|
@ -5579,3 +5735,25 @@ def test_accumulated_json_async_end_of_stream_drains_buffered_value():
|
|||
result = asyncio.run(iterator.__anext__())
|
||||
assert result is not None
|
||||
assert result.choices[0].delta.content == "a"
|
||||
|
||||
|
||||
def test_calculate_web_search_requests_counts_unique_queries():
|
||||
"""Gemini 3 per_query billing charges per unique query executed, not per emitted string.
|
||||
|
||||
Regression for #36377: duplicate webSearchQueries within and across grounding
|
||||
metadata items must collapse to the distinct-query count, and empty strings must
|
||||
be ignored, matching Google's documented Grounding-with-Search billing rule.
|
||||
"""
|
||||
duplicates_in_one_item: Final = [
|
||||
{"webSearchQueries": ["euro 2024 winner", "euro 2024 winner", "spain england final", ""]}
|
||||
]
|
||||
assert VertexGeminiConfig._calculate_web_search_requests(duplicates_in_one_item) == 2
|
||||
|
||||
duplicates_across_items: Final = [
|
||||
{"webSearchQueries": ["euro 2024 winner"]},
|
||||
{"webSearchQueries": ["euro 2024 winner", "spain england final"]},
|
||||
]
|
||||
assert VertexGeminiConfig._calculate_web_search_requests(duplicates_across_items) == 2
|
||||
|
||||
assert VertexGeminiConfig._calculate_web_search_requests([]) is None
|
||||
assert VertexGeminiConfig._calculate_web_search_requests([{"webSearchQueries": ["", ""]}]) is None
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
import pytest
|
||||
import websockets.exceptions # registers websockets.exceptions on the websockets namespace
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.llms.vertex_ai.realtime.transformation import VertexAIRealtimeConfig
|
||||
|
||||
|
|
@ -278,7 +277,7 @@ async def test_vertex_realtime_text_in_text_out():
|
|||
SERVER_TURN_COMPLETE,
|
||||
]
|
||||
|
||||
async def _backend_recv(decode=True): # noqa: ARG001
|
||||
async def _backend_recv(decode=True):
|
||||
if not upstream_messages:
|
||||
# Signal normal connection close so the loop exits cleanly
|
||||
raise websockets.exceptions.ConnectionClosedOK(None, None) # type: ignore[arg-type]
|
||||
|
|
@ -462,3 +461,98 @@ def test_vertex_function_call_output_omits_id():
|
|||
assert "id" not in function_response
|
||||
assert function_response["name"] == "terminate_call"
|
||||
assert function_response["response"] == {"status": "ok"}
|
||||
|
||||
|
||||
def test_vertex_native_audio_keeps_requested_voice(patch_native_audio_cost_map_entry):
|
||||
"""Regression: Vertex Live accepts speechConfig on native audio, so the client's voice must survive.
|
||||
|
||||
Stripping it silently dropped voice selection for every Vertex native-audio
|
||||
session. TEXT is still coerced away, which Vertex does reject.
|
||||
"""
|
||||
cfg = VertexAIRealtimeConfig(
|
||||
access_token="tok", project="my-proj", location="us-central1"
|
||||
)
|
||||
session_update = {
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"output_modalities": ["text"],
|
||||
"audio": {"output": {"voice": "Aoede"}},
|
||||
},
|
||||
}
|
||||
|
||||
messages = cfg.transform_realtime_request(
|
||||
json.dumps(session_update),
|
||||
_NATIVE_AUDIO_MODEL,
|
||||
session_configuration_request=None,
|
||||
)
|
||||
|
||||
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
|
||||
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Aoede"
|
||||
assert generation_config["responseModalities"] == ["AUDIO"]
|
||||
|
||||
|
||||
def test_google_ai_studio_native_audio_keeps_requested_voice(patch_native_audio_cost_map_entry):
|
||||
"""Regression: AI Studio native-audio Live accepts speechConfig too, so the voice survives on both providers."""
|
||||
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
|
||||
|
||||
messages = GeminiRealtimeConfig().transform_realtime_request(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"output_modalities": ["audio"],
|
||||
"audio": {"output": {"voice": "Aoede"}},
|
||||
},
|
||||
}
|
||||
),
|
||||
_NATIVE_AUDIO_MODEL,
|
||||
session_configuration_request=None,
|
||||
)
|
||||
|
||||
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
|
||||
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Aoede"
|
||||
|
||||
|
||||
def test_vertex_native_audio_drops_openai_stock_voice(patch_native_audio_cost_map_entry):
|
||||
"""Regression: OpenAI stock voice names must be dropped, not forwarded verbatim.
|
||||
|
||||
Vertex Live closes the socket with 1007 on an unknown voice name, so a
|
||||
client sending OpenAI's default voice would lose the session entirely.
|
||||
Dropping the voice keeps the session alive on the model's default voice.
|
||||
"""
|
||||
cfg = VertexAIRealtimeConfig(
|
||||
access_token="tok", project="my-proj", location="us-central1"
|
||||
)
|
||||
session_update = {
|
||||
"type": "session.update",
|
||||
"session": {"audio": {"output": {"voice": "alloy"}}},
|
||||
}
|
||||
|
||||
messages = cfg.transform_realtime_request(
|
||||
json.dumps(session_update),
|
||||
_NATIVE_AUDIO_MODEL,
|
||||
session_configuration_request=None,
|
||||
)
|
||||
|
||||
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
|
||||
assert "speechConfig" not in generation_config
|
||||
|
||||
|
||||
def test_vertex_native_audio_unmapped_voice_passes_through(patch_native_audio_cost_map_entry):
|
||||
"""A voice name outside the OpenAI stock set is forwarded verbatim so Gemini-native names keep working."""
|
||||
cfg = VertexAIRealtimeConfig(
|
||||
access_token="tok", project="my-proj", location="us-central1"
|
||||
)
|
||||
session_update = {
|
||||
"type": "session.update",
|
||||
"session": {"audio": {"output": {"voice": "Kore"}}},
|
||||
}
|
||||
|
||||
messages = cfg.transform_realtime_request(
|
||||
json.dumps(session_update),
|
||||
_NATIVE_AUDIO_MODEL,
|
||||
session_configuration_request=None,
|
||||
)
|
||||
|
||||
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
|
||||
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
|
||||
|
|
|
|||
|
|
@ -7394,3 +7394,57 @@ async def test_invalidate_team_member_spend_state_self_delivered_broadcast_does_
|
|||
assert (
|
||||
local_spend_counter_cache.in_memory_cache.get_cache("spend_db_floor:spend:team_member:user-1:team-1") == 0.0
|
||||
), "the handler's self-delivered broadcast erased the post-reset floor marker, reopening the stale-floor race"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_cache_key_object_is_best_effort_when_the_cache_backend_fails(caplog):
|
||||
"""
|
||||
LIT-5898: `_delete_cache_key_object` must not propagate a cache-backend error.
|
||||
|
||||
Every caller runs it after its own write has committed, so a raise here turned a persisted
|
||||
`/key/update` into `400 Authentication Error` (and `/key/block`, `/key/regenerate` into 500s)
|
||||
for operators whose Redis ACL denies `DEL` on LiteLLM's unprefixed token-hash keys. The
|
||||
in-memory entry is already dropped by then, so raising never made the cache less stale.
|
||||
"""
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy.auth.auth_checks import _delete_cache_key_object
|
||||
|
||||
hashed_token = "a" * 64
|
||||
caplog.set_level(logging.WARNING, logger="LiteLLM Proxy")
|
||||
|
||||
failing_cache = MagicMock()
|
||||
failing_cache.delete_cache = MagicMock()
|
||||
failing_logging_obj = MagicMock()
|
||||
failing_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock(
|
||||
side_effect=Exception("No permissions to access a key")
|
||||
)
|
||||
|
||||
await _delete_cache_key_object(
|
||||
hashed_token=hashed_token,
|
||||
user_api_key_cache=failing_cache,
|
||||
proxy_logging_obj=failing_logging_obj,
|
||||
)
|
||||
|
||||
failing_cache.delete_cache.assert_called_once_with(key=hashed_token)
|
||||
failing_logging_obj.internal_usage_cache.dual_cache.async_delete_cache.assert_awaited_once_with(key=hashed_token)
|
||||
assert any("Failed to invalidate cached key entry" in record.getMessage() for record in caplog.records), (
|
||||
"a swallowed cache-eviction failure must still be logged, or a stale auth entry goes unnoticed"
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
healthy_cache = MagicMock()
|
||||
healthy_cache.delete_cache = MagicMock()
|
||||
healthy_logging_obj = MagicMock()
|
||||
healthy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock()
|
||||
|
||||
await _delete_cache_key_object(
|
||||
hashed_token=hashed_token,
|
||||
user_api_key_cache=healthy_cache,
|
||||
proxy_logging_obj=healthy_logging_obj,
|
||||
)
|
||||
|
||||
healthy_cache.delete_cache.assert_called_once_with(key=hashed_token)
|
||||
healthy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache.assert_awaited_once_with(key=hashed_token)
|
||||
assert caplog.records == [], "a healthy eviction must stay silent, and must still reach both caches"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
from litellm.proxy.common_utils.callback_config_validation import (
|
||||
callback_config_error,
|
||||
)
|
||||
|
||||
|
||||
def test_callback_config_error_rejects_invalid_langfuse_environment():
|
||||
for callback in ["langfuse", "langfuse_otel"]:
|
||||
error = callback_config_error(callback, {"langfuse_environment": "Production"})
|
||||
assert error is not None and "langfuse_environment" in error
|
||||
|
||||
assert callback_config_error("langfuse", {"langfuse_environment": "team-a-prod"}) is None
|
||||
assert callback_config_error("langfuse", {"langfuse_public_key": "pk"}) is None
|
||||
|
|
@ -2419,6 +2419,74 @@ def test_clean_endpoint_data_strips_credentials_keeps_routing_fields():
|
|||
assert cleaned.get("api_version") == "2024-10-21"
|
||||
|
||||
|
||||
def test_clean_endpoint_data_strips_extra_headers_and_aws_session_token():
|
||||
"""
|
||||
gh-36898: GET /health must not leak provider credentials that live in
|
||||
`extra_headers` / `headers` / `aws_session_token`. Before the fix these
|
||||
were returned in plaintext (api_key was stripped, but these were not).
|
||||
"""
|
||||
from litellm.proxy.health_check import _clean_endpoint_data
|
||||
|
||||
raw = {
|
||||
"model": "openai/gpt-4o",
|
||||
"api_base": "https://example.test/v1",
|
||||
"extra_headers": {
|
||||
"Authorization": "Bearer CANARY_EXTRA_HEADERS_AUTHORIZATION",
|
||||
"x-goog-api-key": "CANARY_X_GOOG_API_KEY_VALUE",
|
||||
"api-key": "CANARY_AZURE_STYLE_API_KEY",
|
||||
},
|
||||
"headers": {"X-Custom": "CANARY_HEADER_VALUE"},
|
||||
"aws_session_token": "CANARY_AWS_SESSION_TOKEN_VALUE",
|
||||
}
|
||||
|
||||
cleaned = _clean_endpoint_data(raw, details=True)
|
||||
|
||||
assert "extra_headers" not in cleaned
|
||||
assert "headers" not in cleaned
|
||||
assert "aws_session_token" not in cleaned
|
||||
assert cleaned.get("api_base") == "https://example.test/v1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"credential_field",
|
||||
[
|
||||
"api_key",
|
||||
"client_secret",
|
||||
"azure_ad_token",
|
||||
"azure_username",
|
||||
"azure_password",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
"aws_web_identity_token",
|
||||
"vertex_credentials",
|
||||
"vertex_ai_credentials",
|
||||
"extra_headers",
|
||||
"headers",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("details", [True, False, None])
|
||||
def test_clean_endpoint_data_never_displays_credential_fields(credential_field, details):
|
||||
"""
|
||||
LIT-6239 / gh-36898: /health entries, healthy and unhealthy alike, must never
|
||||
carry credential-bearing litellm_params, with or without details.
|
||||
"""
|
||||
from litellm.proxy.health_check import _clean_endpoint_data
|
||||
|
||||
canary = f"CANARY-{credential_field}-VALUE"
|
||||
cleaned = _clean_endpoint_data(
|
||||
{
|
||||
"model": "azure/gpt-5-mini",
|
||||
"api_base": "https://example.test/v1",
|
||||
credential_field: canary,
|
||||
},
|
||||
details=details,
|
||||
)
|
||||
|
||||
assert credential_field not in cleaned
|
||||
assert canary not in str(cleaned)
|
||||
|
||||
|
||||
class TestConfigBaseForHealthCheck:
|
||||
"""A request that sets its own connection fields gets a base without the
|
||||
configuration's credentials; anything it leaves unset still comes from
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
ModelManagementAuthChecks,
|
||||
_get_team_deployments,
|
||||
_raise_if_rate_limits_required_but_missing,
|
||||
clear_cache,
|
||||
delete_team_models,
|
||||
)
|
||||
|
|
@ -4256,3 +4257,41 @@ class TestAutoRouterClassifierDefaultPrompt:
|
|||
for empty in (None, "", "{}"):
|
||||
response = await get_auto_router_classifier_default_prompt(context_window_size=5, tier_labels=empty)
|
||||
assert response.system_prompt == classification_system_prompt(5)
|
||||
|
||||
|
||||
class TestEnforceRpmTpmOnModelAdd:
|
||||
def test_passes_when_disabled_even_without_limits(self):
|
||||
assert (
|
||||
_raise_if_rate_limits_required_but_missing(
|
||||
litellm_params=LiteLLM_Params(model="azure/gpt-5.2"),
|
||||
enforced=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_passes_when_enabled_and_both_set(self):
|
||||
assert (
|
||||
_raise_if_rate_limits_required_but_missing(
|
||||
litellm_params=LiteLLM_Params(model="azure/gpt-5.2", rpm=10, tpm=1000),
|
||||
enforced=True,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params, expected_missing",
|
||||
[
|
||||
(LiteLLM_Params(model="azure/gpt-5.2"), "rpm and tpm"),
|
||||
(LiteLLM_Params(model="azure/gpt-5.2", rpm=10), "tpm"),
|
||||
(LiteLLM_Params(model="azure/gpt-5.2", tpm=1000), "rpm"),
|
||||
(LiteLLM_Params(model="azure/gpt-5.2", rpm=0, tpm=1000), "rpm"),
|
||||
(LiteLLM_Params(model="azure/gpt-5.2", rpm=10, tpm=-1), "tpm"),
|
||||
],
|
||||
)
|
||||
def test_raises_when_enabled_and_missing(self, params, expected_missing):
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
_raise_if_rate_limits_required_but_missing(litellm_params=params, enforced=True)
|
||||
assert expected_missing in str(exc_info.value.message)
|
||||
assert exc_info.value.code == "400"
|
||||
|
|
|
|||
|
|
@ -2366,6 +2366,7 @@ async def test_update_team_team_member_budget_not_passed_to_db(
|
|||
team_member_rpm_limit=None,
|
||||
team_member_tpm_limit=None,
|
||||
team_member_budget_duration=None,
|
||||
explicitly_set_fields=frozenset(),
|
||||
):
|
||||
# Remove team_member_budget from updated_kv as the real function does
|
||||
result_kv = updated_kv.copy()
|
||||
|
|
@ -2738,6 +2739,138 @@ async def test_upsert_team_member_budget_table_no_existing_budget():
|
|||
assert "team_member_budget_duration" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_team_member_budget_table_clears_duration_kept_budget(mock_db_client):
|
||||
"""
|
||||
A request that keeps team_member_budget but explicitly nulls
|
||||
team_member_budget_duration must clear the reset period and its reset time.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
TeamMemberBudgetHandler,
|
||||
)
|
||||
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
|
||||
)
|
||||
|
||||
team_table = MagicMock(spec=LiteLLM_TeamTable)
|
||||
team_table.metadata = {"team_member_budget_id": "existing_budget_123"}
|
||||
|
||||
mock_db_client.db.litellm_budgettable.update = AsyncMock(
|
||||
side_effect=lambda where, data: SimpleNamespace(**data)
|
||||
)
|
||||
|
||||
result = await TeamMemberBudgetHandler.upsert_team_member_budget_table(
|
||||
team_table=team_table,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
updated_kv={
|
||||
"team_id": "test_team_id",
|
||||
"team_member_budget": 100.0,
|
||||
"team_member_budget_duration": None,
|
||||
},
|
||||
team_member_budget=100.0,
|
||||
team_member_budget_duration=None,
|
||||
explicitly_set_fields={
|
||||
"team_member_budget",
|
||||
"team_member_budget_duration",
|
||||
},
|
||||
)
|
||||
|
||||
written = mock_db_client.db.litellm_budgettable.update.call_args.kwargs["data"]
|
||||
assert written["max_budget"] == 100.0
|
||||
assert written["budget_duration"] is None
|
||||
assert written["budget_reset_at"] is None
|
||||
assert "rpm_limit" not in written
|
||||
assert "tpm_limit" not in written
|
||||
assert result["metadata"]["team_member_budget_id"] == "existing_budget_123"
|
||||
assert "team_member_budget" not in result
|
||||
assert "team_member_budget_duration" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_member_budget_table_explicit_null_duration_does_not_inherit_team_duration(
|
||||
mock_db_client,
|
||||
):
|
||||
"""
|
||||
A first-time member budget with an explicitly null duration must never
|
||||
reset, even when the team itself has a reset period.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
TeamMemberBudgetHandler,
|
||||
)
|
||||
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
|
||||
)
|
||||
|
||||
team_table = MagicMock(spec=LiteLLM_TeamTable)
|
||||
team_table.metadata = {}
|
||||
team_table.team_alias = "Test Team"
|
||||
team_table.budget_duration = "30d"
|
||||
|
||||
mock_db_client.db.litellm_budgettable.create = AsyncMock(
|
||||
side_effect=lambda data: SimpleNamespace(**data)
|
||||
)
|
||||
|
||||
result = await TeamMemberBudgetHandler.create_team_member_budget_table(
|
||||
data=team_table,
|
||||
new_team_data_json={"team_id": "test_team_id"},
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
team_member_budget=100.0,
|
||||
team_member_budget_duration=None,
|
||||
explicitly_set_fields={
|
||||
"team_member_budget",
|
||||
"team_member_budget_duration",
|
||||
},
|
||||
)
|
||||
|
||||
written = mock_db_client.db.litellm_budgettable.create.call_args.kwargs["data"]
|
||||
assert written["max_budget"] == 100.0
|
||||
assert "budget_duration" not in written
|
||||
assert "budget_reset_at" not in written
|
||||
assert result["metadata"]["team_member_budget_id"] == written["budget_id"]
|
||||
assert "team_member_budget" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_member_budget_table_inherits_team_duration_when_duration_omitted(
|
||||
mock_db_client,
|
||||
):
|
||||
"""
|
||||
Omitting team_member_budget_duration keeps the existing inheritance of the
|
||||
team's own reset period.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
TeamMemberBudgetHandler,
|
||||
)
|
||||
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
|
||||
)
|
||||
|
||||
team_table = MagicMock(spec=LiteLLM_TeamTable)
|
||||
team_table.metadata = {}
|
||||
team_table.team_alias = "Test Team"
|
||||
team_table.budget_duration = "30d"
|
||||
|
||||
mock_db_client.db.litellm_budgettable.create = AsyncMock(
|
||||
side_effect=lambda data: SimpleNamespace(**data)
|
||||
)
|
||||
|
||||
result = await TeamMemberBudgetHandler.create_team_member_budget_table(
|
||||
data=team_table,
|
||||
new_team_data_json={"team_id": "test_team_id"},
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
team_member_budget=100.0,
|
||||
explicitly_set_fields={"team_member_budget"},
|
||||
)
|
||||
|
||||
written = mock_db_client.db.litellm_budgettable.create.call_args.kwargs["data"]
|
||||
assert written["budget_duration"] == "30d"
|
||||
assert written["budget_reset_at"] is not None
|
||||
assert result["metadata"]["team_member_budget_id"] == written["budget_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_with_team_member_budget_duration(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
|
|
@ -2799,6 +2932,7 @@ async def test_update_team_with_team_member_budget_duration(
|
|||
team_member_rpm_limit=None,
|
||||
team_member_tpm_limit=None,
|
||||
team_member_budget_duration=None,
|
||||
explicitly_set_fields=frozenset(),
|
||||
):
|
||||
result_kv = updated_kv.copy()
|
||||
result_kv.pop("team_member_budget", None)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue