mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge branch 'litellm_internal_staging' of https://github.com/BerriAI/litellm into litellm_lit5458_rerank_sigv4_bearer_fix
# Conflicts: # tests/test_litellm/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
This commit is contained in:
commit
e47e989341
154 changed files with 10321 additions and 865 deletions
23
.github/pull_request_template.md
vendored
23
.github/pull_request_template.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}$")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
71
litellm/llms/bedrock_mantle/passthrough/transformation.py
Normal file
71
litellm/llms/bedrock_mantle/passthrough/transformation.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ class CerebrasConfig(OpenAIGPTConfig):
|
|||
"tool_choice",
|
||||
"tools",
|
||||
"user",
|
||||
"max_retries",
|
||||
"extra_headers",
|
||||
]
|
||||
|
||||
# Only add reasoning_effort for models that support it
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
3
litellm/llms/together_ai/chat/__init__.py
Normal file
3
litellm/llms/together_ai/chat/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .transformation import TogetherAIChatConfig as TogetherAIChatConfig
|
||||
|
||||
TogetherAIConfig = TogetherAIChatConfig
|
||||
89
litellm/llms/together_ai/chat/transformation.py
Normal file
89
litellm/llms/together_ai/chat/transformation.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
0
litellm/llms/vertex_ai/interactions/__init__.py
Normal file
0
litellm/llms/vertex_ai/interactions/__init__.py
Normal file
149
litellm/llms/vertex_ai/interactions/transformation.py
Normal file
149
litellm/llms/vertex_ai/interactions/transformation.py
Normal 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")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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, ...).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
146
litellm/router_utils/reasoning_effort_capability.py
Normal file
146
litellm/router_utils/reasoning_effort_capability.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
307
tests/proxy_admin_ui_tests/test_team_delete_member_add_race.py
Normal file
307
tests/proxy_admin_ui_tests/test_team_delete_member_add_race.py
Normal 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
|
||||
152
tests/proxy_behavior/management/test_team_member_reset_spend.py
Normal file
152
tests/proxy_behavior/management/test_team_member_reset_spend.py
Normal 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"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
0
tests/test_litellm/llms/cerebras/__init__.py
Normal file
0
tests/test_litellm/llms/cerebras/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue