Merge branch 'litellm_internal_staging' of https://github.com/BerriAI/litellm into litellm_lit5458_rerank_sigv4_bearer_fix
Some checks failed
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

# Conflicts:
#	tests/test_litellm/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
This commit is contained in:
mateo-berri 2026-08-25 16:03:38 -07:00
commit e47e989341
154 changed files with 10321 additions and 865 deletions

View file

@ -1,7 +1,10 @@
<!-- The whole description's target audience is humans, not AI agents: write it in plain, simple,
everyday engineering language, extremely parsable and readable at a glance. This goes double for
the TLDR, User Flow, and Caveats sections -->
## TLDR
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max
This section must be extremely human parsable, comprehensible, and readable: its target audience is humans, not AI agents -->
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max -->
Problem this solves:
@ -110,8 +113,20 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
## Caveats (if any)
<!-- Short bullet points, just like the TLDR: one line per bullet, roughly 10 words max
<!-- Group caveats under severity subheadings (### Severe, ### High, ### Medium, ### Low), with
short bullet points inside each, just like the TLDR: one line per bullet, roughly 10 words max
Call out known limitations, follow-up work, or anything a reviewer should watch out for
Include only the tiers that have caveats; drop the empty ones
- Severe: inherent to what the PR deliberately ships, there even when the code works as intended:
it can degrade or take down a running deployment (e.g. a slow or table-locking boot migration),
rewrite data by design, break an existing workflow on purpose, or change auth behavior. An
operator must plan around it before rollout
- High: an unintended hole: a correctness, security, data-loss, or backward-compatibility bug,
unsafe to ship as is
- Medium: a real gap someone can hit, but with a workaround or a narrow blast radius
- Low: anything else worth noting: naming, cleanup, an edge case nobody hits
Nest bullets as deep as helps: hierarchy beats one long line when it makes things clearer to a
human reader
Leave this section empty if there are none -->
## QA runbook
@ -134,6 +149,6 @@ Example checklists:
- [ ] Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
-->
### Final Attestation
## Final Attestation
- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

View file

@ -164,6 +164,7 @@ jobs:
tests/test_litellm/proxy/public_endpoints
tests/test_litellm/proxy/prompts
tests/test_litellm/proxy/rag_endpoints
tests/test_litellm/proxy/rerank_endpoints
tests/test_litellm/proxy/realtime_endpoints
tests/test_litellm/proxy/ui_crud_endpoints
tests/test_litellm/proxy/config_resolvers

View file

@ -37,13 +37,14 @@ If you're resolving a linear ticket, in the "## Linear ticket" section of the PR
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y:
If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y:
- don't use emojis
- don't use "—". Instead, reach for ",", ".", conjunction words, ":", ";", etc. in descending order of preference: vary among them, weighted toward the front of the list, and skip "," where it would cause a comma splice or the sentence is getting long. Overusing any one of them, ";" especially, also feels AI-y. A word cap does not penalize you for adding more sentences: when writing under tight word budgets, prefer a period split or a conjunction over ";", and keep to at most one ";" per message
- don't use the pattern "It's not X, it's Y", "You're not X, you're Y", etc.
- don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
- unless explicitly asked, don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
- don't add a trailing "." at the end of paragraphs (just like this file). That means every paragraph, not just the last one (of the markdown file, PR description, GitHub comment, etc.). Rule of thumb: if you're adding new line(s) before the next sentence, don't add a "."
- don't use →. Instead, prefer not to use arrows, and if need be, use -> instead
- use plain, simple, everyday engineering language: the common phrase engineers actually say over rare compact phrasing, in grammatically complete sentences. When explicitly asked to use bullets or ordered lists and structure legitimately helps the reader, prefer nested bullets (any depth is fine) over dense lines in a flat structure
Don't hesitate to use values in .env to get needed API keys and other secrets, as long as you never add them to conversation history, commit them, or include them in GitHub issues / PRs

View file

@ -1,6 +1,6 @@
{
"reportAny": {
"limit": 19955
"limit": 19949
},
"reportArgumentType": {
"limit": 2566
@ -54,7 +54,7 @@
"limit": 0
},
"reportMissingParameterType": {
"limit": 5663
"limit": 5661
},
"reportMissingTypeArgument": {
"limit": 15555
@ -84,7 +84,7 @@
"limit": 56
},
"reportPrivateUsage": {
"limit": 1822
"limit": 1810
},
"reportRedeclaration": {
"limit": 8
@ -105,10 +105,10 @@
"limit": 109
},
"reportUnknownMemberType": {
"limit": 39011
"limit": 39009
},
"reportUnknownParameterType": {
"limit": 19885
"limit": 19883
},
"reportUnknownVariableType": {
"limit": 30569

View file

@ -199,6 +199,7 @@ standard_logging_payload_excluded_fields: Optional[List[str]] = (
None # Fields to exclude from StandardLoggingPayload before callbacks receive it
)
log_raw_request_response: bool = False
log_client_error_tracebacks: bool = False
request_correlation_in_logs: bool = False
redact_messages_in_exceptions: Optional[bool] = False
redact_user_api_key_info: Optional[bool] = False
@ -1628,6 +1629,9 @@ if TYPE_CHECKING:
AmazonMantleMessagesConfig as AmazonMantleMessagesConfig,
)
from .llms.together_ai.chat import TogetherAIConfig as TogetherAIConfig
from .llms.together_ai.chat.transformation import (
TogetherAIChatConfig as TogetherAIChatConfig,
)
from .llms.nlp_cloud.chat.handler import NLPCloudConfig as NLPCloudConfig
from .llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig as VertexGeminiConfig,
@ -1801,6 +1805,9 @@ if TYPE_CHECKING:
from .llms.gemini.interactions.transformation import (
GoogleAIStudioInteractionsConfig as GoogleAIStudioInteractionsConfig,
)
from .llms.vertex_ai.interactions.transformation import (
VertexAIInteractionsConfig as VertexAIInteractionsConfig,
)
from .llms.openai.chat.o_series_transformation import (
OpenAIOSeriesConfig as OpenAIOSeriesConfig,
OpenAIOSeriesConfig as OpenAIO1Config,

View file

@ -177,6 +177,7 @@ LLM_CONFIG_NAMES: Final = (
"AmazonAnthropicClaudeMessagesConfig",
"AmazonMantleMessagesConfig",
"TogetherAIConfig",
"TogetherAIChatConfig",
"NLPCloudConfig",
"VertexGeminiConfig",
"GoogleAIStudioGeminiConfig",
@ -242,6 +243,7 @@ LLM_CONFIG_NAMES: Final = (
"OpenRouterResponsesAPIConfig",
"BedrockMantleResponsesAPIConfig",
"GoogleAIStudioInteractionsConfig",
"VertexAIInteractionsConfig",
"OpenAIOSeriesConfig",
"AnthropicSkillsConfig",
"BaseSkillsAPIConfig",
@ -740,6 +742,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
"AmazonMantleMessagesConfig",
),
"TogetherAIConfig": (".llms.together_ai.chat", "TogetherAIConfig"),
"TogetherAIChatConfig": (
".llms.together_ai.chat.transformation",
"TogetherAIChatConfig",
),
"NLPCloudConfig": (".llms.nlp_cloud.chat.handler", "NLPCloudConfig"),
"VertexGeminiConfig": (
".llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini",
@ -977,6 +983,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
".llms.gemini.interactions.transformation",
"GoogleAIStudioInteractionsConfig",
),
"VertexAIInteractionsConfig": (
".llms.vertex_ai.interactions.transformation",
"VertexAIInteractionsConfig",
),
"OpenAIOSeriesConfig": (
".llms.openai.chat.o_series_transformation",
"OpenAIOSeriesConfig",

View file

@ -18,6 +18,14 @@ already does when one of its pooled connections errors), leaving every other nod
connections untouched. Every other branch (MOVED, ASK, CLUSTERDOWN, slot-not-covered,
retry-exhaustion) is unchanged from upstream, since those already carry real evidence the
topology changed.
redis-py 8.x fixed this upstream with gentler machinery than this override's
``node.disconnect()`` (which also kills connections other coroutines are mid-operation
on, so one timeout cascades into a reconnect storm and, with TLS, a fresh handshake per
killed connection): it marks in-use connections for reconnect only after their current
operation completes, disconnects only the idle pooled ones, and defers reinitialization
to the outer retry loop. When the installed ``ClusterNode`` has that per-connection
recovery API, the factory returns the base ``RedisCluster`` unmodified.
"""
import asyncio
@ -72,8 +80,16 @@ class _ClusterAttrs(Protocol):
_VERIFIED_REDIS_VERSIONS: Final = frozenset({"5.3.1"})
def get_litellm_async_redis_cluster_class() -> type["_AsyncRedisClusterType"]:
"""Builds the ``RedisCluster`` subclass with the per-node isolation fix.
def get_litellm_async_redis_cluster_class(
cluster_node_class: type | None = None,
) -> type["_AsyncRedisClusterType"]:
"""Returns the base ``RedisCluster`` when the installed redis-py already recovers a
node-level connection error per-connection (8.x+), else builds the ``RedisCluster``
subclass with the per-node isolation fix for older versions whose upstream branch
tears down the whole cluster client.
``cluster_node_class`` exists for dependency injection in tests; production callers
leave it unset and the installed ``ClusterNode`` is used.
Imported lazily because this module is reachable from a base ``import litellm`` while
redis is not a base dependency. Cheap to call repeatedly: the underlying redis
@ -81,7 +97,10 @@ def get_litellm_async_redis_cluster_class() -> type["_AsyncRedisClusterType"]:
"""
import redis
from redis.asyncio.cluster import (
RedisCluster as _BaseAsyncRedisCluster, # pyright: ignore[reportUnknownVariableType] # redis-py ships no resolvable stub for this class under the repo's current (stale) types-redis pin
ClusterNode as _AsyncClusterNode, # pyright: ignore[reportUnknownVariableType] # redis-py ships no resolvable stub for this class under the repo's current (stale) types-redis pin
)
from redis.asyncio.cluster import (
RedisCluster as _BaseAsyncRedisCluster, # pyright: ignore[reportUnknownVariableType] # same stale-stub gap as the import above
)
from redis.cluster import get_node_name
from redis.commands import READ_COMMANDS
@ -98,6 +117,15 @@ def get_litellm_async_redis_cluster_class() -> type["_AsyncRedisClusterType"]:
from redis.exceptions import ConnectionError as _RedisConnectionError
from redis.exceptions import TimeoutError as _RedisTimeoutError
node_class: Final = cluster_node_class if cluster_node_class is not None else _AsyncClusterNode
if hasattr(node_class, "update_active_connections_for_reconnect"):
verbose_logger.debug(
"redis-py %s recovers a node-level connection error per-connection upstream; "
"using the base RedisCluster without litellm's node-isolation override.",
redis.__version__,
)
return _BaseAsyncRedisCluster
if redis.__version__ not in _VERIFIED_REDIS_VERSIONS:
verbose_logger.warning(
"redis-py %s is not in the set this cluster-teardown-storm fix was verified "

View file

@ -5,7 +5,7 @@ Handler for transforming /chat/completions api requests to litellm.responses req
import json
import os
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, Union, cast
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, Union, cast, get_args
from openai.types.responses.custom_tool_param import CustomToolParam
from openai.types.responses.response_input_param import (
@ -35,6 +35,7 @@ from litellm.responses.sse_output_recovery import (
)
from litellm.responses.utils import normalize_responses_api_stream_options
from litellm.types.llms.openai import (
REASONING_EFFORT,
ChatCompletionAnnotation,
ChatCompletionReasoningItem,
ChatCompletionToolCallChunk,
@ -1113,22 +1114,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
)
# If string is passed, map with optional summary based on flag/env var
if reasoning_effort == "none":
return Reasoning(effort="none", summary="detailed") if auto_summary_enabled else Reasoning(effort="none")
elif reasoning_effort == "high":
return Reasoning(effort="high", summary="detailed") if auto_summary_enabled else Reasoning(effort="high")
elif reasoning_effort == "xhigh":
return Reasoning(effort="xhigh", summary="detailed") if auto_summary_enabled else Reasoning(effort="xhigh")
elif reasoning_effort == "medium":
if reasoning_effort in get_args(REASONING_EFFORT):
return (
Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium")
)
elif reasoning_effort == "low":
return Reasoning(effort="low", summary="detailed") if auto_summary_enabled else Reasoning(effort="low")
elif reasoning_effort == "minimal":
return (
Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal")
Reasoning(effort=reasoning_effort, summary="detailed")
if auto_summary_enabled
else Reasoning(effort=reasoning_effort)
)
return None

View file

@ -147,6 +147,7 @@ LITELLM_UI_ALLOW_HEADERS: Final = [
"x-litellm-adaptive-router-model",
"x-litellm-applied-guardrails",
"x-litellm-guardrail-scan-id",
"x-litellm-cache-key",
]
# Gemini model-specific minimal thinking budget constants
@ -750,6 +751,7 @@ openai_compatible_endpoints: Final[list] = [
"api.groq.com/openai/v1",
"https://integrate.api.nvidia.com/v1",
"api.deepseek.com/v1",
"api.together.ai/v1",
"api.together.xyz/v1",
"app.empower.dev/api/v1",
"https://api.friendli.ai/serverless/v1",

View file

@ -47,6 +47,13 @@ def get_provider_interactions_api_config(
return GoogleAIStudioInteractionsConfig()
if provider in (LlmProviders.VERTEX_AI.value, LlmProviders.VERTEX_AI_BETA.value):
from litellm.llms.vertex_ai.interactions.transformation import (
VertexAIInteractionsConfig,
)
return VertexAIInteractionsConfig()
return None

View file

@ -58,6 +58,26 @@ def safe_divide(
return numerator / denominator
def is_expected_client_error(exception: BaseException | None) -> bool:
"""
True when the exception maps to an HTTP 4xx status.
ProxyException stores the status on .code (as a str), HTTPException and
litellm exceptions on .status_code.
"""
if exception is None:
return False
code: Final[object] = getattr(exception, "code", None)
status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None)
if status_code is None or isinstance(status_code, bool):
return False
try:
status: Final = int(str(status_code))
except ValueError:
return False
return 400 <= status < 500
def coerce_token_limit(value: object) -> int | None:
"""
Coerce a max_input_tokens / max_output_tokens value to an int, treating a

View file

@ -272,6 +272,14 @@ def get_llm_provider(
elif endpoint == "api.deepseek.com/v1":
custom_llm_provider = "deepseek"
dynamic_api_key = get_secret_str("DEEPSEEK_API_KEY")
elif endpoint == "api.together.ai/v1" or endpoint == "api.together.xyz/v1":
custom_llm_provider = "together_ai"
dynamic_api_key = api_key or (
get_secret_str("TOGETHER_API_KEY")
or get_secret_str("TOGETHER_AI_API_KEY")
or get_secret_str("TOGETHERAI_API_KEY")
or get_secret_str("TOGETHER_AI_TOKEN")
)
elif endpoint == "ollama.com":
custom_llm_provider = "ollama"
dynamic_api_key = get_secret_str("OLLAMA_API_KEY")
@ -707,7 +715,7 @@ def _get_openai_compatible_provider_info(
dynamic_api_key,
) = litellm.ZAIChatConfig()._get_openai_compatible_provider_info(api_base, api_key)
elif custom_llm_provider == "together_ai":
api_base = api_base or get_secret_str("TOGETHER_AI_API_BASE") or "https://api.together.xyz/v1"
api_base = api_base or get_secret_str("TOGETHER_AI_API_BASE") or "https://api.together.ai/v1"
dynamic_api_key = api_key or (
get_secret_str("TOGETHER_API_KEY")
or get_secret_str("TOGETHER_AI_API_KEY")

View file

@ -172,7 +172,7 @@ def get_supported_openai_params(
if request_type == "embeddings":
return litellm.JinaAIEmbeddingConfig().get_supported_openai_params(model=model)
elif custom_llm_provider == "together_ai":
return litellm.TogetherAIConfig().get_supported_openai_params(model=model)
return litellm.TogetherAIChatConfig().get_supported_openai_params(model=model)
elif custom_llm_provider == "databricks":
if request_type == "chat_completion":
return litellm.DatabricksConfig().get_supported_openai_params(model=model)

View file

@ -62,7 +62,7 @@ from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.deepeval.deepeval import DeepEvalLogger
from litellm.integrations.mlflow import MlflowLogger
from litellm.integrations.sqs import SQSLogger
from litellm.litellm_core_utils.core_helpers import reconstruct_model_name
from litellm.litellm_core_utils.core_helpers import is_expected_client_error, reconstruct_model_name
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
cost_breakdown_with_guardrail,
@ -3124,6 +3124,13 @@ class Logging(LiteLLMLoggingBaseClass):
if not hasattr(self, "model_call_details"):
self.model_call_details = {}
if (
self.model_call_details.get("log_event_type") == "failed_api_call"
and self.model_call_details.get("exception") is exception
and self.model_call_details.get("standard_logging_object") is not None
):
return start_time, self.model_call_details["end_time"]
self.model_call_details["log_event_type"] = "failed_api_call"
self.model_call_details["exception"] = exception
self.model_call_details["traceback_exception"] = (
@ -5455,9 +5462,10 @@ class StandardLoggingPayloadSetup:
error_class: Final[str] = str(original_exception.__class__.__name__) if original_exception else ""
_llm_provider_in_exception: Final = getattr(original_exception, "llm_provider", "")
# Get traceback information (first 100 lines)
traceback_info = traceback_str or ""
if original_exception:
if original_exception and (
litellm.log_client_error_tracebacks or not is_expected_client_error(original_exception)
):
tb: Final[TracebackType | None] = getattr(original_exception, "__traceback__", None)
if tb:
tb_lines: Final = traceback.format_tb(tb)
@ -5930,11 +5938,15 @@ def get_standard_logging_object_payload(
response_model_name = final_response_obj.get("model")
# For Azure Model Router, preserve the actual model in the top-level standard
# logging payload only when the user has opted in.
# logging payload.
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
requested_model: Final = kwargs.get("model")
if (
isinstance(requested_model, str)
and ("model_router" in requested_model.lower() or "model-router" in requested_model.lower())
stamped_selected_model: Final = AzureFoundryModelInfo.get_model_router_selected_model(hidden_params)
if stamped_selected_model is not None:
model_name = stamped_selected_model
elif (
AzureFoundryModelInfo.is_model_router_call(model=requested_model, hidden_params=hidden_params)
and isinstance(response_model_name, str)
and response_model_name
):

View file

@ -643,49 +643,6 @@ def claude_2_1_pt(
return prompt
### TOGETHER AI
def get_model_info(token, model):
try:
headers: Final = {"Authorization": f"Bearer {token}"}
client: Final = HTTPHandler(concurrent_limit=1)
response: Final = client.get("https://api.together.xyz/models/info", headers=headers)
if response.status_code == 200:
model_info: Final = response.json()
for m in model_info:
if m["name"].lower().strip() == model.strip():
return m["config"].get("prompt_format", None), m["config"].get("chat_template", None)
return None, None
else:
return None, None
except Exception: # safely fail a prompt template request
return None, None
## OLD TOGETHER AI FLOW
# def format_prompt_togetherai(messages, prompt_format, chat_template):
# if prompt_format is None:
# return default_pt(messages)
# human_prompt, assistant_prompt = prompt_format.split("{prompt}")
# if chat_template is not None:
# prompt = hf_chat_template(
# model=None, messages=messages, chat_template=chat_template
# )
# elif prompt_format is not None:
# prompt = custom_prompt(
# role_dict={},
# messages=messages,
# initial_prompt_value=human_prompt,
# final_prompt_value=assistant_prompt,
# )
# else:
# prompt = default_pt(messages)
# return prompt
### IBM Granite

View file

@ -1215,8 +1215,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if reasoning_effort is None or reasoning_effort == "none":
return None
if AnthropicConfig._is_adaptive_thinking_model(model, custom_llm_provider):
# without display, Anthropic defaults adaptive thinking to
# display="omitted" and returns a blank thinking block
return AnthropicThinkingParam(
type="adaptive",
display="summarized",
)
elif reasoning_effort == "low":
return AnthropicThinkingParam(
@ -2144,7 +2147,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
)
@staticmethod
def _thinking_tokens_from_usage(usage_object: Mapping[str, object]) -> int | None:
def thinking_tokens_from_usage(usage_object: Mapping[str, object]) -> int | None:
details: Final = usage_object.get("output_tokens_details")
if not isinstance(details, Mapping):
return None
@ -2176,7 +2179,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
reported_thinking_tokens: Final = (
iteration_thinking_tokens
if iteration_thinking_tokens is not None
else self._thinking_tokens_from_usage(usage_object)
else self.thinking_tokens_from_usage(usage_object)
)
if reported_thinking_tokens is not None:
capped_reported: Final = min(max(0, reported_thinking_tokens), completion_tokens)
@ -2199,7 +2202,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
def _sum_iteration_thinking_tokens(self, iterations: Sequence[object]) -> int | None:
per_iteration: Final = tuple(
self._thinking_tokens_from_usage(iteration) if isinstance(iteration, Mapping) else None
self.thinking_tokens_from_usage(iteration) if isinstance(iteration, Mapping) else None
for iteration in iterations
)
reported: Final = tuple(tokens for tokens in per_iteration if tokens is not None)

View file

@ -38,6 +38,21 @@ DROP_DISABLED_THINKING_WARNING: Final = (
"thinking blocks, and those thinking tokens are billed as output tokens."
)
# Anthropic error `type` (both the JSON error body and SSE `event: error`
# payloads use this field) mapped to the HTTP status code it corresponds to.
ANTHROPIC_ERROR_STATUS_CODE_MAP: Final = MappingProxyType(
{
"invalid_request_error": 400,
"authentication_error": 401,
"permission_error": 403,
"not_found_error": 404,
"rate_limit_error": 429,
"api_error": 500,
"overloaded_error": 503,
"timeout_error": 504,
}
)
_BEDROCK_VERSION_SUFFIX_RE: Final = re.compile(r"-v\d+(?::\d+)?$")
_INFERENCE_PROFILE_MINOR_RE: Final = re.compile(r":\d+$")
_DATED_RELEASE_SUFFIX_RE: Final = re.compile(r"-\d{8}$")

View file

@ -434,7 +434,7 @@ class LiteLLMAnthropicMessagesAdapter:
content_items = list(content.get("content", []))
# Single-item text keeps the backward-compatible string format; a single
# image becomes a structured image_url part
# image or document becomes a structured image_url part
if len(content_items) == 1:
c = content_items[0]
if isinstance(c, str):
@ -454,7 +454,7 @@ class LiteLLMAnthropicMessagesAdapter:
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)
elif c.get("type") == "image":
elif c.get("type") in ("image", "document"):
image_part = self._tool_result_image_part(c.get("source"))
tool_result = ChatCompletionToolMessage(
role="tool",
@ -482,7 +482,7 @@ class LiteLLMAnthropicMessagesAdapter:
text=c.get("text", ""),
)
)
elif c.get("type") == "image":
elif c.get("type") in ("image", "document"):
image_part = self._tool_result_image_part(c.get("source"))
if image_part:
combined_content_parts.append(image_part)

View file

@ -1,6 +1,6 @@
import asyncio
import json
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from datetime import datetime
from typing import Any, Final, Protocol, runtime_checkable
@ -11,9 +11,11 @@ from typing_extensions import TypedDict
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.anthropic.common_utils import ANTHROPIC_ERROR_STATUS_CODE_MAP
from litellm.proxy.pass_through_endpoints.success_handler import (
PassThroughEndpointLogging,
)
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
from litellm.types.utils import GenericStreamingChunk, ModelResponseStream
@ -33,26 +35,239 @@ def _is_message_stop_chunk(chunk: object) -> bool:
return False
def _is_provider_error_chunk(chunk: object) -> bool:
def is_anthropic_ping_chunk(chunk: object) -> bool:
"""
Whether a chunk is a pure ``ping`` keepalive frame. It carries no content
and can recur indefinitely on a slow-starting or idle connection, so a
mid-stream fallback wrapper drops it outright while still deciding
whether to commit to the primary stream, rather than buffering it.
A physical transport chunk that coalesces a ping with any other SSE
event (``message_start``, ``content_block_delta``, ``event: error``, ...)
is NOT a pure ping - dropping it whole would discard those events - so
only a chunk whose every ``event:`` line is ``event: ping`` qualifies.
"""
if isinstance(chunk, dict):
return chunk.get("type") == "error"
return chunk.get("type") == "ping"
if isinstance(chunk, (bytes, bytearray)):
return any(line == b"event: error" for line in chunk.splitlines())
event_lines: Final = tuple(line for line in chunk.splitlines() if line.startswith(b"event:"))
return bool(event_lines) and all(line == b"event: ping" for line in event_lines)
return False
def is_anthropic_content_delta_chunk(chunk: object) -> bool:
"""
Whether a chunk carries actual assistant-generated output (a
``content_block_delta`` frame), as opposed to a lifecycle/bookkeeping
frame (``message_start``, ``content_block_start``/``stop``,
``message_delta``, ``message_stop``, ``ping``) that carries nothing
worth preserving before an invisible mid-stream fallback retry.
"""
if isinstance(chunk, dict):
return chunk.get("type") == "content_block_delta"
if isinstance(chunk, (bytes, bytearray)):
return any(line == b"event: content_block_delta" for line in chunk.splitlines())
return False
def _decoded_sse_data_line(line: bytes) -> object | None:
if not line.startswith(b"data:"):
return None
try:
return json.loads(line[len(b"data:") :].strip())
except (ValueError, TypeError):
return None
def _anthropic_error_event_payload(chunk: object) -> Mapping[str, object] | None:
if isinstance(chunk, dict):
return chunk if chunk.get("type") == "error" else None
if isinstance(chunk, (bytes, bytearray)):
decoded_lines: Final = (_decoded_sse_data_line(line) for line in chunk.splitlines())
return next(
(
candidate
for candidate in decoded_lines
if isinstance(candidate, dict) and candidate.get("type") == "error"
),
None,
)
return None
def _anthropic_error_body(chunk: object) -> Mapping[str, object] | None:
"""Return the ``error`` object of an Anthropic SSE ``event: error`` chunk, or None."""
payload: Final = _anthropic_error_event_payload(chunk)
error_body: Final = payload.get("error") if payload is not None else None
return error_body if isinstance(error_body, dict) else None
def _is_provider_error_chunk(chunk: object) -> bool:
return _anthropic_error_body(chunk) is not None
def parse_anthropic_error_event(chunk: object) -> tuple[str, str, int] | None:
"""
Extract ``(error_type, message, http_status_code)`` from an Anthropic SSE
``event: error`` chunk (raw bytes or an already-decoded dict), or None if
``chunk`` is not an error event.
The status code is looked up via ANTHROPIC_ERROR_STATUS_CODE_MAP,
defaulting to 500 for an error ``type`` Anthropic hasn't documented yet.
"""
error_body: Final = _anthropic_error_body(chunk)
if error_body is None:
return None
error_type: Final = error_body.get("type")
if not isinstance(error_type, str):
return None
message: Final = error_body.get("message")
return (
error_type,
message if isinstance(message, str) else error_type,
ANTHROPIC_ERROR_STATUS_CODE_MAP.get(error_type, 500),
)
def _is_terminal_stream_chunk(chunk: object) -> bool:
return _is_message_stop_chunk(chunk) or _is_provider_error_chunk(chunk)
def _sse_event(event_type: str, payload: Mapping[str, object]) -> bytes:
return f"event: {event_type}\ndata: {json.dumps(payload)}\n\n".encode()
def _incomplete_stream_error_sse_event() -> bytes:
payload: Final = json.dumps(
{
"type": "error",
"error": {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE},
}
return _sse_event( # mutable-ok: one-shot JSON payload, never mutated after construction
"error",
{"type": "error", "error": {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE}},
)
def _anthropic_content_block_start_and_deltas(
block: Mapping[str, object],
) -> tuple[Mapping[str, object], tuple[Mapping[str, object], ...]]:
"""
``(content_block_start.content_block, content_block_delta.delta events)``
for one Anthropic response content block. A thinking block emits both a
thinking_delta and a trailing signature_delta - a real Anthropic stream
does the same, and dropping the signature makes any replay of that
assistant message (a follow-up turn, a tool-use continuation) fail
Anthropic's thinking-signature verification. redacted_thinking has no
delta at all - it is sent complete in content_block_start.
"""
match block.get("type"):
case "tool_use":
return (
{ # mutable-ok: one-shot payload
"id": block.get("id"),
"name": block.get("name"),
"input": {}, # mutable-ok: one-shot payload
"type": "tool_use",
},
(
{ # mutable-ok: one-shot payload
"partial_json": json.dumps(block.get("input") or {}), # mutable-ok: one-shot payload
"type": "input_json_delta",
},
),
)
case "thinking":
signature: Final = block.get("signature")
signature_deltas: Final = (
({"signature": signature, "type": "signature_delta"},) # mutable-ok: one-shot payload
if isinstance(signature, str) and signature
else ()
)
return (
{"thinking": "", "signature": "", "type": "thinking"}, # mutable-ok: one-shot payload
(
{"thinking": block.get("thinking") or "", "type": "thinking_delta"}, # mutable-ok: one-shot payload
*signature_deltas,
),
)
case "redacted_thinking":
return ({"type": "redacted_thinking", "data": block.get("data")}, ()) # mutable-ok: one-shot JSON payload
case _:
return (
{"type": "text", "text": ""}, # mutable-ok: one-shot JSON payload
({"type": "text_delta", "text": block.get("text") or ""},), # mutable-ok: one-shot JSON payload
)
def anthropic_messages_response_as_sse_events(response: AnthropicMessagesResponse) -> tuple[bytes, ...]:
"""
Render a complete (non-streaming) AnthropicMessagesResponse as the SSE
event sequence a real streaming request would have produced.
A mid-stream fallback can resolve to a non-streaming response even
though the client asked to stream (e.g. an agentic tool-use loop that
intercepts and returns a complete message) - yielding that dict directly
into a `/v1/messages` SSE byte stream would produce a malformed
response, so it's synthesized into the message_start/content_block_*/
message_delta/message_stop lifecycle a real stream would have sent.
"""
content_blocks: Final = response.get("content") or ()
content_events: Final = (
event for index, block in enumerate(content_blocks) for event in _anthropic_content_block_events(index, block)
)
# A real message_start always carries a null stop_reason/stop_sequence and
# a zero output_tokens - those are only known once generation finishes, so
# copying the completed response's final values here would let a client
# treat the message as already finished, or double-count output tokens.
message_start_usage: Final = { # mutable-ok: one-shot JSON payload
**(response.get("usage") or {}),
"output_tokens": 0,
}
message_start_payload: Final = { # mutable-ok: one-shot JSON payload, never mutated after construction
"type": "message_start",
"message": { # mutable-ok: one-shot JSON payload
**response,
"content": [], # mutable-ok: one-shot JSON payload
"stop_reason": None,
"stop_sequence": None,
"usage": message_start_usage,
},
}
message_delta_payload: Final = { # mutable-ok: one-shot JSON payload, never mutated after construction
"type": "message_delta",
"delta": { # mutable-ok: one-shot JSON payload
"stop_reason": response.get("stop_reason"),
"stop_sequence": response.get("stop_sequence"),
},
"usage": response.get("usage") or {}, # mutable-ok: one-shot JSON payload
}
return (
_sse_event("message_start", message_start_payload),
*content_events,
_sse_event("message_delta", message_delta_payload),
_sse_event("message_stop", {"type": "message_stop"}), # mutable-ok: one-shot JSON payload
)
def _anthropic_content_block_events(index: int, block: Mapping[str, object]) -> tuple[bytes, ...]:
start_block, deltas = _anthropic_content_block_start_and_deltas(block)
start_payload: Final = { # mutable-ok: one-shot payload
"type": "content_block_start",
"index": index,
"content_block": start_block,
}
stop_payload: Final = { # mutable-ok: one-shot payload
"type": "content_block_stop",
"index": index,
}
delta_events: Final = tuple(
_sse_event(
"content_block_delta",
{"type": "content_block_delta", "index": index, "delta": delta}, # mutable-ok: one-shot payload
)
for delta in deltas
)
return (
_sse_event("content_block_start", start_payload),
*delta_events,
_sse_event("content_block_stop", stop_payload),
)
return f"event: error\ndata: {payload}\n\n".encode()
class AnthropicMessagesStreamHiddenParams(TypedDict):

View file

@ -87,6 +87,51 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
return source.get("url")
return None
@staticmethod
def _translate_anthropic_document_block_to_file_part(
block: Mapping[str, object],
) -> dict[str, str] | None: # mutable-ok: API message payload
"""Convert an Anthropic document block to a Responses input_file part."""
raw_source: Final = block.get("source")
if not isinstance(raw_source, Mapping):
return None
source: Final = cast(Mapping[str, object], raw_source) # cast-ok: untrusted client payload
source_type: Final = source.get("type")
if source_type == "base64":
data: Final = source.get("data")
if not isinstance(data, str) or not data:
return None
raw_media_type: Final = source.get("media_type")
media_type: Final = (
raw_media_type if isinstance(raw_media_type, str) and raw_media_type else "application/pdf"
)
raw_title: Final = block.get("title")
filename: Final = raw_title if isinstance(raw_title, str) and raw_title else "document.pdf"
return { # mutable-ok: API message payload
"type": "input_file",
"filename": filename,
"file_data": f"data:{media_type};base64,{data}",
}
if source_type == "url":
url: Final = source.get("url")
if not isinstance(url, str) or not url:
return None
return {"type": "input_file", "file_url": url} # mutable-ok: API message payload
return None
@staticmethod
def _tool_result_output_value(
output_text: str,
file_parts: tuple[dict[str, str], ...], # mutable-ok: json content parts
) -> str | list[dict[str, str]]: # mutable-ok: API message payload
"""Plain string output, or a part list when document file parts are present."""
if not file_parts:
return output_text
text_parts: Final = (
[{"type": "input_text", "text": output_text}] if output_text else [] # mutable-ok: API message payload
)
return [*text_parts, *file_parts] # mutable-ok: API message payload
@staticmethod
def _translate_midturn_system_content_to_responses(
content: str | Iterable[AnthropicSystemMessageContent],
@ -169,6 +214,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
system text -> message(role=system, input_text)
user text -> message(role=user, input_text)
user image -> message(role=user, input_image)
user document -> message(role=user, input_file)
user tool_result -> function_call_output
assistant text -> message(role=assistant, output_text)
assistant thinking -> reasoning
@ -223,9 +269,25 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
{"type": "input_image", "image_url": url}, block.get("prompt_cache_breakpoint")
)
)
elif btype == "document":
file_part = self._translate_anthropic_document_block_to_file_part(block)
if file_part:
user_parts.append(
with_prompt_cache_breakpoint(file_part, block.get("prompt_cache_breakpoint"))
)
elif btype == "tool_result":
tool_use_id = block.get("tool_use_id", "")
inner = block.get("content")
document_candidates = (
tuple(
self._translate_anthropic_document_block_to_file_part(c)
for c in inner
if isinstance(c, dict) and c.get("type") == "document"
)
if isinstance(inner, list)
else ()
)
tool_file_parts = tuple(part for part in document_candidates if part is not None)
if inner is None:
output_text = ""
elif isinstance(inner, str):
@ -258,7 +320,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
{
"type": "function_call_output",
"call_id": tool_use_id,
"output": output_text,
"output": self._tool_result_output_value(output_text, tool_file_parts),
}
)
if tool_image_parts:

View file

@ -22,19 +22,7 @@ from litellm.types.llms.openai import (
from litellm.types.utils import CallTypes, LlmProviders, ModelResponse
from ..chat.transformation import AnthropicConfig
from ..common_utils import AnthropicModelInfo
# Map Anthropic error types to HTTP status codes
ANTHROPIC_ERROR_STATUS_CODE_MAP: Final = {
"invalid_request_error": 400,
"authentication_error": 401,
"permission_error": 403,
"not_found_error": 404,
"rate_limit_error": 429,
"api_error": 500,
"overloaded_error": 503,
"timeout_error": 504,
}
from ..common_utils import ANTHROPIC_ERROR_STATUS_CODE_MAP, AnthropicModelInfo
class AnthropicFilesHandler:

View file

@ -65,15 +65,24 @@ class AzureModelRouterConfig(AzureAIStudioConfig):
Extracts the actual model used from the Azure response (e.g., gpt-5-nano-2025-08-07)
and returns it with the azure_ai/ prefix for proper display and cost tracking.
Also stamps that model onto ``_hidden_params`` so downstream consumers (spend logs,
response restamping) can read it instead of guessing the route from the model string.
"""
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
from litellm.llms.azure_ai.common_utils import (
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY,
AzureFoundryModelInfo,
)
from litellm.router_utils.add_retry_fallback_headers import (
get_hidden_params_dict,
)
# Get base model for the parent call (strips routing prefixes for API compatibility)
base_model: Final[str] = AzureFoundryModelInfo.get_base_model(model)
# Call parent transform_response first - this will extract the actual model
# from the raw response (e.g., "gpt-5-nano-2025-08-07")
model_response = super().transform_response(
transformed_response: Final = super().transform_response(
model=base_model,
raw_response=raw_response,
model_response=model_response,
@ -86,7 +95,15 @@ class AzureModelRouterConfig(AzureAIStudioConfig):
api_key=api_key,
json_mode=json_mode,
)
return model_response
selected_model: Final = transformed_response.model
if selected_model:
# Rebuilt rather than mutated in place: ModelResponseBase declares _hidden_params as a
# class-level dict, so an in-place write can bleed into unrelated responses.
transformed_response._hidden_params = { # pyright: ignore[reportPrivateUsage] # ModelResponse exposes no public hidden-params setter # mutable-ok: ModelResponse requires _hidden_params to be a plain dict
**get_hidden_params_dict(transformed_response),
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model,
}
return transformed_response
def calculate_additional_costs(self, model: str, prompt_tokens: int, completion_tokens: int) -> dict | None:
"""

View file

@ -51,6 +51,9 @@ def get_azure_ai_auth_headers(
)
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: Final = "azure_model_router_selected_model"
class AzureFoundryModelInfo(BaseLLMModelInfo):
"""Model info for Azure AI / Azure Foundry models."""
@ -82,6 +85,41 @@ class AzureFoundryModelInfo(BaseLLMModelInfo):
return "model_router"
return "default"
@staticmethod
def get_model_router_selected_model(hidden_params: Mapping[str, object] | None) -> str | None:
"""The model Azure Model Router actually served, stamped by ``AzureModelRouterConfig``.
Reading this beats re-deriving the route from a model string: the stamp is set on the
code path that was actually taken, so it holds no matter what the caller named the model.
"""
if not hidden_params:
return None
selected: Final = hidden_params.get(AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY)
if isinstance(selected, str) and selected:
return selected
return None
@staticmethod
def is_model_router_call(
model: str | None = None,
hidden_params: Mapping[str, object] | None = None,
) -> bool:
"""Whether a request went down the Azure Model Router route.
Prefers the response stamp, then the deployment's litellm model path, and only then the
caller-supplied name. The last two go through ``get_azure_ai_route`` so the model-router
name heuristic lives in exactly one place.
"""
if AzureFoundryModelInfo.get_model_router_selected_model(hidden_params) is not None:
return True
deployment_model: Final = (
hidden_params.get("litellm_model_name") or hidden_params.get("model") if hidden_params is not None else None
)
return any(
isinstance(candidate, str) and AzureFoundryModelInfo.get_azure_ai_route(candidate) == "model_router"
for candidate in (deployment_model, model)
)
@staticmethod
def get_api_base(api_base: str | None = None) -> str | None:
return api_base or litellm.api_base or get_secret_str("AZURE_AI_API_BASE")

View file

@ -1617,6 +1617,8 @@ class AmazonConverseConfig(BaseConfig):
}
if additional_request_params:
data["additionalModelRequestFields"] = additional_request_params
if "thinking" in additional_request_params:
data["additionalModelResponseFieldPaths"] = ("/usage/output_tokens_details",)
if system_content_blocks:
data["system"] = system_content_blocks
@ -1801,6 +1803,17 @@ class AmazonConverseConfig(BaseConfig):
thinking_blocks_list.append(_redacted_block)
return thinking_blocks_list
@staticmethod
def thinking_tokens_from_additional_fields(additional_fields: object) -> int | None:
"""Converse omits thinking tokens from its usage block; they only arrive under
``additionalModelResponseFields`` when ``/usage/output_tokens_details`` is requested."""
if not isinstance(additional_fields, Mapping):
return None
usage: Final = additional_fields.get("usage")
if not isinstance(usage, Mapping):
return None
return AnthropicConfig.thinking_tokens_from_usage(usage)
@staticmethod
def is_converse_usage_shape(usage_object: Mapping[str, object]) -> bool:
"""Converse-family models report camelCase token counts, not Anthropic's snake_case."""
@ -1842,6 +1855,7 @@ class AmazonConverseConfig(BaseConfig):
usage: ConverseTokenUsageBlock,
reasoning_content: str | None = None,
thinking_ran: bool = False,
provider_reasoning_tokens: int | None = None,
) -> Usage:
input_tokens = usage["inputTokens"]
output_tokens: Final = usage["outputTokens"]
@ -1862,9 +1876,14 @@ class AmazonConverseConfig(BaseConfig):
cache_creation_tokens=cache_creation_input_tokens,
text_tokens=raw_input_tokens,
)
reasoning_tokens: Final = (
estimated_reasoning_tokens: Final = (
token_counter(text=reasoning_content, count_response_tokens=True) if reasoning_content else 0
)
reasoning_tokens: Final = (
min(max(0, provider_reasoning_tokens), output_tokens)
if provider_reasoning_tokens is not None
else estimated_reasoning_tokens
)
completion_tokens_details: Final = (
CompletionTokensDetailsWrapper(
reasoning_tokens=reasoning_tokens,
@ -2272,6 +2291,9 @@ class AmazonConverseConfig(BaseConfig):
completion_response["usage"],
reasoning_content=chat_completion_message.get("reasoning_content"),
thinking_ran=reasoningContentBlocks is not None,
provider_reasoning_tokens=self.thinking_tokens_from_additional_fields(
completion_response.get("additionalModelResponseFields")
),
)
## HANDLE TOOL CALLS

View file

@ -331,6 +331,7 @@ class AWSEventStreamDecoder:
self.json_mode = json_mode
self._current_tool_name: str | None = None
self._thinking_ran = False
self._provider_reasoning_tokens: int | None = None
def check_empty_tool_call_args(self) -> bool:
"""
@ -559,10 +560,14 @@ class AWSEventStreamDecoder:
tool_use = self._handle_converse_stop_event(content_block_index)
elif "stopReason" in chunk_data:
finish_reason = map_finish_reason(chunk_data.get("stopReason", "stop"))
self._provider_reasoning_tokens = AmazonConverseConfig.thinking_tokens_from_additional_fields(
chunk_data.get("additionalModelResponseFields")
)
elif "usage" in chunk_data:
usage = converse_config.transform_usage(
chunk_data.get("usage", {}),
thinking_ran=self._thinking_ran,
provider_reasoning_tokens=self._provider_reasoning_tokens,
)
if thinking_blocks:
self._thinking_ran = True

View file

@ -1,4 +1,5 @@
import json
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final, Optional, cast
from httpx import Response
@ -93,6 +94,9 @@ class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamD
endpoint_url,
)
def get_bedrock_bearer_token(self, litellm_params: Mapping[str, object]) -> str | None:
return None
def sign_request(
self,
headers: dict,
@ -109,6 +113,7 @@ class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamD
request_data=request_data or {},
api_base=api_base,
model=model,
api_key=self.get_bedrock_bearer_token(optional_params),
)
def logging_non_streaming_response(

View file

@ -29,6 +29,7 @@ class BedrockRerankHandler(BaseAWSLLM):
async def arerank(
self,
prepared_request: BedrockPreparedRequest,
logging_obj: LitellmLogging,
timeout: float | httpx.Timeout | None = None,
client: AsyncHTTPHandler | None = None,
):
@ -40,6 +41,7 @@ class BedrockRerankHandler(BaseAWSLLM):
headers=dict(prepared_request["prepped"].headers),
data=prepared_request["body"],
timeout=timeout,
logging_obj=logging_obj,
)
response.raise_for_status()
except httpx.HTTPStatusError as err:
@ -98,6 +100,7 @@ class BedrockRerankHandler(BaseAWSLLM):
if _is_async:
return self.arerank(
prepared_request,
logging_obj=logging_obj,
timeout=timeout,
client=client if client is not None and isinstance(client, AsyncHTTPHandler) else None,
)

View file

@ -13,6 +13,7 @@ global state.
"""
import re
from collections.abc import Mapping
from typing import Final
from botocore.exceptions import (
@ -31,30 +32,39 @@ BEDROCK_MANTLE_DEFAULT_REGION: Final = "us-east-1"
MANTLE_HOST_RE: Final = re.compile(r"^https?://bedrock-mantle\.([^/.]+)\.api\.aws", re.IGNORECASE)
def resolve_mantle_bearer_token(api_key: str | None) -> str | None:
return api_key or get_secret_str("BEDROCK_MANTLE_API_KEY") or get_secret_str("AWS_BEARER_TOKEN_BEDROCK")
def resolve_mantle_region(params: Mapping[str, object]) -> str:
region: Final = params.get("aws_region_name")
if isinstance(region, str) and region:
BaseAWSLLM._validate_aws_region_name(region)
return region
api_base: Final = params.get("api_base")
base: Final = (api_base if isinstance(api_base, str) else None) or get_secret_str("BEDROCK_MANTLE_API_BASE")
if base:
match: Final = MANTLE_HOST_RE.match(base.rstrip("/"))
if match:
return match.group(1)
return (
get_secret_str("BEDROCK_MANTLE_REGION")
or get_secret_str("AWS_REGION_NAME")
or get_secret_str("AWS_REGION")
or BEDROCK_MANTLE_DEFAULT_REGION
)
class BedrockMantleAuthMixin:
_aws_signer: BaseAWSLLM
@staticmethod
def _resolve_bearer_token(api_key: str | None) -> str | None:
return api_key or get_secret_str("BEDROCK_MANTLE_API_KEY") or get_secret_str("AWS_BEARER_TOKEN_BEDROCK")
return resolve_mantle_bearer_token(api_key)
@staticmethod
def _resolve_region(params: dict) -> str:
region: Final = params.get("aws_region_name")
if region:
BaseAWSLLM._validate_aws_region_name(region)
return region
base: Final = params.get("api_base") or get_secret_str("BEDROCK_MANTLE_API_BASE")
if base:
match: Final = MANTLE_HOST_RE.match(base.rstrip("/"))
if match:
return match.group(1)
return (
get_secret_str("BEDROCK_MANTLE_REGION")
or get_secret_str("AWS_REGION_NAME")
or get_secret_str("AWS_REGION")
or BEDROCK_MANTLE_DEFAULT_REGION
)
return resolve_mantle_region(params)
def sign_request(
self,

View file

@ -0,0 +1,71 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final, Literal, Optional
from httpx import Response
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.bedrock.passthrough.transformation import BedrockPassthroughConfig
from litellm.llms.bedrock_mantle.common_utils import (
MANTLE_HOST_RE,
resolve_mantle_bearer_token,
resolve_mantle_region,
)
from litellm.types.utils import LlmProviders
if TYPE_CHECKING:
from litellm.types.utils import CostResponseTypes
class BedrockMantlePassthroughConfig(BedrockPassthroughConfig):
"""Native Bedrock runtime passthrough (InvokeModel, Converse) for deployments declared as bedrock_mantle.
The Mantle host only serves the OpenAI-compatible surface, so a Mantle api_base lends its region and the
request itself goes to bedrock-runtime, signed with the deployment's Bearer token or SigV4 credentials.
"""
def _get_aws_region_name(
self,
optional_params: Mapping[str, object],
model: str | None = None,
model_id: str | None = None,
) -> str:
return resolve_mantle_region(optional_params)
def get_runtime_endpoint(
self,
api_base: str | None,
aws_bedrock_runtime_endpoint: str | None,
aws_region_name: str,
endpoint_type: Literal["runtime", "agent", "agentcore"] | None = "runtime",
) -> tuple[str, str]:
is_mantle_host: Final = api_base is not None and MANTLE_HOST_RE.match(api_base.rstrip("/")) is not None
return super().get_runtime_endpoint(
api_base=None if is_mantle_host else api_base,
aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint,
aws_region_name=aws_region_name,
endpoint_type=endpoint_type,
)
def get_bedrock_bearer_token(self, litellm_params: Mapping[str, object]) -> str | None:
api_key: Final = litellm_params.get("api_key")
return resolve_mantle_bearer_token(api_key if isinstance(api_key, str) else None)
def logging_non_streaming_response(
self,
model: str,
custom_llm_provider: str,
httpx_response: Response,
request_data: dict, # mutable-ok: mirrors the inherited BedrockPassthroughConfig signature
logging_obj: Logging,
endpoint: str,
) -> Optional["CostResponseTypes"]:
is_converse: Final = "invoke" not in endpoint and "converse" in endpoint
shape_provider: Final = LlmProviders.BEDROCK.value if is_converse else custom_llm_provider
return super().logging_non_streaming_response(
model=model,
custom_llm_provider=shape_provider,
httpx_response=httpx_response,
request_data=request_data,
logging_obj=logging_obj,
endpoint=endpoint,
)

View file

@ -15,8 +15,12 @@ role / access key / profile / web identity), signed via the shared
BaseAWSLLM._sign_request after the request body is finalized.
"""
import json
from collections.abc import Mapping
from typing import Any, Final
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
@ -50,6 +54,33 @@ _BEDROCK_MANTLE_SUPPORTED_SERVICE_TIERS: Final = frozenset({"auto", "default"})
_CODEX_ADDITIONAL_TOOLS_INPUT_ITEM_TYPE: Final = "additional_tools"
_CODEX_AGENT_MESSAGE_INPUT_ITEM_TYPE: Final = "agent_message"
_CODEX_CONTEXT_COMPACTION_INPUT_ITEM_TYPE: Final = "context_compaction"
_CODEX_LOCAL_SHELL_CALL_INPUT_ITEM_TYPE: Final = "local_shell_call"
class _RewrittenOutputTextBlock(TypedDict):
type: ReadOnly[str]
text: ReadOnly[str]
class _RewrittenAssistantMessageItem(TypedDict):
type: ReadOnly[str]
role: ReadOnly[str]
content: ReadOnly[tuple[_RewrittenOutputTextBlock, ...]]
class _RewrittenCompactionItem(TypedDict):
type: ReadOnly[str]
encrypted_content: ReadOnly[str]
class _RewrittenFunctionCallItem(TypedDict):
type: ReadOnly[str]
call_id: ReadOnly[str]
name: ReadOnly[str]
arguments: ReadOnly[str]
class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPIConfig):
def __init__(
@ -155,6 +186,7 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
headers: dict,
) -> dict:
remaining_input, hoisted_tools = self._hoist_codex_additional_tools(input)
normalized_input: Final = self._normalize_codex_input_items(remaining_input)
request_params: Final = (
{
**response_api_optional_request_params,
@ -168,7 +200,7 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
)
return super().transform_responses_api_request(
model=model,
input=remaining_input,
input=normalized_input,
response_api_optional_request_params=request_params,
litellm_params=litellm_params,
headers=headers,
@ -210,6 +242,91 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
)
return remaining_input, cls._filter_unsupported_tools(hoisted_tools)
@staticmethod
def _agent_message_text(item: "Mapping[str, Any]") -> str:
content: Final = item.get("content")
if not isinstance(content, list):
return ""
return "".join(
str(block.get("text") or block.get("encrypted_content") or "")
for block in content
if isinstance(block, dict)
)
@classmethod
def _normalize_agent_message_item(cls, item: "Mapping[str, Any]") -> "_RewrittenAssistantMessageItem | None":
text: Final = cls._agent_message_text(item)
if not text:
return None
rewritten: Final[_RewrittenAssistantMessageItem] = {
"type": "message",
"role": "assistant",
"content": ({"type": "output_text", "text": text},),
}
return rewritten
@staticmethod
def _normalize_context_compaction_item(item: "Mapping[str, Any]") -> "_RewrittenCompactionItem | None":
encrypted_content: Final = item.get("encrypted_content")
if not isinstance(encrypted_content, str) or not encrypted_content:
return None
rewritten: Final[_RewrittenCompactionItem] = {"type": "compaction", "encrypted_content": encrypted_content}
return rewritten
@staticmethod
def _normalize_local_shell_call_item(item: "Mapping[str, Any]") -> "_RewrittenFunctionCallItem | None":
call_id: Final = item.get("call_id")
if not isinstance(call_id, str) or not call_id:
return None
action: Final = item.get("action")
rewritten: Final[_RewrittenFunctionCallItem] = {
"type": "function_call",
"call_id": call_id,
"name": "local_shell",
"arguments": json.dumps(action) if isinstance(action, dict) else "{}",
}
return rewritten
@classmethod
def _normalize_codex_input_item(cls, item: object) -> "tuple[object, str | None]":
"""Returns (normalized item or None to drop it, original type when rewritten)."""
if not isinstance(item, dict):
return item, None
item_type: Final = item.get("type")
if item_type == _CODEX_AGENT_MESSAGE_INPUT_ITEM_TYPE:
return cls._normalize_agent_message_item(item), item_type
if item_type == _CODEX_CONTEXT_COMPACTION_INPUT_ITEM_TYPE:
return cls._normalize_context_compaction_item(item), item_type
if item_type == _CODEX_LOCAL_SHELL_CALL_INPUT_ITEM_TYPE:
return cls._normalize_local_shell_call_item(item), item_type
return item, None
@classmethod
def _normalize_codex_input_items(
cls,
input: "str | ResponseInputParam",
) -> "str | ResponseInputParam":
"""Rewrite Codex history item types Mantle rejects with 400 "Invalid
'input': value did not match any expected variant" into supported
equivalents. `agent_message` (Codex multi-agent traffic; its
encrypted_content slot carries the plaintext payload when the model
never issued encrypted args) becomes an assistant message,
`context_compaction` becomes the `compaction` spelling Mantle accepts,
and `local_shell_call` becomes the function_call its recorded
function_call_output already pairs with.
"""
if not isinstance(input, list):
return input
normalized: Final = tuple(cls._normalize_codex_input_item(item) for item in input)
rewritten_types: Final = sorted(frozenset(item_type for _, item_type in normalized if item_type is not None))
if rewritten_types:
verbose_logger.warning(
"Bedrock Mantle Responses API: rewrote Codex input item type(s) %s that Mantle rejects.",
rewritten_types,
)
kept: Final = [item for item, _ in normalized if item is not None] # mutable-ok: ResponseInputParam is a list
return kept # pyright: ignore[reportReturnType] # Codex passthrough items sit outside the OpenAI input union
def map_openai_params(
self,
response_api_optional_params: ResponsesAPIOptionalRequestParams,

View file

@ -68,6 +68,8 @@ class CerebrasConfig(OpenAIGPTConfig):
"tool_choice",
"tools",
"user",
"max_retries",
"extra_headers",
]
# Only add reasoning_effort for models that support it

View file

@ -1203,6 +1203,7 @@ class BaseLLMHTTPHandler:
headers=headers,
data=json.dumps(request_data),
timeout=timeout,
logging_obj=logging_obj,
)
except Exception as e:
raise self._handle_error(e=e, provider_config=provider_config)

View file

@ -16,7 +16,7 @@ def _normalize_reasoning_effort_for_chat_completion(
) -> str | None:
"""Convert reasoning_effort to the string format expected by OpenAI chat completion API.
The chat completion API expects a simple string: 'none', 'low', 'medium', 'high', or 'xhigh'.
The chat completion API expects an effort string such as 'low' or 'high'.
Config/deployments may pass the Responses API format: {'effort': 'high', 'summary': 'detailed'}.
"""
if value is None:

View file

@ -1,58 +0,0 @@
"""
Support for OpenAI's `/v1/chat/completions` endpoint.
Calls done in OpenAI/openai.py as TogetherAI is openai-compatible.
Docs: https://docs.together.ai/reference/completions-1
"""
from typing import Final
from litellm._logging import verbose_logger
from litellm.utils import supports_function_calling
from ..openai.chat.gpt_transformation import OpenAIGPTConfig
class TogetherAIConfig(OpenAIGPTConfig):
def get_supported_openai_params(self, model: str) -> list:
"""
Only some together models support response_format / tool calling
Docs: https://docs.together.ai/docs/json-mode
"""
# Use supports_function_calling() — which reads _get_model_info_helper
# directly — instead of get_model_info(). get_model_info() calls
# get_supported_openai_params() as its first step, which routes back
# into this method for together_ai models, creating a recursion that
# only terminates when Python's recursion limit or the "not mapped"
# exception in _get_model_info_helper is hit (~332 deep calls).
supports_fc: bool | None = None
try:
supports_fc = supports_function_calling(model, custom_llm_provider="together_ai")
except Exception as e:
verbose_logger.debug("Error getting supported openai params: %s", e)
optional_params: Final = super().get_supported_openai_params(model)
if supports_fc is not True:
verbose_logger.debug(
"Only some together models support function calling/response_format. Docs - https://docs.together.ai/docs/function-calling"
)
optional_params.remove("tools")
optional_params.remove("tool_choice")
optional_params.remove("function_call")
optional_params.remove("response_format")
return optional_params
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
mapped_openai_params: Final = super().map_openai_params(non_default_params, optional_params, model, drop_params)
if "response_format" in mapped_openai_params and mapped_openai_params["response_format"] == {"type": "text"}:
mapped_openai_params.pop("response_format")
return mapped_openai_params

View file

@ -0,0 +1,3 @@
from .transformation import TogetherAIChatConfig as TogetherAIChatConfig
TogetherAIConfig = TogetherAIChatConfig

View file

@ -0,0 +1,89 @@
"""
Translates from OpenAI's `/v1/chat/completions` to Together AI's `/v1/chat/completions`.
Docs: https://docs.together.ai/docs/chat-overview
"""
from collections.abc import Container
from types import MappingProxyType
from typing import Final
import litellm
from litellm._logging import verbose_logger
from litellm.exceptions import UnsupportedParamsError
from litellm.utils import supports_function_calling
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
TOOL_CALLING_PARAMS: Final = ("tools", "tool_choice", "function_call")
PLAIN_TEXT_RESPONSE_FORMAT: Final = MappingProxyType({"type": "text"})
FUNCTION_CALLING_DOCS_URL: Final = "https://docs.together.ai/docs/function-calling"
def _function_calling_verdict(model: str) -> bool | None:
try:
if supports_function_calling(model, custom_llm_provider="together_ai"):
return True
except Exception as e:
verbose_logger.debug("Error checking together_ai function calling support for %s: %s", model, e)
registry_entry: Final = litellm.model_cost.get(f"together_ai/{model}")
if isinstance(registry_entry, dict) and registry_entry.get("supports_function_calling") is False:
return False
return None
def _tool_params_to_drop(passed_params: Container[str], model: str, drop_params: bool) -> tuple[str, ...]:
passed_tool_params: Final = tuple(param for param in TOOL_CALLING_PARAMS if param in passed_params)
if not passed_tool_params:
return ()
verdict: Final = _function_calling_verdict(model)
if verdict is True:
return ()
if verdict is None:
verbose_logger.warning(
"together_ai model %s has no function calling entry in the model registry; passing %s through for Together to validate. Docs - %s",
model,
", ".join(passed_tool_params),
FUNCTION_CALLING_DOCS_URL,
)
return ()
if drop_params or litellm.drop_params:
verbose_logger.warning(
"together_ai model %s does not support function calling per the model registry; dropping %s. Docs - %s",
model,
", ".join(passed_tool_params),
FUNCTION_CALLING_DOCS_URL,
)
return passed_tool_params
raise UnsupportedParamsError(
status_code=500,
message=f"together_ai does not support parameters: {', '.join(passed_tool_params)}, for model={model}. To drop it from the call, set `litellm.drop_params = True`.",
)
class TogetherAIChatConfig(OpenAIGPTConfig):
def get_supported_openai_params(self, model: str) -> list:
supports_fc: Final = _function_calling_verdict(model)
supported_params: Final = super().get_supported_openai_params(model)
if supports_fc is True:
return supported_params
verbose_logger.debug(
"Only some together models support response_format. Docs - https://docs.together.ai/docs/function-calling"
)
return [ # mutable-ok: the inherited contract returns a plain list; building fresh avoids mutating the base class's value
param for param in supported_params if param != "response_format"
]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
mapped_openai_params: Final = super().map_openai_params(non_default_params, optional_params, model, drop_params)
for param in _tool_params_to_drop(mapped_openai_params, model, drop_params):
mapped_openai_params.pop(param)
if mapped_openai_params.get("response_format") == PLAIN_TEXT_RESPONSE_FORMAT:
mapped_openai_params.pop("response_format")
return mapped_openai_params

View file

@ -16,11 +16,16 @@ from litellm.llms.together_ai.rerank.transformation import TogetherAIRerankConfi
from litellm.types.rerank import RerankRequest, RerankResponse
def _rerank_url(api_base: str) -> str:
return f"{api_base.rstrip('/')}/rerank"
class TogetherAIRerank(BaseLLM):
def rerank(
self,
model: str,
api_key: str,
api_base: str,
query: str,
documents: list[str | dict[str, Any]],
top_n: int | None = None,
@ -46,10 +51,10 @@ class TogetherAIRerank(BaseLLM):
raise ValueError("TogetherAI does not support max_chunks_per_doc")
if _is_async:
return self.async_rerank(request_data_dict, api_key) # Call async method
return self.async_rerank(request_data_dict, api_key, api_base)
response: Final = client.post(
"https://api.together.xyz/v1/rerank",
_rerank_url(api_base),
headers={
"accept": "application/json",
"content-type": "application/json",
@ -69,11 +74,12 @@ class TogetherAIRerank(BaseLLM):
self,
request_data_dict: dict[str, Any],
api_key: str,
api_base: str,
) -> RerankResponse:
client: Final = get_async_httpx_client(llm_provider=litellm.LlmProviders.TOGETHER_AI) # Use async client
response: Final = await client.post(
"https://api.together.xyz/v1/rerank",
_rerank_url(api_base),
headers={
"accept": "application/json",
"content-type": "application/json",

View file

@ -0,0 +1,149 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Final
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.llms.gemini.interactions.transformation import GoogleAIStudioInteractionsConfig
from litellm.llms.vertex_ai.common_utils import validate_vertex_location
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
VERTEX_INTERACTIONS_API_VERSION: Final = "v1beta1"
VERTEX_INTERACTIONS_DEFAULT_LOCATION: Final = "global"
@dataclass(frozen=True, slots=True)
class VertexInteractionsTarget:
base_url: str
project_id: str
location: str
@property
def collection_url(self) -> str:
return (
f"{self.base_url}/{VERTEX_INTERACTIONS_API_VERSION}"
f"/projects/{self.project_id}/locations/{self.location}/interactions"
)
def interaction_url(self, interaction_id: str) -> str:
encoded_interaction_id: Final = encode_url_path_segment(interaction_id, field_name="interaction_id")
return f"{self.collection_url}/{encoded_interaction_id}"
class VertexAIInteractionsConfig(VertexBase, GoogleAIStudioInteractionsConfig):
def __init__(
self,
mint_access_token: Callable[[VERTEX_CREDENTIALS_TYPES | None, str | None], tuple[str, str]] | None = None,
) -> None:
super().__init__()
self._mint_access_token: Final[Callable[[VERTEX_CREDENTIALS_TYPES | None, str | None], tuple[str, str]]] = (
mint_access_token or self._mint_access_token_with_vertex_base
)
def _mint_access_token_with_vertex_base(
self,
credentials: VERTEX_CREDENTIALS_TYPES | None,
project_id: str | None,
) -> tuple[str, str]:
return self._ensure_access_token(
credentials=credentials, project_id=project_id, custom_llm_provider="vertex_ai"
)
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.VERTEX_AI
@property
def api_version(self) -> str:
return VERTEX_INTERACTIONS_API_VERSION
def get_default_vertex_location(self) -> str:
return VERTEX_INTERACTIONS_DEFAULT_LOCATION
def _mint(self, litellm_params: GenericLiteLLMParams) -> tuple[str, str]:
raw_params: Final = litellm_params.model_dump()
return self._mint_access_token(
self.safe_get_vertex_ai_credentials(raw_params),
self.safe_get_vertex_ai_project(raw_params),
)
def _target(self, api_base: str | None, litellm_params: GenericLiteLLMParams) -> VertexInteractionsTarget:
_, project_id = self._mint(litellm_params)
if not project_id:
raise ValueError(
"Vertex AI project is required. Set vertex_project, litellm.vertex_project, or VERTEXAI_PROJECT"
)
location: Final = validate_vertex_location(
self.explicit_vertex_ai_location(litellm_params.model_dump()) or VERTEX_INTERACTIONS_DEFAULT_LOCATION
)
return VertexInteractionsTarget(
base_url=self.get_api_base(api_base or None, location),
project_id=project_id,
location=location,
)
def validate_environment(
self,
headers: Mapping[str, str],
model: str,
litellm_params: GenericLiteLLMParams | None,
) -> dict: # mutable-ok: BaseInteractionsAPIConfig declares plain-dict headers
access_token, _ = self._mint(litellm_params or GenericLiteLLMParams())
return { # mutable-ok: BaseInteractionsAPIConfig declares plain-dict headers
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
**headers,
}
def get_complete_url(
self,
api_base: str | None,
model: str | None,
agent: str | None = None,
litellm_params: Mapping[str, object] | None = None,
stream: bool | None = None,
) -> str:
params: Final = (
GenericLiteLLMParams.model_validate(litellm_params) if litellm_params else GenericLiteLLMParams()
)
collection_url: Final = self._target(api_base, params).collection_url
return f"{collection_url}?alt=sse" if stream else collection_url
def _interaction_by_id_request(
self,
interaction_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
url_suffix: str = "",
) -> tuple[str, dict]: # mutable-ok: BaseInteractionsAPIConfig declares a plain-dict request body
target: Final = self._target(api_base or None, litellm_params)
return f"{target.interaction_url(interaction_id)}{url_suffix}", {} # mutable-ok: same base contract
def transform_get_interaction_request(
self,
interaction_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: Mapping[str, str],
) -> tuple[str, dict]: # mutable-ok: BaseInteractionsAPIConfig declares a plain-dict request body
return self._interaction_by_id_request(interaction_id, api_base, litellm_params)
def transform_delete_interaction_request(
self,
interaction_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: Mapping[str, str],
) -> tuple[str, dict]: # mutable-ok: BaseInteractionsAPIConfig declares a plain-dict request body
return self._interaction_by_id_request(interaction_id, api_base, litellm_params)
def transform_cancel_interaction_request(
self,
interaction_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: Mapping[str, str],
) -> tuple[str, dict]: # mutable-ok: BaseInteractionsAPIConfig declares a plain-dict request body
return self._interaction_by_id_request(interaction_id, api_base, litellm_params, url_suffix=":cancel")

View file

@ -24,6 +24,7 @@ from concurrent import futures
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from copy import deepcopy
from functools import partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, Union, cast, get_args
from litellm._logging import _redact_string
@ -416,7 +417,7 @@ async def acompletion(
logprobs: bool | None = None,
top_logprobs: int | None = None,
deployment_id=None,
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "default"] | None = None,
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"] | None = None,
verbosity: Literal["low", "medium", "high"] | None = None,
safety_identifier: str | None = None,
service_tier: str | None = None,
@ -602,7 +603,7 @@ async def acompletion(
_, custom_llm_provider, _, _ = get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,
api_base=base_url,
api_base=kwargs.get("api_base") or base_url,
)
fallbacks = fallbacks or litellm.model_fallbacks
@ -1811,6 +1812,56 @@ def _complete_fireworks_ai(
return response
def _complete_together_ai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
acompletion: Final = ctx.acompletion
api_base: Final = ctx.api_base
api_key: Final = ctx.api_key
client: Final = _dispatch_client_http(ctx)
custom_llm_provider: Final = ctx.custom_llm_provider
headers: Final = ctx.headers
litellm_params: Final = ctx.litellm_params
logging: Final = ctx.logging
messages: Final = ctx.messages
model: Final = ctx.model
model_response: Final = ctx.model_response
optional_params: Final = ctx.optional_params
provider_config: Final = ctx.provider_config
shared_session: Final = ctx.shared_session
stream: Final = ctx.stream
timeout: Final = ctx.timeout
try:
response: Final = base_llm_http_handler.completion(
model=model,
messages=messages,
headers=headers,
model_response=model_response,
api_key=api_key,
api_base=api_base,
acompletion=acompletion,
logging_obj=logging,
optional_params=optional_params,
litellm_params=litellm_params,
shared_session=shared_session,
timeout=timeout,
client=client,
custom_llm_provider=custom_llm_provider,
encoding=_get_encoding(),
stream=stream,
provider_config=provider_config,
)
except Exception as e:
logging.post_call(
input=messages,
api_key=api_key,
original_response=str(e),
additional_args=MappingProxyType({"headers": headers}),
)
raise
return response
def _complete_heroku(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
acompletion: Final = ctx.acompletion
api_base: Final = ctx.api_base
@ -4920,7 +4971,7 @@ def completion(
logit_bias: dict | None = None,
user: str | None = None,
# openai v1.0+ new params
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "default"] | None = None,
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"] | None = None,
verbosity: Literal["low", "medium", "high"] | None = None,
response_format: dict | type[BaseModel] | None = None,
seed: int | None = None,
@ -5600,6 +5651,8 @@ def completion(
elif custom_llm_provider == "fireworks_ai":
## COMPLETION CALL
response = _complete_fireworks_ai(_dispatch_ctx)
elif custom_llm_provider == "together_ai":
response = _complete_together_ai(_dispatch_ctx)
elif custom_llm_provider == "heroku":
response = _complete_heroku(_dispatch_ctx)
@ -5649,7 +5702,6 @@ def completion(
or custom_llm_provider == "volcengine"
or custom_llm_provider == "anyscale"
or custom_llm_provider == "openai"
or custom_llm_provider == "together_ai"
or custom_llm_provider == "nebius"
or custom_llm_provider == "wandb"
or custom_llm_provider == "clarifai"
@ -5699,14 +5751,6 @@ def completion(
response = _complete_openrouter(_dispatch_ctx)
elif custom_llm_provider == "vercel_ai_gateway":
response = _complete_vercel_ai_gateway(_dispatch_ctx)
elif (
custom_llm_provider == "together_ai"
or ("togethercomputer" in model)
or (model in litellm.together_ai_models)
):
"""
Deprecated. We now do together ai calls via the openai client - https://docs.together.ai/docs/openai-api-compatibility
"""
elif custom_llm_provider == "palm":
raise ValueError(
"Palm was decommisioned on October 2024. Please use the `gemini/` route for Gemini Google AI Studio Models. Announcement: https://ai.google.dev/palm_docs/palm?hl=en"

View file

@ -37886,6 +37886,7 @@
"output_cost_per_token": 1e-07
},
"together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": {
"deprecation_date": "2026-02-06",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -37902,6 +37903,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": {
"deprecation_date": "2026-07-10",
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262000,
@ -37914,6 +37916,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": {
"deprecation_date": "2026-04-16",
"input_cost_per_token": 6.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 256000,
@ -37926,6 +37929,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": {
"deprecation_date": "2026-02-06",
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 40000,
@ -37937,6 +37941,7 @@
"supports_tool_choice": false
},
"together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": {
"deprecation_date": "2026-06-04",
"input_cost_per_token": 2e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 256000,
@ -37949,11 +37954,15 @@
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-R1": {
"deprecation_date": "2026-05-14",
"input_cost_per_token": 3e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 128000,
"max_output_tokens": 20480,
"max_tokens": 20480,
"metadata": {
"successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro"
},
"mode": "chat",
"output_cost_per_token": 7e-06,
"supports_function_calling": true,
@ -37962,6 +37971,7 @@
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-R1-0528-tput": {
"deprecation_date": "2026-02-03",
"input_cost_per_token": 5.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 128000,
@ -37979,6 +37989,9 @@
"max_input_tokens": 65536,
"max_output_tokens": 8192,
"max_tokens": 8192,
"metadata": {
"successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro"
},
"mode": "chat",
"output_cost_per_token": 1.25e-06,
"supports_function_calling": true,
@ -37987,9 +38000,13 @@
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-V3.1": {
"deprecation_date": "2026-05-14",
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"max_tokens": 16384,
"metadata": {
"successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro"
},
"mode": "chat",
"output_cost_per_token": 1.7e-06,
"source": "https://www.together.ai/models/deepseek-v3-1",
@ -38001,6 +38018,7 @@
"max_output_tokens": 16384
},
"together_ai/meta-llama/Llama-3.2-3B-Instruct-Turbo": {
"deprecation_date": "2026-03-06",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -38009,16 +38027,21 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": {
"input_cost_per_token": 8.8e-07,
"input_cost_per_token": 1.04e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 131072,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 8.8e-07,
"output_cost_per_token": 1.04e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free": {
"deprecation_date": "2025-11-13",
"input_cost_per_token": 0,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38029,6 +38052,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
"deprecation_date": "2026-03-31",
"input_cost_per_token": 2.7e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38039,6 +38063,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": {
"deprecation_date": "2026-02-06",
"input_cost_per_token": 1.8e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38049,6 +38074,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": {
"deprecation_date": "2026-02-06",
"input_cost_per_token": 3.5e-06,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38059,6 +38085,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": {
"deprecation_date": "2026-02-25",
"input_cost_per_token": 8.8e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38069,6 +38096,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": {
"deprecation_date": "2026-03-06",
"input_cost_per_token": 1.8e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38079,6 +38107,7 @@
"supports_tool_choice": true
},
"together_ai/mistralai/Mistral-7B-Instruct-v0.1": {
"deprecation_date": "2025-11-13",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -38087,6 +38116,7 @@
"supports_tool_choice": true
},
"together_ai/mistralai/Mistral-Small-24B-Instruct-2501": {
"deprecation_date": "2026-04-02",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -38094,6 +38124,7 @@
"supports_tool_choice": true
},
"together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": {
"deprecation_date": "2026-04-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38106,6 +38137,9 @@
"together_ai/moonshotai/Kimi-K2-Instruct": {
"input_cost_per_token": 1e-06,
"litellm_provider": "together_ai",
"metadata": {
"successor": "together_ai/moonshotai/Kimi-K3"
},
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://www.together.ai/models/kimi-k2-instruct",
@ -38149,6 +38183,7 @@
"supports_tool_choice": true
},
"together_ai/zai-org/GLM-4.5-Air-FP8": {
"deprecation_date": "2026-04-02",
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 128000,
@ -38166,6 +38201,9 @@
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"max_tokens": 200000,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
"mode": "chat",
"output_cost_per_token": 2.2e-06,
"source": "https://www.together.ai/models/glm-4-6",
@ -38175,11 +38213,15 @@
"supports_tool_choice": true
},
"together_ai/zai-org/GLM-4.7": {
"deprecation_date": "2026-04-02",
"input_cost_per_token": 4.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"max_tokens": 200000,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://www.together.ai/models/glm-4-7",
@ -38189,11 +38231,15 @@
"supports_tool_choice": true
},
"together_ai/moonshotai/Kimi-K2.5": {
"deprecation_date": "2026-05-21",
"input_cost_per_token": 5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"metadata": {
"successor": "together_ai/moonshotai/Kimi-K3"
},
"mode": "chat",
"output_cost_per_token": 2.8e-06,
"source": "https://www.together.ai/models/kimi-k2-5",
@ -38203,9 +38249,13 @@
"supports_reasoning": true
},
"together_ai/moonshotai/Kimi-K2-Instruct-0905": {
"deprecation_date": "2026-03-06",
"input_cost_per_token": 1e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"metadata": {
"successor": "together_ai/moonshotai/Kimi-K3"
},
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://www.together.ai/models/kimi-k2-0905",
@ -38214,9 +38264,13 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct": {
"deprecation_date": "2026-04-02",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"metadata": {
"successor": "together_ai/Qwen/Qwen3.7-Plus"
},
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://www.together.ai/models/qwen3-next-80b-a3b-instruct",
@ -38226,9 +38280,13 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": {
"deprecation_date": "2026-02-25",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"metadata": {
"successor": "together_ai/Qwen/Qwen3.6-Plus"
},
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://www.together.ai/models/qwen3-next-80b-a3b-thinking",
@ -38238,6 +38296,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3.5-397B-A17B": {
"deprecation_date": "2026-06-29",
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
@ -38249,6 +38308,292 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/MiniMaxAI/MiniMax-M3": {
"input_cost_per_token": 3e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 524288,
"max_output_tokens": 524288,
"max_tokens": 524288,
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/Prism-ML/Ternary-Bonsai-27B": {
"input_cost_per_token": 0.0,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 0.0,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/Qwen/Qwen3.5-9B": {
"input_cost_per_token": 1.7e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 2.5e-07,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/Qwen/Qwen3.6-Plus": {
"input_cost_per_token": 5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 1000000,
"max_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_reasoning": true
},
"together_ai/Qwen/Qwen3.7-Max": {
"input_cost_per_token": 1.25e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 1000000,
"max_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 3.75e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/Qwen/Qwen3.7-Plus": {
"input_cost_per_token": 3.2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 1000000,
"max_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 1.28e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/Qwen/Qwen3.8-2.4T-A95B": {
"input_cost_per_token": 2.5e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1010000,
"max_output_tokens": 1010000,
"max_tokens": 1010000,
"mode": "chat",
"output_cost_per_token": 6.25e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/arize-ai/qwen-2-1.5b-instruct": {
"input_cost_per_token": 1e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 32768,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 1e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/deepseek-ai/DeepSeek-V4-Flash-0731": {
"input_cost_per_token": 1.4e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-V4-Pro": {
"input_cost_per_token": 1.74e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 512000,
"max_output_tokens": 512000,
"max_tokens": 512000,
"mode": "chat",
"output_cost_per_token": 3.48e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-V4-Pro-0813": {
"input_cost_per_token": 1.32e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 3.96e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/google/gemma-3n-E4B-it": {
"input_cost_per_token": 6e-08,
"litellm_provider": "together_ai",
"max_input_tokens": 32768,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 1.2e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/google/gemma-4-31B-it": {
"input_cost_per_token": 3.9e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 9.7e-07,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/intfloat/multilingual-e5-large-instruct": {
"input_cost_per_token": 2e-08,
"litellm_provider": "together_ai",
"max_input_tokens": 514,
"max_tokens": 514,
"mode": "embedding",
"output_cost_per_token": 2e-08,
"output_vector_size": 1024,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/meta-llama/Llama-Guard-4-12B": {
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 2e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/meta-models/Muse-Glimmer-30B": {
"input_cost_per_token": 3.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 131072,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/moonshotai/Kimi-K2.7-Code": {
"input_cost_per_token": 9.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/moonshotai/Kimi-K3": {
"input_cost_per_token": 3e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 1.5e-05,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/nvidia/nemotron-3-ultra-550b-a55b": {
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 512288,
"max_output_tokens": 512288,
"max_tokens": 512288,
"mode": "chat",
"output_cost_per_token": 3.6e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/pearl-ai/gemma-4-31b-it": {
"input_cost_per_token": 2.8e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 8.6e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/thinkingmachines/Inkling": {
"input_cost_per_token": 1e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 524288,
"max_output_tokens": 524288,
"max_tokens": 524288,
"mode": "chat",
"output_cost_per_token": 4.05e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/thinkingmachines/Inkling-Small": {
"input_cost_per_token": 5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 524288,
"max_output_tokens": 524288,
"max_tokens": 524288,
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/zai-org/GLM-5.2": {
"input_cost_per_token": 1.4e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1048575,
"max_output_tokens": 1048575,
"max_tokens": 1048575,
"mode": "chat",
"output_cost_per_token": 4.4e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"tts-1": {
"input_cost_per_character": 1.5e-05,
"litellm_provider": "openai",
@ -49016,12 +49361,13 @@
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"litellm_provider": "bedrock_mantle",
"max_input_tokens": 1000000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"use_openai_responses_path": true,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
@ -49048,12 +49394,13 @@
"output_cost_per_token": 1.32e-05,
"output_cost_per_token_above_272k_tokens": 1.98e-05,
"litellm_provider": "bedrock_mantle",
"max_input_tokens": 1000000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"use_openai_responses_path": true,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
@ -49080,12 +49427,13 @@
"output_cost_per_token": 1.32e-06,
"output_cost_per_token_above_272k_tokens": 1.98e-06,
"litellm_provider": "bedrock_mantle",
"max_input_tokens": 1000000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"use_openai_responses_path": true,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [

View file

@ -199,7 +199,7 @@ def llm_passthrough_route(
api_key=api_key,
)
litellm_params_dict: Final = get_litellm_params(**kwargs)
litellm_params_dict: Final = get_litellm_params(api_key=api_key, api_base=api_base, **kwargs)
if client is None:
from litellm.llms.custom_httpx.http_handler import (

View file

@ -815,6 +815,7 @@ class LiteLLMRoutes(enum.Enum):
"/team/member_add",
"/team/member_delete",
"/team/member_update",
"/team/{team_id}/member/{user_id}/reset_spend",
"/team/permissions_list",
"/team/permissions_update",
"/team/daily/activity",
@ -1287,6 +1288,16 @@ class RegenerateKeyRequest(GenerateKeyRequest):
class ResetSpendRequest(LiteLLMPydanticObjectBase):
reset_to: float
@field_validator("reset_to", mode="before")
@classmethod
def reject_bool_reset_to(cls, v):
# bool is a subclass of int, so pydantic silently coerces True/False into
# 1.0/0.0 for a `float` field: a caller who accidentally sends a boolean
# would otherwise get an unintended spend reset instead of a 422.
if isinstance(v, bool):
raise ValueError("reset_to must be a number, not a boolean") # noqa: TRY004 # pydantic needs ValueError
return v
class KeyRequest(LiteLLMPydanticObjectBase):
keys: list[str] | None = None

View file

@ -71,7 +71,6 @@ from litellm.proxy.auth.budget_throttle import (
)
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
from litellm.proxy.common_utils.http_parsing_utils import (
_safe_get_request_headers,
_safe_get_request_query_params,
@ -87,6 +86,8 @@ from litellm.proxy.common_utils.user_api_key_cache import (
object_permission_cache_key,
tag_cache_key,
tag_registry_cache_key,
team_membership_auth_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.guardrails.tool_name_extraction import (
@ -1129,7 +1130,8 @@ def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool:
Parameters:
- user_route: str - the route the user is trying to call
- allowed_routes: List[str|LiteLLMRoutes] - the list of allowed routes for the user.
- allowed_routes: List[str|LiteLLMRoutes] - the list of allowed routes for the user. Entries are a route group name
(e.g. "openai_routes"), an exact route, or a trailing-wildcard prefix (e.g. "/internal-models/*").
"""
from starlette.routing import compile_path
@ -1139,7 +1141,7 @@ def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool:
regex, _, _ = compile_path(template)
if regex.match(user_route):
return True
elif allowed_route == user_route:
elif RouteChecks.route_matches_wildcard_pattern(route=user_route, pattern=allowed_route):
return True
return False
@ -1967,7 +1969,7 @@ async def get_team_membership(
if user_id is None or team_id is None:
return None
_key: Final = f"team_membership:{user_id}:{team_id}"
_key: Final = team_membership_reservation_cache_key(user_id=user_id, team_id=team_id)
# check if in cache
cached_membership_obj: Final = await user_api_key_cache.async_get_cache(
@ -2402,6 +2404,116 @@ async def _cache_team_object(
)
async def invalidate_team_member_spend_state(
user_id: str,
team_id: str,
user_api_key_cache: UserApiKeyCache,
new_spend: float | None = None,
) -> None:
"""
Clear every cached read path for one team member's budget so a spend
reset or a raised cap takes effect on the next request instead of
waiting on the membership cache's TTL.
Two independently-keyed cache entries hold the same LiteLLM_TeamMembership
row: user_api_key_auth.py's admission check writes ``{team_id}_{user_id}``,
while budget_reservation.py's pre-call reservation and auth_checks.py's own
get_team_membership() (used by _check_team_member_budget) both write
``team_membership:{user_id}:{team_id}``. Both formats must be invalidated
explicitly; writing one does not refresh the other. All keys are also
broadcast (LIT-3803): each worker's own in-memory copy (membership object,
spend counter, or the counter's own short-TTL DB-floor marker) survives
eviction elsewhere until its TTL, so the handling worker alone clearing its
copy leaves every other worker still enforcing the pre-reset budget.
``new_spend`` is only passed by reset_team_member_spend_fn, which knows the
exact post-reset value: it is SET everywhere (matching /key/{key}/reset_spend's
own precedent) rather than deleted, so a worker's next read reflects it
directly instead of re-deriving it through a DB reseed. team_member_update
only changes the budget cap, not the tracked spend, so it passes no
new_spend; the live spend counter is untouched in that case (deleting it
would force a reseed from the DB's own spend column, which lags the live
counter via periodic batch writes, briefly under-enforcing the raised cap
against a spend value lower than what was actually tracked) and only the
membership caches carrying the new cap are invalidated.
The floor marker (``spend_db_floor:``, proxy_server.py's
_authoritative_floor_spend) caches the pre-reset DB spend for
SPEND_DB_FLOOR_CACHE_TTL_SECONDS; left stale after a real reset, a request
landing on the pod that cached it can read that higher floor and raise the
counter right back above the just-reset spend. It is overwritten here with
the post-reset floor (not merely deleted) and _authoritative_floor_spend
re-checks the marker after its DB read, so a floor read already in flight
on this pod when the reset commits cannot clobber it with the pre-reset
value. Both keys are broadcast as SETs carrying new_spend, not deletes:
every subscriber (remote pods AND this pod's own, which receives its own
message) writes the post-reset value, so the self-delivered message cannot
erase the guard just written here.
Raises HTTPException(503) if Redis still holds the stale pre-reset counter
after both the SET and the fallback DELETE fail: budget checks read Redis
first, so returning success would leave the old value authoritative for
every worker despite the DB write having committed.
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
evict_and_broadcast,
publish_auth_cache_invalidation,
)
if new_spend is not None:
from litellm.proxy.proxy_server import SPEND_DB_FLOOR_CACHE_TTL_SECONDS, spend_counter_cache
spend_counter_key: Final = f"spend:team_member:{user_id}:{team_id}"
spend_db_floor_key: Final = f"spend_db_floor:{spend_counter_key}"
spend_counter_cache.in_memory_cache.set_cache(key=spend_counter_key, value=new_spend, ttl=60)
if spend_counter_cache.redis_cache is not None:
try:
await spend_counter_cache.redis_cache.async_set_cache(key=spend_counter_key, value=new_spend, ttl=60)
except Exception as e: # noqa: BLE001 # fall back to deleting the stale entry before giving up
verbose_proxy_logger.warning(
"Failed to set spend counter %s in Redis after reset: %s; deleting it instead so the next "
"read reseeds from the DB rather than keeping the stale pre-reset value authoritative",
spend_counter_key,
e,
)
try:
await spend_counter_cache.redis_cache.async_delete_cache(key=spend_counter_key)
except Exception: # noqa: BLE001 # stale value now authoritative in Redis; surface instead of reporting success
verbose_proxy_logger.warning(
"Failed to delete stale spend counter %s in Redis after a failed reset write",
spend_counter_key,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={ # mutable-ok: HTTPException.detail takes a dict
"error": "Spend was reset in the database, but Redis is unreachable and still "
"holds the pre-reset counter. Retry once Redis is reachable."
},
) from e
spend_counter_cache.in_memory_cache.set_cache(
key=spend_db_floor_key,
value=new_spend,
ttl=SPEND_DB_FLOOR_CACHE_TTL_SECONDS,
)
await publish_auth_cache_invalidation(cache_key=spend_counter_key, new_value=new_spend, ttl=60)
await publish_auth_cache_invalidation(
cache_key=spend_db_floor_key,
new_value=new_spend,
ttl=SPEND_DB_FLOOR_CACHE_TTL_SECONDS,
)
await evict_and_broadcast(
cache_keys=(
team_membership_auth_cache_key(team_id=team_id, user_id=user_id),
team_membership_reservation_cache_key(user_id=user_id, team_id=team_id),
),
user_api_key_cache=user_api_key_cache,
)
async def delete_cache_team_object(
team_id: str,
team_alias: str | None,
@ -2629,20 +2741,9 @@ async def _get_team_object_from_user_api_key_cache(
async def _get_team_object_from_cache(
key: str,
proxy_logging_obj: ProxyLogging | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None,
) -> LiteLLM_TeamTableCachedObj | None:
## INTERNAL USAGE CACHE (plain DualCache) — checked before UserApiKeyCache stores ##
if proxy_logging_obj is not None and proxy_logging_obj.internal_usage_cache.dual_cache:
cached_raw: Final = await proxy_logging_obj.internal_usage_cache.dual_cache.async_get_cache(
key=key, parent_otel_span=parent_otel_span
)
if cached_raw is not None:
from_internal: Final = CacheCodec.deserialize(cached_raw, LiteLLM_TeamTableCachedObj)
if from_internal is not None:
return from_internal
decoded: Final = await user_api_key_cache.async_get_cache(
key=key,
parent_otel_span=parent_otel_span,
@ -2678,7 +2779,6 @@ async def get_team_object(
if not check_db_only:
cached_team_obj: Final = await _get_team_object_from_cache(
key=key,
proxy_logging_obj=proxy_logging_obj,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
@ -2841,7 +2941,6 @@ async def get_team_object_by_alias(
cached_team_obj: Final = await _get_team_object_from_cache(
key=cache_key,
proxy_logging_obj=proxy_logging_obj,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)

View file

@ -11,6 +11,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import EMPTY_MAPPING
from litellm.integrations.otel.runtime import seed_request_identity
from litellm.litellm_core_utils.core_helpers import is_expected_client_error
from litellm.proxy._types import (
LitellmUserRoles,
ProxyErrorTypes,
@ -109,7 +110,12 @@ class UserAPIKeyAuthExceptionHandler:
request=request,
use_x_forwarded_for=general_settings.get("use_x_forwarded_for") is True,
)
verbose_proxy_logger.exception(
log_fn: Final = (
verbose_proxy_logger.error
if is_expected_client_error(e) and not litellm.log_client_error_tracebacks
else verbose_proxy_logger.exception
)
log_fn(
"litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - %s\nRequester IP Address:%s",
e,
requester_ip,

View file

@ -608,7 +608,7 @@ def route_in_additonal_public_routes(current_route: str):
# Check wildcard patterns
for route_pattern in routes_defined:
if RouteChecks._route_matches_wildcard_pattern(route=current_route, pattern=route_pattern):
if RouteChecks.route_matches_wildcard_pattern(route=current_route, pattern=route_pattern):
return True
return False

View file

@ -181,7 +181,7 @@ class RouteChecks:
# check if wildcard pattern is allowed
for allowed_route in valid_token.allowed_routes:
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route):
if RouteChecks.route_matches_wildcard_pattern(route=route, pattern=allowed_route):
return True
if denied_auth_enforced_pass_through_route:
@ -329,7 +329,7 @@ class RouteChecks:
route_allowed = True
break
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route):
if RouteChecks.route_matches_wildcard_pattern(route=route, pattern=allowed_route):
route_allowed = True
break
@ -397,7 +397,7 @@ class RouteChecks:
return True
# Check for wildcard patterns like "/containers/*"
if RouteChecks._is_wildcard_pattern(pattern=openai_route):
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=openai_route):
if RouteChecks.route_matches_wildcard_pattern(route=route, pattern=openai_route):
return True
# Check for Google routes with placeholders like "/v1beta/models/{model_name}:generateContent"
@ -517,7 +517,7 @@ class RouteChecks:
return pattern.endswith("*")
@staticmethod
def _route_matches_wildcard_pattern(route: str, pattern: str) -> bool:
def route_matches_wildcard_pattern(route: str, pattern: str) -> bool:
"""
Check if route matches the wildcard pattern
@ -594,7 +594,7 @@ class RouteChecks:
# e.g calling /anthropic/v1/messages is allowed if allowed_routes has /anthropic/*
#########################################################
if any(
RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route)
RouteChecks.route_matches_wildcard_pattern(route=route, pattern=allowed_route)
for allowed_route in allowed_routes
if RouteChecks._is_wildcard_pattern(pattern=allowed_route)
):

View file

@ -87,7 +87,10 @@ from litellm.proxy.common_utils.http_parsing_utils import (
populate_request_with_path_params,
)
from litellm.proxy.common_utils.realtime_utils import _realtime_request_body
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
team_membership_auth_cache_key,
)
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.utils import (
@ -1970,8 +1973,10 @@ async def _user_api_key_auth_builder(
# Check 3. Check if user is in their team budget
if not skip_budget_checks and valid_token.team_member_spend is not None:
if prisma_client is not None:
_cache_key: Final = f"{valid_token.team_id}_{valid_token.user_id}"
_user_id: Final = valid_token.user_id
_team_id: Final = valid_token.team_id
if prisma_client is not None and _user_id is not None and _team_id is not None:
_cache_key: Final = team_membership_auth_cache_key(team_id=_team_id, user_id=_user_id)
team_member_info = await user_api_key_cache.async_get_cache(
key=_cache_key,
@ -1979,25 +1984,21 @@ async def _user_api_key_auth_builder(
)
if team_member_info is None:
# read from DB
_user_id: Final = valid_token.user_id
_team_id: Final = valid_token.team_id
if _user_id is not None and _team_id is not None:
_db_member: Final = await TeamMembershipRepository(prisma_client).table.find_first(
where={
"user_id": _user_id,
"team_id": _team_id,
},
include={"litellm_budget_table": True},
_db_member: Final = await TeamMembershipRepository(prisma_client).table.find_first(
where={
"user_id": _user_id,
"team_id": _team_id,
},
include={"litellm_budget_table": True},
)
if _db_member is not None:
team_member_info = LiteLLM_TeamMembership(**_db_member.dict())
await user_api_key_cache.async_set_cache(
key=_cache_key,
value=team_member_info,
model_type=LiteLLM_TeamMembership,
ttl=5,
)
if _db_member is not None:
team_member_info = LiteLLM_TeamMembership(**_db_member.dict())
await user_api_key_cache.async_set_cache(
key=_cache_key,
value=team_member_info,
model_type=LiteLLM_TeamMembership,
ttl=5,
)
if team_member_info is not None and team_member_info.litellm_budget_table is not None:
team_member_budget: Final = team_member_info.litellm_budget_table.max_budget
@ -2013,11 +2014,16 @@ async def _user_api_key_auth_builder(
max_budget=team_member_budget,
)
if team_member_spend > team_member_budget:
_entity_id: Final = f"{valid_token.user_id}:{valid_token.team_id}"
raise litellm.BudgetExceededError(
current_cost=team_member_spend,
max_budget=team_member_budget,
message=(
f"Budget has been exceeded! TeamMember={_entity_id} "
f"Current cost: {team_member_spend}, Max budget: {team_member_budget}"
),
entity_type=Litellm_EntityType.TEAM_MEMBER.value,
entity_id=f"{valid_token.user_id}:{valid_token.team_id}",
entity_id=_entity_id,
)
# Check 3. If token is expired

View file

@ -33,7 +33,7 @@ from litellm.constants import (
UNSAFE_PROXY_RESPONSE_HEADERS,
)
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket
from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket, is_expected_client_error
from litellm.litellm_core_utils.dd_tracing import NullTracer, tracer
from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params,
@ -1138,24 +1138,25 @@ async def open_sse_before_first_byte(
)
def _is_azure_model_router_request(model: str) -> bool:
def _is_azure_model_router_request(model: str, hidden_params: Mapping[str, object] | None = None) -> bool:
"""
Check if the requested model is an Azure Model Router.
Check if a request went down the Azure Model Router route.
Azure Model Router models follow the pattern:
- azure_ai/model_router/<deployment-name>
- azure_ai/model-router
- model_router/<deployment-name>
- model-router
``model`` here is what the *client* sent, a model group alias with no ``model_router/``
prefix, so matching on it alone only works when the operator happened to put "model-router"
in the alias. Where the response is in hand its stamp answers this outright, so callers
should pass ``hidden_params``.
Args:
model: The requested model name
hidden_params: ``_hidden_params`` from the response, when the caller has it
Returns:
bool: True if this is an Azure Model Router request
"""
model_lower: Final = model.lower()
return "model-router" in model_lower or "model_router" in model_lower
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
return AzureFoundryModelInfo.is_model_router_call(model=model, hidden_params=hidden_params)
def _override_openai_response_model(
@ -1223,7 +1224,7 @@ def _override_openai_response_model(
return
# Check if this is an Azure Model Router request - if so, preserve the actual model used
if _is_azure_model_router_request(requested_model):
if _is_azure_model_router_request(requested_model, hidden_params):
verbose_proxy_logger.debug(
"%s: Azure Model Router detected - preserving actual model used from response instead of overriding to router model.",
log_context,
@ -1379,7 +1380,12 @@ def _log_llm_api_exception(e: Exception) -> None:
"litellm.proxy.proxy_server._handle_llm_api_exception(): client disconnected, upstream LLM request cancelled"
)
return
verbose_proxy_logger.exception("litellm.proxy.proxy_server._handle_llm_api_exception(): Exception occured - %s", e)
log_fn: Final = (
verbose_proxy_logger.error
if is_expected_client_error(e) and not litellm.log_client_error_tracebacks
else verbose_proxy_logger.exception
)
log_fn("litellm.proxy.proxy_server._handle_llm_api_exception(): Exception occured - %s", e)
async def _cancel_llm_call_on_client_disconnect(

View file

@ -12,6 +12,7 @@ from litellm.proxy.common_utils.config_sync_pubsub import (
)
if TYPE_CHECKING:
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.caching.redis_cache import RedisCache
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
@ -30,15 +31,24 @@ def auth_cache_invalidation_channel(redis_cache: "RedisCache") -> str:
@dataclass(frozen=True, slots=True)
class _CacheInvalidationMessage:
cache_key: str
new_value: float | None = None
ttl: float | None = None
def _cache_invalidation_message_json(cache_key: str) -> str:
return json.dumps(asdict(_CacheInvalidationMessage(cache_key=cache_key)))
def _cache_invalidation_message_json(cache_key: str, new_value: float | None = None, ttl: float | None = None) -> str:
message: Final = asdict(_CacheInvalidationMessage(cache_key=cache_key, new_value=new_value, ttl=ttl))
return json.dumps({field: value for field, value in message.items() if value is not None})
def _cache_key_from_message_data(data: object) -> str | None:
def _finite_number_or_none(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def _message_from_data(data: object) -> _CacheInvalidationMessage | None:
if isinstance(data, bytes):
data = data.decode("utf-8", errors="replace")
data = data.decode("utf-8", errors="replace") # rebind-ok: normalizing the wire payload to str
if not isinstance(data, str):
return None
try:
@ -48,14 +58,28 @@ def _cache_key_from_message_data(data: object) -> str | None:
if not isinstance(parsed, dict):
return None
cache_key: Final = parsed.get("cache_key")
return cache_key if isinstance(cache_key, str) else None
if not isinstance(cache_key, str):
return None
return _CacheInvalidationMessage(
cache_key=cache_key,
new_value=_finite_number_or_none(parsed.get("new_value")),
ttl=_finite_number_or_none(parsed.get("ttl")),
)
async def publish_auth_cache_invalidation(cache_key: str) -> None:
async def publish_auth_cache_invalidation(
cache_key: str, new_value: float | None = None, ttl: float | None = None
) -> None:
"""
Best-effort broadcast so every worker drops its local in-memory copy of a
mutated management object; without this, only the handling worker and Redis
are evicted and other workers keep serving the stale object until its TTL.
Passing ``new_value`` broadcasts a SET instead of a delete: every subscriber
(including the publishing worker's own, which receives its own message)
writes the value into its additional in-memory caches rather than deleting
the key. A spend reset uses this so the handler's self-delivered message
cannot erase the freshly-written post-reset counter or floor marker.
"""
redis_cache: Final = coordination_redis_cache()
if redis_cache is None:
@ -68,7 +92,10 @@ async def publish_auth_cache_invalidation(cache_key: str) -> None:
cache_key,
)
return
await client.publish(auth_cache_invalidation_channel(redis_cache), _cache_invalidation_message_json(cache_key))
await client.publish(
auth_cache_invalidation_channel(redis_cache),
_cache_invalidation_message_json(cache_key, new_value=new_value, ttl=ttl),
)
except Exception as e: # noqa: BLE001 # best-effort publish; mutations must never fail on redis errors
verbose_proxy_logger.warning("auth cache invalidation publish for %s failed: %s", cache_key, e)
@ -95,15 +122,17 @@ async def evict_and_broadcast(cache_keys: Sequence[str], user_api_key_cache: "Us
class AuthCacheInvalidationSubscriber:
__slots__ = ("_redis_cache", "_task", "_user_api_key_cache")
__slots__ = ("_additional_in_memory_caches", "_redis_cache", "_task", "_user_api_key_cache")
def __init__(
self,
redis_cache: "RedisCache",
user_api_key_cache: "UserApiKeyCache",
additional_in_memory_caches: Sequence["InMemoryCache"] = (),
) -> None:
self._redis_cache = redis_cache
self._user_api_key_cache = user_api_key_cache
self._additional_in_memory_caches = tuple(additional_in_memory_caches)
self._task: asyncio.Task[None] | None = None
def start(self) -> None:
@ -160,12 +189,18 @@ class AuthCacheInvalidationSubscriber:
def _apply_message(self, message: object) -> None:
data: Final = message.get("data") if isinstance(message, dict) else None
cache_key: Final = _cache_key_from_message_data(data)
if cache_key is None:
parsed: Final = _message_from_data(data)
if parsed is None:
return
if parsed.new_value is not None:
for additional_cache in self._additional_in_memory_caches:
additional_cache.set_cache(parsed.cache_key, parsed.new_value, ttl=parsed.ttl)
return
in_memory_cache: Final = self._user_api_key_cache.in_memory_cache
if in_memory_cache is not None:
in_memory_cache.delete_cache(cache_key)
in_memory_cache.delete_cache(parsed.cache_key)
for additional_cache in self._additional_in_memory_caches:
additional_cache.delete_cache(parsed.cache_key)
@staticmethod
async def _close_pubsub(pubsub: _ConfigSyncPubSub) -> None:

View file

@ -200,6 +200,21 @@ def end_user_restricted_registry_cache_key() -> str:
return "end_user_restricted_registry"
def team_membership_auth_cache_key(team_id: str, user_id: str) -> str:
"""Cache key one team member's ``LiteLLM_TeamMembership`` row is stored under for the admission check."""
return f"{team_id}_{user_id}"
def team_membership_reservation_cache_key(user_id: str, team_id: str) -> str:
"""Cache key the pre-call budget reservation stores the same ``LiteLLM_TeamMembership`` row under.
Deliberately not unified with ``team_membership_auth_cache_key``: the two readers wrote independent
keys before this file existed, so a fix that invalidates one must invalidate both explicitly rather
than assume a single write is visible to both.
"""
return f"team_membership:{user_id}:{team_id}"
def get_management_object_ttl(cache: DualCache) -> float:
"""
In-memory TTL for management-object cache writes (keys, teams, users, budgets, ...).

View file

@ -210,7 +210,6 @@ async def _patch_team_caches_add_access_group(
for team_id in team_ids:
cached_team = await _get_team_object_from_cache(
key=f"team_id:{team_id}",
proxy_logging_obj=proxy_logging_obj,
user_api_key_cache=user_api_key_cache,
parent_otel_span=None,
)
@ -240,7 +239,6 @@ async def _patch_team_caches_remove_access_group(
for team_id in team_ids:
cached_team = await _get_team_object_from_cache(
key=f"team_id:{team_id}",
proxy_logging_obj=proxy_logging_obj,
user_api_key_cache=user_api_key_cache,
parent_otel_span=None,
)

View file

@ -198,15 +198,8 @@ class ScimTransformations:
@staticmethod
def _get_scim_member_value(member: Member) -> str:
"""
Get the SCIM member value. Use user_email if available, otherwise use user_id.
SCIM member value should be the unique identifier for the user.
"""
if hasattr(member, "user_email") and member.user_email:
return member.user_email
elif hasattr(member, "user_id"):
return member.user_id or ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
return ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
"""The member's SCIM resource id, which LiteLLM serves as user_id (RFC 7643 §8.7.1)."""
return member.user_id or ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
@staticmethod
def _get_scim_member_display(member: Member) -> str:

View file

@ -176,6 +176,10 @@ class UserProvisionerHelpers:
is persisted too, so re-upserting an existing email demotes a user who is no
longer in the admin group instead of leaving the stale role.
IdPs like Entra manage membership exclusively through /Groups and never send
``groups`` on POST /Users, so a request without teams means "unspecified",
not "remove from every team": existing memberships are preserved then.
Args:
prisma_client: Database client
new_user_request: New user request data
@ -194,7 +198,8 @@ class UserProvisionerHelpers:
if not existing_user:
return None
new_teams: Final = list(dict.fromkeys(new_user_request.teams or []))
requested_teams: Final = list(dict.fromkeys(new_user_request.teams or []))
new_teams: Final = requested_teams if requested_teams else list(existing_user.teams or [])
if new_user_request.user_id != existing_user.user_id:
verbose_proxy_logger.info(

View file

@ -16,11 +16,12 @@ import traceback
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Annotated, Final, NamedTuple, Protocol, TypedDict, TypeVar, cast
from typing import Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, JsonValue
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
@ -56,6 +57,7 @@ from litellm.proxy._types import (
PatchTeamRequest,
ProxyErrorTypes,
ProxyException,
ResetSpendRequest,
SpecialManagementEndpointEnums,
SpecialModelNames,
SpecialProxyStrings,
@ -84,6 +86,7 @@ from litellm.proxy.auth.auth_checks import (
get_team_membership,
get_team_object,
get_user_object,
invalidate_team_member_spend_state,
)
from litellm.proxy.auth.auth_utils import (
enforce_batch_enqueued_token_limit_is_admin_only,
@ -114,6 +117,7 @@ from litellm.proxy.management_endpoints.tag_management_endpoints import (
get_daily_activity,
)
from litellm.proxy.management_helpers.access_group_team_sync import (
TEAM_ADVISORY_LOCK_SQL,
AccessGroupSyncTx,
invalidate_access_group_caches,
reconcile_team_access_group_membership,
@ -132,6 +136,7 @@ from litellm.proxy.management_helpers.team_metadata_validation import (
validate_team_metadata_if_configured,
)
from litellm.proxy.management_helpers.utils import (
MemberWriteTx,
add_new_member,
management_endpoint_wrapper,
)
@ -328,11 +333,44 @@ class _TeamIdInFilter(TypedDict, total=False):
team_id: Mapping[str, Sequence[str]]
class _DeletedTeamsResult(TypedDict):
deleted_teams: ReadOnly[Sequence[str]]
class _ErrorDetail(TypedDict):
error: ReadOnly[str]
class _TeamCreateTx(AccessGroupSyncTx, Protocol):
@property
def litellm_teamtable(self) -> "_PrismaTableActions[LiteLLM_TeamTable]": ...
class _MemberDeleteTx(Protocol):
"""The tables `/team/member_delete` reads while it holds the team's advisory lock.
Reading them off the transaction keeps the whole endpoint on the one pooled connection
it already checked out: a request that has the lock but still needs another connection
can be starved by the lock waiters, which is a deadlock rather than a wait when enough
of them hold the rest of the pool."""
@property
def litellm_usertable(self) -> "_PrismaTableActions[LiteLLM_UserTable]": ...
@property
def litellm_verificationtoken(self) -> "_PrismaTableActions[LiteLLM_VerificationToken]": ...
class _TeamDeleteTx(AccessGroupSyncTx, Protocol):
async def execute_raw(self, query: str, *args: object) -> int: ...
@property
def litellm_teamtable(self) -> "_PrismaTableActions[LiteLLM_TeamTable]": ...
@property
def litellm_teammembership(self) -> "_PrismaTableActions[LiteLLM_TeamMembership]": ...
_STRIP_DELETED_TEAM_FROM_USERS_SQL: Final = """
UPDATE "LiteLLM_UserTable" SET teams = array_remove(teams, $1) WHERE $1 = ANY(teams)
"""
@ -2578,8 +2616,13 @@ async def _process_team_members(
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: str,
tx: MemberWriteTx | None = None,
) -> tuple[list[LiteLLM_UserTable], list[LiteLLM_TeamMembership]]:
"""Process and add new team members."""
"""Process and add new team members.
``tx`` is the caller's open transaction, when it has one, so the member writes run on the
connection it already holds instead of checking out a second one.
"""
updated_users: Final[list[LiteLLM_UserTable]] = []
updated_team_memberships: Final[list[LiteLLM_TeamMembership]] = []
@ -2605,6 +2648,7 @@ async def _process_team_members(
default_team_budget_id=default_team_budget_id,
allowed_models=member_allowed_models,
budget_duration=data.budget_duration,
tx=tx,
)
except Exception as e:
raise HTTPException(
@ -2627,6 +2671,7 @@ async def _process_team_members(
default_team_budget_id=default_team_budget_id,
allowed_models=member_allowed_models,
budget_duration=data.budget_duration,
tx=tx,
)
except Exception as e:
raise HTTPException(
@ -2706,65 +2751,40 @@ async def _add_team_members_to_team(
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: str,
) -> tuple[LiteLLM_TeamTable, list[LiteLLM_UserTable], list[LiteLLM_TeamMembership]]:
"""Add team members to the team.
"""Add team members to the team, under the team's advisory lock.
The members_with_roles reconciliation runs inside a transaction that locks
the team row with ``SELECT ... FOR UPDATE`` before reading the current
membership. Concurrent /team/member_add calls for the same team therefore
serialize on the row lock and each appends onto the other's committed
result, instead of both rewriting the whole JSON array from a stale
snapshot (which silently drops one member on the losing write).
The lock (``TEAM_ADVISORY_LOCK_SQL``, keyed on the team id) is taken first, and the
team is re-read under it before any write, so a delete that already committed is
visible here before this call writes anything: the user and membership writes only
happen once the re-read proves the team is still live. /team/delete takes the same
lock around its own sweep-and-delete, so the two can never interleave; whichever
acquires the lock first runs to completion before the other's re-read can proceed.
The same lock serializes this against /team/delete: the delete cannot remove
the row while the reconcile holds it, and a reconcile that finds the row
already gone cleans up after itself rather than leaving the member pointing
at a deleted team id.
"""
# Process and add new members
updated_users, updated_team_memberships = await _process_team_members(
data=data,
complete_team_data=complete_team_data,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
updated_team: Final = await _write_members_with_roles_locked(
data=data,
complete_team_data=complete_team_data,
prisma_client=prisma_client,
updated_users=updated_users,
)
if updated_team is None:
await _sweep_deleted_team_references(team_ids=(data.team_id,), prisma_client=prisma_client)
raise HTTPException(
status_code=404,
detail={"error": f"Team={data.team_id} was deleted while this member add was running"},
)
return updated_team, updated_users, updated_team_memberships
async def _write_members_with_roles_locked(
data: TeamMemberAddRequest,
complete_team_data: LiteLLM_TeamTable,
prisma_client: PrismaClient,
updated_users: list[LiteLLM_UserTable],
) -> LiteLLM_TeamTable | None:
"""Reconcile members_with_roles under the team row lock. None when the team row is gone.
That read is at least as recent as the user and membership writes the caller
already made, so a missing row means /team/delete committed after them. Its
post-delete sweep can have run before those writes landed, which is why the
caller sweeps this team id again rather than only reporting the 404.
The user and membership writes run on this transaction too, not on a second
connection from the pool: a lock waiter that needs a connection it hasn't got yet is
a waiter that can deadlock the pool, since enough concurrent adds for one team would
hold every connection waiting on the lock while the holder waits for a free one.
"""
async with prisma_client.tx() as tx:
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, data.team_id)
locked_members: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, data.team_id)
if locked_members is None:
return None
gone_detail: Final[_ErrorDetail] = {
"error": f"Team={data.team_id} was deleted while this member add was running"
}
raise HTTPException(status_code=404, detail=gone_detail)
complete_team_data.members_with_roles = locked_members
updated_users, updated_team_memberships = await _process_team_members(
data=data,
complete_team_data=complete_team_data,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
tx=tx,
)
await _update_team_members_list(
data=data,
complete_team_data=complete_team_data,
@ -2772,11 +2792,13 @@ async def _write_members_with_roles_locked(
)
_db_team_members: Final = [m.model_dump() for m in complete_team_data.members_with_roles]
return await tx.litellm_teamtable.update(
updated_team: Final = await tx.litellm_teamtable.update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_team_members)},
)
return updated_team, updated_users, updated_team_memberships
def _emit_team_members_metric(team: LiteLLM_TeamTable) -> None:
"""Update the Prometheus team members gauge after a membership change.
@ -3157,10 +3179,6 @@ async def team_member_add(
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
# Check if updated_team is None
if updated_team is None:
raise HTTPException(status_code=404, detail={"error": f"Team with id {data.team_id} not found"})
_emit_team_members_metric(complete_team_data)
await _create_team_member_add_audit_logs(
@ -3274,45 +3292,63 @@ async def team_member_delete(
)
## DELETE MEMBER FROM TEAM
removed_team_members, new_team_members = _cleanup_members_with_roles(
existing_team_row=existing_team_row,
data=data,
)
if not removed_team_members:
raise HTTPException(status_code=400, detail={"error": "User not found in team"})
existing_team_row.members_with_roles = new_team_members
_db_new_team_members: Final[list[dict]] = [m.model_dump() for m in new_team_members]
## DELETE TEAM ID from USER ROW, IF EXISTS ##
# get user row
removed_user_ids: Final = frozenset(m.user_id for m in removed_team_members if m.user_id is not None)
key_val: Final[Mapping[str, object]] = (
{"user_id": {"in": sorted(removed_user_ids)}} if removed_user_ids else {"user_email": data.user_email}
)
existing_user_rows: Final[Sequence[LiteLLM_UserTable]] = await _user_db(prisma_client).find_many(where=key_val)
# Also clean up any existing team membership rows for this user and team
user_ids_to_delete: Final = removed_user_ids.union(
(data.user_id,) if data.user_id is not None else (),
(user.user_id for user in existing_user_rows if user.user_id),
)
## DELETE KEYS CREATED BY USER FOR THIS TEAM
# Fetch keys before deletion so their audit records can be persisted alongside the delete.
# An empty user_ids_to_delete still resolves cleanly: prisma's "in": [] matches no rows.
keys_to_delete: Final[list[LiteLLM_VerificationToken]] = await _tokens_db(prisma_client).find_many(
where={
"user_id": {"in": sorted(user_ids_to_delete)},
"team_id": data.team_id,
}
)
# All four cleanups run on one connection so a failure between them leaves
# no partial removal: either every write below lands, or none of them do.
# Everything from here on runs under the team's advisory lock, the same one
# /team/member_add and /team/delete take: without it, this endpoint's own row-level
# update lock used to be the only thing serializing it against a concurrent member_add,
# and only by accident (their SELECT ... FOR UPDATE contended for the same row lock this
# UPDATE takes). Now that member_add reads under the advisory lock instead, this has to
# take it too, and re-read the roster under it rather than off the snapshot validated
# above, or a member_add that commits in between can have its addition silently
# overwritten by this delete computing from stale data.
async with prisma_client.tx() as tx:
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, data.team_id)
fresh_members: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, data.team_id)
if fresh_members is None:
raise HTTPException(
status_code=400,
detail={"error": f"Team id={data.team_id} does not exist in db"},
)
removed_team_members, new_team_members = _cleanup_members_with_roles(
existing_team_row=LiteLLM_TeamTable(team_id=data.team_id, members_with_roles=fresh_members),
data=data,
)
if not removed_team_members:
raise HTTPException(status_code=400, detail={"error": "User not found in team"})
existing_team_row.members_with_roles = new_team_members
_db_new_team_members: Final[list[dict]] = [m.model_dump() for m in new_team_members]
## DELETE TEAM ID from USER ROW, IF EXISTS ##
# get user row
removed_user_ids: Final = frozenset(m.user_id for m in removed_team_members if m.user_id is not None)
key_val: Final[Mapping[str, object]] = (
{"user_id": {"in": sorted(removed_user_ids)}} if removed_user_ids else {"user_email": data.user_email}
)
member_tx: Final[_MemberDeleteTx] = tx
existing_user_rows: Final[Sequence[LiteLLM_UserTable]] = await member_tx.litellm_usertable.find_many(
where=key_val
)
# Also clean up any existing team membership rows for this user and team
user_ids_to_delete: Final = removed_user_ids.union(
(data.user_id,) if data.user_id is not None else (),
(user.user_id for user in existing_user_rows if user.user_id),
)
## DELETE KEYS CREATED BY USER FOR THIS TEAM
# Fetch keys before deletion so their audit records can be persisted alongside the delete.
# An empty user_ids_to_delete still resolves cleanly: prisma's "in": [] matches no rows.
keys_to_delete: Final[list[LiteLLM_VerificationToken]] = await member_tx.litellm_verificationtoken.find_many(
where={
"user_id": {"in": sorted(user_ids_to_delete)},
"team_id": data.team_id,
}
)
await tx.litellm_teamtable.update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_new_team_members)},
@ -3392,7 +3428,7 @@ async def team_member_update(
Update team member budgets and team member role
"""
from litellm.proxy.proxy_server import premium_user, prisma_client
from litellm.proxy.proxy_server import premium_user, prisma_client, user_api_key_cache
if prisma_client is None:
raise HTTPException(status_code=500, detail={"error": "No db connected"})
@ -3491,6 +3527,12 @@ async def team_member_update(
budget_patch=budget_patch,
team_default_budget_id=team_default_budget_id,
)
if budget_patch:
await invalidate_team_member_spend_state(
user_id=received_user_id,
team_id=data.team_id,
user_api_key_cache=user_api_key_cache,
)
### update team member role
if data.role is not None:
@ -3527,6 +3569,125 @@ async def team_member_update(
)
def _check_not_resetting_own_spend(user_id: str, user_api_key_dict: UserAPIKeyAuth) -> None:
"""
_verify_team_access authorizes a team admin (or org admin) over their own
team, with no check that the target user_id differs from the caller. Left
unchecked, that admin could target their own LiteLLM_TeamMembership row and
repeatedly reset it to 0 right before it crosses their per-member cap,
consuming the shared team budget without the configured limit ever binding.
Only a proxy admin may reset an admin's own spend.
"""
if user_id == user_api_key_dict.user_id and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
_raise_reset_spend_error(status.HTTP_403_FORBIDDEN, "Cannot reset your own spend. Ask a proxy admin.")
def _raise_reset_spend_error(status_code: int, message: str) -> NoReturn:
detail: Final = {"error": message} # mutable-ok: HTTPException.detail takes a dict
raise HTTPException(status_code=status_code, detail=detail)
def _validate_team_member_reset_spend_value(
reset_to: object,
membership: LiteLLM_TeamMembership,
) -> float:
if not isinstance(reset_to, (int, float)):
_raise_reset_spend_error(status.HTTP_400_BAD_REQUEST, "reset_to must be a float")
reset_to_float: Final = float(reset_to)
if not math.isfinite(reset_to_float) or reset_to_float < 0:
_raise_reset_spend_error(status.HTTP_400_BAD_REQUEST, "reset_to must be a finite number >= 0")
current_spend: Final = membership.spend or 0.0
if reset_to_float > current_spend:
_raise_reset_spend_error(
status.HTTP_400_BAD_REQUEST,
f"reset_to ({reset_to_float}) must be <= current spend ({current_spend})",
)
max_budget: Final = membership.litellm_budget_table.max_budget if membership.litellm_budget_table else None
if max_budget is not None and reset_to_float > max_budget:
_raise_reset_spend_error(
status.HTTP_400_BAD_REQUEST,
f"reset_to ({reset_to_float}) must be <= budget ({max_budget})",
)
return reset_to_float
@router.post(
"/team/{team_id}/member/{user_id}/reset_spend",
tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence
dependencies=(Depends(user_api_key_auth),),
)
@management_endpoint_wrapper
async def reset_team_member_spend_fn(
team_id: str,
user_id: str,
data: ResetSpendRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Reset a team member's tracked spend against their per-member budget.
A member's spend is tracked separately from both their own personal
budget and the team's own budget (LiteLLM_TeamMembership.spend), so
neither /user/update nor /team/update can clear it: this is the only
endpoint that does. The cross-pod spend counter and cached membership
reads are invalidated so the reset takes effect on the member's next
request rather than waiting on the membership cache's TTL.
"""
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
if prisma_client is None:
_raise_reset_spend_error(status.HTTP_500_INTERNAL_SERVER_ERROR, "DB not connected. prisma_client is None")
team_obj: Final = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=None,
proxy_logging_obj=proxy_logging_obj,
check_db_only=True,
)
await _verify_team_access(team_obj=team_obj, user_api_key_dict=user_api_key_dict)
_check_not_resetting_own_spend(user_id=user_id, user_api_key_dict=user_api_key_dict)
membership_where: Final = { # mutable-ok: prisma client requires a plain dict where= argument
"user_id_team_id": {"user_id": user_id, "team_id": team_id} # mutable-ok: same prisma where= argument
}
_membership_row: Final = await _team_membership_db(prisma_client).find_unique(
where=membership_where,
include={"litellm_budget_table": True}, # mutable-ok: prisma client requires a plain dict include= argument
)
if _membership_row is None:
_raise_reset_spend_error(status.HTTP_404_NOT_FOUND, f"User {user_id} is not a member of team {team_id}.")
membership: Final = LiteLLM_TeamMembership.model_validate(_membership_row.model_dump())
current_spend: Final = membership.spend or 0.0
reset_to: Final = _validate_team_member_reset_spend_value(data.reset_to, membership)
await _team_membership_db(prisma_client).update(
where=membership_where,
data={"spend": reset_to}, # mutable-ok: prisma client requires a plain dict data= argument
)
await invalidate_team_member_spend_state(
user_id=user_id,
team_id=team_id,
user_api_key_cache=user_api_key_cache,
new_spend=reset_to,
)
return { # mutable-ok: matches this router's established untyped-response-dict convention
"team_id": team_id,
"user_id": user_id,
"spend": reset_to,
"previous_spend": current_spend,
"max_budget": membership.litellm_budget_table.max_budget if membership.litellm_budget_table else None,
}
def _create_results_from_response(
members: list[Member],
response: TeamAddMemberResponse,
@ -3882,7 +4043,21 @@ async def delete_team(
await _sweep_deleted_team_references(team_ids=data.team_ids, prisma_client=prisma_client)
## DELETE TEAMS
deleted_teams: Final = await prisma_client.delete_data(team_id_list=data.team_ids, table_name="team")
# Both the delete and the reconcile sweep run under every team's advisory lock
# (TEAM_ADVISORY_LOCK_SQL, the same one /team/member_add takes before its own writes),
# sorted so two overlapping batch deletes always request their locks in the same order.
# A member_add mid-flight for one of these teams either finishes its write and releases
# the lock before this transaction starts, in which case this sweep reaches what it wrote,
# or is still waiting on the lock, in which case its own re-read happens after this commits
# and sees the row gone before it writes anything.
delete_filter: Final[_TeamIdInFilter] = {"team_id": {"in": data.team_ids}}
async with prisma_client.tx() as tx:
for team_id in sorted(data.team_ids):
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id)
await tx.litellm_teamtable.delete_many(where=delete_filter)
await _sweep_deleted_team_references_tx(team_ids=data.team_ids, tx=tx)
deleted_teams: Final[_DeletedTeamsResult] = {"deleted_teams": data.team_ids}
# Evict AFTER the rows are gone. Both writers of these keys (`_cache_team_object` and
# `get_team_object_by_alias`) hydrate from the db, so evicting first leaves a window where a
@ -3895,12 +4070,6 @@ async def delete_team(
proxy_logging_obj=proxy_logging_obj,
)
# Sweep again now the team is gone. A `/team/member_add` that landed between the first sweep
# and the delete would have re-appended the reference; an add still in flight sees the row
# missing under its own row lock and sweeps what it wrote. Both passes are idempotent, and
# keeping the first one means a failure here still leaves a team the admin can retry deleting.
await _sweep_deleted_team_references(team_ids=data.team_ids, prisma_client=prisma_client)
for deleted_team in team_rows:
await sync_team_access_group_membership(prisma_client=prisma_client, team_id=deleted_team.team_id)
@ -3929,6 +4098,16 @@ async def _sweep_deleted_team_references(team_ids: Sequence[str], prisma_client:
_ = await _team_membership_db(prisma_client).delete_many(where=_TeamIdInFilter(team_id={"in": tuple(team_ids)}))
async def _sweep_deleted_team_references_tx(team_ids: Sequence[str], tx: _TeamDeleteTx) -> None:
"""Same sweep as `_sweep_deleted_team_references`, run on the transaction that holds
every id's advisory lock and deletes the team rows, so it commits or rolls back with them."""
for team_id in team_ids:
_ = await tx.execute_raw(_STRIP_DELETED_TEAM_FROM_USERS_SQL, team_id)
membership_filter: Final[_TeamIdInFilter] = {"team_id": {"in": tuple(team_ids)}}
_ = await tx.litellm_teammembership.delete_many(where=membership_filter)
async def _invalidate_deleted_key_cache(
keys: Sequence[LiteLLM_VerificationToken],
user_api_key_cache: UserApiKeyCache,

View file

@ -23,9 +23,11 @@ from pydantic import BaseModel, TypeAdapter
from litellm.proxy.auth.auth_checks import _delete_cache_access_object
# hashtext collisions only cost two unrelated teams a little serialization, and the
# lock is never taken by the access-group endpoints, so it cannot join their
# access-group-then-team lock order to form a cycle.
_LOCK_TEAM_SQL: Final = "SELECT pg_advisory_xact_lock(hashtext($1)) IS NULL AS locked"
# lock is never taken by the access-group endpoints as a SELECT ... FOR UPDATE row lock,
# so it cannot join their access-group-then-team lock order to form a cycle. team_endpoints
# reuses this exact statement to serialize /team/member_add and /team/delete against each
# other and against this mirror, rather than defining a second, divergent lock on the same key.
TEAM_ADVISORY_LOCK_SQL: Final = "SELECT pg_advisory_xact_lock(hashtext($1)) IS NULL AS locked"
_READ_TEAM_SQL: Final = 'SELECT access_group_ids FROM "LiteLLM_TeamTable" WHERE team_id = $1'
@ -138,7 +140,7 @@ async def reconcile_team_access_group_membership(tx: AccessGroupSyncTx, team_id:
concurrent write for a different team cannot be lost the way a read-modify-write of
the whole array can, and the pair commits together or not at all.
"""
await tx.query_raw(_LOCK_TEAM_SQL, team_id)
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id)
team_rows: Final = _TeamRows.validate_python(await tx.query_raw(_READ_TEAM_SQL, team_id))
desired: Final = (team_rows[0].access_group_ids or ()) if team_rows else ()
affected: Final = _AffectedGroups.validate_python(await tx.query_raw(_AFFECTED_SQL, team_id, desired))

View file

@ -34,7 +34,7 @@ from litellm.proxy._types import ( # key request types; user request types; tea
)
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.utils import PrismaClient
from litellm.proxy.utils import PrismaClient, jsonify_object
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.table_repositories import TeamMembershipRepository
from litellm.repositories.user_repository import UserRepository
@ -79,6 +79,8 @@ class _PrismaUserTable(Protocol):
self, *, where: Mapping[str, object], data: Mapping[str, Mapping[str, object]]
) -> _PrismaUserRecord | None: ...
async def find_many(self, *, where: Mapping[str, object]) -> Sequence[_PrismaUserRecord]: ...
class _PrismaTeamMembershipTable(Protocol):
"""Team membership table actions the management helpers issue."""
@ -86,6 +88,73 @@ class _PrismaTeamMembershipTable(Protocol):
async def create(self, *, data: Mapping[str, object], include: Mapping[str, bool]) -> _PrismaRecord: ...
class MemberWriteTx(Protocol):
"""Transaction surface `add_new_member` writes through when the caller owns one.
A caller already holding a transaction, and with it a pooled connection plus that
transaction's locks, passes it here so these writes reuse that connection rather than
checking out another one that lock waiters may already have drained from the pool.
"""
@property
def litellm_usertable(self) -> _PrismaUserTable: ...
@property
def litellm_budgettable(self) -> _PrismaBudgetTable: ...
@property
def litellm_teammembership(self) -> _PrismaTeamMembershipTable: ...
def _user_table(prisma_client: PrismaClient, tx: MemberWriteTx | None) -> _PrismaUserTable:
return tx.litellm_usertable if tx is not None else UserRepository(prisma_client).table
def _budget_table(prisma_client: PrismaClient, tx: MemberWriteTx | None) -> _PrismaBudgetTable:
return tx.litellm_budgettable if tx is not None else BudgetRepository(prisma_client).table
def _team_membership_table(prisma_client: PrismaClient, tx: MemberWriteTx | None) -> _PrismaTeamMembershipTable:
return tx.litellm_teammembership if tx is not None else TeamMembershipRepository(prisma_client).table
async def _find_users_by_email(
prisma_client: PrismaClient, tx: MemberWriteTx | None, user_email: str
) -> Sequence[_PrismaUserRecord]:
if tx is not None:
return await tx.litellm_usertable.find_many(where={"user_email": user_email})
rows: Final[Sequence[_PrismaUserRecord] | None] = await prisma_client.get_data(
key_val={"user_email": user_email},
table_name="user",
query_type="find_all",
)
return rows if rows is not None else ()
async def _upsert_user_row(
user_table: _PrismaUserTable, user_id: str, create_data: Mapping[str, object]
) -> _PrismaUserRecord | None:
"""Insert the user row if it is absent, leaving an existing row as it is.
Upserting keeps concurrent provisioning of the same new user from racing on create.
The update branch re-states user_id rather than being empty because Prisma only
compiles an upsert down to INSERT ... ON CONFLICT when the update is non-empty, and
otherwise falls back to a racy SELECT-then-INSERT.
"""
return await user_table.upsert(
where={"user_id": user_id},
data={"create": create_data, "update": {"user_id": user_id}},
)
async def _create_user_row(
prisma_client: PrismaClient, tx: MemberWriteTx | None, user_data: dict[str, object]
) -> _PrismaUserRecord | None:
if tx is not None:
return await _upsert_user_row(tx.litellm_usertable, str(user_data["user_id"]), jsonify_object(user_data))
return await prisma_client.insert_data(data=user_data, table_name="user")
def get_new_internal_user_defaults(user_id: str, user_email: str | None = None) -> dict[str, object]:
user_info: Final = litellm.default_internal_user_params or {}
@ -206,6 +275,7 @@ async def _clone_team_default_budget_for_member(
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: str,
budget_duration_override: str | None = None,
tx: MemberWriteTx | None = None,
) -> str | None:
"""
Create a new budget row that copies the values from the team's default
@ -220,7 +290,7 @@ async def _clone_team_default_budget_for_member(
member while keeping the default's other limits, so an admin can set a
member's reset cadence without discarding the team default's max_budget.
"""
budget_table: Final[_PrismaBudgetTable] = BudgetRepository(prisma_client).table
budget_table: Final[_PrismaBudgetTable] = _budget_table(prisma_client, tx)
default_budget: Final = await budget_table.find_unique(where={"budget_id": default_team_budget_id})
if default_budget is None:
return None
@ -248,7 +318,7 @@ async def _clone_team_default_budget_for_member(
if cloned_data.get("budget_duration"):
cloned_data["budget_reset_at"] = get_budget_reset_time(cloned_data["budget_duration"])
new_budget: Final[_PrismaBudgetRecord] = await BudgetRepository(prisma_client).table.create(data=cloned_data)
new_budget: Final[_PrismaBudgetRecord] = await budget_table.create(data=cloned_data)
return new_budget.budget_id
@ -260,6 +330,7 @@ async def _resolve_member_budget_id(
allowed_models: list[str] | None,
budget_duration: str | None,
default_team_budget_id: str | None,
tx: MemberWriteTx | None = None,
) -> str | None:
"""
Resolve the budget a new team member should be linked to.
@ -279,6 +350,7 @@ async def _resolve_member_budget_id(
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
budget_duration_override=budget_duration,
tx=tx,
)
if not has_explicit_limit and budget_duration is None:
@ -295,12 +367,14 @@ async def _resolve_member_budget_id(
if budget_duration is not None:
budget_data["budget_duration"] = budget_duration
budget_data["budget_reset_at"] = get_budget_reset_time(budget_duration=budget_duration)
budget_table: Final[_PrismaBudgetTable] = BudgetRepository(prisma_client).table
budget_table: Final[_PrismaBudgetTable] = _budget_table(prisma_client, tx)
response: Final = await budget_table.create(data=budget_data)
return response.budget_id
async def _append_team_id_if_absent(prisma_client: PrismaClient, user_id: str, team_id: str) -> None:
async def _append_team_id_if_absent(
prisma_client: PrismaClient, user_id: str, team_id: str, tx: MemberWriteTx | None = None
) -> None:
"""Append team_id to a user's teams array, only if it is not already present.
The row-level filter makes the append a no-op once the team is present, so
@ -309,7 +383,7 @@ async def _append_team_id_if_absent(prisma_client: PrismaClient, user_id: str, t
number of teams a user belongs to). Teams added concurrently for a different
team id are unaffected, since each update filters on its own team id.
"""
user_table: Final[_PrismaUserTable] = UserRepository(prisma_client).table
user_table: Final[_PrismaUserTable] = _user_table(prisma_client, tx)
await user_table.update_many(
where={"user_id": user_id, "NOT": {"teams": {"has": team_id}}},
data={"teams": {"push": [team_id]}},
@ -326,6 +400,7 @@ async def add_new_member(
default_team_budget_id: str | None = None,
allowed_models: list[str] | None = None,
budget_duration: str | None = None,
tx: MemberWriteTx | None = None,
) -> tuple[LiteLLM_UserTable, LiteLLM_TeamMembership | None]:
"""
Add a new member to a team
@ -334,49 +409,41 @@ async def add_new_member(
- add team member w/ budget to team member table
Returns created/existing user + team membership w/ budget id
Callers already inside a transaction pass it as ``tx`` so every write here runs on that
connection instead of borrowing more from the pool while the caller's locks are held.
"""
returned_user: LiteLLM_UserTable | None = None
returned_team_membership: LiteLLM_TeamMembership | None = None
## ADD TEAM ID, to USER TABLE IF NEW ##
if new_member.user_id is not None:
new_user_defaults = get_new_internal_user_defaults(user_id=new_member.user_id)
# Upsert ensures the user row exists atomically (no create race when the
# same new user is provisioned concurrently), seeding teams on create.
# The teams append lives in the filtered update below rather than the
# upsert's update branch so an already-existing user does not get a
# duplicate team id. The update branch still has to write something:
# Prisma only compiles an upsert down to INSERT ... ON CONFLICT when it
# is non-empty, and falls back to a racy SELECT-then-INSERT when it is
# not, so this re-states user_id as a no-op rather than being empty.
user_table: Final[_PrismaUserTable] = UserRepository(prisma_client).table
_returned_user: _PrismaUserRecord | None = await user_table.upsert(
where={"user_id": new_member.user_id},
data={
"create": {"teams": [team_id], **new_user_defaults},
"update": {"user_id": new_member.user_id},
},
# The teams append lives in the filtered update below rather than the upsert's
# update branch so an already-existing user does not get a duplicate team id.
_returned_user: _PrismaUserRecord | None = await _upsert_user_row(
_user_table(prisma_client, tx),
new_member.user_id,
{"teams": [team_id], **new_user_defaults},
)
await _append_team_id_if_absent(prisma_client, new_member.user_id, team_id)
await _append_team_id_if_absent(prisma_client, new_member.user_id, team_id, tx)
if _returned_user is not None:
returned_user = LiteLLM_UserTable.model_validate(_returned_user.model_dump())
elif new_member.user_email is not None:
new_user_defaults = get_new_internal_user_defaults(user_id=str(uuid.uuid4()), user_email=new_member.user_email)
## user email is not unique acc. to prisma schema -> future improvement
### for now: check if it exists in db, if not - insert it
existing_user_row: Final[list[_PrismaUserRecord] | None] = await prisma_client.get_data(
key_val={"user_email": new_member.user_email},
table_name="user",
query_type="find_all",
existing_user_row: Final[Sequence[_PrismaUserRecord]] = await _find_users_by_email(
prisma_client, tx, new_member.user_email
)
if existing_user_row is None or (isinstance(existing_user_row, list) and len(existing_user_row) == 0):
if len(existing_user_row) == 0:
new_user_defaults["teams"] = [team_id]
_returned_user = await prisma_client.insert_data(data=new_user_defaults, table_name="user")
_returned_user = await _create_user_row(prisma_client, tx, new_user_defaults)
if _returned_user is not None:
returned_user = LiteLLM_UserTable.model_validate(_returned_user.model_dump())
elif len(existing_user_row) == 1:
user_info: Final = existing_user_row[0]
await _append_team_id_if_absent(prisma_client, user_info.user_id, team_id)
await _append_team_id_if_absent(prisma_client, user_info.user_id, team_id, tx)
returned_user = LiteLLM_UserTable.model_validate(user_info.model_dump())
elif len(existing_user_row) > 1:
raise HTTPException(
@ -392,10 +459,11 @@ async def add_new_member(
allowed_models=allowed_models,
budget_duration=budget_duration,
default_team_budget_id=default_team_budget_id,
tx=tx,
)
if _budget_id and returned_user is not None and returned_user.user_id is not None:
membership_table: Final[_PrismaTeamMembershipTable] = TeamMembershipRepository(prisma_client).table
membership_table: Final[_PrismaTeamMembershipTable] = _team_membership_table(prisma_client, tx)
_returned_team_membership: Final = await membership_table.create(
data={
"team_id": team_id,

View file

@ -30,7 +30,7 @@ class PolicyMatcher:
"""
Check if a value matches any of the given patterns.
Uses the existing RouteChecks._route_matches_wildcard_pattern helper.
Uses the existing RouteChecks.route_matches_wildcard_pattern helper.
Args:
value: The value to check (e.g., team alias, key alias, model)
@ -45,7 +45,7 @@ class PolicyMatcher:
for pattern in patterns:
# Use existing wildcard pattern matching helper
if RouteChecks._route_matches_wildcard_pattern(route=value, pattern=pattern):
if RouteChecks.route_matches_wildcard_pattern(route=value, pattern=pattern):
return True
return False

View file

@ -100,7 +100,7 @@ def _filter_keys_by_tags(keys: list, tag_patterns: list) -> tuple:
key_alias = key.key_alias or ""
key_tags = _get_tags_from_metadata(key.metadata, getattr(key, "metadata_json", None))
if key_tags and any(
RouteChecks._route_matches_wildcard_pattern(route=tag, pattern=pat)
RouteChecks.route_matches_wildcard_pattern(route=tag, pattern=pat)
for tag in key_tags
for pat in tag_patterns
):
@ -123,7 +123,7 @@ def _filter_teams_by_tags(teams: list, tag_patterns: list) -> tuple:
team_alias = team.team_alias or ""
team_tags = _get_tags_from_metadata(team.metadata)
if team_tags and any(
RouteChecks._route_matches_wildcard_pattern(route=tag, pattern=pat)
RouteChecks.route_matches_wildcard_pattern(route=tag, pattern=pat)
for tag in team_tags
for pat in tag_patterns
):
@ -152,7 +152,7 @@ async def _find_affected_by_team_patterns(
for team in all_teams:
team_alias = team.team_alias or ""
if team_alias and any(
RouteChecks._route_matches_wildcard_pattern(route=team_alias, pattern=pat) for pat in team_patterns
RouteChecks.route_matches_wildcard_pattern(route=team_alias, pattern=pat) for pat in team_patterns
):
if team_alias not in existing_teams:
new_teams.append(team_alias)
@ -190,7 +190,7 @@ async def _find_affected_keys_by_alias(prisma_client: object, key_patterns: list
for key in keys:
key_alias = key.key_alias or ""
if key_alias and any(
RouteChecks._route_matches_wildcard_pattern(route=key_alias, pattern=pat) for pat in key_patterns
RouteChecks.route_matches_wildcard_pattern(route=key_alias, pattern=pat) for pat in key_patterns
):
if key_alias not in existing_keys:
affected.append(key_alias)

View file

@ -2555,6 +2555,12 @@ async def _authoritative_floor_spend(
if db_spend is None:
return None
# a spend reset that committed during the DB read above wrote the post-reset
# floor to the marker; keep it over this read's now-stale pre-commit value
rechecked: Final = spend_counter_cache.in_memory_cache.get_cache(key=marker_key)
if rechecked is not None:
return float(rechecked)
spend_counter_cache.in_memory_cache.set_cache(
key=marker_key,
value=db_spend,
@ -6798,6 +6804,7 @@ class ProxyConfig:
subscriber: Final = AuthCacheInvalidationSubscriber(
redis_cache=redis_cache,
user_api_key_cache=user_api_key_cache,
additional_in_memory_caches=(spend_counter_cache.in_memory_cache,),
)
self.auth_cache_invalidation_subscriber = subscriber
subscriber.start()

View file

@ -90,12 +90,15 @@ async def rerank(
fastapi_response.headers.update(
ProxyBaseLLMRequestProcessing.get_custom_headers(
user_api_key_dict=user_api_key_dict,
call_id=hidden_params.get("litellm_call_id", None) or data.get("litellm_call_id", None),
model_id=model_id,
cache_key=cache_key,
api_base=api_base,
version=version,
response_cost=hidden_params.get("response_cost", None),
model_region=getattr(user_api_key_dict, "allowed_model_region", ""),
request_data=data,
hidden_params=hidden_params,
**additional_headers,
)
)

View file

@ -25,7 +25,11 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_utils import get_model_from_request
from litellm.proxy.auth.budget_throttle import should_throttle_budget_exceeded
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.user_api_key_cache import end_user_cache_key, tag_cache_key
from litellm.proxy.common_utils.user_api_key_cache import (
end_user_cache_key,
tag_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
@ -546,7 +550,9 @@ async def _get_team_member_budget_counter(
if team_object is None or team_object.team_id is None or user_object is None or valid_token.user_id is None:
return None
membership_cache_key: Final = f"team_membership:{valid_token.user_id}:{team_object.team_id}"
membership_cache_key: Final = team_membership_reservation_cache_key(
user_id=valid_token.user_id, team_id=team_object.team_id
)
cached_team_membership: Final = await user_api_key_cache.async_get_cache(key=membership_cache_key)
team_membership: LiteLLM_TeamMembership | None = None
if isinstance(cached_team_membership, LiteLLM_TeamMembership):

View file

@ -444,7 +444,9 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
or None
)
raw_model: Final = cast(str, kwargs.get("model") or "")
model_name: Final = reconstruct_model_name(raw_model, custom_llm_provider, metadata or {})
model_name: Final = (
standard_logging_payload.get("model") if standard_logging_payload is not None else None
) or reconstruct_model_name(raw_model, custom_llm_provider, metadata or {})
try:
payload: Final[SpendLogsPayload] = SpendLogsPayload(

View file

@ -91,7 +91,7 @@ from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.prometheus import PrometheusLogger
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.integrations.SlackAlerting.utils import _add_langfuse_trace_id_to_alert
from litellm.litellm_core_utils.core_helpers import coerce_token_limit
from litellm.litellm_core_utils.core_helpers import coerce_token_limit, is_expected_client_error
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
@ -2575,20 +2575,36 @@ class ProxyLogging:
api_key="",
)
# log the custom exception
await litellm_logging_obj.async_failure_handler(
exception=original_exception,
traceback_exception=traceback.format_exc(),
await self._dispatch_proxy_only_failure_handlers(
litellm_logging_obj=litellm_logging_obj,
original_exception=original_exception,
)
threading.Thread(
target=litellm_logging_obj.failure_handler,
args=(
original_exception,
traceback.format_exc(),
),
daemon=True,
).start()
@staticmethod
async def _dispatch_proxy_only_failure_handlers(
litellm_logging_obj: Logging,
original_exception: Exception | None,
) -> None:
"""Runs the async failure handler plus the threaded sync handler. Expected
client (4xx) errors skip traceback formatting unless
litellm.log_client_error_tracebacks is set."""
include_traceback: Final = litellm.log_client_error_tracebacks or not is_expected_client_error(
original_exception
)
traceback_str: Final = traceback.format_exc() if include_traceback else ""
await litellm_logging_obj.async_failure_handler(
exception=original_exception,
traceback_exception=traceback_str,
)
threading.Thread(
target=litellm_logging_obj.failure_handler,
args=(
original_exception,
traceback_str,
),
daemon=True,
).start()
async def post_call_success_hook(
self,

View file

@ -58,19 +58,22 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
return LiteLLM_TeamTable.model_validate(data)
async def get_members_with_roles_locked(self, tx: "Prisma", team_id: str) -> list[Member] | None:
"""Return the team's members_with_roles, locking the row FOR UPDATE.
"""Return the team's members_with_roles. The caller must already hold
``TEAM_ADVISORY_LOCK_SQL`` for this team_id on ``tx`` before calling this.
``None`` when the team row is gone, which a caller holding the lock can
only see if a delete committed under it, as opposed to ``[]`` for a team
that simply has no members.
``None`` when the team row is gone, which is only possible under that lock if
a delete committed before this read, as opposed to ``[]`` for a team that
simply has no members.
Must be called inside a transaction so the row lock is held until
commit. This serializes concurrent membership writers on the team row
so the losing writer appends onto the winner's committed result instead
of overwriting it from a stale snapshot.
A plain read is enough here because the advisory lock, not a row lock, is what
serializes this against a concurrent writer: ``SELECT ... FOR UPDATE`` would
additionally take a row lock on ``LiteLLM_TeamTable``, and the access-group
endpoints lock an access group and then a team row, so a team-row-first lock
here can deadlock with them. The advisory lock cannot, since those endpoints
never take it.
"""
rows: Final = await tx.query_raw(
'SELECT members_with_roles FROM "LiteLLM_TeamTable" WHERE team_id = $1 FOR UPDATE',
'SELECT members_with_roles FROM "LiteLLM_TeamTable" WHERE team_id = $1',
team_id,
)
if not rows:

View file

@ -277,6 +277,8 @@ def rerank(
if api_key is None:
raise ValueError("TogetherAI API key is required, please set 'TOGETHERAI_API_KEY' in your environment")
api_base = dynamic_api_base or optional_params.api_base or litellm.api_base or "https://api.together.ai/v1"
response = together_rerank.rerank(
model=model,
query=query,
@ -286,6 +288,7 @@ def rerank(
return_documents=return_documents,
max_chunks_per_doc=max_chunks_per_doc,
api_key=api_key,
api_base=api_base,
_is_async=_is_async,
)
elif _custom_llm_provider == litellm.LlmProviders.JINA_AI:

View file

@ -8,6 +8,7 @@
# Thank you ! We ❤️ you! - Krrish & Ishaan
import asyncio
import contextlib
import copy
import enum
import hashlib
@ -20,7 +21,7 @@ import time
import traceback
import weakref
from collections import defaultdict
from collections.abc import AsyncGenerator, Callable, Generator, Mapping, Sequence
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Mapping, Sequence
from functools import lru_cache
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypeVar, Union, cast
@ -168,6 +169,11 @@ from litellm.router_utils.pre_call_checks.model_rate_limit_check import (
from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import (
PromptCachingDeploymentCheck,
)
from litellm.router_utils.reasoning_effort_capability import (
deployment_is_catalog_mapped,
intersect_supported_reasoning_efforts,
resolve_supported_reasoning_efforts,
)
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
increment_deployment_failures_for_current_minute,
increment_deployment_successes_for_current_minute,
@ -243,6 +249,7 @@ from .router_utils.pattern_match_deployments import PatternMatchRouter
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.exceptions import MidStreamFallbackError
from litellm.responses.streaming_iterator import (
BaseResponsesAPIStreamingIterator,
)
@ -259,6 +266,9 @@ if TYPE_CHECKING:
from litellm.router_strategy.quality_router.quality_router import (
QualityRouter,
)
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
)
from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject
from litellm.types.llms.openai import (
ResponseAPIUsage,
@ -356,6 +366,101 @@ def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream])
return False
# Router._aanthropic_messages_streaming_iterator buffers lifecycle chunks
# until real content commits the primary stream; a hostile or slow-starting
# upstream that never emits content or an error could otherwise grow that
# buffer without bound, so hitting this cap forces an early commit instead.
MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS: Final = 200
def _anthropic_stream_should_drop_pre_content_ping(chunk: object, has_generated_content: bool) -> bool:
"""A `ping` keepalive seen before any real content is dropped outright - it recurs indefinitely on a
slow-starting connection and carries nothing worth buffering toward a possible fallback."""
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import is_anthropic_ping_chunk
if has_generated_content:
return False
return is_anthropic_ping_chunk(chunk)
def _is_retriable_anthropic_status(status_code: int) -> bool:
return status_code == 429 or status_code >= 500
def _anthropic_stream_should_decline_fallback(has_generated_content: bool, error: "MidStreamFallbackError") -> bool:
"""
A MidStreamFallbackError raised directly by the source iterator (the
completion-bridge path's CustomStreamWrapper, e.g. on a transport drop)
carries its own pre_first_chunk bookkeeping - gated the same way a
detected SSE error event is, so a fallback is never appended after real
content already reached the client on either path.
"""
return has_generated_content or not error.is_pre_first_chunk
def _anthropic_stream_commits_now(chunk: object, has_generated_content: bool, buffered_chunk_count: int) -> bool:
"""
Whether `chunk` should make Router._aanthropic_messages_streaming_iterator
commit to the primary Anthropic stream (real content arrived, or the
pre-content buffer cap was hit) rather than keep buffering lifecycle
frames toward a possible fallback.
"""
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
is_anthropic_content_delta_chunk,
)
if has_generated_content:
return False
return is_anthropic_content_delta_chunk(chunk) or buffered_chunk_count >= MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS
class FallbackAwareAnthropicMessagesStream:
"""
Bare async generators can't carry the `_hidden_params` attribute the
proxy reads response headers off of (see
router_utils.add_retry_fallback_headers.get_hidden_params_dict), so this
thin wrapper carries it through from the source iterator - mirrors
AnthropicMessagesStreamingResponse. Used by
Router._aanthropic_messages_streaming_iterator.
"""
def __init__(self, async_generator: AsyncGenerator[bytes, None], source_iterator: object) -> None:
self._async_generator = async_generator
self._hidden_params = dict( # mutable-ok: mutated in place by merge_fallback_hidden_params
getattr(source_iterator, "_hidden_params", None) or {}
)
def __aiter__(self) -> "FallbackAwareAnthropicMessagesStream":
return self
async def __anext__(self) -> bytes:
return await self._async_generator.__anext__()
async def aclose(self) -> None:
await self._async_generator.aclose()
def merge_fallback_hidden_params(
self,
fallback_hidden_params: Mapping[str, object],
fallback_headers: Mapping[str, object],
) -> None:
"""
Raw bytes can't carry their own _hidden_params the way a
ModelResponseStream/ResponsesAPI event can, so a mid-stream
fallback's provider headers (e.g. Bedrock's x-amzn-requestid) are
merged onto the wrapper itself instead - mirrors
Router._apply_fallback_hidden_params_to_item's merge shape.
"""
existing_headers: Final = cast( # cast-ok: additional_headers is always a dict[str, object] when present
"dict[str, object]", self._hidden_params.get("additional_headers") or {}
)
self._hidden_params = { # mutable-ok: matches _hidden_params' existing dict[str, object] shape
**self._hidden_params,
**fallback_hidden_params,
"additional_headers": {**existing_headers, **fallback_headers}, # mutable-ok: same shape
}
class RoutingArgs(enum.Enum):
ttl = 60 # 1min (RPM/TPM expire key)
@ -4801,6 +4906,264 @@ class Router:
)
return response
async def _aanthropic_messages_streaming_iterator(
self,
response: AsyncIterator[bytes],
initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
) -> AsyncIterator[bytes]:
"""
Wrap an anthropic_messages (/v1/messages) streaming response so a
mid-stream provider error triggers the Router's fallback chain
(parity with _acompletion_streaming_iterator for the
chat-completions path). See #24004.
anthropic_messages goes through _ageneric_api_call_with_fallbacks
rather than _acompletion, so the returned byte iterator is never
wrapped by the chat-completions fallback handler. Two failure
shapes land here:
- the completion-bridge path (deployments with no native
/v1/messages endpoint, via
LiteLLMMessagesToCompletionTransformationHandler) already
raises MidStreamFallbackError out of its underlying
CustomStreamWrapper; this wrapper only needs to catch it.
- a native Anthropic/Bedrock passthrough never raises anything
for a provider SSE `event: error` frame (e.g. `overloaded_error`,
`internal_server_error`) - it is forwarded to the client as-is -
so this wrapper detects it via parse_anthropic_error_event and
raises MidStreamFallbackError itself.
Only an error before any real content (a content_block_delta frame)
has reached the caller triggers a fallback attempt, mirroring the
restriction _acompletion_streaming_iterator applies: once generated
output has already reached the caller, retrying would start a
second, overlapping Anthropic message lifecycle on the same SSE
stream, so the error is left to propagate instead of being retried
invisibly. A non-retriable client error (4xx other than 429) is
never worth a fallback attempt either, so it is also left to
propagate.
Lifecycle/bookkeeping frames (message_start, content_block_start,
ping, ...) do not by themselves disqualify a fallback attempt -
Anthropic routinely sends message_start before an overload error -
but they are BUFFERED rather than forwarded immediately, since
forwarding one and then appending a fallback attempt's own
message_start would produce two overlapping message lifecycles on
one SSE stream. Buffered frames are flushed, in order, the moment
real content arrives (the primary attempt has committed by then
anyway) or once the stream ends without ever producing content or
an error.
"""
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
aclose_if_supported,
parse_anthropic_error_event,
)
source_iterator: Final = response
async def stream_with_fallbacks() -> AsyncGenerator[bytes, None]:
from litellm.exceptions import MidStreamFallbackError
# Lifecycle/bookkeeping frames (message_start, content_block_start,
# ping, ...) are held back rather than forwarded immediately:
# Anthropic routinely sends message_start before an overload
# error, and once a byte reaches the client a fallback attempt
# can only append its OWN message_start, producing two
# overlapping message lifecycles on one SSE stream. Buffered
# frames are flushed the moment real content (content_block_delta)
# arrives - at that point the primary attempt has committed and a
# clean retry is no longer possible anyway - or once the primary
# stream ends without ever producing content. A `ping` keepalive
# is dropped outright rather than buffered, since it can recur
# indefinitely on a slow-starting connection and carries nothing
# worth preserving; hitting MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS
# forces the same early commit as real content arriving, so a
# hostile or pathological upstream can't grow the buffer forever.
has_generated_content = False # rebind-ok: set once real content is seen, or the buffer cap is hit
buffered_lifecycle_chunks: tuple[bytes, ...] = () # rebind-ok: flushed once committed or on decline
model: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
try:
async for chunk in source_iterator:
if _anthropic_stream_should_drop_pre_content_ping(chunk, has_generated_content):
continue
if _anthropic_stream_commits_now(chunk, has_generated_content, len(buffered_lifecycle_chunks)):
has_generated_content = True # rebind-ok: real content seen, or the buffer cap was hit
error_event = parse_anthropic_error_event(chunk)
retriable_pending_error = ( # rebind-ok: freshly computed each iteration, never carried over
not has_generated_content
and error_event is not None
and _is_retriable_anthropic_status(error_event[2])
)
if not has_generated_content and not retriable_pending_error and error_event is None:
buffered_lifecycle_chunks = (*buffered_lifecycle_chunks, chunk)
continue
if retriable_pending_error:
assert error_event is not None # guard-ok: retriable_pending_error implies this
_error_type, message, status_code = error_event
raise MidStreamFallbackError(
message=message,
model=model,
llm_provider="anthropic",
original_exception=litellm.exceptions.APIError(
status_code=status_code,
message=message,
llm_provider="anthropic",
model=model,
),
is_pre_first_chunk=True,
)
for buffered_chunk in buffered_lifecycle_chunks:
yield buffered_chunk
buffered_lifecycle_chunks = ()
yield chunk
for buffered_chunk in buffered_lifecycle_chunks:
yield buffered_chunk
except MidStreamFallbackError as e:
if _anthropic_stream_should_decline_fallback(has_generated_content, e):
for buffered_chunk in buffered_lifecycle_chunks:
yield buffered_chunk
if e.original_exception is not None:
raise e.original_exception from e
raise
async for item in self._aanthropic_messages_fallback_attempt(e, initial_kwargs, wrapper):
yield item
finally:
with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
await aclose_if_supported(source_iterator)
# Referenced by stream_with_fallbacks via closure - assigned here, before
# the generator body ever runs, so the reference resolves fine despite
# being defined textually after the function that captures it.
wrapper: Final = FallbackAwareAnthropicMessagesStream(stream_with_fallbacks(), source_iterator)
return wrapper
async def _aanthropic_messages_fallback_attempt(
self,
e: "MidStreamFallbackError",
initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
wrapper: "FallbackAwareAnthropicMessagesStream",
) -> AsyncGenerator[bytes, None]:
"""
Re-enters the Router's fallback chain for a mid-stream
anthropic_messages error and yields whatever the fallback attempt
produces. Split out of _aanthropic_messages_streaming_iterator to
keep each function's cyclomatic complexity within the repo's C901
budget.
"""
from litellm.exceptions import MidStreamFallbackError
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
aclose_if_supported,
anthropic_messages_response_as_sse_events,
)
fallback_response = None # rebind-ok: pre-init so finally can close it if a fallback was actually attempted
try:
model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: model group
fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the common_utils list|None param
"fallbacks", self.fallbacks
)
context_window_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
"context_window_fallbacks", self.context_window_fallbacks
)
content_policy_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
"content_policy_fallbacks", self.content_policy_fallbacks
)
initial_kwargs["original_function"] = self._ageneric_api_call_with_fallbacks_helper
self._update_kwargs_before_fallbacks(
model=model_group,
kwargs=initial_kwargs,
metadata_variable_name="litellm_metadata",
)
fallback_response = await self.async_function_with_fallbacks_common_utils( # rebind-ok: set on success
e=e,
disable_fallbacks=False,
fallbacks=fallbacks,
context_window_fallbacks=context_window_fallbacks,
content_policy_fallbacks=content_policy_fallbacks,
model_group=model_group,
args=(),
kwargs=initial_kwargs,
include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
)
fallback_hidden_params, fallback_headers = Router._prepare_fallback_hidden_params(fallback_response)
wrapper.merge_fallback_hidden_params(fallback_hidden_params, fallback_headers)
if hasattr(fallback_response, "__aiter__"):
async for fallback_item in fallback_response:
yield fallback_item
else:
# A fallback can resolve to a complete AnthropicMessagesResponse
# dict even for a streaming request (e.g. an agentic tool-use
# interception loop) - yielding it as-is would put a raw dict
# into a byte stream, so it's synthesized into the SSE
# lifecycle a real stream would have sent instead.
for event in anthropic_messages_response_as_sse_events(
cast("AnthropicMessagesResponse", fallback_response) # cast-ok: non-streaming shape by elimination
):
yield event
except Exception as fallback_error:
verbose_router_logger.error("Anthropic messages streaming fallback also failed: %s", fallback_error)
if isinstance(fallback_error, MidStreamFallbackError) and fallback_error.original_exception is not None:
raise fallback_error.original_exception from fallback_error
raise
finally:
if fallback_response is not None:
with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
await aclose_if_supported(fallback_response)
async def _aanthropic_messages_with_streaming_fallbacks(
self,
original_function: Callable,
**kwargs: object, # kwargs-ok: forwarded verbatim to original_function, shape varies per call site
) -> Union["AnthropicMessagesResponse", AsyncIterator[bytes]]:
"""
_ageneric_api_call_with_fallbacks for anthropic_messages, with the
addition of mid-stream fallback handling (see
_aanthropic_messages_streaming_iterator). Parity with
_aresponses_with_streaming_fallbacks for the Responses API.
"""
from litellm.litellm_core_utils.core_helpers import safe_deep_copy
# Snapshot the request kwargs before the primary attempt mutates them
# in place: _update_kwargs_with_deployment writes deployment-specific
# fields (deployment, model_info, api_base, tags, ...) into the
# SAME litellm_metadata/metadata dicts a shallow .copy() would still
# share, leaking primary-deployment metadata into the mid-stream
# fallback request. safe_deep_copy avoids deep-copying the full
# kwargs (which can hold non-deepcopyable logging handles/clients).
fallback_kwargs: Final[dict[str, object]] = kwargs.copy() # mutable-ok: mutated below before re-entry
if isinstance(fallback_kwargs.get("litellm_metadata"), dict):
fallback_kwargs["litellm_metadata"] = safe_deep_copy(fallback_kwargs["litellm_metadata"])
if isinstance(fallback_kwargs.get("metadata"), dict):
fallback_kwargs["metadata"] = safe_deep_copy(fallback_kwargs["metadata"])
fallback_kwargs["original_generic_function"] = original_function
response: Final = await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
if kwargs.get("stream") and hasattr(response, "__aiter__"):
return await self._aanthropic_messages_streaming_iterator(
response=cast("AsyncIterator[bytes]", response), # cast-ok: stream=True always returns a byte iterator
initial_kwargs=fallback_kwargs,
)
return response
async def _dispatch_generic_call_type(
self,
call_type: str,
original_function: Callable,
**kwargs: object, # kwargs-ok: forwarded verbatim to the per-call-type helper, shape varies per call site
):
"""
factory_function's shared dispatch for call types with no
call-specific handling, except anthropic_messages: kept out of
factory_function's own async_wrapper (already at the repo's C901
complexity ceiling) so routing its mid-stream fallback handling
(#24004) doesn't add another branch there.
"""
if call_type == "anthropic_messages":
return await self._aanthropic_messages_with_streaming_fallbacks(
original_function=original_function, **kwargs
)
return await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
def _generic_api_call_with_fallbacks(self, model: str, original_function: Callable, **kwargs):
"""
Make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
@ -5987,7 +6350,8 @@ class Router:
"aget_skill",
"adelete_skill",
):
return await self._ageneric_api_call_with_fallbacks(
return await self._dispatch_generic_call_type(
call_type=call_type,
original_function=original_function,
**kwargs,
)
@ -8341,6 +8705,7 @@ class Router:
) = litellm.get_llm_provider(
model=deployment.litellm_params.model,
custom_llm_provider=deployment.litellm_params.get("custom_llm_provider", None),
api_base=deployment.litellm_params.api_base,
)
# done reading model["litellm_params"]
# Check if provider is supported: either in enum or JSON-configured
@ -9448,6 +9813,8 @@ class Router:
except Exception:
model_info = None
deployment_is_mapped = deployment_is_catalog_mapped(model_info, model_info_dict)
# get llm provider
litellm_model, llm_provider = "", ""
try:
@ -9490,6 +9857,7 @@ class Router:
"model_group": user_facing_model_group_name,
"providers": [llm_provider],
**model_info,
"supported_reasoning_efforts": None,
}
)
else:
@ -9567,6 +9935,11 @@ class Router:
if model_info.get("rpm", None) is not None and _deployment_rpm is None:
_deployment_rpm = model_info.get("rpm")
model_group_info.supported_reasoning_efforts = intersect_supported_reasoning_efforts(
model_group_info.supported_reasoning_efforts,
resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=deployment_is_mapped),
)
if _deployment_tpm is not None:
if total_tpm is None:
total_tpm = 0

View file

@ -0,0 +1,146 @@
"""Resolve which reasoning_effort values a deployment, and by intersection a model group, accepts.
The model map's supports_*_reasoning_effort flags are the only signal, and each level's polarity
mirrors how a request path reads that same flag. medium and high are unconditional for a reasoning
model. minimal and low are opt-out: openai/chat/gpt_5_transformation.py refuses them only when the
map says false. xhigh and max are opt-in. none is opt-out everywhere except the azure gpt-5 family,
whose config raises UnsupportedParamsError without an explicit true.
xhigh is gated on the request path by the openai and azure gpt-5 configs. max is not gated there at
all: every entry carrying supports_max_reasoning_effort is Claude-family, and
anthropic/chat/transformation.py gates max on the output_config path while its reasoning_effort
path maps any level to a thinking budget. Making max opt-in is a deliberate trade, then, since an
explicit flag is the only signal that the tier is a real one rather than litellm rounding the level
to a budget, and a missing flag costs advisory metadata rather than a rejected request.
A deployment the map describes with no effort flags at all resolves to None rather than to the
opt-out defaults. 689 of the map's 854 reasoning entries carry no flag, and the o-series, xai and
bedrock nova entries among them take neither none nor minimal, so composing a set out of the
defaults alone would advertise levels those providers reject.
The advertisement order is the REASONING_EFFORT declaration order, which is presentation only. It
is not a strength scale and does not reconcile with bedrock's output_config ceiling order in
llms/bedrock/common_utils.py, which ranks max below xhigh while the thinking-budget constants rank
it above.
"""
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Final, get_args
import litellm
from litellm.types.llms.openai import REASONING_EFFORT
REASONING_EFFORT_ADVERTISEMENT_ORDER: Final = get_args(REASONING_EFFORT)
_EMPTY_ENTRY: Final[Mapping[str, object]] = MappingProxyType({})
_EFFORT_FLAGS: Final = (
("none", "supports_none_reasoning_effort"),
("minimal", "supports_minimal_reasoning_effort"),
("low", "supports_low_reasoning_effort"),
("xhigh", "supports_xhigh_reasoning_effort"),
("max", "supports_max_reasoning_effort"),
)
_OPT_OUT_EFFORTS: Final = ("minimal", "low")
_OPT_IN_EFFORTS: Final = ("xhigh", "max")
_UNCONDITIONAL_EFFORTS: Final = frozenset(("medium", "high"))
def _bare_model_entry(model_info: Mapping[str, object]) -> Mapping[str, object]:
"""The unprefixed twin of a provider-prefixed map entry, which is where the flags often live:
azure/gpt-5-mini carries none of them while gpt-5-mini carries all three. The request-path
gates resolve through the same twin (_supports_factory, #20885), so reading it here is what
keeps the advertisement and the gate on the same answer."""
key: Final = model_info.get("key")
provider: Final = model_info.get("litellm_provider")
if not isinstance(key, str) or not isinstance(provider, str) or not key.startswith(f"{provider}/"):
return _EMPTY_ENTRY
entry: Final[Mapping[str, object] | None] = litellm.model_cost.get(key.removeprefix(f"{provider}/"))
return entry if entry is not None else _EMPTY_ENTRY
def _declared_effort_flags(model_info: Mapping[str, object]) -> Mapping[str, object]:
bare: Final = _bare_model_entry(model_info)
return MappingProxyType(
{
effort: model_info.get(flag) if model_info.get(flag) is not None else bare.get(flag)
for effort, flag in _EFFORT_FLAGS
}
)
def _supports_none_reasoning_effort(model_info: Mapping[str, object], flag: object) -> bool:
"""Opt-in only where a request path refuses the level. AzureOpenAIGPT5Config raises
UnsupportedParamsError on reasoning_effort='none' without an explicit true, and it is selected
only for the gpt-5 family, so every other azure deployment keeps the opt-out default."""
if model_info.get("litellm_provider") != "azure":
return flag is not False
from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config
key: Final = model_info.get("key")
if not isinstance(key, str) or not AzureOpenAIGPT5Config.is_model_gpt_5_model(key):
return flag is not False
return flag is True
def deployment_is_catalog_mapped(
resolved_model_info: Mapping[str, object] | None,
operator_model_info: Mapping[str, object],
) -> bool:
"""Whether the model map described this deployment, as opposed to the operator describing it.
Every deployment is registered in the cost map under its own id, so a mode the operator wrote
on an off-map deployment reads back here exactly like one the catalog supplied. Excluding it is
what stops such a deployment from claiming to be a known non-reasoning model and emptying the
levels its mapped siblings agree on.
"""
if resolved_model_info is None or resolved_model_info.get("mode") is None:
return False
return operator_model_info.get("mode") is None
def resolve_supported_reasoning_efforts(
model_info: Mapping[str, object],
*,
deployment_is_mapped: bool,
) -> tuple[str, ...] | None:
"""None = nothing is known about this deployment, so it must not narrow its group; () = a known
model that accepts no effort level, which correctly empties the group.
Telling those apart needs provenance the flattened ModelInfo does not carry. A deployment the
map does not describe arrives with supports_reasoning None, exactly like a mapped non-reasoning
model: 2273 of the map's 3165 entries omit the key rather than setting it false, so reading an
unset flag as () would let one custom deployment empty every level its mapped siblings agree
on. deployment_is_mapped is that provenance, and an operator who wants either answer for an
off-map deployment gets it by setting supports_reasoning explicitly.
"""
supports_reasoning: Final = model_info.get("supports_reasoning")
if supports_reasoning is not True:
return () if supports_reasoning is False or deployment_is_mapped else None
flags: Final = _declared_effort_flags(model_info)
if all(value is None for value in flags.values()):
return None
opt_out: Final = frozenset(effort for effort in _OPT_OUT_EFFORTS if flags[effort] is not False)
opt_in: Final = frozenset(effort for effort in _OPT_IN_EFFORTS if flags[effort] is True)
none_level: Final = (
frozenset(("none",)) if _supports_none_reasoning_effort(model_info, flags["none"]) else frozenset()
)
allowed: Final = opt_out | _UNCONDITIONAL_EFFORTS | opt_in | none_level
return tuple(effort for effort in REASONING_EFFORT_ADVERTISEMENT_ORDER if effort in allowed)
def intersect_supported_reasoning_efforts(
current: Sequence[str] | None,
resolved: Sequence[str] | None,
) -> tuple[str, ...] | None:
"""Deployments without metadata (None) never narrow the group; an effort survives only when
every deployment with metadata accepts it, so the group offers nothing routing could reject."""
if resolved is None:
return tuple(current) if current is not None else None
if current is None:
return tuple(resolved)
keep: Final = frozenset(current) & frozenset(resolved)
return tuple(effort for effort in REASONING_EFFORT_ADVERTISEMENT_ORDER if effort in keep)

View file

@ -685,6 +685,7 @@ ANTHROPIC_API_ONLY_HEADERS: Final = { # fails if calling anthropic on vertex ai
class AnthropicThinkingParam(TypedDict, total=False):
type: ReadOnly[Literal["enabled", "adaptive", "disabled"]]
budget_tokens: int
display: ReadOnly[Literal["summarized", "omitted"]]
class ANTHROPIC_HOSTED_TOOLS(str, Enum):

View file

@ -1,4 +1,5 @@
import json
from collections.abc import Sequence
from enum import Enum
from typing import TYPE_CHECKING, Any, Final, Literal
@ -396,7 +397,7 @@ class OutputConfigBlock(TypedDict, total=False):
class CommonRequestObject(TypedDict, total=False): # common request object across sync + async flows
additionalModelRequestFields: dict
additionalModelResponseFieldPaths: list[str]
additionalModelResponseFieldPaths: Sequence[str]
inferenceConfig: InferenceConfig
system: list[SystemContentBlock]
toolConfig: ToolConfigBlock

View file

@ -1840,7 +1840,7 @@ ResponsesAPIStreamingResponse = Annotated[
]
REASONING_EFFORT = Literal["none", "minimal", "low", "medium", "high", "xhigh"]
REASONING_EFFORT = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
class OpenAIRealtimeStreamSession(TypedDict, total=False):

View file

@ -637,6 +637,7 @@ class ModelGroupInfo(BaseModel):
supports_url_context: bool = Field(default=False)
supports_reasoning: bool = Field(default=False)
supports_function_calling: bool = Field(default=False)
supported_reasoning_efforts: tuple[str, ...] | None = Field(default=None)
supported_openai_params: list[str] | None = Field(default=[])
configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None

View file

@ -4130,7 +4130,7 @@ def get_optional_params(
drop_params=(drop_params if drop_params is not None and isinstance(drop_params, bool) else False),
)
elif custom_llm_provider == "together_ai":
optional_params = litellm.TogetherAIConfig().map_openai_params(
optional_params = litellm.TogetherAIChatConfig().map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=model,
@ -7898,7 +7898,7 @@ class ProviderConfigManager:
LlmProviders.GALADRIEL: (lambda: litellm.GaladrielChatConfig(), False),
LlmProviders.REPLICATE: (lambda: litellm.ReplicateConfig(), False),
LlmProviders.HUGGINGFACE: (lambda: litellm.HuggingFaceChatConfig(), False),
LlmProviders.TOGETHER_AI: (lambda: litellm.TogetherAIConfig(), False),
LlmProviders.TOGETHER_AI: (lambda: litellm.TogetherAIChatConfig(), False),
LlmProviders.OPENROUTER: (lambda: litellm.OpenrouterConfig(), False),
LlmProviders.VERCEL_AI_GATEWAY: (
lambda: litellm.VercelAIGatewayConfig(),
@ -8610,6 +8610,12 @@ class ProviderConfigManager:
)
return BedrockPassthroughConfig()
elif LlmProviders.BEDROCK_MANTLE == provider:
from litellm.llms.bedrock_mantle.passthrough.transformation import (
BedrockMantlePassthroughConfig,
)
return BedrockMantlePassthroughConfig()
elif LlmProviders.VLLM == provider or LlmProviders.HOSTED_VLLM == provider:
from litellm.llms.vllm.passthrough.transformation import (
VLLMPassthroughConfig,

View file

@ -37886,6 +37886,7 @@
"output_cost_per_token": 1e-07
},
"together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": {
"deprecation_date": "2026-02-06",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -37902,6 +37903,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": {
"deprecation_date": "2026-07-10",
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262000,
@ -37914,6 +37916,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": {
"deprecation_date": "2026-04-16",
"input_cost_per_token": 6.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 256000,
@ -37926,6 +37929,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": {
"deprecation_date": "2026-02-06",
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 40000,
@ -37937,6 +37941,7 @@
"supports_tool_choice": false
},
"together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": {
"deprecation_date": "2026-06-04",
"input_cost_per_token": 2e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 256000,
@ -37949,11 +37954,15 @@
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-R1": {
"deprecation_date": "2026-05-14",
"input_cost_per_token": 3e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 128000,
"max_output_tokens": 20480,
"max_tokens": 20480,
"metadata": {
"successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro"
},
"mode": "chat",
"output_cost_per_token": 7e-06,
"supports_function_calling": true,
@ -37962,6 +37971,7 @@
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-R1-0528-tput": {
"deprecation_date": "2026-02-03",
"input_cost_per_token": 5.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 128000,
@ -37979,6 +37989,9 @@
"max_input_tokens": 65536,
"max_output_tokens": 8192,
"max_tokens": 8192,
"metadata": {
"successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro"
},
"mode": "chat",
"output_cost_per_token": 1.25e-06,
"supports_function_calling": true,
@ -37987,9 +38000,13 @@
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-V3.1": {
"deprecation_date": "2026-05-14",
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"max_tokens": 16384,
"metadata": {
"successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro"
},
"mode": "chat",
"output_cost_per_token": 1.7e-06,
"source": "https://www.together.ai/models/deepseek-v3-1",
@ -38001,6 +38018,7 @@
"max_output_tokens": 16384
},
"together_ai/meta-llama/Llama-3.2-3B-Instruct-Turbo": {
"deprecation_date": "2026-03-06",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -38009,16 +38027,21 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": {
"input_cost_per_token": 8.8e-07,
"input_cost_per_token": 1.04e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 131072,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 8.8e-07,
"output_cost_per_token": 1.04e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free": {
"deprecation_date": "2025-11-13",
"input_cost_per_token": 0,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38029,6 +38052,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
"deprecation_date": "2026-03-31",
"input_cost_per_token": 2.7e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38039,6 +38063,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": {
"deprecation_date": "2026-02-06",
"input_cost_per_token": 1.8e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38049,6 +38074,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": {
"deprecation_date": "2026-02-06",
"input_cost_per_token": 3.5e-06,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38059,6 +38085,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": {
"deprecation_date": "2026-02-25",
"input_cost_per_token": 8.8e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38069,6 +38096,7 @@
"supports_tool_choice": true
},
"together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": {
"deprecation_date": "2026-03-06",
"input_cost_per_token": 1.8e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38079,6 +38107,7 @@
"supports_tool_choice": true
},
"together_ai/mistralai/Mistral-7B-Instruct-v0.1": {
"deprecation_date": "2025-11-13",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -38087,6 +38116,7 @@
"supports_tool_choice": true
},
"together_ai/mistralai/Mistral-Small-24B-Instruct-2501": {
"deprecation_date": "2026-04-02",
"litellm_provider": "together_ai",
"mode": "chat",
"supports_function_calling": true,
@ -38094,6 +38124,7 @@
"supports_tool_choice": true
},
"together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": {
"deprecation_date": "2026-04-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"mode": "chat",
@ -38106,6 +38137,9 @@
"together_ai/moonshotai/Kimi-K2-Instruct": {
"input_cost_per_token": 1e-06,
"litellm_provider": "together_ai",
"metadata": {
"successor": "together_ai/moonshotai/Kimi-K3"
},
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://www.together.ai/models/kimi-k2-instruct",
@ -38149,6 +38183,7 @@
"supports_tool_choice": true
},
"together_ai/zai-org/GLM-4.5-Air-FP8": {
"deprecation_date": "2026-04-02",
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 128000,
@ -38166,6 +38201,9 @@
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"max_tokens": 200000,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
"mode": "chat",
"output_cost_per_token": 2.2e-06,
"source": "https://www.together.ai/models/glm-4-6",
@ -38175,11 +38213,15 @@
"supports_tool_choice": true
},
"together_ai/zai-org/GLM-4.7": {
"deprecation_date": "2026-04-02",
"input_cost_per_token": 4.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 200000,
"max_output_tokens": 200000,
"max_tokens": 200000,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://www.together.ai/models/glm-4-7",
@ -38189,11 +38231,15 @@
"supports_tool_choice": true
},
"together_ai/moonshotai/Kimi-K2.5": {
"deprecation_date": "2026-05-21",
"input_cost_per_token": 5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"metadata": {
"successor": "together_ai/moonshotai/Kimi-K3"
},
"mode": "chat",
"output_cost_per_token": 2.8e-06,
"source": "https://www.together.ai/models/kimi-k2-5",
@ -38203,9 +38249,13 @@
"supports_reasoning": true
},
"together_ai/moonshotai/Kimi-K2-Instruct-0905": {
"deprecation_date": "2026-03-06",
"input_cost_per_token": 1e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"metadata": {
"successor": "together_ai/moonshotai/Kimi-K3"
},
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://www.together.ai/models/kimi-k2-0905",
@ -38214,9 +38264,13 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct": {
"deprecation_date": "2026-04-02",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"metadata": {
"successor": "together_ai/Qwen/Qwen3.7-Plus"
},
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://www.together.ai/models/qwen3-next-80b-a3b-instruct",
@ -38226,9 +38280,13 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": {
"deprecation_date": "2026-02-25",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"metadata": {
"successor": "together_ai/Qwen/Qwen3.6-Plus"
},
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://www.together.ai/models/qwen3-next-80b-a3b-thinking",
@ -38238,6 +38296,7 @@
"supports_tool_choice": true
},
"together_ai/Qwen/Qwen3.5-397B-A17B": {
"deprecation_date": "2026-06-29",
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
@ -38249,6 +38308,292 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/MiniMaxAI/MiniMax-M3": {
"input_cost_per_token": 3e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 524288,
"max_output_tokens": 524288,
"max_tokens": 524288,
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/Prism-ML/Ternary-Bonsai-27B": {
"input_cost_per_token": 0.0,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 0.0,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/Qwen/Qwen3.5-9B": {
"input_cost_per_token": 1.7e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 2.5e-07,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/Qwen/Qwen3.6-Plus": {
"input_cost_per_token": 5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 1000000,
"max_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_reasoning": true
},
"together_ai/Qwen/Qwen3.7-Max": {
"input_cost_per_token": 1.25e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 1000000,
"max_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 3.75e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/Qwen/Qwen3.7-Plus": {
"input_cost_per_token": 3.2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 1000000,
"max_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 1.28e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/Qwen/Qwen3.8-2.4T-A95B": {
"input_cost_per_token": 2.5e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1010000,
"max_output_tokens": 1010000,
"max_tokens": 1010000,
"mode": "chat",
"output_cost_per_token": 6.25e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/arize-ai/qwen-2-1.5b-instruct": {
"input_cost_per_token": 1e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 32768,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 1e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/deepseek-ai/DeepSeek-V4-Flash-0731": {
"input_cost_per_token": 1.4e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-V4-Pro": {
"input_cost_per_token": 1.74e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 512000,
"max_output_tokens": 512000,
"max_tokens": 512000,
"mode": "chat",
"output_cost_per_token": 3.48e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/deepseek-ai/DeepSeek-V4-Pro-0813": {
"input_cost_per_token": 1.32e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 3.96e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/google/gemma-3n-E4B-it": {
"input_cost_per_token": 6e-08,
"litellm_provider": "together_ai",
"max_input_tokens": 32768,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 1.2e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/google/gemma-4-31B-it": {
"input_cost_per_token": 3.9e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 9.7e-07,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/intfloat/multilingual-e5-large-instruct": {
"input_cost_per_token": 2e-08,
"litellm_provider": "together_ai",
"max_input_tokens": 514,
"max_tokens": 514,
"mode": "embedding",
"output_cost_per_token": 2e-08,
"output_vector_size": 1024,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/meta-llama/Llama-Guard-4-12B": {
"input_cost_per_token": 2e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 2e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/meta-models/Muse-Glimmer-30B": {
"input_cost_per_token": 3.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 131072,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/moonshotai/Kimi-K2.7-Code": {
"input_cost_per_token": 9.5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/moonshotai/Kimi-K3": {
"input_cost_per_token": 3e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 1.5e-05,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"together_ai/nvidia/nemotron-3-ultra-550b-a55b": {
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 512288,
"max_output_tokens": 512288,
"max_tokens": 512288,
"mode": "chat",
"output_cost_per_token": 3.6e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/pearl-ai/gemma-4-31b-it": {
"input_cost_per_token": 2.8e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 8.6e-07,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/thinkingmachines/Inkling": {
"input_cost_per_token": 1e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 524288,
"max_output_tokens": 524288,
"max_tokens": 524288,
"mode": "chat",
"output_cost_per_token": 4.05e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"together_ai/thinkingmachines/Inkling-Small": {
"input_cost_per_token": 5e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 524288,
"max_output_tokens": 524288,
"max_tokens": 524288,
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/zai-org/GLM-5.2": {
"input_cost_per_token": 1.4e-06,
"litellm_provider": "together_ai",
"max_input_tokens": 1048575,
"max_output_tokens": 1048575,
"max_tokens": 1048575,
"mode": "chat",
"output_cost_per_token": 4.4e-06,
"source": "https://docs.together.ai/docs/serverless-models",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"tts-1": {
"input_cost_per_character": 1.5e-05,
"litellm_provider": "openai",
@ -49016,12 +49361,13 @@
"output_cost_per_token": 3.3e-05,
"output_cost_per_token_above_272k_tokens": 4.95e-05,
"litellm_provider": "bedrock_mantle",
"max_input_tokens": 1000000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"use_openai_responses_path": true,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
@ -49048,12 +49394,13 @@
"output_cost_per_token": 1.32e-05,
"output_cost_per_token_above_272k_tokens": 1.98e-05,
"litellm_provider": "bedrock_mantle",
"max_input_tokens": 1000000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"use_openai_responses_path": true,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
@ -49080,12 +49427,13 @@
"output_cost_per_token": 1.32e-06,
"output_cost_per_token_above_272k_tokens": 1.98e-06,
"litellm_provider": "bedrock_mantle",
"max_input_tokens": 1000000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"use_openai_responses_path": true,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [

View file

@ -1,6 +1,6 @@
{
"ANN001": {
"limit": 3018
"limit": 3016
},
"ANN002": {
"limit": 71
@ -9,7 +9,7 @@
"limit": 827
},
"ANN201": {
"limit": 2016
"limit": 2015
},
"ANN202": {
"limit": 852
@ -57,7 +57,7 @@
"limit": 3
},
"BLE001": {
"limit": 2919
"limit": 2918
},
"C401": {
"limit": 8
@ -168,7 +168,7 @@
"limit": 3
},
"RET504": {
"limit": 176
"limit": 175
},
"RUF012": {
"limit": 240

View file

@ -7,6 +7,7 @@ import pytest
import litellm
import asyncio
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
@pytest.fixture(scope="session")
@ -38,6 +39,8 @@ def setup_and_teardown():
yield
# Teardown code (executes after the yield point)
# LoggingWorker carries still-queued coroutines onto the next test's loop, where they'd log into that test's callbacks
asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue())
loop.close() # Close the loop created earlier
asyncio.set_event_loop(None) # Remove the reference to the loop

View file

@ -1,6 +1,9 @@
import os
import pytest
import asyncio
import subprocess
import sys
from pathlib import Path
from typing import Optional
from unittest.mock import AsyncMock, patch
@ -458,3 +461,45 @@ async def test_mcp_tool_call_hook():
logged_standard_logging_payload is not None
), "Standard logging payload should not be None"
assert logged_standard_logging_payload["response_cost"] == 1.42
_QUEUED_LOGGING_OUTLIVES_TEST = '''
import time
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
ran_at = []
async def _record_run():
ran_at.append(time.monotonic())
async def test_1_leaves_logging_queued_behind_a_stopped_worker():
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(_record_run())
await GLOBAL_LOGGING_WORKER.stop()
assert ran_at == []
async def test_2_starts_after_the_previous_tests_logging_ran():
started_at = time.monotonic()
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(_record_run())
await GLOBAL_LOGGING_WORKER.flush()
assert [t < started_at for t in ran_at] == [True, False]
'''
def test_logging_queued_by_one_test_is_drained_before_the_next(tmp_path: Path):
"""Regression: a logging coroutine queued by one test must not run inside a later test (it would log into that
test's callbacks, which is how test_mcp_tool_call_hook captured a gpt-4o-mini payload under xdist)."""
(tmp_path / "conftest.py").write_text((Path(__file__).parent / "conftest.py").read_text())
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\nasyncio_mode = "auto"\n')
(tmp_path / "test_queued_logging.py").write_text(_QUEUED_LOGGING_OUTLIVES_TEST)
result = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "test_queued_logging.py"],
cwd=tmp_path,
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stdout + result.stderr

View file

@ -0,0 +1,307 @@
"""
Real-Postgres coverage for the /team/member_add vs /team/delete race (LIT-5544), and for
/team/member_delete's participation in the same lock.
A member_add that validated the team before a delete began could previously still commit
its writes after the delete's reference sweeps had already run, leaving a user record and
a membership row pointing at a team id that no longer exists. Neither side of that race can
be forced by a sequential script: it needs one request to be genuinely mid-flight while the
other commits. A mocked prisma cannot arbitrate that either, since the property under test
is whether Postgres's own advisory lock actually serializes the two requests.
These tests pin the interleaving the same way test_access_group_team_sync.py does: a second
real connection holds the team's advisory lock in its own transaction, so the function under
test is provably blocked on it rather than hoping a sleep lands in the right gap.
"""
import asyncio
import json
import os
from contextlib import asynccontextmanager
from datetime import timedelta
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from litellm.proxy._types import (
DeleteTeamRequest,
LitellmUserRoles,
Member,
TeamMemberAddRequest,
UserAPIKeyAuth,
)
from litellm.caching.caching import DualCache
from litellm.proxy.utils import PrismaClient, ProxyLogging
TEAM = "lit5544-race-team"
USER = "lit5544-race-user"
_DELETE_SEEDED = 'DELETE FROM "LiteLLM_TeamMembership" WHERE team_id = $1'
_DELETE_USER = 'DELETE FROM "LiteLLM_UserTable" WHERE user_id = $1'
_DELETE_TEAM = 'DELETE FROM "LiteLLM_TeamTable" WHERE team_id = $1'
_LOCK_SQL = "SELECT pg_advisory_xact_lock(hashtext($1)) IS NULL AS locked"
@asynccontextmanager
async def _clean_db():
"""Connects inside the running test's loop: an async fixture would be torn up on a
different loop than the test body, which prisma's engine lock refuses outright."""
from prisma import Prisma
if not os.getenv("DATABASE_URL"):
pytest.fail("DATABASE_URL is required; these tests must not silently skip")
db = Prisma()
await db.connect()
try:
await db.execute_raw(_DELETE_SEEDED, TEAM)
await db.execute_raw(_DELETE_USER, USER)
await db.execute_raw(_DELETE_TEAM, TEAM)
yield db
finally:
await db.execute_raw(_DELETE_SEEDED, TEAM)
await db.execute_raw(_DELETE_USER, USER)
await db.execute_raw(_DELETE_TEAM, TEAM)
await db.disconnect()
@asynccontextmanager
async def _real_prisma_client():
"""The full app-level PrismaClient, not the raw generated client: add_new_member reads
and writes through PrismaClient.get_data/insert_data, which the raw client doesn't have."""
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
client = PrismaClient(database_url=os.environ["DATABASE_URL"], proxy_logging_obj=proxy_logging_obj)
await client.connect()
try:
yield client
finally:
await client.db.disconnect()
def _admin_auth():
return UserAPIKeyAuth(user_id="lit5544-admin", api_key="sk-lit5544", user_role=LitellmUserRoles.PROXY_ADMIN.value)
@pytest.mark.asyncio
async def test_member_add_blocked_by_delete_writes_no_dangling_reference():
"""
member_add re-reads the team under the advisory lock before writing anything. When a
delete already holds that lock and then removes the row, member_add's re-read must see
the row gone and raise, without ever calling the write that appends the user/membership
references, which is the only way this leaves zero trace after the delete wins.
"""
from litellm.proxy._types import LiteLLM_TeamTable
from litellm.proxy.management_endpoints.team_endpoints import (
_add_team_members_to_team,
)
async with _clean_db() as db:
await db.litellm_teamtable.create(data={"team_id": TEAM, "team_alias": TEAM, "members_with_roles": "[]"})
async with _real_prisma_client() as prisma_client:
from prisma import Prisma
blocker = Prisma()
await blocker.connect()
lock_acquired = asyncio.Event()
async def add_member():
lock_acquired.set()
await _add_team_members_to_team(
data=TeamMemberAddRequest(
team_id=TEAM,
member=Member(user_id=USER, role="user"),
max_budget_in_team=5.0,
),
complete_team_data=LiteLLM_TeamTable(team_id=TEAM, members_with_roles=[]),
prisma_client=prisma_client,
user_api_key_dict=_admin_auth(),
litellm_proxy_admin_name="lit5544-admin",
)
try:
async with blocker.tx(timeout=timedelta(seconds=30)) as held:
await held.query_raw(_LOCK_SQL, TEAM)
task = asyncio.create_task(add_member())
await lock_acquired.wait()
await asyncio.sleep(0.2)
assert not task.done(), "member_add did not wait on the team's advisory lock"
# the delete wins the race: strip the team row while the lock is held
await held.execute_raw(_DELETE_TEAM, TEAM)
with pytest.raises(HTTPException) as exc_info:
await asyncio.wait_for(task, timeout=30)
assert exc_info.value.status_code == 404
finally:
await blocker.disconnect()
user_row = await db.litellm_usertable.find_unique(where={"user_id": USER})
assert user_row is None, "member_add must not have written a user row for a team that was gone under its lock"
membership_row = await db.litellm_teammembership.find_first(where={"team_id": TEAM, "user_id": USER})
assert membership_row is None
@pytest.mark.asyncio
async def test_member_delete_blocked_by_member_add_removes_from_the_fresh_roster():
"""
team_member_delete takes the same advisory lock and re-reads the roster under it, so a
member_add that committed while member_delete was waiting on the lock is not silently
undone. Without the re-read, member_delete would compute its new roster from the stale
snapshot it validated against before the lock, and its write would overwrite the
member_add's addition right back out even though member_add's request already succeeded.
"""
import litellm.proxy.proxy_server as proxy_server_module
from litellm.proxy._types import TeamMemberDeleteRequest
from litellm.proxy.management_endpoints.team_endpoints import team_member_delete
other_user = f"{USER}-other"
seeded_roster = '[{"user_id": "%s", "user_email": null, "role": "user"}]' % USER
winning_add_roster = (
'[{"user_id": "%s", "user_email": null, "role": "user"}, '
'{"user_id": "%s", "user_email": null, "role": "user"}]' % (USER, other_user)
)
async with _clean_db() as db:
await db.litellm_teamtable.create(
data={"team_id": TEAM, "team_alias": TEAM, "members_with_roles": seeded_roster}
)
async with _real_prisma_client() as prisma_client:
original_prisma_client = proxy_server_module.prisma_client
proxy_server_module.prisma_client = prisma_client
try:
from prisma import Prisma
blocker = Prisma()
await blocker.connect()
lock_acquired = asyncio.Event()
async def run_delete():
lock_acquired.set()
return await team_member_delete(
data=TeamMemberDeleteRequest(team_id=TEAM, user_id=USER),
user_api_key_dict=_admin_auth(),
)
try:
async with blocker.tx(timeout=timedelta(seconds=30)) as held:
await held.query_raw(_LOCK_SQL, TEAM)
task = asyncio.create_task(run_delete())
await lock_acquired.wait()
await asyncio.sleep(0.2)
assert not task.done(), "member_delete did not wait on the team's advisory lock"
# member_add wins the race: it adds `other_user` while holding the lock
await held.litellm_teamtable.update(
where={"team_id": TEAM},
data={"members_with_roles": winning_add_roster},
)
await asyncio.wait_for(task, timeout=30)
finally:
await blocker.disconnect()
finally:
proxy_server_module.prisma_client = original_prisma_client
team_row = await db.litellm_teamtable.find_unique(where={"team_id": TEAM})
raw_roster = team_row.members_with_roles
parsed_roster = json.loads(raw_roster) if isinstance(raw_roster, str) else raw_roster
remaining_ids = {m["user_id"] for m in parsed_roster}
assert remaining_ids == {other_user}, (
"member_delete must remove only the user it targeted from the roster it actually "
"committed to, not silently drop the member the winning add just committed"
)
@pytest.mark.asyncio
async def test_delete_blocked_by_member_add_sweeps_the_fresh_reference():
"""
A member_add that wins the lock race writes its reference and releases the lock; the
delete that was waiting on it must then run its locked sweep against the row as it
actually is, not a stale snapshot, and reap that reference rather than leaving it
stranded on a team id the delete is about to remove.
"""
import litellm.proxy.proxy_server as proxy_server_module
from litellm.proxy._types import LiteLLM_TeamTable
from litellm.proxy.management_endpoints.team_endpoints import delete_team
async with _clean_db() as db:
await db.litellm_teamtable.create(data={"team_id": TEAM, "team_alias": TEAM, "members_with_roles": "[]"})
async with _real_prisma_client() as prisma_client:
proxy_logging_obj = prisma_client.proxy_logging_obj
original_prisma_client = proxy_server_module.prisma_client
original_admin_name = proxy_server_module.litellm_proxy_admin_name
original_proxy_logging_obj = proxy_server_module.proxy_logging_obj
original_cache = proxy_server_module.user_api_key_cache
original_router = proxy_server_module.llm_router
proxy_server_module.prisma_client = prisma_client
proxy_server_module.litellm_proxy_admin_name = "lit5544-admin"
proxy_server_module.proxy_logging_obj = proxy_logging_obj
proxy_server_module.user_api_key_cache = original_cache or proxy_logging_obj.internal_usage_cache
proxy_server_module.llm_router = None
async def restore():
proxy_server_module.prisma_client = original_prisma_client
proxy_server_module.litellm_proxy_admin_name = original_admin_name
proxy_server_module.proxy_logging_obj = original_proxy_logging_obj
proxy_server_module.user_api_key_cache = original_cache
proxy_server_module.llm_router = original_router
try:
from prisma import Prisma
blocker = Prisma()
await blocker.connect()
lock_acquired = asyncio.Event()
async def run_delete():
lock_acquired.set()
return await delete_team(
data=DeleteTeamRequest(team_ids=[TEAM]),
http_request=MagicMock(),
user_api_key_dict=_admin_auth(),
litellm_changed_by="lit5544-admin",
)
try:
async with blocker.tx(timeout=timedelta(seconds=30)) as held:
await held.query_raw(_LOCK_SQL, TEAM)
task = asyncio.create_task(run_delete())
await lock_acquired.wait()
await asyncio.sleep(0.3)
assert not task.done(), "delete_team did not wait on the team's advisory lock"
# member_add wins the race: write the reference while holding the lock
await held.litellm_usertable.upsert(
where={"user_id": USER},
data={
"create": {"user_id": USER, "teams": [TEAM]},
"update": {"teams": {"push": [TEAM]}},
},
)
await held.litellm_teammembership.create(data={"team_id": TEAM, "user_id": USER})
await held.litellm_teamtable.update(
where={"team_id": TEAM},
data={"members_with_roles": '[{"user_id": "%s", "role": "user"}]' % USER},
)
await asyncio.wait_for(task, timeout=30)
finally:
await blocker.disconnect()
finally:
await restore()
team_row = await db.litellm_teamtable.find_unique(where={"team_id": TEAM})
assert team_row is None
user_row = await db.litellm_usertable.find_unique(where={"user_id": USER})
assert user_row is not None and TEAM not in user_row.teams, (
"delete_team's locked sweep must reap the reference member_add wrote just before losing the lock"
)
membership_row = await db.litellm_teammembership.find_first(where={"team_id": TEAM, "user_id": USER})
assert membership_row is None

View file

@ -0,0 +1,152 @@
import uuid
import pytest
from .actors import Actor
from .conftest import create_scratch_team
pytestmark = pytest.mark.asyncio(loop_scope="session")
_SEED_SPEND = 5.0
_RESET_TO = 2.0
# POST /team/{team_id}/member/{user_id}/reset_spend. The handler gate is
# _verify_team_access (proxy admin / team admin of this team / org admin of
# the team's org) — the same gate /team/member_update uses, so this mirrors
# that file's matrix exactly.
_MATRIX = [
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200),
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
("alpha/owner", Actor.OWNER, "alpha", 403),
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
]
async def _seed_target(prisma, world, shape: str, team_id: str, member_id: str) -> None:
if shape == "alpha":
await create_scratch_team(
prisma,
team_id,
organization_id=world.org_a_id,
admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id],
)
elif shape == "beta":
await create_scratch_team(prisma, team_id, organization_id=world.org_b_id)
else: # pragma: no cover - guard
pytest.fail(f"unknown shape={shape}")
await prisma.db.litellm_teammembership.create(
data={"user_id": member_id, "team_id": team_id, "spend": _SEED_SPEND}
)
@pytest.mark.parametrize(
"actor,shape,expected_status",
[(a, sh, s) for (_id, a, sh, s) in _MATRIX],
ids=[s[0] for s in _MATRIX],
)
async def test_team_member_reset_spend_authz_matrix(
actor: Actor,
shape: str,
expected_status: int,
proxy_client,
prisma,
scratch,
world,
):
member_id = scratch.tag("member")
await _seed_target(prisma, world, shape, scratch.prefix, member_id)
caller = world.keys[actor]
resp = await proxy_client.post(
f"/team/{scratch.prefix}/member/{member_id}/reset_spend",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={"reset_to": _RESET_TO},
)
assert (
resp.status_code == expected_status
), f"{actor.value} {shape}: {resp.status_code} {resp.text}"
row = await prisma.db.litellm_teammembership.find_unique(
where={"user_id_team_id": {"user_id": member_id, "team_id": scratch.prefix}}
)
assert row is not None
if expected_status == 200:
assert row.spend == _RESET_TO
else:
assert row.spend == _SEED_SPEND, "denied but spend reset"
async def test_team_member_reset_spend_missing_team_is_404(proxy_client, world):
resp = await proxy_client.post(
f"/team/behavior-pin-no-such-team/member/{uuid.uuid4().hex}/reset_spend",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"reset_to": 0.0},
)
assert resp.status_code == 404, resp.text
async def test_team_member_reset_spend_missing_membership_is_404(
proxy_client, prisma, scratch, world
):
"""A well-formed team but a user_id with no LiteLLM_TeamMembership row is 404."""
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id)
resp = await proxy_client.post(
f"/team/{scratch.prefix}/member/{uuid.uuid4().hex}/reset_spend",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"reset_to": 0.0},
)
assert resp.status_code == 404, resp.text
async def test_team_member_reset_spend_above_current_spend_is_400(
proxy_client, prisma, scratch, world
):
member_id = scratch.tag("member")
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id)
await prisma.db.litellm_teammembership.create(
data={"user_id": member_id, "team_id": scratch.prefix, "spend": 1.0}
)
resp = await proxy_client.post(
f"/team/{scratch.prefix}/member/{member_id}/reset_spend",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"reset_to": 5.0},
)
assert resp.status_code == 400, resp.text
async def test_team_member_reset_spend_team_admin_cannot_reset_own_spend(
proxy_client, prisma, scratch, world
):
"""A team admin targeting their own LiteLLM_TeamMembership row is 403: unchecked, an
admin could repeatedly zero their own spend right before it crosses their per-member
cap, consuming the shared team budget without the configured limit ever binding."""
team_admin = world.keys[Actor.TEAM_ADMIN]
await create_scratch_team(
prisma,
scratch.prefix,
organization_id=world.org_a_id,
admin_user_ids=[team_admin.user_id],
)
await prisma.db.litellm_teammembership.create(
data={"user_id": team_admin.user_id, "team_id": scratch.prefix, "spend": _SEED_SPEND}
)
resp = await proxy_client.post(
f"/team/{scratch.prefix}/member/{team_admin.user_id}/reset_spend",
headers={"Authorization": f"Bearer {team_admin.cleartext}"},
json={"reset_to": 0.0},
)
assert resp.status_code == 403, resp.text
row = await prisma.db.litellm_teammembership.find_unique(
where={"user_id_team_id": {"user_id": team_admin.user_id, "team_id": scratch.prefix}}
)
assert row is not None and row.spend == _SEED_SPEND, "denied but spend reset"

View file

@ -1169,6 +1169,22 @@ async def test_create_user_default_budget(prisma_client, user_role): # noqa: F8
assert mock_client.call_args.kwargs["data"]["budget_duration"] is None
def _member_add_tx_cm(team_table):
"""Transaction whose member writes land on whatever tables are mocked on `prisma_client.db`"""
class _Tx:
query_raw = AsyncMock(return_value=[{"members_with_roles": []}])
litellm_teamtable = team_table
def __getattr__(self, table_name):
return getattr(litellm.proxy.proxy_server.prisma_client.db, table_name)
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=_Tx())
tx_cm.__aexit__ = AsyncMock(return_value=None)
return tx_cm
@pytest.mark.parametrize("new_member_method", ["user_id", "user_email"])
@pytest.mark.asyncio
@pytest.mark.skip(reason="Requires reliable external DB connection (prisma).")
@ -1230,7 +1246,7 @@ async def test_create_team_member_add(prisma_client, new_member_method): # noqa
)
)
mock_litellm_usertable.upsert = mock_client
mock_litellm_usertable.find_many = AsyncMock(return_value=None)
mock_litellm_usertable.find_many = AsyncMock(return_value=[])
# Mock find_first for user_email validation (returns None for new users)
mock_litellm_usertable.find_first = AsyncMock(return_value=None)
# Mock find_unique for user_id validation (returns None for new users)
@ -1245,12 +1261,7 @@ async def test_create_team_member_add(prisma_client, new_member_method): # noqa
return_value=LiteLLM_TeamTableCachedObj(team_id="1234")
)
tx_mock = AsyncMock()
tx_mock.query_raw = AsyncMock(return_value=[{"members_with_roles": []}])
tx_mock.litellm_teamtable = team_mock_client
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=tx_mock)
tx_cm.__aexit__ = AsyncMock(return_value=None)
tx_cm = _member_add_tx_cm(team_mock_client)
original_tx = litellm.proxy.proxy_server.prisma_client.tx
litellm.proxy.proxy_server.prisma_client.tx = MagicMock(
return_value=tx_cm
@ -1432,7 +1443,7 @@ async def test_create_team_member_add_team_admin(
)
)
mock_litellm_usertable.upsert = mock_client
mock_litellm_usertable.find_many = AsyncMock(return_value=None)
mock_litellm_usertable.find_many = AsyncMock(return_value=[])
# Mock find_first for user_email validation (returns None for new users)
mock_litellm_usertable.find_first = AsyncMock(return_value=None)
# Mock find_unique for user_id validation (returns None for new users)
@ -1443,12 +1454,7 @@ async def test_create_team_member_add_team_admin(
return_value=LiteLLM_TeamTableCachedObj(team_id="1234")
)
tx_mock = AsyncMock()
tx_mock.query_raw = AsyncMock(return_value=[{"members_with_roles": []}])
tx_mock.litellm_teamtable = team_mock_client
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=tx_mock)
tx_cm.__aexit__ = AsyncMock(return_value=None)
tx_cm = _member_add_tx_cm(team_mock_client)
with (
patch.object(

View file

@ -28,6 +28,14 @@ if TYPE_CHECKING:
from redis.asyncio.cluster import RedisCluster as _AsyncRedisClusterType
class _NodeClassWithPerConnectionRecovery:
def update_active_connections_for_reconnect(self) -> None: ...
class _NodeClassWithoutPerConnectionRecovery:
pass
class _FakeClusterNode:
def __init__(self, name: str, raises: Exception | None = None, response: object = None) -> None:
self.name = name
@ -47,7 +55,9 @@ class _FakeNodesManager:
def _build_cluster_instance() -> "_AsyncRedisClusterType":
cluster_cls = get_litellm_async_redis_cluster_class()
cluster_cls = get_litellm_async_redis_cluster_class(
cluster_node_class=_NodeClassWithoutPerConnectionRecovery
)
instance = cluster_cls.__new__(cluster_cls)
instance.RedisClusterRequestTTL = 1
instance.reinitialize_counter = 0
@ -58,6 +68,33 @@ def _build_cluster_instance() -> "_AsyncRedisClusterType":
return instance
def test_per_connection_recovery_redis_py_gets_the_unmodified_upstream_class() -> None:
"""Regression (redis-py 8.x): when upstream ClusterNode already recovers a node-level
connection error per-connection, the factory must NOT install the copied override,
whose node.disconnect() also kills connections other coroutines are mid-operation on."""
from redis.asyncio.cluster import RedisCluster
cluster_cls = get_litellm_async_redis_cluster_class(
cluster_node_class=_NodeClassWithPerConnectionRecovery
)
assert cluster_cls is RedisCluster
def test_pre_recovery_redis_py_still_gets_the_node_isolation_override() -> None:
"""Old redis-py (5.x) responds to a node-level error with a full-cluster aclose(),
so those versions must keep litellm's per-node isolation override."""
from redis.asyncio.cluster import RedisCluster
cluster_cls = get_litellm_async_redis_cluster_class(
cluster_node_class=_NodeClassWithoutPerConnectionRecovery
)
assert cluster_cls is not RedisCluster
assert issubclass(cluster_cls, RedisCluster)
assert "_execute_command" in cluster_cls.__dict__
@pytest.mark.asyncio
@pytest.mark.parametrize("error_cls", [RedisConnectionError, RedisTimeoutError])
async def test_node_level_error_resets_only_that_node_not_the_whole_client(error_cls: type[Exception]) -> None:

View file

@ -2,7 +2,7 @@ import datetime
import json
import os
import unittest
from typing import TYPE_CHECKING, List, Literal, Optional, Tuple
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Tuple
from unittest.mock import ANY, MagicMock, Mock, patch
import httpx
@ -1585,10 +1585,16 @@ def test_map_reasoning_effort_adds_summary_detailed(monkeypatch):
assert result_dict["summary"] == "custom_summary"
print("✓ Dict input is passed through without modification")
# Test 5: None/unknown values return None
result_unknown = handler._map_reasoning_effort("unknown_value")
assert result_unknown is None
print("✓ Unknown reasoning_effort values return None")
# Test 5: every REASONING_EFFORT level reaches the provider, and anything else (a typo, an
# unshipped level, "default") is dropped so the request still succeeds at the provider default
from litellm.types.llms.openai import Reasoning
for effort in ("max", "xhigh", "none"):
result_passthrough = handler._map_reasoning_effort(effort)
assert result_passthrough == Reasoning(effort=effort)
for dropped in ("ultra", "hgih", "unknown_value", "", "default"):
assert handler._map_reasoning_effort(dropped) is None
print("✓ Enumerated levels pass through and unknown ones are dropped")
print(
"✓ All reasoning_effort behaviors work correctly with flag/env var control"
@ -2438,6 +2444,32 @@ def test_map_optional_params_preserves_reasoning_summary():
assert responses_api_request["reasoning"]["summary"] == "detailed"
@pytest.mark.parametrize("reasoning_effort", ["max", "high"])
def test_transform_request_bedrock_mantle_tools_keeps_reasoning_effort(monkeypatch, reasoning_effort):
"""Regression for reasoning_effort=max being dropped on the chat -> Responses bridge (issue #38084)."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
monkeypatch.setattr(litellm, "reasoning_auto_summary", False)
monkeypatch.delenv("LITELLM_REASONING_AUTO_SUMMARY", raising=False)
handler: Final = LiteLLMResponsesTransformationHandler()
result: Final = handler.transform_request(
model="openai.gpt-5.6-sol",
messages=[{"role": "user", "content": "Say pong"}],
optional_params={
"reasoning_effort": reasoning_effort,
"tools": [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}],
},
litellm_params={"custom_llm_provider": "bedrock_mantle"},
headers={},
litellm_logging_obj=Mock(),
)
assert result["reasoning"] == {"effort": reasoning_effort}
def test_map_optional_params_tool_choice_chat_nested_to_responses_api():
"""Chat tool_choice must become Responses ToolChoiceFunction (top-level name)."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (

View file

@ -478,10 +478,10 @@ def test_generic_cost_per_token_minimax_m3_above_512k_tokens(_local_model_cost_m
],
)
def test_generic_cost_per_token_bedrock_mantle_gpt56_long_context(_local_model_cost_map, model):
"""Bedrock GPT-5.6 supports a 1M context window, billed at the long-context rates above 272K."""
"""Bedrock GPT-5.6 enforces a 1,050,000-token context window, billed at the long-context rates above 272K."""
model_cost_map = litellm.model_cost[model]
assert model_cost_map["max_input_tokens"] == 1000000
assert model_cost_map["max_input_tokens"] == 1050000
cached_tokens = 100000
completion_tokens = 1000

View file

@ -255,3 +255,26 @@ class TestRedactNestedMatchAndRegexKeys:
def test_passes_through_none_and_str(self):
assert redact_nested_match_and_regex_keys(None) is None
assert redact_nested_match_and_regex_keys("plain") == "plain"
class TestIsExpectedClientError:
def test_status_ranges(self):
from litellm.litellm_core_utils.core_helpers import is_expected_client_error
class WithStatusCode(Exception):
def __init__(self, status_code):
self.status_code = status_code
class WithCode(Exception):
def __init__(self, code):
self.code = code
assert is_expected_client_error(WithStatusCode(400)) is True
assert is_expected_client_error(WithStatusCode(429)) is True
assert is_expected_client_error(WithStatusCode(499)) is True
assert is_expected_client_error(WithStatusCode(500)) is False
assert is_expected_client_error(WithStatusCode(399)) is False
assert is_expected_client_error(WithCode("403")) is True
assert is_expected_client_error(WithCode("invalid_request_error")) is False
assert is_expected_client_error(Exception("no status")) is False
assert is_expected_client_error(None) is False

View file

@ -133,3 +133,54 @@ class TestGetLlmProviderRejectsAttackerSmuggledApiBase:
assert provider == "groq"
assert dynamic_api_key == "server-real-groq-key"
class TestTogetherApiBaseResolvesProvider:
"""
Regression for the Together host migration: both the current
``api.together.ai`` host and the legacy ``api.together.xyz`` host must
resolve to ``together_ai`` when passed as ``api_base``. Before the fix
the endpoint list carried the legacy host but the provider-mapping
chain had no branch for it, so the match fell through with a None
provider and the deployment failed with "LLM Provider NOT provided".
"""
@pytest.mark.parametrize(
"api_base",
[
"https://api.together.ai/v1",
"https://api.together.xyz/v1",
],
)
def test_together_api_base_resolves_to_together_ai(self, api_base, monkeypatch):
monkeypatch.setenv("TOGETHER_API_KEY", "together-key-from-env")
model, provider, dynamic_api_key, returned_api_base = get_llm_provider(
model="some-model",
api_base=api_base,
)
assert provider == "together_ai"
assert dynamic_api_key == "together-key-from-env"
assert returned_api_base == api_base
assert model == "some-model"
def test_explicit_api_key_beats_together_env_key(self, monkeypatch):
monkeypatch.setenv("TOGETHER_API_KEY", "together-key-from-env")
_, provider, dynamic_api_key, _ = get_llm_provider(
model="some-model",
api_base="https://api.together.ai/v1",
api_key="explicit-caller-key",
)
assert provider == "together_ai"
assert dynamic_api_key == "explicit-caller-key"
def test_together_default_api_base_is_together_ai(self, monkeypatch):
monkeypatch.delenv("TOGETHER_AI_API_BASE", raising=False)
_, provider, _, api_base = get_llm_provider(model="together_ai/some-model")
assert provider == "together_ai"
assert api_base == "https://api.together.ai/v1"

View file

@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import time
import httpx
@ -3867,6 +3866,90 @@ def test_get_standard_logging_object_payload_includes_litellm_call_id(logging_ob
assert payload["litellm_call_id"] == call_id
# ── Azure Model Router selected-model attribution ────────────────────────────
def _model_router_response(selected_model: str, stamp: bool):
"""A ModelResponse as AzureModelRouterConfig hands it back, with or without the stamp."""
from litellm.llms.azure_ai.common_utils import (
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY,
)
from litellm.types.utils import ModelResponse
response = ModelResponse(model=selected_model)
response._hidden_params = (
{AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model} if stamp else {}
)
return response
def test_standard_logging_payload_uses_stamped_model_router_model(logging_obj):
"""
The selected model must win off the stamp, not off "model-router" appearing in the
requested model. An operator whose model group is named anything else was invisible
to the name check, so their logs and spend rows named the router instead.
"""
import datetime
from litellm.litellm_core_utils.litellm_logging import (
get_standard_logging_object_payload,
)
now = datetime.datetime.now()
payload = get_standard_logging_object_payload(
kwargs={
"model": "azure_ai/smart-pick",
"custom_llm_provider": "azure_ai",
"messages": [],
"litellm_params": {"metadata": {}},
},
init_response_obj=_model_router_response(
"azure_ai/grok-4-1-fast-reasoning", stamp=True
),
start_time=now,
end_time=now,
logging_obj=logging_obj,
status="success",
)
assert payload is not None
assert payload["model"] == "azure_ai/grok-4-1-fast-reasoning"
def test_standard_logging_payload_keeps_requested_model_without_router_stamp(
logging_obj,
):
"""
Control for the test above: an ordinary azure_ai deployment is unaffected, so the stamp
is what redirects attribution rather than the response model winning unconditionally.
"""
import datetime
from litellm.litellm_core_utils.litellm_logging import (
get_standard_logging_object_payload,
)
now = datetime.datetime.now()
payload = get_standard_logging_object_payload(
kwargs={
"model": "azure_ai/smart-pick",
"custom_llm_provider": "azure_ai",
"messages": [],
"litellm_params": {"metadata": {}},
},
init_response_obj=_model_router_response(
"azure_ai/grok-4-1-fast-reasoning", stamp=False
),
start_time=now,
end_time=now,
logging_obj=logging_obj,
status="success",
)
assert payload is not None
assert payload["model"] == "azure_ai/smart-pick"
def _make_dict_logging_obj():
"""Build a Logging instance configured for a non-streaming dict result."""
obj = LitellmLogging(
@ -5595,3 +5678,62 @@ def test_get_custom_logger_compatible_class_finds_v2_newrelic(monkeypatch):
logging_module._in_memory_loggers.clear()
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()
class _ClientError(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
self.message = message
super().__init__(message)
def _raise_and_catch(exc):
try:
raise exc
except Exception as caught:
return caught
def test_get_error_information_skips_traceback_for_expected_4xx(monkeypatch):
"""Regression for LIT-6043: expected client (4xx) errors must not pay for
traceback.format_tb on every rejected request unless
litellm.log_client_error_tracebacks is enabled."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
client_exc = _raise_and_catch(_ClientError(status_code=403, message="team does not allow model"))
assert client_exc.__traceback__ is not None
result = StandardLoggingPayloadSetup.get_error_information(client_exc)
assert result["traceback"] == ""
server_exc = _raise_and_catch(_ClientError(status_code=500, message="boom"))
result = StandardLoggingPayloadSetup.get_error_information(server_exc)
assert "test_litellm_logging" in result["traceback"]
monkeypatch.setattr(litellm, "log_client_error_tracebacks", True)
result = StandardLoggingPayloadSetup.get_error_information(client_exc)
assert "test_litellm_logging" in result["traceback"]
def test_failure_handler_helper_fn_builds_payload_once_per_exception():
"""Regression for LIT-6043: async and sync failure handlers both call
_failure_handler_helper_fn for the same failed request; the standardized
payload must be built once, not once per handler."""
obj = LitellmLogging(
model="gpt-4o",
messages=[{"role": "user", "content": "Hey"}],
stream=False,
call_type="acompletion",
start_time=time.time(),
litellm_call_id="lit-6043-1",
function_id="f",
)
exc = _raise_and_catch(_ClientError(status_code=400, message="invalid model"))
obj._failure_handler_helper_fn(exception=exc, traceback_exception="")
first_payload = obj.model_call_details["standard_logging_object"]
assert first_payload is not None
obj._failure_handler_helper_fn(exception=exc, traceback_exception="")
assert obj.model_call_details["standard_logging_object"] is first_payload
other_exc = _raise_and_catch(_ClientError(status_code=429, message="rate limited"))
obj._failure_handler_helper_fn(exception=other_exc, traceback_exception="")
assert obj.model_call_details["standard_logging_object"] is not first_payload

View file

@ -1,3 +1,4 @@
import base64
from typing import Any, cast
import pytest
@ -11,6 +12,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
)
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
_bedrock_converse_messages_pt,
)
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
OPENAI_MAX_TOOL_NAME_LENGTH,
@ -3872,6 +3874,75 @@ def test_tool_result_plain_text_unchanged_by_openai_transform():
assert _image_urls_in_user_messages(result) == []
TOOL_RESULT_PDF_B64 = base64.b64encode(b"%PDF-1.4 minimal regression fixture").decode()
def _base64_pdf_block():
return {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": TOOL_RESULT_PDF_B64},
}
def test_tool_result_single_document_kept_as_pdf_data_url():
adapter = LiteLLMAnthropicMessagesAdapter()
translated = adapter.translate_anthropic_messages_to_openai(
messages=[
_anthropic_tool_use_turn("toolu_01"),
_anthropic_tool_result_turn({"toolu_01": [_base64_pdf_block()]}),
]
)
tool_messages = [m for m in translated if m.get("role") == "tool"]
assert len(tool_messages) == 1
assert tool_messages[0]["content"] == [
{
"type": "image_url",
"image_url": {"url": f"data:application/pdf;base64,{TOOL_RESULT_PDF_B64}"},
}
]
def test_tool_result_text_and_document_reach_bedrock_converse_tool_result():
"""Claude Code >= 2.1.245 sends Read-tool PDF output as a document block inside
tool_result; dropping it left bedrock converse models blind to the PDF content."""
adapter = LiteLLMAnthropicMessagesAdapter()
translated = adapter.translate_anthropic_messages_to_openai(
messages=[
AnthropicMessagesUserMessageParam(role="user", content="Read pong.pdf"),
_anthropic_tool_use_turn("toolu_01"),
_anthropic_tool_result_turn(
{
"toolu_01": [
{"type": "text", "text": "PDF file read: pong.pdf (579 bytes)"},
_base64_pdf_block(),
]
}
),
]
)
converse_messages = _bedrock_converse_messages_pt(
messages=translated,
model="anthropic.claude-haiku-4-5-20251001-v1:0",
llm_provider="bedrock_converse",
)
tool_results = [
block["toolResult"]
for message in converse_messages
for block in message["content"]
if "toolResult" in block
]
assert len(tool_results) == 1
documents = [part["document"] for part in tool_results[0]["content"] if "document" in part]
assert len(documents) == 1
assert documents[0]["format"] == "pdf"
assert documents[0]["source"]["bytes"] == TOOL_RESULT_PDF_B64
texts = [part["text"] for part in tool_results[0]["content"] if "text" in part]
assert texts == ["PDF file read: pong.pdf (579 bytes)"]
def test_translate_anthropic_to_openai_carries_prompt_cache_breakpoint_on_system_and_user_blocks():
explicit = {"mode": "explicit"}
openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(

View file

@ -42,7 +42,7 @@ def test_reasoning_effort_maps_to_output_config_for_adaptive_model(
)
assert "reasoning_effort" not in result
assert result.get("thinking") == {"type": "adaptive"}
assert result.get("thinking") == {"type": "adaptive", "display": "summarized"}
assert result.get("output_config") == {"effort": expected_effort}

View file

@ -11,6 +11,10 @@ from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterato
BaseAnthropicMessagesStreamingIterator,
_incomplete_stream_error_sse_event,
_is_message_stop_chunk,
_is_provider_error_chunk,
anthropic_messages_response_as_sse_events,
is_anthropic_content_delta_chunk,
parse_anthropic_error_event,
)
@ -157,6 +161,96 @@ def test_is_message_stop_chunk_ignores_substring_in_payload():
assert _is_message_stop_chunk(delta_frame_with_substring) is False
def test_parse_anthropic_error_event_from_dict_chunk():
"""Regression for #24004: dict-shaped error chunks parse to
(type, message, status) so the Router can decide whether to fall back."""
chunk = {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
assert parse_anthropic_error_event(chunk) == ("overloaded_error", "Overloaded", 503)
assert _is_provider_error_chunk(chunk) is True
def test_parse_anthropic_error_event_from_sse_bytes():
"""Regression for #24004: a raw `event: error` SSE frame (what a native
Anthropic/Bedrock passthrough forwards verbatim today) must parse
identically to the dict shape so the Router can raise a fallback."""
sse_chunk = (
b"event: error\n"
b'data: {"type": "error", "error": {"type": "internal_server_error", "message": "boom"}}\n\n'
)
assert parse_anthropic_error_event(sse_chunk) == ("internal_server_error", "boom", 500)
assert _is_provider_error_chunk(sse_chunk) is True
def test_parse_anthropic_error_event_defaults_status_for_unknown_type():
chunk = {"type": "error", "error": {"type": "some_future_error_type", "message": "?"}}
assert parse_anthropic_error_event(chunk) == ("some_future_error_type", "?", 500)
def test_parse_anthropic_error_event_missing_message_falls_back_to_type():
chunk = {"type": "error", "error": {"type": "overloaded_error"}}
assert parse_anthropic_error_event(chunk) == ("overloaded_error", "overloaded_error", 503)
def test_parse_anthropic_error_event_non_string_error_type_returns_none():
"""A malformed error body whose `type` field isn't a string (e.g. an
upstream bug sends null or a number) must not be treated as an error
event rather than crashing or forwarding a garbage error_type."""
chunk = {"type": "error", "error": {"type": None, "message": "boom"}}
assert parse_anthropic_error_event(chunk) is None
def test_decoded_sse_data_line_swallows_invalid_json():
"""A `data:` line that isn't valid JSON (a malformed/truncated frame)
must not be treated as an error event or raise, just be ignored."""
malformed_frame = b"event: error\ndata: {not valid json\n\n"
assert parse_anthropic_error_event(malformed_frame) is None
assert _is_provider_error_chunk(malformed_frame) is False
class TestIsAnthropicContentDeltaChunk:
def test_dict_content_block_delta(self):
assert is_anthropic_content_delta_chunk({"type": "content_block_delta"}) is True
def test_dict_other_type(self):
assert is_anthropic_content_delta_chunk({"type": "message_start"}) is False
def test_bytes_content_block_delta(self):
assert is_anthropic_content_delta_chunk(b"event: content_block_delta\ndata: {}\n\n") is True
def test_bytes_other_event(self):
assert is_anthropic_content_delta_chunk(b"event: message_start\ndata: {}\n\n") is False
def test_neither_dict_nor_bytes(self):
assert is_anthropic_content_delta_chunk("content_block_delta") is False
assert is_anthropic_content_delta_chunk(None) is False
@pytest.mark.parametrize(
"chunk",
[
{"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hi"}},
b'event: content_block_delta\ndata: {"type": "content_block_delta"}\n\n',
b"raw-bytes",
"error",
None,
],
)
def test_parse_anthropic_error_event_non_error_chunks_return_none(chunk):
assert parse_anthropic_error_event(chunk) is None
assert _is_provider_error_chunk(chunk) is False
def test_parse_anthropic_error_event_ignores_substring_in_payload():
"""A content_block_delta whose partial_json happens to contain the
literal string `"type": "error"` must not be misread as an error event."""
delta_frame_with_substring = (
b"event: content_block_delta\n"
b'data: {"type": "content_block_delta", "delta": '
b'{"type": "input_json_delta", "partial_json": "\\"type\\": \\"error\\""}}\n\n'
)
assert parse_anthropic_error_event(delta_frame_with_substring) is None
@pytest.mark.asyncio
async def test_async_sse_wrapper_emits_error_when_bytes_stream_only_mentions_message_stop_in_payload():
"""
@ -307,3 +401,117 @@ def test_incomplete_stream_error_sse_event_is_valid_anthropic_error():
"error": {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE},
}
assert event.endswith("\n\n")
def _decode_sse_events(events: tuple[bytes, ...]) -> list[tuple[str, dict]]:
decoded = []
for event in events:
assert isinstance(event, bytes)
lines = event.decode().split("\n")
assert lines[0].startswith("event: ")
decoded.append((lines[0].removeprefix("event: "), json.loads(lines[1].removeprefix("data: "))))
return decoded
def test_anthropic_messages_response_as_sse_events_text_block():
response = {
"id": "msg_1",
"model": "claude-haiku",
"role": "assistant",
"type": "message",
"stop_reason": "end_turn",
"stop_sequence": None,
"content": [{"type": "text", "text": "hello"}],
"usage": {"input_tokens": 3, "output_tokens": 2},
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
types = [event_type for event_type, _ in decoded]
assert types == [
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]
# message_start must not carry generated content itself, matching a real
# streaming response - it arrives via the content_block_delta that follows.
assert decoded[0][1]["message"]["content"] == []
assert decoded[0][1]["message"]["id"] == "msg_1"
# Bugbot regression: message_start must not carry the completed response's
# final stop_reason/stop_sequence/output_tokens - a real stream keeps those
# null/zero until message_delta, so a client could otherwise treat the
# message as already finished, or double-count output tokens.
assert decoded[0][1]["message"]["stop_reason"] is None
assert decoded[0][1]["message"]["stop_sequence"] is None
assert decoded[0][1]["message"]["usage"] == {"input_tokens": 3, "output_tokens": 0}
assert decoded[1][1]["content_block"] == {"type": "text", "text": ""}
assert decoded[2][1]["delta"] == {"type": "text_delta", "text": "hello"}
assert decoded[4][1]["delta"]["stop_reason"] == "end_turn"
assert decoded[4][1]["usage"] == {"input_tokens": 3, "output_tokens": 2}
def test_anthropic_messages_response_as_sse_events_tool_use_block():
response = {
"id": "msg_2",
"content": [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "NYC"}}],
"stop_reason": "tool_use",
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
content_block_start = dict(decoded)["content_block_start"]
assert content_block_start["content_block"] == {
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {},
}
content_block_delta = dict(decoded)["content_block_delta"]
assert json.loads(content_block_delta["delta"]["partial_json"]) == {"city": "NYC"}
assert content_block_delta["delta"]["type"] == "input_json_delta"
def test_anthropic_messages_response_as_sse_events_thinking_block_emits_signature_delta():
"""Bugbot regression: a thinking block's real `signature` must reach the
client via a trailing signature_delta, not be silently dropped - Anthropic
rejects a replayed assistant message (a follow-up turn, a tool-use
continuation) whose thinking block lacks its original signature."""
response = {
"id": "msg_5",
"content": [{"type": "thinking", "thinking": "let me think", "signature": "sig-abc123"}],
"stop_reason": "end_turn",
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
deltas = [payload["delta"] for event_type, payload in decoded if event_type == "content_block_delta"]
assert deltas == [
{"type": "thinking_delta", "thinking": "let me think"},
{"type": "signature_delta", "signature": "sig-abc123"},
]
def test_anthropic_messages_response_as_sse_events_thinking_block_without_signature_omits_delta():
response = {
"id": "msg_6",
"content": [{"type": "thinking", "thinking": "let me think", "signature": None}],
"stop_reason": "end_turn",
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
deltas = [payload["delta"] for event_type, payload in decoded if event_type == "content_block_delta"]
assert deltas == [{"type": "thinking_delta", "thinking": "let me think"}]
def test_anthropic_messages_response_as_sse_events_multiple_blocks_are_indexed():
response = {
"id": "msg_3",
"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}],
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
starts = [payload for event_type, payload in decoded if event_type == "content_block_start"]
assert [s["index"] for s in starts] == [0, 1]
deltas = [payload for event_type, payload in decoded if event_type == "content_block_delta"]
assert [d["delta"]["text"] for d in deltas] == ["a", "b"]
def test_anthropic_messages_response_as_sse_events_no_content_blocks():
response = {"id": "msg_4", "content": [], "stop_reason": "end_turn"}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
assert [event_type for event_type, _ in decoded] == ["message_start", "message_delta", "message_stop"]

View file

@ -16,7 +16,10 @@ from litellm.constants import (
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_BOUNDARY
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_BOUNDARY,
TOOL_RESULT_IMAGE_PLACEHOLDER,
)
from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter,
)
@ -1555,6 +1558,217 @@ class TestToolResultImages:
assert self._input_images(items) == []
class TestToolResultDocuments:
"""Documents inside tool_result blocks must survive translation (LIT-6135):
the function_call_output output becomes a list of parts carrying the joined
text as input_text and each document as an input_file. Without documents the
output stays the plain string it always was."""
PDF_B64 = "JVBERi0xLjQKJSBQT05H"
PDF_DATA_URI = "data:application/pdf;base64,JVBERi0xLjQKJSBQT05H"
PDF_URL = "https://example.com/report.pdf"
PNG_B64 = "iVBORw0KGgoAAAANSUhEUg=="
def _messages(self, tool_result_content):
return [
{"role": "user", "content": "read the pdf"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_01", "name": "read", "input": {}}],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}
],
},
]
def _translate(self, tool_result_content):
return _ADAPTER.translate_messages_to_responses_input(self._messages(tool_result_content))
@staticmethod
def _tool_output(items):
return next(item for item in items if item.get("type") == "function_call_output")["output"]
def _base64_document(self, **extra):
return {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": self.PDF_B64},
**extra,
}
def test_text_and_base64_document_produce_part_list(self):
output = self._tool_output(
self._translate([{"type": "text", "text": "PDF file read: mystery.pdf"}, self._base64_document()])
)
assert output == [
{"type": "input_text", "text": "PDF file read: mystery.pdf"},
{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI},
]
def test_document_only_produces_single_file_part(self):
output = self._tool_output(self._translate([self._base64_document()]))
assert output == [{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI}]
def test_document_title_becomes_filename(self):
output = self._tool_output(self._translate([self._base64_document(title="quarterly-report.pdf")]))
assert output == [
{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}
]
def test_url_document_becomes_file_url_part(self):
output = self._tool_output(
self._translate([{"type": "document", "source": {"type": "url", "url": self.PDF_URL}}])
)
assert output == [{"type": "input_file", "file_url": self.PDF_URL}]
def test_document_with_empty_data_falls_back_to_string_output(self):
output = self._tool_output(
self._translate(
[
{"type": "text", "text": "PDF file read"},
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": ""}},
]
)
)
assert output == "PDF file read"
def test_document_without_source_dict_keeps_string_output(self):
output = self._tool_output(
self._translate([{"type": "text", "text": "stub"}, {"type": "document", "source": self.PDF_URL}])
)
assert output == "stub"
def test_text_only_tool_result_keeps_plain_string_output(self):
output = self._tool_output(self._translate([{"type": "text", "text": "plain result"}]))
assert output == "plain result"
def test_file_id_source_document_keeps_string_output(self):
output = self._tool_output(
self._translate(
[
{"type": "text", "text": "stub"},
{"type": "document", "source": {"type": "file", "file_id": "file_abc123"}},
]
)
)
assert output == "stub"
def test_url_source_without_url_keeps_string_output(self):
output = self._tool_output(
self._translate([{"type": "text", "text": "stub"}, {"type": "document", "source": {"type": "url"}}])
)
assert output == "stub"
def test_text_image_and_document_mix(self):
items = self._translate(
[
{"type": "text", "text": "captured"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": self.PNG_B64}},
self._base64_document(),
]
)
output = self._tool_output(items)
assert output == [
{"type": "input_text", "text": f"captured\n{TOOL_RESULT_IMAGE_PLACEHOLDER}"},
{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI},
]
image_message = next(
item
for item in items
if item.get("type") == "message"
and any(part.get("type") == "input_image" for part in item.get("content", []))
)
assert image_message["content"] == [
{"type": "input_text", "text": TOOL_RESULT_IMAGE_BOUNDARY},
{"type": "input_image", "image_url": f"data:image/png;base64,{self.PNG_B64}"},
]
class TestUserContentDocuments:
"""Documents in plain user content must survive translation (LIT-6144): each
document block becomes an input_file part of the user message, in block order,
exactly like image blocks become input_image parts. Untranslatable documents
are dropped without disturbing the surrounding parts."""
PDF_B64 = "JVBERi0xLjQKJSBQT05H"
PDF_DATA_URI = "data:application/pdf;base64,JVBERi0xLjQKJSBQT05H"
PDF_URL = "https://example.com/report.pdf"
EXPLICIT = {"mode": "explicit"}
def _translate(self, user_content):
return _ADAPTER.translate_messages_to_responses_input([{"role": "user", "content": user_content}])
@staticmethod
def _user_content(items):
return next(item for item in items if item.get("type") == "message" and item.get("role") == "user")["content"]
def _base64_document(self, **extra):
return {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": self.PDF_B64},
**extra,
}
def test_document_then_text_keeps_block_order(self):
content = self._user_content(
self._translate([self._base64_document(), {"type": "text", "text": "what does the pdf say?"}])
)
assert content == [
{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI},
{"type": "input_text", "text": "what does the pdf say?"},
]
def test_document_title_becomes_filename(self):
content = self._user_content(self._translate([self._base64_document(title="quarterly-report.pdf")]))
assert content == [
{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}
]
def test_url_document_becomes_file_url_part(self):
content = self._user_content(
self._translate([{"type": "document", "source": {"type": "url", "url": self.PDF_URL}}])
)
assert content == [{"type": "input_file", "file_url": self.PDF_URL}]
def test_document_only_content_still_produces_user_message(self):
content = self._user_content(self._translate([self._base64_document()]))
assert content == [{"type": "input_file", "filename": "document.pdf", "file_data": self.PDF_DATA_URI}]
def test_empty_base64_data_drops_only_the_document_part(self):
content = self._user_content(
self._translate(
[
{"type": "text", "text": "still here"},
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": ""}},
]
)
)
assert content == [{"type": "input_text", "text": "still here"}]
def test_non_dict_source_drops_only_the_document_part(self):
content = self._user_content(
self._translate([{"type": "text", "text": "still here"}, {"type": "document", "source": self.PDF_URL}])
)
assert content == [{"type": "input_text", "text": "still here"}]
def test_document_breakpoint_rides_on_the_file_part(self):
content = self._user_content(
self._translate([self._base64_document(prompt_cache_breakpoint=self.EXPLICIT)])
)
assert content == [
{
"type": "input_file",
"filename": "document.pdf",
"file_data": self.PDF_DATA_URI,
"prompt_cache_breakpoint": self.EXPLICIT,
}
]
def _contains_key(value, key) -> bool:
if isinstance(value, dict):
return key in value or any(_contains_key(v, key) for v in value.values())

View file

@ -5,6 +5,8 @@ Verifies that reasoning_effort=None returns None for all models,
including Claude Opus 4.6.
"""
import pytest
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
@ -35,6 +37,16 @@ class TestMapReasoningEffort:
)
assert result["type"] == "adaptive"
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
def test_adaptive_mapping_requests_summarized_display(self, effort):
"""Regression LIT-5714: adaptive thinking without ``display`` makes Anthropic
return a blank thinking block, so reasoning_effort callers always got
``reasoning_content: ""``."""
result = AnthropicConfig._map_reasoning_effort(
reasoning_effort=effort, model="claude-opus-4-6", custom_llm_provider="anthropic"
)
assert result["display"] == "summarized"
def test_other_model_low_returns_enabled_with_budget(self):
result = AnthropicConfig._map_reasoning_effort(
reasoning_effort="low", model="claude-4-sonnet-20250514", custom_llm_provider="anthropic"

View file

@ -201,6 +201,90 @@ def test_azure_model_router_response_shows_actual_model():
)
def test_azure_model_router_stamps_selected_model_on_hidden_params():
"""
The selected model must be stamped on _hidden_params, not left for downstream code to
re-derive by looking for "model-router" in the model string. Deployments whose alias
does not contain that text are invisible to the string check.
"""
from httpx import Response
from litellm.llms.azure_ai.common_utils import (
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY,
AzureFoundryModelInfo,
)
from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj
from litellm.types.utils import ModelResponse
raw_response_json = {
"id": "chatcmpl-test456",
"object": "chat.completion",
"created": 1234567890,
"model": "grok-4-1-fast-reasoning",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "pong"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
mock_response = MagicMock(spec=Response)
mock_response.json.return_value = raw_response_json
mock_response.text = json.dumps(raw_response_json)
mock_response.headers = {}
logging_obj = MagicMock(spec=LiteLLMLoggingObj)
logging_obj.post_call = MagicMock()
logging_obj.model_call_details = {}
result = AzureModelRouterConfig().transform_response(
model="smart-pick",
raw_response=mock_response,
model_response=ModelResponse(),
logging_obj=logging_obj,
request_data={},
messages=[{"role": "user", "content": "Reply with just pong"}],
optional_params={},
litellm_params={"model": "azure_ai/model_router/smart-pick"},
encoding=None,
api_key="test-key",
json_mode=False,
)
assert result._hidden_params[AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY] == result.model
assert (
result._hidden_params[AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY]
== "azure_ai/grok-4-1-fast-reasoning"
)
assert AzureFoundryModelInfo.get_model_router_selected_model(
result._hidden_params
) == ("azure_ai/grok-4-1-fast-reasoning")
assert (
AzureFoundryModelInfo.is_model_router_call(
model="smart-pick", hidden_params=result._hidden_params
)
is True
)
def test_azure_model_router_stamp_does_not_leak_across_responses():
"""
ModelResponse declares _hidden_params as a class-level dict, so the stamp has to be written
as a fresh dict. Mutating in place would bleed the selected model into unrelated responses.
"""
from litellm.llms.azure_ai.common_utils import (
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY,
)
from litellm.types.utils import ModelResponse
untouched = ModelResponse()
assert AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY not in (untouched._hidden_params or {})
def test_drop_tool_level_extra_fields_strips_copilot_mcp_server_name():
"""
Regression test: Azure AI returns 400 when tools contain copilot_mcp_server_name.

View file

@ -341,7 +341,7 @@ def test_messages_thinking_shape_follows_exact_azure_entry_flag(local_model_cost
)
result = transform()
assert result.get("thinking") == {"type": "adaptive"}
assert result.get("thinking") == {"type": "adaptive", "display": "summarized"}
assert result.get("output_config") == {"effort": "medium"}
monkeypatch.setitem(

View file

@ -366,6 +366,96 @@ def test_output_config_effort_forwarded_into_additional_request_fields(model):
assert additional.get("output_config") == {"effort": "high"}
def test_reasoning_effort_requests_summarized_display_converse():
"""Regression LIT-5714: adaptive thinking synthesized from reasoning_effort must
request the summarized display, otherwise the provider returns a blank thinking
block and reasoning_content is always empty."""
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
non_default_params={"reasoning_effort": "high"},
optional_params={},
model="bedrock/converse/us.anthropic.claude-opus-4-7",
drop_params=False,
)
assert optional_params["thinking"]["type"] == "adaptive"
assert optional_params["thinking"]["display"] == "summarized"
def test_thinking_request_adds_output_tokens_details_response_path():
"""Regression LIT-5714: the Converse usage block has no thinking-token field, so
thinking requests must ask for ``/usage/output_tokens_details`` via
``additionalModelResponseFieldPaths``."""
config = AmazonConverseConfig()
result = config._transform_request(
model="bedrock/converse/us.anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
optional_params={
"maxTokens": 256,
"thinking": {"type": "adaptive", "display": "summarized"},
"output_config": {"effort": "high"},
},
litellm_params={},
headers={},
)
assert result["additionalModelResponseFieldPaths"] == ("/usage/output_tokens_details",)
def test_request_without_thinking_omits_response_field_paths():
config = AmazonConverseConfig()
result = config._transform_request(
model="bedrock/converse/us.anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
optional_params={"maxTokens": 256},
litellm_params={},
headers={},
)
assert "additionalModelResponseFieldPaths" not in result
def test_transform_usage_prefers_provider_reasoning_tokens():
"""Regression LIT-5714: provider-reported thinking tokens must win over the
token_counter estimate derived from visible reasoning text."""
config = AmazonConverseConfig()
usage = config.transform_usage(
{"inputTokens": 40, "outputTokens": 3002, "totalTokens": 3042},
reasoning_content="a short reasoning summary",
thinking_ran=True,
provider_reasoning_tokens=1033,
)
assert usage.completion_tokens_details.reasoning_tokens == 1033
assert usage.completion_tokens_details.text_tokens == 3002 - 1033
def test_transform_usage_falls_back_to_estimate_without_provider_tokens():
config = AmazonConverseConfig()
usage = config.transform_usage(
{"inputTokens": 40, "outputTokens": 300, "totalTokens": 340},
reasoning_content="a short reasoning summary",
thinking_ran=True,
)
assert usage.completion_tokens_details.reasoning_tokens > 0
assert usage.completion_tokens_details.reasoning_tokens < 300
def test_thinking_tokens_parsed_from_additional_model_response_fields():
parsed = AmazonConverseConfig.thinking_tokens_from_additional_fields(
{"usage": {"output_tokens_details": {"thinking_tokens": 92}}}
)
assert parsed == 92
assert AmazonConverseConfig.thinking_tokens_from_additional_fields(None) is None
assert AmazonConverseConfig.thinking_tokens_from_additional_fields({"usage": {}}) is None
@pytest.mark.parametrize(
"model,effort,expected_effort",
[

View file

@ -208,6 +208,29 @@ def test_bedrock_converse_streaming_consistent_id():
), "All chunk IDs must match the one captured from the messageStart event"
def test_converse_streaming_usage_uses_provider_thinking_tokens():
"""Regression LIT-5714: the messageStop event carries provider thinking tokens
under ``additionalModelResponseFields``; the usage chunk must report them instead
of a token_counter estimate."""
chunks = [
{
"contentBlockIndex": 0,
"delta": {"reasoningContent": {"text": "thinking about it"}},
},
{
"stopReason": "end_turn",
"additionalModelResponseFields": {"usage": {"output_tokens_details": {"thinking_tokens": 1033}}},
},
{"usage": {"inputTokens": 40, "outputTokens": 3002, "totalTokens": 3042}},
]
decoder = AWSEventStreamDecoder(model="bedrock/anthropic.claude-opus-4-7")
parsed = [decoder.converse_chunk_parser(chunk) for chunk in chunks]
usage = parsed[-1].usage
assert usage.completion_tokens_details.reasoning_tokens == 1033
@pytest.mark.asyncio
async def test_make_call_does_not_rechunk_stream_by_default():
"""Re-chunking the event stream into fixed 1024-byte blocks holds small

View file

@ -1387,7 +1387,7 @@ def test_bedrock_messages_maps_reasoning_effort_for_adaptive_model(
)
assert "reasoning_effort" not in result
assert result.get("thinking") == {"type": "adaptive"}
assert result.get("thinking") == {"type": "adaptive", "display": "summarized"}
assert result.get("output_config") == {"effort": expected_effort}
@ -2935,7 +2935,7 @@ def test_bedrock_messages_thinking_shape_follows_exact_bedrock_entry_flag(
)
result = transform()
assert result.get("thinking") == {"type": "adaptive"}
assert result.get("thinking") == {"type": "adaptive", "display": "summarized"}
assert result.get("output_config") == {"effort": "medium"}
monkeypatch.setitem(litellm.model_cost[model], "supports_adaptive_thinking", False)

View file

@ -78,7 +78,7 @@ def test_bedrock_rerank_header_forwarding_sync(model):
with (
patch.object(client, "post") as mock_post,
patch(
patch( # test-quality-ok: boto credential lookup needs live AWS; the HTTP boundary is already a MockTransport
"litellm.llms.bedrock.rerank.handler.BedrockRerankHandler._get_boto_credentials_from_optional_params",
return_value=mock_credentials_info,
),
@ -171,7 +171,7 @@ async def test_bedrock_rerank_header_forwarding_async(model):
with (
patch.object(client, "post", new_callable=AsyncMock) as mock_post,
patch(
patch( # test-quality-ok: boto credential lookup needs live AWS; the HTTP boundary is already a MockTransport
"litellm.llms.bedrock.rerank.handler.BedrockRerankHandler._get_boto_credentials_from_optional_params",
return_value=mock_credentials_info,
),
@ -242,7 +242,7 @@ def test_bedrock_rerank_timeout_sync():
with (
patch.object(client, "post") as mock_post,
patch(
patch( # test-quality-ok: boto credential lookup needs live AWS; the HTTP boundary is already a MockTransport
"litellm.llms.bedrock.rerank.handler.BedrockRerankHandler._get_boto_credentials_from_optional_params",
return_value=mock_credentials_info,
),
@ -286,7 +286,7 @@ async def test_bedrock_rerank_timeout_async():
with (
patch.object(client, "post", new_callable=AsyncMock) as mock_post,
patch(
patch( # test-quality-ok: boto credential lookup needs live AWS; the HTTP boundary is already a MockTransport
"litellm.llms.bedrock.rerank.handler.BedrockRerankHandler._get_boto_credentials_from_optional_params",
return_value=mock_credentials_info,
),
@ -341,7 +341,7 @@ def test_bedrock_rerank_extra_headers_and_headers_merge():
with (
patch.object(client, "post") as mock_post,
patch(
patch( # test-quality-ok: boto credential lookup needs live AWS; the HTTP boundary is already a MockTransport
"litellm.llms.bedrock.rerank.handler.BedrockRerankHandler._get_boto_credentials_from_optional_params",
return_value=mock_credentials_info,
),
@ -461,3 +461,32 @@ def test_bedrock_rerank_signs_with_sigv4_even_when_bedrock_api_key_is_set(monkey
assert authorization.startswith("AWS4-HMAC-SHA256"), (
f"rerank must sign with SigV4, got Authorization={authorization[:30]}"
)
@pytest.mark.asyncio
async def test_bedrock_rerank_records_llm_api_duration():
"""The bedrock rerank handler must feed httpx timing into the logging obj, so the
proxy can emit x-litellm-overhead-duration-ms / x-litellm-timing-* on /rerank."""
import httpx
def handle(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=bedrock_rerank_response)
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
with patch( # test-quality-ok: boto credential lookup needs live AWS; the HTTP boundary is already a MockTransport
"litellm.llms.bedrock.rerank.handler.BedrockRerankHandler._get_boto_credentials_from_optional_params",
return_value=create_mock_credentials(),
):
response = await litellm.arerank(
model="bedrock/arn:aws:bedrock:us-east-1::foundation-model/cohere.rerank-v3-5:0",
query=test_query,
documents=test_documents,
top_n=3,
client=client,
aws_region_name="us-east-1",
)
assert response._hidden_params["litellm_overhead_time_ms"] is not None
assert response._hidden_params["_response_ms"] >= response._hidden_params["litellm_overhead_time_ms"]

View file

@ -0,0 +1,197 @@
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from botocore.credentials import Credentials
from litellm.llms.bedrock.passthrough.transformation import BedrockPassthroughConfig
from litellm.llms.bedrock_mantle.passthrough.transformation import BedrockMantlePassthroughConfig
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.passthrough.main import llm_passthrough_route
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
MANTLE_API_BASE = "https://bedrock-mantle.us-east-2.api.aws"
INVOKE_ENDPOINT = "model/us.openai.gpt-5.6-sol/invoke"
CONVERSE_ENDPOINT = "model/us.openai.gpt-5.6-sol/converse"
REQUEST_BODY = {"messages": [{"role": "user", "content": "say pong"}], "max_completion_tokens": 64}
@pytest.fixture
def no_ambient_aws(monkeypatch):
for name in (
"AWS_BEARER_TOKEN_BEDROCK",
"BEDROCK_MANTLE_API_KEY",
"BEDROCK_MANTLE_API_BASE",
"BEDROCK_MANTLE_REGION",
"AWS_BEDROCK_RUNTIME_ENDPOINT",
"AWS_REGION_NAME",
"AWS_REGION",
"AWS_DEFAULT_REGION",
):
monkeypatch.delenv(name, raising=False)
def test_bedrock_mantle_registers_its_own_bedrock_passthrough_config():
config = ProviderConfigManager.get_provider_passthrough_config(
model="us.openai.gpt-5.6-sol", provider=LlmProviders.BEDROCK_MANTLE
)
assert isinstance(config, BedrockMantlePassthroughConfig)
assert isinstance(config, BedrockPassthroughConfig)
def test_mantle_api_base_only_lends_its_region_to_the_runtime_url(no_ambient_aws):
url, base_url = BedrockMantlePassthroughConfig().get_complete_url(
api_base=MANTLE_API_BASE,
api_key=None,
model="us.openai.gpt-5.6-sol",
endpoint=INVOKE_ENDPOINT,
request_query_params=None,
litellm_params={"api_base": MANTLE_API_BASE},
)
assert str(url) == f"https://bedrock-runtime.us-east-2.amazonaws.com/{INVOKE_ENDPOINT}"
assert base_url == "https://bedrock-runtime.us-east-2.amazonaws.com"
def test_explicit_region_and_non_mantle_api_base_are_kept(no_ambient_aws):
vpc_endpoint = "https://vpce-0123.bedrock-runtime.us-east-1.vpce.amazonaws.com"
url, base_url = BedrockMantlePassthroughConfig().get_complete_url(
api_base=vpc_endpoint,
api_key=None,
model="us.openai.gpt-5.6-sol",
endpoint=INVOKE_ENDPOINT,
request_query_params=None,
litellm_params={"api_base": vpc_endpoint, "aws_region_name": "us-east-1"},
)
assert str(url) == f"{vpc_endpoint}/{INVOKE_ENDPOINT}"
assert base_url == vpc_endpoint
def test_region_falls_back_to_the_mantle_default_without_any_hint(no_ambient_aws):
url, _ = BedrockMantlePassthroughConfig().get_complete_url(
api_base=None,
api_key=None,
model="us.openai.gpt-5.6-sol",
endpoint=INVOKE_ENDPOINT,
request_query_params=None,
litellm_params={},
)
assert str(url) == f"https://bedrock-runtime.us-east-1.amazonaws.com/{INVOKE_ENDPOINT}"
@pytest.mark.parametrize(
("litellm_params", "env", "expected_bearer"),
[
({"api_key": "deployment-bedrock-api-key"}, {}, "deployment-bedrock-api-key"),
({}, {"BEDROCK_MANTLE_API_KEY": "mantle-env-key"}, "mantle-env-key"),
({}, {"AWS_BEARER_TOKEN_BEDROCK": "aws-env-key"}, "aws-env-key"),
],
)
def test_sign_request_uses_the_deployment_bearer_token(no_ambient_aws, monkeypatch, litellm_params, env, expected_bearer):
for name, value in env.items():
monkeypatch.setenv(name, value)
headers, body = BedrockMantlePassthroughConfig().sign_request(
headers={},
litellm_params=litellm_params,
request_data=REQUEST_BODY,
api_base=f"https://bedrock-runtime.us-east-1.amazonaws.com/{INVOKE_ENDPOINT}",
model="us.openai.gpt-5.6-sol",
)
assert headers["Authorization"] == f"Bearer {expected_bearer}"
assert body is not None
assert json.loads(body) == REQUEST_BODY
def test_sign_request_falls_back_to_sigv4_scoped_to_the_mantle_region(no_ambient_aws):
config = BedrockMantlePassthroughConfig()
with patch.object(config, "get_credentials", return_value=Credentials("AKIA", "secret")):
headers, body = config.sign_request(
headers={},
litellm_params={"api_base": MANTLE_API_BASE},
request_data=REQUEST_BODY,
api_base=f"https://bedrock-runtime.us-east-2.amazonaws.com/{INVOKE_ENDPOINT}",
model="us.openai.gpt-5.6-sol",
)
assert headers["Authorization"].startswith("AWS4-HMAC-SHA256 Credential=AKIA/")
assert "/us-east-2/bedrock/aws4_request" in headers["Authorization"]
assert body is not None
assert json.loads(body) == REQUEST_BODY
@pytest.mark.parametrize(
("route_kwargs", "env", "expected_bearer"),
[
({"api_key": "deployment-bedrock-api-key"}, {}, "deployment-bedrock-api-key"),
({}, {"BEDROCK_MANTLE_API_KEY": "mantle-env-key"}, "mantle-env-key"),
],
)
def test_invoke_passthrough_route_reaches_bedrock_runtime_for_a_mantle_deployment(
no_ambient_aws, monkeypatch, route_kwargs, env, expected_bearer
):
for name, value in env.items():
monkeypatch.setenv(name, value)
client = HTTPHandler()
with (
patch.object(client.client, "send", return_value=MagicMock(status_code=200)),
patch.object(client.client, "build_request", wraps=client.client.build_request) as build_request,
):
response = llm_passthrough_route(
model="bedrock_mantle/us.openai.gpt-5.6-sol",
endpoint=INVOKE_ENDPOINT,
method="POST",
api_base=MANTLE_API_BASE,
json=dict(REQUEST_BODY),
client=client,
litellm_logging_obj=MagicMock(),
**route_kwargs,
)
assert response.status_code == 200
sent = build_request.call_args.kwargs
assert str(sent["url"]) == f"https://bedrock-runtime.us-east-2.amazonaws.com/{INVOKE_ENDPOINT}"
assert sent["headers"]["Authorization"] == f"Bearer {expected_bearer}"
assert json.loads(sent["content"]) == REQUEST_BODY
def _logged_model_response(endpoint, body):
request = httpx.Request("POST", f"https://bedrock-runtime.us-east-1.amazonaws.com/{endpoint}")
return BedrockMantlePassthroughConfig().logging_non_streaming_response(
model="us.openai.gpt-5.6-sol",
custom_llm_provider="bedrock_mantle",
httpx_response=httpx.Response(200, json=body, request=request),
request_data={"messages": [{"role": "user", "content": [{"text": "say pong"}]}]},
logging_obj=MagicMock(),
endpoint=endpoint,
)
def test_converse_logging_parses_the_converse_response_shape():
result = _logged_model_response(
CONVERSE_ENDPOINT,
{
"metrics": {"latencyMs": 800.0},
"output": {"message": {"content": [{"text": "pong"}], "role": "assistant"}},
"stopReason": "end_turn",
"usage": {"inputTokens": 8, "outputTokens": 5, "totalTokens": 13},
},
)
assert result.choices[0].message.content == "pong"
assert result.usage.prompt_tokens == 8
assert result.usage.completion_tokens == 5
def test_invoke_logging_parses_the_openai_chat_response_shape():
result = _logged_model_response(
INVOKE_ENDPOINT,
{
"choices": [{"finish_reason": "stop", "index": 0, "message": {"content": "pong", "role": "assistant"}}],
"created": 1787677792,
"id": "chatcmpl-regression",
"model": "us.openai.gpt-5.6-sol",
"object": "chat.completion",
"usage": {"completion_tokens": 5, "prompt_tokens": 8, "total_tokens": 13},
},
)
assert result.choices[0].message.content == "pong"
assert result.usage.prompt_tokens == 8
assert result.usage.completion_tokens == 5

View file

@ -8,7 +8,9 @@ gate, the URL construction for both paths, and the shared Bearer auth.
"""
import copy
import json
import logging
from pathlib import Path
import pytest
from botocore.exceptions import (
@ -623,6 +625,181 @@ class TestBedrockMantleCodexAdditionalTools:
assert "additional_tools" in str(mock_debug.call_args)
class TestBedrockMantleCodexInputItemNormalization:
"""Mantle 400s ("Invalid 'input': value did not match any expected variant")
on the Codex history item types agent_message, context_compaction, and
local_shell_call (verified against bedrock-mantle.us-east-1.api.aws with
openai.gpt-5.6-sol), so the config must rewrite them into supported
equivalents. agent_message is what every Codex multi-agent v2 session sends,
and its encrypted_content slot carries the verbatim plaintext payload when
the upstream model never issued encrypted args, so that slot must be
preserved, not dropped. Mantle also rejects assistant messages with
input_text content, so the rewrite must use output_text."""
_USER_MESSAGE = {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Continue."}],
}
def _transform(self, input):
cfg = BedrockMantleResponsesAPIConfig()
return cfg.transform_responses_api_request(
model="openai.gpt-5.6-sol",
input=input,
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
def test_plaintext_agent_message_becomes_assistant_output_text_message(self):
body = self._transform(
input=[
self._USER_MESSAGE,
{
"type": "agent_message",
"id": "amsg_1",
"author": "/root/arithmetic",
"recipient": "/root",
"content": [{"type": "input_text", "text": "Message Type: FINAL_ANSWER\nPayload:\n2+2 is 4."}],
},
]
)
assert body["input"] == [
self._USER_MESSAGE,
{
"type": "message",
"role": "assistant",
"content": ({"type": "output_text", "text": "Message Type: FINAL_ANSWER\nPayload:\n2+2 is 4."},),
},
]
def test_agent_message_encrypted_content_payload_is_preserved(self):
body = self._transform(
input=[
{
"type": "agent_message",
"author": "/root",
"recipient": "/root/arithmetic",
"content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nPayload:\n"},
{"type": "encrypted_content", "encrypted_content": "Answer the question 'what is 2+2'."},
],
},
self._USER_MESSAGE,
]
)
assert body["input"][0] == {
"type": "message",
"role": "assistant",
"content": (
{
"type": "output_text",
"text": "Message Type: NEW_TASK\nPayload:\nAnswer the question 'what is 2+2'.",
},
),
}
def test_agent_message_without_any_text_is_dropped(self):
body = self._transform(
input=[
{"type": "agent_message", "author": "/root", "recipient": "/root/a", "content": []},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
def test_context_compaction_becomes_compaction_with_same_ciphertext(self):
body = self._transform(
input=[
{"type": "context_compaction", "id": "cc_1", "encrypted_content": "smry_abc123"},
self._USER_MESSAGE,
]
)
assert body["input"] == [
{"type": "compaction", "encrypted_content": "smry_abc123"},
self._USER_MESSAGE,
]
def test_context_compaction_without_ciphertext_is_dropped(self):
body = self._transform(
input=[
{"type": "context_compaction", "id": "cc_1"},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
def test_local_shell_call_becomes_function_call_keeping_call_id_pairing(self):
body = self._transform(
input=[
{
"type": "local_shell_call",
"id": "lsh_1",
"call_id": "call_1",
"status": "completed",
"action": {"type": "exec", "command": ["echo", "hi"]},
},
{"type": "function_call_output", "call_id": "call_1", "output": "hi\n"},
self._USER_MESSAGE,
]
)
assert body["input"] == [
{
"type": "function_call",
"call_id": "call_1",
"name": "local_shell",
"arguments": '{"type": "exec", "command": ["echo", "hi"]}',
},
{"type": "function_call_output", "call_id": "call_1", "output": "hi\n"},
self._USER_MESSAGE,
]
def test_local_shell_call_without_call_id_is_dropped(self):
body = self._transform(
input=[
{"type": "local_shell_call", "status": "completed", "action": {"type": "exec", "command": ["ls"]}},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
def test_mantle_supported_item_types_pass_through_untouched(self):
supported_items = [
self._USER_MESSAGE,
{"type": "compaction", "encrypted_content": "smry_abc123"},
{"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "call_2"},
{"type": "function_call_output", "call_id": "call_2", "output": "ok"},
{"type": "tool_search_call", "call_id": "call_3", "execution": "server", "arguments": {"query": "x"}},
{"type": "tool_search_output", "call_id": "call_3", "status": "completed", "execution": "server", "tools": []},
{"type": "compaction_trigger"},
]
body = self._transform(input=copy.deepcopy(supported_items))
assert body["input"] == supported_items
def test_string_input_passes_through(self):
body = self._transform(input="Say hi.")
assert body["input"] == "Say hi."
def test_rewrite_is_logged_as_warning_naming_the_types(self, caplog):
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
body = self._transform(
input=[
{"type": "agent_message", "author": "a", "recipient": "b", "content": [{"type": "input_text", "text": "hi"}]},
self._USER_MESSAGE,
]
)
assert body["input"][0]["role"] == "assistant"
rewrite_warnings = [
record.getMessage()
for record in caplog.records
if record.levelno == logging.WARNING and "rewrote Codex input item type" in record.getMessage()
]
assert rewrite_warnings == [
"Bedrock Mantle Responses API: rewrote Codex input item type(s) ['agent_message'] that Mantle rejects."
]
class TestBedrockMantleResponsesRegistry:
def test_registry_returns_config_for_gpt_5_5(self, local_cost_map):
# gpt-5.x advertises /v1/responses in supported_endpoints (capability)
@ -1523,7 +1700,7 @@ class TestBedrockMantleResponsesPricing:
assert info["cache_creation_input_token_cost"] == pytest.approx(cache_creation_cost)
assert info["cache_read_input_token_cost"] == pytest.approx(cache_read_cost)
assert info["output_cost_per_token"] == pytest.approx(output_cost)
assert info["max_input_tokens"] == 1000000
assert info["max_input_tokens"] == 1050000
assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(input_cost * 2)
assert info["cache_creation_input_token_cost_above_272k_tokens"] == pytest.approx(cache_creation_cost * 2)
assert info["cache_read_input_token_cost_above_272k_tokens"] == pytest.approx(cache_read_cost * 2)
@ -1565,3 +1742,42 @@ class TestBedrockMantleResponsesPricing:
def test_models_registered(self, local_cost_map):
assert "bedrock_mantle/openai.gpt-5.5" in litellm.bedrock_mantle_models
assert "bedrock_mantle/openai.gpt-5.4" in litellm.bedrock_mantle_models
def _repo_cost_map(map_name: str) -> dict[str, dict[str, object]]:
repo_root = Path(__file__).resolve().parents[4]
paths = {
"root": repo_root / "model_prices_and_context_window.json",
"bundled_backup": repo_root / "litellm" / "model_prices_and_context_window_backup.json",
}
return json.loads(paths[map_name].read_text())
class TestGpt56MantleRegistryEntries:
"""Locks the gpt-5.6 frontier entries to Bedrock Mantle's live behavior.
Mantle enforces a 1,050,000-token prompt maximum for gpt-5.6 sol/terra/luna
(oversize requests 400 with "prompt tokens (N) exceed model maximum
(1050000)", and a 1,030,590-token request completes), matching the OpenAI
Bedrock guide. mode must stay "responses": Mantle's native
/v1/chat/completions rejects function tools unless reasoning_effort is
"none", so chat traffic has to keep bridging to the Responses API
(see the responses_api_bridge tests above).
"""
@pytest.mark.parametrize("map_name", ("root", "bundled_backup"))
@pytest.mark.parametrize(
"key",
(
"bedrock_mantle/openai.gpt-5.6-sol",
"bedrock_mantle/openai.gpt-5.6-terra",
"bedrock_mantle/openai.gpt-5.6-luna",
),
)
def test_entry_matches_mantle_enforced_limits(self, map_name, key):
entry = _repo_cost_map(map_name)[key]
assert entry["max_input_tokens"] == 1050000
assert entry["max_output_tokens"] == 128000
assert entry["mode"] == "responses"
assert entry["use_openai_responses_path"] is True
assert entry["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses"]

Some files were not shown because too many files have changed in this diff Show more