Merge remote-tracking branch 'origin/litellm_internal_staging' into pragnyan/fix-xiaomi-output-config

This commit is contained in:
pragnyanramtha 2026-05-21 07:16:31 +00:00
commit 80b62872a7
231 changed files with 20858 additions and 3170 deletions

View file

@ -158,6 +158,8 @@ jobs:
CHOCOLATEY_CONFIRM_ALL: "true"
- run:
name: Install Dependencies
environment:
UV_HTTP_TIMEOUT: "300"
command: |
$installer = Join-Path $env:TEMP "uv-install.ps1"
Invoke-WebRequest -Uri https://astral.sh/uv/0.10.9/install.ps1 -OutFile $installer

View file

@ -215,8 +215,10 @@ jobs:
tests/proxy_unit_tests/test_models_fallback_endpoint.py
tests/proxy_unit_tests/test_google_endpoint_routing.py
tests/proxy_unit_tests/test_google_gemini_proxy_request.py
tests/proxy_unit_tests/test_gemini_agents_endpoints.py
tests/proxy_unit_tests/test_get_favicon.py
tests/proxy_unit_tests/test_get_image.py
tests/proxy_unit_tests/test_reducto_ocr_route.py
tests/proxy_unit_tests/test_ui_path_detection.py
tests/proxy_unit_tests/test_prompt_test_endpoint.py
tests/proxy_unit_tests/test_check_batch_cost.py

View file

@ -0,0 +1,34 @@
name: "Unit Tests: Proxy Management-Endpoint Behavior Pinning"
on:
pull_request:
branches:
- main
- litellm_internal_staging
- litellm_oss_branch
- "litellm_**"
permissions:
contents: read
id-token: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
proxy-mgmt-behavior:
uses: ./.github/workflows/_test-unit-services-base.yml
with:
test-path: tests/proxy_behavior
# workers=0 (no xdist): the world seed is a single shared Postgres
# state — two xdist workers both call seed_world() and race on the
# ``behavior-pin-budget`` row, producing UniqueViolation + cascading
# missing-membership FK failures. The whole suite is ~7s sequentially,
# so the cost of disabling parallelism here is negligible.
workers: 0
reruns: 0
enable-postgres: true
artifact-name: proxy-mgmt-behavior
timeout-minutes: 15

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
version = "0.4.72"
version = "0.4.73"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.4.72"
version = "0.4.73"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",

View file

@ -225,6 +225,10 @@ use_chat_completions_url_for_anthropic_messages: bool = bool(
route_all_chat_openai_to_responses: bool = (
os.getenv("LITELLM_ROUTE_ALL_CHAT_OPENAI_TO_RESPONSES", "false").lower() == "true"
) # When True, routes all OpenAI /chat/completions requests through the Responses API bridge
use_legacy_interactions_schema: bool = (
os.getenv("LITELLM_USE_LEGACY_INTERACTIONS_SCHEMA", "false").lower() == "true"
) # When True, sends Api-Revision: 2026-05-07 to Google so responses use the legacy `outputs`
# schema instead of the new `steps` schema. Remove this flag after June 8, 2026.
retry = True
### AUTH ###
api_key: Optional[str] = None
@ -409,6 +413,12 @@ internal_user_budget_duration: Optional[str] = None
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
max_end_user_budget: Optional[float] = None
max_end_user_budget_id: Optional[str] = None
# When True, end-user IDs extracted from requests are validated against
# LiteLLM_EndUserTable / LiteLLM_UserTable. Values that do not resolve to a
# known row are dropped before reaching spend logs. Defaults to False for
# backwards compatibility — arbitrary client-supplied identifiers still
# pass through unchanged.
validate_end_user_id_in_db: bool = False
disable_end_user_cost_tracking: Optional[bool] = None
disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
@ -632,6 +642,7 @@ minimax_models: Set = set()
aws_polly_models: Set = set()
gigachat_models: Set = set()
llamagate_models: Set = set()
reducto_models: Set = set()
bedrock_mantle_models: Set = set()
@ -899,6 +910,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
gigachat_models.add(key)
elif value.get("litellm_provider") == "llamagate":
llamagate_models.add(key)
elif value.get("litellm_provider") == "reducto":
reducto_models.add(key)
elif value.get("litellm_provider") == "bedrock_mantle":
bedrock_mantle_models.add(key)
@ -1010,6 +1023,7 @@ model_list = list(
| ovhcloud_models
| lemonade_models
| docker_model_runner_models
| reducto_models
| bedrock_mantle_models
| set(clarifai_models)
)
@ -1116,6 +1130,7 @@ models_by_provider: dict = {
"aws_polly": aws_polly_models,
"gigachat": gigachat_models,
"llamagate": llamagate_models,
"reducto": reducto_models,
"bedrock_mantle": bedrock_mantle_models,
}
@ -1288,6 +1303,18 @@ from .responses.main import *
# Interactions API is available as litellm.interactions module
# Usage: litellm.interactions.create(), litellm.interactions.get(), etc.
from . import interactions
from .interactions.agents.main import (
acreate as acreate_agent,
create as create_agent,
alist as alist_agents,
list as list_agents,
aget as aget_agent,
get as get_agent,
adelete as adelete_agent,
delete as delete_agent,
alist_versions as alist_agent_versions,
list_versions as list_agent_versions,
)
from .skills.main import (
create_skill,
acreate_skill,

View file

@ -30,6 +30,11 @@ from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.bridges.completion_transformation import (
CompletionTransformationBridge,
)
from litellm.responses.sse_output_recovery import (
parse_sse_json_chunk,
record_output_item_chunk,
record_output_text_chunk,
)
from litellm.types.llms.openai import (
ChatCompletionAnnotation,
ChatCompletionReasoningItem,
@ -97,7 +102,7 @@ def _build_reasoning_item(
def _reasoning_item_to_response_input(
r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]]
r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]],
) -> Dict[str, Any]:
"""Convert a stored ChatCompletionReasoningItem back to a Responses API input item."""
r_input: Dict[str, Any] = {
@ -601,6 +606,79 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
return choices
@classmethod
def _extract_output_from_completed_event(
cls, parsed_chunk: Dict[str, Any]
) -> Optional[List[Dict[str, Any]]]:
response_payload = parsed_chunk.get("response")
if not isinstance(response_payload, dict):
return None
response_output = response_payload.get("output")
if not isinstance(response_output, list) or len(response_output) == 0:
return None
return cast(List[Dict[str, Any]], response_output)
@classmethod
def _recover_output_items_from_raw_sse(
cls, raw_sse: Optional[str]
) -> List[Dict[str, Any]]:
if not raw_sse or not isinstance(raw_sse, str):
return []
recovered_output_items: Dict[int, Dict[str, Any]] = {}
recovered_text_only_items: Dict[int, Dict[str, Any]] = {}
for chunk in raw_sse.splitlines():
parsed_chunk = parse_sse_json_chunk(chunk)
if parsed_chunk is None:
continue
event_type = parsed_chunk.get("type")
if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
recovered_output = cls._extract_output_from_completed_event(
parsed_chunk
)
if recovered_output is not None:
return recovered_output
continue
if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
record_output_item_chunk(
parsed_chunk=parsed_chunk,
output_items=recovered_output_items,
)
continue
if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE:
record_output_text_chunk(
parsed_chunk=parsed_chunk,
output_items=recovered_output_items,
text_only_items=recovered_text_only_items,
)
continue
# Merge text-only items into the recovered output items. Real
# OUTPUT_ITEM_DONE events take precedence at any given output_index,
# but text-only items at indices without a matching OUTPUT_ITEM_DONE
# must still be preserved (e.g. multi-output responses where some
# indices only emitted OUTPUT_TEXT_DONE).
merged_items: Dict[int, Dict[str, Any]] = {**recovered_text_only_items}
merged_items.update(recovered_output_items)
if merged_items:
return [item for _, item in sorted(merged_items.items())]
return []
@classmethod
def _recover_output_items_from_logging(
cls, logging_obj: "LiteLLMLoggingObj"
) -> List[Dict[str, Any]]:
model_call_details = getattr(logging_obj, "model_call_details", {}) or {}
original_response = model_call_details.get("original_response")
return cls._recover_output_items_from_raw_sse(original_response)
def transform_response( # noqa: PLR0915
self,
model: str,
@ -625,9 +703,22 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
if raw_response.error is not None:
raise ValueError(f"Error in response: {raw_response.error}")
output_items = raw_response.output
if len(output_items) == 0:
recovered_output_items = self._recover_output_items_from_logging(
logging_obj
)
if recovered_output_items:
output_items = cast(Any, recovered_output_items)
raw_response.output = cast(Any, recovered_output_items)
verbose_logger.warning(
"Recovered empty Responses API output from raw SSE for model=%s",
model,
)
# Convert response output to choices using the static helper
choices = self._convert_response_output_to_choices(
output_items=raw_response.output,
output_items=output_items,
handle_raw_dict_callback=self._handle_raw_dict_response_item,
)
@ -641,7 +732,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
)
else:
raise ValueError(
f"Unknown items in responses API response: {raw_response.output}"
f"Unknown items in responses API response: {output_items}"
)
setattr(model_response, "choices", choices)
@ -1237,7 +1328,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
raise ValueError(
f"Chat provider: Invalid function argument delta {parsed_chunk}"
)
elif event_type == "response.output_item.done":
elif event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
# New output item added
output_item = parsed_chunk.get("item", {})
if output_item.get("type") == "function_call":

View file

@ -1879,10 +1879,6 @@ def ocr_cost(
if response.usage_info is None:
raise ValueError("OCR response usage_info is None")
pages_processed = response.usage_info.pages_processed
if pages_processed is None:
raise ValueError("OCR response pages_processed is None")
try:
model_info: Optional[ModelInfo] = litellm.get_model_info(
model=model, custom_llm_provider=custom_llm_provider
@ -1890,9 +1886,49 @@ def ocr_cost(
except Exception:
model_info = None
ocr_cost_per_page: float = 0.0
credits = getattr(response.usage_info, "credits", None)
cost_per_credit = None
if model_info is not None:
ocr_cost_per_page = model_info.get("ocr_cost_per_page") or 0.0
cost_per_credit = model_info.get("ocr_cost_per_credit")
if credits is not None and cost_per_credit is not None:
return cost_per_credit * credits, 0.0
ocr_cost_per_page: Optional[float] = None
if model_info is not None:
ocr_cost_per_page = model_info.get("ocr_cost_per_page")
pages_processed = response.usage_info.pages_processed
if pages_processed is None:
if cost_per_credit is not None or ocr_cost_per_page is None:
# Surface missing usage data instead of silently under-reporting
# cost. The previous behavior raised ValueError; we now return 0.0
# for credit-priced or unpriced models, so log a warning to keep
# the regression visible to operators.
verbose_logger.warning(
"OCR cost: model=%s custom_llm_provider=%s response.usage_info."
"pages_processed is None and credits=%s; returning 0.0 cost.",
model,
custom_llm_provider,
credits,
)
return 0.0, 0.0
raise ValueError("OCR response pages_processed is None")
if ocr_cost_per_page is None:
# No per-page pricing configured. Either the model is on credit-based
# pricing (and credits weren't returned, so the credit branch above did
# not match) or the model has no OCR pricing entry at all. Surface a
# warning so that missing pricing entries are visible rather than
# silently producing zero cost for billable usage.
verbose_logger.warning(
"OCR cost: model=%s custom_llm_provider=%s reported "
"pages_processed=%s but no ocr_cost_per_page is configured; "
"returning 0.0 cost.",
model,
custom_llm_provider,
pages_processed,
)
return 0.0, 0.0
total_ocr_processing_cost: float = ocr_cost_per_page * pages_processed
return total_ocr_processing_cost, 0.0

View file

@ -14,22 +14,38 @@ from litellm.integrations.custom_logger import CustomLogger
class CustomBatchLogger(CustomLogger):
preserve_events_added_during_flush = False
# Default cap on the in-memory log queue. Prevents unbounded memory growth
# if ``async_send_batch`` consistently fails (e.g. the destination is
# unreachable) and events are preserved across flush attempts. Subclasses
# may override by passing ``max_queue_size`` or by setting the attribute
# directly (see ``RubrikLogger`` for an example).
DEFAULT_MAX_QUEUE_SIZE = 50_000
def __init__(
self,
flush_lock: Optional[asyncio.Lock] = None,
batch_size: Optional[int] = None,
flush_interval: Optional[int] = None,
max_queue_size: Optional[int] = None,
**kwargs,
) -> None:
"""
Args:
flush_lock (Optional[asyncio.Lock], optional): Lock to use when flushing the queue. Defaults to None. Only used for custom loggers that do batching
max_queue_size (Optional[int], optional): Maximum number of events to retain in ``log_queue``. When the limit is exceeded (e.g. because the send destination is unreachable and events are preserved for retry), the oldest events are dropped. Defaults to ``DEFAULT_MAX_QUEUE_SIZE``.
"""
self.log_queue: List = []
self.flush_interval = flush_interval or litellm.DEFAULT_FLUSH_INTERVAL_SECONDS
self.batch_size: int = batch_size or litellm.DEFAULT_BATCH_SIZE
self.last_flush_time = time.time()
self.flush_lock = flush_lock
self.max_queue_size: int = (
max_queue_size
if max_queue_size is not None
else self.DEFAULT_MAX_QUEUE_SIZE
)
super().__init__(**kwargs)
@ -47,11 +63,40 @@ class CustomBatchLogger(CustomLogger):
async with self.flush_lock:
if self.log_queue:
log_queue_length = len(self.log_queue)
verbose_logger.debug(
"CustomLogger: Flushing batch of %s events", len(self.log_queue)
)
await self.async_send_batch()
self.log_queue.clear()
try:
await self.async_send_batch()
except Exception:
# If the underlying batch send raised, do NOT drop the
# in-flight events. They will be retried on the next flush.
# Most existing async_send_batch implementations swallow
# their own errors, so this only affects loggers that opt
# in to surfacing failures (e.g. Rubrik).
verbose_logger.exception(
"CustomLogger: async_send_batch raised; preserving "
"%s events in queue for retry",
log_queue_length,
)
# Guard against unbounded queue growth if the destination
# is persistently unreachable. Drop the oldest events
# beyond ``max_queue_size``.
overflow = len(self.log_queue) - self.max_queue_size
if overflow > 0:
del self.log_queue[:overflow]
verbose_logger.warning(
"CustomLogger: log queue exceeded max_queue_size=%s; "
"dropped %s oldest events.",
self.max_queue_size,
overflow,
)
return
if self.preserve_events_added_during_flush:
del self.log_queue[:log_queue_length]
else:
self.log_queue.clear()
self.last_flush_time = time.time()
async def async_send_batch(self, *args, **kwargs):

View file

@ -673,6 +673,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
if parent_otel_span is not None:
parent_otel_span.set_status(Status(StatusCode.ERROR))
# Stamp team attributes onto the SERVER (root) span too, so the
# trace root is team-filterable on the failure path like the
# child exception span below.
self._set_team_attributes_on_span(
span=parent_otel_span,
team_id=user_api_key_dict.team_id,
team_alias=user_api_key_dict.team_alias,
)
# Stamp structured error attrs on the SERVER span itself; the
# failure path otherwise only sets its status (_handle_failure
# records on the litellm_request child span). Inline import:
@ -709,6 +718,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
key="exception",
value=str(original_exception),
)
self._set_team_attributes_on_span(
span=exception_logging_span,
team_id=user_api_key_dict.team_id,
team_alias=user_api_key_dict.team_alias,
)
exception_logging_span.set_status(Status(StatusCode.ERROR))
exception_logging_span.end(end_time=self._to_ns(datetime.now()))
@ -1012,6 +1026,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
):
parent_span.end(end_time=self._to_ns(end_time))
# Stamp team attributes onto the SERVER (root) span before it is
# closed, so the trace root carries them like every child span.
self._set_team_attributes_on_proxy_span_from_kwargs(kwargs)
# close the proxy span explicitly from kwargs metadata
# after all child spans (litellm_request, guardrail, raw_request)
# have been fully recorded and exported.
@ -1070,8 +1088,70 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
)
raw_span.set_status(Status(StatusCode.OK))
self.set_raw_request_attributes(raw_span, kwargs, response_obj)
self._set_team_attributes_from_kwargs(raw_span, kwargs)
raw_span.end(end_time=self._to_ns(end_time))
def _set_team_attributes_on_span(
self,
span: Span,
team_id: Optional[str],
team_alias: Optional[str],
) -> None:
"""Stamp team_id / team_alias onto a span so every child span of a
litellm_request trace carries them, not just the root span.
Empty strings are treated as absent: a request made with the master
key or a team-less virtual key carries ``user_api_key_team_id=""``
in ``standard_logging_object.metadata``; propagating that to every
span only adds noise that makes traces look mis-instrumented.
"""
if team_id:
self.safe_set_attribute(
span=span,
key="metadata.user_api_key_team_id",
value=team_id,
)
if team_alias:
self.safe_set_attribute(
span=span,
key="metadata.user_api_key_team_alias",
value=team_alias,
)
def _set_team_attributes_from_kwargs(self, span: Span, kwargs: dict) -> None:
"""Pull team_id / team_alias from the standard logging metadata in kwargs and stamp them onto span."""
std_log = kwargs.get("standard_logging_object")
md: dict = {}
if isinstance(std_log, dict):
md = std_log.get("metadata") or {}
elif std_log is not None:
md = getattr(std_log, "metadata", None) or {}
self._set_team_attributes_on_span(
span=span,
team_id=md.get("user_api_key_team_id"),
team_alias=md.get("user_api_key_team_alias"),
)
def _set_team_attributes_on_proxy_span_from_kwargs(self, kwargs: dict) -> None:
"""Stamp team attributes onto the proxy SERVER (root) span so the
trace root is filterable by team, not just its children. The root
span is created in auth before the team is resolved and is
otherwise only closed (never re-attributed) on the success path.
Guarded to the LiteLLM-created proxy span (by name + recording) so
externally provided parent spans are never mutated.
"""
litellm_params = kwargs.get("litellm_params") or {}
metadata = litellm_params.get("metadata") or {}
proxy_span = metadata.get("litellm_parent_otel_span")
if (
proxy_span is not None
and getattr(proxy_span, "name", None) == LITELLM_PROXY_REQUEST_SPAN_NAME
and hasattr(proxy_span, "is_recording")
and proxy_span.is_recording()
):
self._set_team_attributes_from_kwargs(proxy_span, kwargs)
def _record_metrics(self, kwargs, response_obj, start_time, end_time):
duration_s = (end_time - start_time).total_seconds()
params = kwargs.get("litellm_params") or {}
@ -1531,11 +1611,13 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
"masked_entity_count", safe_dumps(masked_entity_count)
)
self.safe_set_attribute(
span=guardrail_span,
key="guardrail_response",
value=guardrail_information.get("guardrail_response"),
)
guardrail_response = guardrail_information.get("guardrail_response")
if guardrail_response is not None:
guardrail_span.set_attribute(
"guardrail_response", safe_dumps(guardrail_response)
)
self._set_team_attributes_from_kwargs(guardrail_span, kwargs)
guardrail_span.end(end_time=self._to_ns(end_time_datetime))

View file

@ -0,0 +1,605 @@
"""Rubrik LiteLLM Plugin for tool blocking and batch logging."""
import asyncio
import os
import random
import time
import urllib.parse
import uuid
from collections import Counter
from typing import TYPE_CHECKING, Any, Literal, Optional
import httpx
from litellm._logging import verbose_logger
from litellm.integrations.custom_batch_logger import CustomBatchLogger
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
ModifyResponseException,
)
from litellm.litellm_core_utils.core_helpers import safe_deep_copy
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import (
ChatCompletionMessageToolCall,
Function,
GenericGuardrailAPIInputs,
StandardLoggingPayload,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObj,
)
_ENDPOINT_ANTHROPIC_MESSAGES = "/v1/messages"
_WEBHOOK_PATH_TOOL_BLOCKING = "/v1/after_completion/openai/v1"
_WEBHOOK_PATH_LOGGING_BATCH = "/v1/litellm/batch"
_MAX_QUEUE_SIZE = 10_000
_DROP_WARNING_INTERVAL_SECONDS = 60.0
class _MalformedToolBlockingResponseError(Exception):
"""Raised when the tool blocking service returns a structurally invalid
response (e.g. empty ``choices``).
Distinct from transient network/HTTP errors so callers can surface a
louder, misconfiguration-style log instead of treating it as a routine
fail-open.
"""
class RubrikLogger(CustomGuardrail, CustomBatchLogger):
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
**kwargs,
):
self.flush_lock = asyncio.Lock()
kwargs.setdefault("guardrail_name", "rubrik")
# `initialize_guardrail` always passes these kwargs explicitly, with
# value `None` when the user omits `mode` / `default_on` from the
# guardrail config. Coerce None (omitted) to the desired default
# while preserving any explicit value the caller did set --
# in particular `default_on=False` if the user wants the guardrail
# off by default.
kwargs["event_hook"] = kwargs.get("event_hook") or GuardrailEventHooks.post_call
if kwargs.get("default_on") is None:
kwargs["default_on"] = True
super().__init__(
flush_lock=self.flush_lock,
**kwargs,
)
verbose_logger.debug("initializing rubrik logger")
self.sampling_rate = 1.0
rbrk_sampling_rate = os.getenv("RUBRIK_SAMPLING_RATE")
if rbrk_sampling_rate is not None:
try:
parsed_rate = float(rbrk_sampling_rate.strip())
self.sampling_rate = max(0.0, min(1.0, parsed_rate))
if parsed_rate != self.sampling_rate:
verbose_logger.warning(
f"RUBRIK_SAMPLING_RATE={parsed_rate} clamped to "
f"{self.sampling_rate}"
)
except ValueError:
verbose_logger.warning(
f"Invalid RUBRIK_SAMPLING_RATE: {rbrk_sampling_rate!r}, using 1.0"
)
self.key = api_key or os.getenv("RUBRIK_API_KEY")
if not self.key:
verbose_logger.warning(
"Rubrik: No API key configured. Requests will be unauthenticated."
)
_batch_size = os.getenv("RUBRIK_BATCH_SIZE")
if _batch_size:
try:
self.batch_size = int(_batch_size)
except ValueError:
verbose_logger.warning(
f"Invalid RUBRIK_BATCH_SIZE: {_batch_size!r}, using default"
)
# Cap the in-memory retry queue so a Rubrik webhook outage cannot let
# authenticated traffic accumulate prompt/response payloads until the
# proxy runs out of memory. Once the cap is reached, oldest events are
# dropped to make room for fresh ones (drop-oldest backpressure).
self.max_queue_size = _MAX_QUEUE_SIZE
self._dropped_since_warning = 0
self._last_drop_warning_time = 0.0
_webhook_url = api_base or os.getenv("RUBRIK_WEBHOOK_URL")
if _webhook_url is None:
raise ValueError(
"Rubrik webhook URL not configured. "
"Set RUBRIK_WEBHOOK_URL or pass api_base."
)
_webhook_url = _webhook_url.rstrip("/").removesuffix("/v1")
self.tool_blocking_endpoint = f"{_webhook_url}{_WEBHOOK_PATH_TOOL_BLOCKING}"
self.logging_endpoint = f"{_webhook_url}{_WEBHOOK_PATH_LOGGING_BATCH}"
self.async_httpx_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.LoggingCallback
)
self.tool_blocking_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.LoggingCallback,
params={"timeout": httpx.Timeout(5.0, connect=2.0)},
)
self._headers: dict[str, str] = {"Content-Type": "application/json"}
if self.key:
self._headers["Authorization"] = f"Bearer {self.key}"
# Periodic flush is started lazily on the first log event so that
# low-traffic deployments still get their batches drained even when the
# logger is instantiated outside a running event loop (sync init).
self._flush_task: Optional[asyncio.Task[Any]] = (
self._start_periodic_flush_task()
)
def _start_periodic_flush_task(self) -> Optional[asyncio.Task[Any]]:
"""Start the periodic flush task only when an event loop is already running."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
verbose_logger.debug(
"Rubrik logger init: no running event loop, "
"periodic flush will start on first log event."
)
return None
return loop.create_task(self.periodic_flush())
def _ensure_periodic_flush_task(self) -> None:
# Synchronous helper: in asyncio's cooperative model there is no await
# between the check and assignment, so two callers cannot race here.
if self._flush_task is None or self._flush_task.done():
self._flush_task = self._start_periodic_flush_task()
async def aclose(self):
"""Close the dedicated HTTP clients used by this logger."""
# Cancel the periodic flush task before closing the HTTP clients so
# the loop doesn't wake up and try to POST via a closed client.
if self._flush_task is not None and not self._flush_task.done():
self._flush_task.cancel()
try:
await self._flush_task
except (asyncio.CancelledError, Exception):
pass
self._flush_task = None
await self.tool_blocking_client.close()
await self.async_httpx_client.close()
# -- Guardrail hook --------------------------------------------------------
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> GenericGuardrailAPIInputs:
"""Validate tool calls against the blocking service (fail-open)."""
if input_type != "response":
return inputs
tool_calls = inputs.get("tool_calls")
if not tool_calls:
return inputs
try:
return await self._check_tool_calls(
inputs, tool_calls, request_data, logging_obj
)
except ModifyResponseException:
raise
except _MalformedToolBlockingResponseError as e:
# Distinct from transient errors: the service responded but the
# payload was structurally invalid, which usually indicates a
# misconfigured webhook or a breaking change in its response
# format. Log loudly so operators notice their tool-blocking
# policy is not actually being enforced.
verbose_logger.critical(
"Tool blocking service returned a malformed response: %s. "
"Tool calls are NOT being checked -- verify the webhook "
"configuration. Returning original response unchanged.",
e,
exc_info=True,
)
return inputs
except Exception as e:
verbose_logger.error(
f"Tool blocking hook failed: {e}. "
"Returning original response unchanged.",
exc_info=True,
)
return inputs
async def _check_tool_calls(
self,
inputs: GenericGuardrailAPIInputs,
tool_calls: Any,
request_data: dict,
logging_obj: Optional["LiteLLMLoggingObj"],
) -> GenericGuardrailAPIInputs:
"""Send tool calls to blocking service, raise if any are blocked."""
message_tool_calls = self._normalize_tool_calls(tool_calls)
call_details = (
getattr(logging_obj, "model_call_details", {}) if logging_obj else {}
)
response = request_data.get("response")
request_id = getattr(response, "id", None) if response else None
if logging_obj and not call_details:
verbose_logger.warning(
"Rubrik: logging_obj present but model_call_details is empty "
"-- request context will be missing"
)
response_data = self._build_tool_call_payload(message_tool_calls, request_id)
req_data = self._extract_request_data(call_details)
service_response = await self._post_to_tool_blocking_service(
response_data, req_data
)
blocked_explanation = self._extract_blocked_tools(
service_response, message_tool_calls
)
if blocked_explanation is not None:
model = self._resolve_model(request_data, call_details)
raise ModifyResponseException(
message=blocked_explanation,
model=model,
request_data=request_data,
guardrail_name=self.guardrail_name,
)
return inputs
@staticmethod
def _normalize_tool_calls(tool_calls: Any) -> list[ChatCompletionMessageToolCall]:
"""Convert tool_calls from inputs to ChatCompletionMessageToolCall objects."""
result = []
for tc in tool_calls:
if isinstance(tc, ChatCompletionMessageToolCall):
result.append(tc)
elif isinstance(tc, dict):
func = tc.get("function", {})
result.append(
ChatCompletionMessageToolCall(
id=tc.get("id", ""),
type=tc.get("type", "function"),
function=Function(
name=func.get("name", ""),
arguments=func.get("arguments", ""),
),
)
)
elif hasattr(tc, "id") and hasattr(tc, "function"):
result.append(
ChatCompletionMessageToolCall(
id=tc.id or "",
type=getattr(tc, "type", None) or "function",
function=tc.function,
)
)
else:
raise TypeError(
f"Cannot normalize tool_call of type {type(tc).__name__}"
)
return result
@staticmethod
def _build_tool_call_payload(
tool_calls: list[ChatCompletionMessageToolCall],
request_id: str | None,
) -> dict[str, Any]:
"""Build a full OpenAI ChatCompletion-format dict for the blocking service."""
return {
"id": request_id or f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
"created": int(time.time()),
"model": "",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
tc.model_dump(exclude_none=True) for tc in tool_calls
],
},
"finish_reason": "tool_calls",
}
],
}
@staticmethod
def _extract_request_data(call_details: dict[str, Any]) -> dict[str, Any]:
"""Extract original request data from model_call_details."""
if not call_details:
return {}
litellm_params = call_details.get("litellm_params", {}) or {}
return {
"messages": call_details.get("messages"),
"model": call_details.get("model"),
"proxy_server_request": RubrikLogger._sanitize_proxy_server_request(
litellm_params.get("proxy_server_request")
),
}
@staticmethod
def _sanitize_proxy_server_request(proxy_server_request: Any) -> Any:
"""Allowlist only routing fields (``url``, ``method``) when forwarding
``proxy_server_request`` to the external Rubrik webhook, dropping
inbound ``headers`` (Authorization, Cookie, x-api-key, ...) and the raw
request ``body`` so proxy credentials are not exfiltrated."""
if not isinstance(proxy_server_request, dict):
return proxy_server_request
return {
key: proxy_server_request[key]
for key in ("url", "method")
if key in proxy_server_request
}
@staticmethod
def _resolve_model(
request_data: dict[str, Any], call_details: dict[str, Any]
) -> str:
"""Get the model name for the ModifyResponseException."""
response = request_data.get("response")
if response and hasattr(response, "model"):
return response.model or "unknown"
return call_details.get("model", "unknown")
# -- Logging hooks ---------------------------------------------------------
async def _prepare_log_payload(
self, kwargs: dict, event_type: str
) -> StandardLoggingPayload | None:
"""Shared logic for success and failure logging."""
if random.random() > self.sampling_rate:
verbose_logger.debug(
f"Skipping Rubrik {event_type} logging "
f"(sampling_rate={self.sampling_rate})"
)
return None
# Deep-copy so mutations don't affect other callbacks sharing this object
standard_logging_payload: StandardLoggingPayload = safe_deep_copy(
kwargs["standard_logging_object"]
)
# For Anthropic /v1/messages requests, LiteLLM creates a separate
# ModelResponse (with a generated chatcmpl-* id) for logging, which
# differs from the original Anthropic msg-* id on the response dict.
# Normalize to litellm_call_id so that the logging and tool-blocking
# endpoints see the same request identifier.
litellm_params = kwargs.get("litellm_params", {}) or {}
proxy_request = litellm_params.get("proxy_server_request", {}) or {}
url_path = urllib.parse.urlparse(proxy_request.get("url", "")).path
if url_path.endswith(_ENDPOINT_ANTHROPIC_MESSAGES):
_litellm_call_id = kwargs.get("litellm_call_id")
if _litellm_call_id:
standard_logging_payload["id"] = _litellm_call_id # type: ignore[literal-required]
if "system" in kwargs:
system_prompt_msg_list = kwargs["system"]
try:
if system_prompt_msg_list:
system_scaffold = {
"role": "system",
"content": system_prompt_msg_list,
}
if isinstance(standard_logging_payload["messages"], list):
standard_logging_payload["messages"].insert(0, system_scaffold)
elif isinstance(standard_logging_payload["messages"], (dict, str)):
standard_logging_payload["messages"] = [
system_scaffold,
standard_logging_payload["messages"],
]
except Exception as e:
verbose_logger.warning(
f"Rubrik: failed to prepend system prompt: {e}",
exc_info=True,
)
return standard_logging_payload
async def _enqueue_log_event(self, kwargs: dict, event_type: str):
try:
self._ensure_periodic_flush_task()
payload = await self._prepare_log_payload(kwargs, event_type)
if payload is None:
return
self.log_queue.append(payload)
self._enforce_max_queue_size()
if len(self.log_queue) >= self.batch_size:
await self.flush_queue()
except Exception as e:
verbose_logger.error(
f"Rubrik {event_type} logging hook failed: {e}. "
"Skipping logging for this event.",
exc_info=True,
)
def _enforce_max_queue_size(self) -> None:
overflow = len(self.log_queue) - self.max_queue_size
if overflow <= 0:
return
del self.log_queue[:overflow]
self._dropped_since_warning += overflow
now = time.time()
if now - self._last_drop_warning_time >= _DROP_WARNING_INTERVAL_SECONDS:
verbose_logger.warning(
"Rubrik: log queue exceeded max_queue_size=%s; dropped %s "
"oldest events since the last warning. The Rubrik webhook may "
"be unhealthy or undersized for current traffic.",
self.max_queue_size,
self._dropped_since_warning,
)
self._dropped_since_warning = 0
self._last_drop_warning_time = now
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
await self._enqueue_log_event(kwargs, "success")
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
await self._enqueue_log_event(kwargs, "failure")
# -- Batch logging ---------------------------------------------------------
async def _log_batch_to_rubrik(self, data):
# NOTE: this method intentionally re-raises on failure so the parent
# CustomBatchLogger.flush_queue keeps the unsent events in the queue
# for the next flush attempt instead of silently dropping them.
try:
response = await self.async_httpx_client.post(
url=self.logging_endpoint,
json=data,
headers=self._headers,
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
verbose_logger.exception(
f"Rubrik HTTP Error: {e.response.status_code} - {e.response.text}"
)
raise
except Exception:
verbose_logger.exception("Rubrik Layer Error")
raise
async def async_send_batch(self):
"""Handles sending batches of responses to Rubrik.
Note: the canonical flush path is :meth:`flush_queue`, which takes a
single snapshot used for both sending and queue draining. This method
is kept for direct callers / tests; it intentionally does NOT remove
events from the queue.
"""
if not self.log_queue:
return
log_queue_snapshot = list(self.log_queue)
verbose_logger.debug(
"Rubrik: Flushing batch of %s events", len(log_queue_snapshot)
)
await self._log_batch_to_rubrik(
data=log_queue_snapshot,
)
async def flush_queue(self):
"""Snapshot, send, and drain in one consistent step.
Overrides the base implementation so the same snapshot drives both
the HTTP send and the queue truncation. This avoids the subtle
coupling where the base class captures `len(self.log_queue)`
separately from the snapshot taken inside `async_send_batch`,
which could otherwise drift in a future refactor and cause
duplicate deliveries to Rubrik.
"""
if self.flush_lock is None:
return
async with self.flush_lock:
if not self.log_queue:
return
snapshot = list(self.log_queue)
verbose_logger.debug("Rubrik: Flushing batch of %s events", len(snapshot))
try:
await self._log_batch_to_rubrik(data=snapshot)
except Exception:
# Already logged with traceback inside _log_batch_to_rubrik.
# Preserve the in-flight events for retry on the next flush.
return
del self.log_queue[: len(snapshot)]
self.last_flush_time = time.time()
# -- Tool blocking service -------------------------------------------------
async def _post_to_tool_blocking_service(
self,
response_data: dict[str, Any],
request_data: dict[str, Any],
) -> dict[str, Any]:
"""Post a payload to the tool blocking service and return the response.
Args:
response_data: The OpenAI-formatted response payload to send.
request_data: Original LLM request data to include alongside
the response for additional context. Empty dict if unavailable.
Raises:
Exception: If the service is unavailable or returns an error.
"""
envelope = {
"request": request_data,
"response": response_data,
}
verbose_logger.debug(
f"Sending request to tool blocking service: "
f"{self.tool_blocking_endpoint}"
)
http_response = await self.tool_blocking_client.post(
self.tool_blocking_endpoint,
json=envelope,
headers=self._headers,
)
http_response.raise_for_status()
result: dict[str, Any] = http_response.json()
return result
@staticmethod
def _extract_blocked_tools(
service_response: dict[str, Any],
all_tool_calls: list[ChatCompletionMessageToolCall],
) -> Optional[str]:
"""Return the blocking explanation if any tool calls were blocked.
Compares the service response (which contains only allowed tools) against
the full set of tool calls. Returns ``None`` if all tools are allowed, or
the explanation string (prefixed with newlines) otherwise.
Expects service_response in OpenAI chat completion format:
{"choices": [{"message": {"tool_calls": [...], "content": "..."}}]}
"""
choices = service_response.get("choices", [])
if not choices:
raise _MalformedToolBlockingResponseError(
"Tool blocking service returned empty response"
)
message = choices[0].get("message", {})
returned_tool_calls = message.get("tool_calls") or []
blocking_explanation = message.get("content", "")
allowed_id_counts: Counter = Counter(
tc["id"]
for tc in returned_tool_calls
if isinstance(tc, dict) and tc.get("id")
)
required_id_counts: Counter = Counter(tc.id for tc in all_tool_calls if tc.id)
all_allowed = len(returned_tool_calls) >= len(all_tool_calls) and all(
allowed_id_counts.get(tc_id, 0) >= count
for tc_id, count in required_id_counts.items()
)
if all_allowed:
return None
explanation = blocking_explanation or "Tool call blocked by policy."
return f"\n\n{explanation}"

View file

@ -5,31 +5,40 @@ This module provides SDK methods for Google's Interactions API.
Usage:
import litellm
# Create an interaction with a model
response = litellm.interactions.create(
model="gemini-2.5-flash",
input="Hello, how are you?"
)
# Create an interaction with an agent
response = litellm.interactions.create(
agent="deep-research-pro-preview-12-2025",
input="Research the current state of cancer research"
)
# Async version
response = await litellm.interactions.acreate(...)
# Get an interaction
response = litellm.interactions.get(interaction_id="...")
# Delete an interaction
result = litellm.interactions.delete(interaction_id="...")
# Cancel an interaction
result = litellm.interactions.cancel(interaction_id="...")
# Create a managed agent on the provider side
result = litellm.interactions.agents.create(
name="waverunner",
custom_llm_provider="gemini",
api_key="...",
base_agent="gemini-2.5-flash",
instructions="You are a helpful assistant.",
)
Methods:
- create(): Sync create interaction
- acreate(): Async create interaction
@ -39,8 +48,12 @@ Methods:
- adelete(): Async delete interaction
- cancel(): Sync cancel interaction
- acancel(): Async cancel interaction
Sub-modules:
- agents: Provider-side agent creation (litellm.interactions.agents.create)
"""
from litellm.interactions import agents
from litellm.interactions.main import (
acancel,
acreate,
@ -65,4 +78,6 @@ __all__ = [
# Cancel
"cancel",
"acancel",
# Sub-modules
"agents",
]

View file

@ -0,0 +1,39 @@
"""
litellm.interactions.agents
Full CRUD SDK for provider-side managed agents (e.g. Gemini v1beta/agents).
litellm.interactions.agents.create(name=..., ...)
litellm.interactions.agents.list(api_key=...)
litellm.interactions.agents.get(name=..., ...)
litellm.interactions.agents.delete(name=..., ...)
litellm.interactions.agents.list_versions(name=..., ...)
Async counterparts: acreate, alist, aget, adelete, alist_versions
"""
from litellm.interactions.agents.main import (
acreate,
adelete,
aget,
alist,
alist_versions,
create,
delete,
get,
list,
list_versions,
)
__all__ = [
"create",
"acreate",
"list",
"alist",
"get",
"aget",
"delete",
"adelete",
"list_versions",
"alist_versions",
]

View file

@ -0,0 +1,478 @@
"""
HTTP handler for the Agents API.
Extends InteractionsHTTPHandler so that the shared HTTP infrastructure
(_handle_error, _sync_client, _async_client) is reused rather than
duplicated. BaseAgentsAPIConfig stays as pure transform code.
"""
from typing import Any, Coroutine, Dict, Optional, Union
import httpx
from litellm.constants import request_timeout
from litellm.interactions.http_handler import InteractionsHTTPHandler
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.types.agents import (
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
AgentVersionsResponse,
)
from litellm.types.router import GenericLiteLLMParams
class AgentsHTTPHandler(InteractionsHTTPHandler):
"""HTTP handler for Agents API CRUD requests."""
# ------------------------------------------------------------------ #
# CREATE #
# ------------------------------------------------------------------ #
def create_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[HTTPHandler] = None,
_is_async: bool = False,
) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
if _is_async:
return self.async_create_agent(
agents_api_config=agents_api_config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
)
sync_httpx_client = self._sync_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url = agents_api_config.get_complete_url(
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
data = agents_api_config.transform_create_request(
name=name, litellm_params=dict(litellm_params)
)
if extra_body:
data.update(extra_body)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={
"complete_input_dict": data,
"api_base": url,
"headers": headers,
},
)
try:
response = sync_httpx_client.post(
url=url, headers=headers, json=data, timeout=timeout or request_timeout
)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(
original_response=response.text,
additional_args={"complete_input_dict": data},
)
return agents_api_config.transform_create_response(
raw_response=response, name=name
)
async def async_create_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> AgentCreateResponse:
async_httpx_client = self._async_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url = agents_api_config.get_complete_url(
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
data = agents_api_config.transform_create_request(
name=name, litellm_params=dict(litellm_params)
)
if extra_body:
data.update(extra_body)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={
"complete_input_dict": data,
"api_base": url,
"headers": headers,
},
)
try:
response = await async_httpx_client.post(
url=url, headers=headers, json=data, timeout=timeout or request_timeout
)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(
original_response=response.text,
additional_args={"complete_input_dict": data},
)
return agents_api_config.transform_create_response(
raw_response=response, name=name
)
# ------------------------------------------------------------------ #
# LIST #
# ------------------------------------------------------------------ #
def list_agents(
self,
agents_api_config: BaseAgentsAPIConfig,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[HTTPHandler] = None,
_is_async: bool = False,
) -> Union[AgentListResponse, Coroutine[Any, Any, AgentListResponse]]:
if _is_async:
return self.async_list_agents(
agents_api_config=agents_api_config,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
)
sync_httpx_client = self._sync_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url, params = agents_api_config.transform_list_request(
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input="list_agents",
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = sync_httpx_client.get(url=url, headers=headers, params=params)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_list_response(raw_response=response)
async def async_list_agents(
self,
agents_api_config: BaseAgentsAPIConfig,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> AgentListResponse:
async_httpx_client = self._async_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url, params = agents_api_config.transform_list_request(
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input="list_agents",
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = await async_httpx_client.get(
url=url, headers=headers, params=params
)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_list_response(raw_response=response)
# ------------------------------------------------------------------ #
# GET #
# ------------------------------------------------------------------ #
def get_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[HTTPHandler] = None,
_is_async: bool = False,
) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
if _is_async:
return self.async_get_agent(
agents_api_config=agents_api_config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
)
sync_httpx_client = self._sync_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url, params = agents_api_config.transform_get_request(
name=name,
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = sync_httpx_client.get(url=url, headers=headers, params=params)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_get_response(
raw_response=response, name=name
)
async def async_get_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> AgentCreateResponse:
async_httpx_client = self._async_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url, params = agents_api_config.transform_get_request(
name=name,
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = await async_httpx_client.get(
url=url, headers=headers, params=params
)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_get_response(
raw_response=response, name=name
)
# ------------------------------------------------------------------ #
# DELETE #
# ------------------------------------------------------------------ #
def delete_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[HTTPHandler] = None,
_is_async: bool = False,
) -> Union[AgentDeleteResult, Coroutine[Any, Any, AgentDeleteResult]]:
if _is_async:
return self.async_delete_agent(
agents_api_config=agents_api_config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
)
sync_httpx_client = self._sync_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url = agents_api_config.transform_delete_request(
name=name,
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = sync_httpx_client.delete(
url=url, headers=headers, timeout=timeout or request_timeout
)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_delete_response(
raw_response=response, name=name
)
async def async_delete_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> AgentDeleteResult:
async_httpx_client = self._async_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url = agents_api_config.transform_delete_request(
name=name,
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = await async_httpx_client.delete(
url=url, headers=headers, timeout=timeout or request_timeout
)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_delete_response(
raw_response=response, name=name
)
# ------------------------------------------------------------------ #
# LIST VERSIONS #
# ------------------------------------------------------------------ #
def list_agent_versions(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[HTTPHandler] = None,
_is_async: bool = False,
) -> Union[AgentVersionsResponse, Coroutine[Any, Any, AgentVersionsResponse]]:
if _is_async:
return self.async_list_agent_versions(
agents_api_config=agents_api_config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
)
sync_httpx_client = self._sync_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url, params = agents_api_config.transform_list_versions_request(
name=name,
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = sync_httpx_client.get(url=url, headers=headers, params=params)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_list_versions_response(
raw_response=response, name=name
)
async def async_list_agent_versions(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> AgentVersionsResponse:
async_httpx_client = self._async_client(litellm_params, client)
headers = agents_api_config.validate_environment(
headers=extra_headers or {}, litellm_params=dict(litellm_params)
)
url, params = agents_api_config.transform_list_versions_request(
name=name,
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
logging_obj.pre_call(
input=name,
api_key="",
additional_args={"api_base": url, "headers": headers},
)
try:
response = await async_httpx_client.get(
url=url, headers=headers, params=params
)
except Exception as e:
raise self._handle_error(e=e, provider_config=agents_api_config)
logging_obj.post_call(original_response=response.text, additional_args={})
return agents_api_config.transform_list_versions_response(
raw_response=response, name=name
)
agents_http_handler = AgentsHTTPHandler()

View file

@ -0,0 +1,523 @@
"""
LiteLLM Agents API - Main Module
Usage:
import litellm
# Create
response = litellm.interactions.agents.create(
name="waverunner",
custom_llm_provider="gemini",
api_key="...",
base_agent="gemini-2.5-flash",
instructions="You are a helpful assistant.",
)
# List
response = litellm.interactions.agents.list(api_key="...", custom_llm_provider="gemini")
# Get
response = litellm.interactions.agents.get(name="waverunner", api_key="...")
# Delete
result = litellm.interactions.agents.delete(name="waverunner", api_key="...")
# List versions
result = litellm.interactions.agents.list_versions(name="waverunner", api_key="...")
# Async versions: acreate, alist, aget, adelete, alist_versions
"""
import asyncio
import contextvars
from functools import partial
from typing import Any, Coroutine, Dict, Optional, Union
import httpx
import litellm
from litellm.interactions.agents.http_handler import agents_http_handler
from litellm.interactions.agents.utils import get_provider_agents_api_config
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.agents import (
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
AgentVersionsResponse,
)
from litellm.types.interactions import InteractionEnvironment
from litellm.types.router import GenericLiteLLMParams
from litellm.utils import client
# ------------------------------------------------------------------ #
# Shared helpers #
# ------------------------------------------------------------------ #
def _get_agents_api_config(custom_llm_provider: str):
config = get_provider_agents_api_config(custom_llm_provider)
if config is None:
raise litellm.BadRequestError(
message=(
f"Provider '{custom_llm_provider}' does not have a native "
"agents API. Use the proxy POST /v1/agents endpoint to store "
"agents locally."
),
model="",
llm_provider=custom_llm_provider,
)
return config
def _make_logging_obj(
kwargs: Dict[str, Any],
model: str,
custom_llm_provider: str,
call_type: str,
optional_params: Dict[str, Any],
) -> LiteLLMLoggingObj:
litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None)
litellm_logging_obj.update_from_kwargs(
kwargs=kwargs,
model=model,
optional_params=optional_params,
litellm_params={"litellm_call_id": litellm_call_id},
custom_llm_provider=custom_llm_provider,
)
return litellm_logging_obj
# ================================================================== #
# CREATE #
# ================================================================== #
@client
async def acreate(
name: str,
base_agent: Optional[str] = None,
instructions: Optional[str] = None,
base_environment: Optional[InteractionEnvironment] = None,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> AgentCreateResponse:
"""Async: Create a managed agent on the provider side."""
local_vars = locals()
try:
loop = asyncio.get_event_loop()
kwargs["acreate_agent"] = True
func = partial(
create,
name=name,
base_agent=base_agent,
instructions=instructions,
base_environment=base_environment,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
**kwargs,
)
ctx = contextvars.copy_context()
init_response = await loop.run_in_executor(None, partial(ctx.run, func))
if asyncio.iscoroutine(init_response):
return await init_response
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
@client
def create(
name: str,
base_agent: Optional[str] = None,
instructions: Optional[str] = None,
base_environment: Optional[InteractionEnvironment] = None,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
"""
Sync: Create a managed agent on the provider side.
Args:
name: Name for the agent (required).
base_agent: Base agent to derive from (e.g. "waverunner").
instructions: System instructions for the agent.
base_environment: Environment to fork from — an env_id string or a
dict like ``{"type": "remote", "sources": [...]}``.
custom_llm_provider: Provider to use, e.g. "gemini".
extra_headers: Additional HTTP headers.
extra_body: Additional request body fields.
timeout: Request timeout.
**kwargs: Forwarded to GenericLiteLLMParams (api_key, api_base, etc.).
"""
local_vars = locals()
custom_llm_provider = (
custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
)
try:
_is_async = kwargs.pop("acreate_agent", False) is True
if base_agent is not None:
kwargs["base_agent"] = base_agent
if instructions is not None:
kwargs["instructions"] = instructions
if base_environment is not None:
kwargs["base_environment"] = base_environment
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params = GenericLiteLLMParams(**kwargs)
logging_obj = _make_logging_obj(
kwargs, name, custom_llm_provider, "create_agent", {}
)
config = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.create_agent(
agents_api_config=config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
_is_async=_is_async,
)
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
# ================================================================== #
# LIST #
# ================================================================== #
@client
async def alist(
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> AgentListResponse:
"""Async: List all agents on the provider side."""
local_vars = locals()
try:
loop = asyncio.get_event_loop()
kwargs["alist_agents"] = True
func = partial(
list,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
timeout=timeout,
**kwargs,
)
ctx = contextvars.copy_context()
init_response = await loop.run_in_executor(None, partial(ctx.run, func))
if asyncio.iscoroutine(init_response):
return await init_response
return init_response
except Exception as e:
raise litellm.exception_type(
model="",
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
@client
def list(
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> Union[AgentListResponse, Coroutine[Any, Any, AgentListResponse]]:
"""Sync: List all agents on the provider side."""
local_vars = locals()
custom_llm_provider = (
custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
)
try:
_is_async = kwargs.pop("alist_agents", False) is True
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params = GenericLiteLLMParams(**kwargs)
logging_obj = _make_logging_obj(
kwargs, "", custom_llm_provider, "list_agents", {}
)
config = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.list_agents(
agents_api_config=config,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
_is_async=_is_async,
)
except Exception as e:
raise litellm.exception_type(
model="",
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
# ================================================================== #
# GET #
# ================================================================== #
@client
async def aget(
name: str,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> AgentCreateResponse:
"""Async: Get a specific agent by name."""
local_vars = locals()
try:
loop = asyncio.get_event_loop()
kwargs["aget_agent"] = True
func = partial(
get,
name=name,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
timeout=timeout,
**kwargs,
)
ctx = contextvars.copy_context()
init_response = await loop.run_in_executor(None, partial(ctx.run, func))
if asyncio.iscoroutine(init_response):
return await init_response
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
@client
def get(
name: str,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
"""Sync: Get a specific agent by name."""
local_vars = locals()
custom_llm_provider = (
custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
)
try:
_is_async = kwargs.pop("aget_agent", False) is True
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params = GenericLiteLLMParams(**kwargs)
logging_obj = _make_logging_obj(
kwargs, name, custom_llm_provider, "get_agent", {"name": name}
)
config = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.get_agent(
agents_api_config=config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
_is_async=_is_async,
)
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
# ================================================================== #
# DELETE #
# ================================================================== #
@client
async def adelete(
name: str,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> AgentDeleteResult:
"""Async: Delete a specific agent by name."""
local_vars = locals()
try:
loop = asyncio.get_event_loop()
kwargs["adelete_agent"] = True
func = partial(
delete,
name=name,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
timeout=timeout,
**kwargs,
)
ctx = contextvars.copy_context()
init_response = await loop.run_in_executor(None, partial(ctx.run, func))
if asyncio.iscoroutine(init_response):
return await init_response
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
@client
def delete(
name: str,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> Union[AgentDeleteResult, Coroutine[Any, Any, AgentDeleteResult]]:
"""Sync: Delete a specific agent by name."""
local_vars = locals()
custom_llm_provider = (
custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
)
try:
_is_async = kwargs.pop("adelete_agent", False) is True
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params = GenericLiteLLMParams(**kwargs)
logging_obj = _make_logging_obj(
kwargs, name, custom_llm_provider, "delete_agent", {"name": name}
)
config = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.delete_agent(
agents_api_config=config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
_is_async=_is_async,
)
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
# ================================================================== #
# LIST VERSIONS #
# ================================================================== #
@client
async def alist_versions(
name: str,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> AgentVersionsResponse:
"""Async: List versions of a specific agent."""
local_vars = locals()
try:
loop = asyncio.get_event_loop()
kwargs["alist_agent_versions"] = True
func = partial(
list_versions,
name=name,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
timeout=timeout,
**kwargs,
)
ctx = contextvars.copy_context()
init_response = await loop.run_in_executor(None, partial(ctx.run, func))
if asyncio.iscoroutine(init_response):
return await init_response
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
@client
def list_versions(
name: str,
custom_llm_provider: Optional[str] = None,
extra_headers: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
**kwargs,
) -> Union[AgentVersionsResponse, Coroutine[Any, Any, AgentVersionsResponse]]:
"""Sync: List versions of a specific agent."""
local_vars = locals()
custom_llm_provider = (
custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
)
try:
_is_async = kwargs.pop("alist_agent_versions", False) is True
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params = GenericLiteLLMParams(**kwargs)
logging_obj = _make_logging_obj(
kwargs, name, custom_llm_provider, "list_agent_versions", {"name": name}
)
config = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.list_agent_versions(
agents_api_config=config,
name=name,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
timeout=timeout,
_is_async=_is_async,
)
except Exception as e:
raise litellm.exception_type(
model=name,
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)

View file

@ -0,0 +1,23 @@
"""
Utility functions for the Agents API SDK.
"""
from typing import Optional
from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
def get_provider_agents_api_config(
custom_llm_provider: Optional[str],
) -> Optional[BaseAgentsAPIConfig]:
"""
Return a provider-specific BaseAgentsAPIConfig if the provider has a
native agent-creation API, or None otherwise.
"""
from litellm.types.utils import LlmProviders
if custom_llm_provider == LlmProviders.GEMINI.value:
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
return GeminiAgentsConfig()
return None

View file

@ -41,27 +41,55 @@ from litellm.types.interactions import (
from litellm.types.router import GenericLiteLLMParams
class InteractionsHTTPHandler:
class _BaseHTTPHandler:
"""
Shared HTTP infrastructure for LiteLLM handler classes.
Provides common client resolution and error-mapping helpers so that
handler subclasses (InteractionsHTTPHandler, AgentsHTTPHandler, …) do
not duplicate this boilerplate.
"""
def _handle_error(self, e: Exception, provider_config: Any) -> Exception:
if isinstance(e, httpx.HTTPStatusError):
return provider_config.get_error_class(
error_message=e.response.text,
status_code=e.response.status_code,
headers=dict(e.response.headers),
)
return e
def _sync_client(
self,
litellm_params: GenericLiteLLMParams,
client: Optional[HTTPHandler],
) -> HTTPHandler:
return client or _get_httpx_client(
params={"ssl_verify": litellm_params.get("ssl_verify", None)}
)
def _async_client(
self,
litellm_params: GenericLiteLLMParams,
client: Optional[AsyncHTTPHandler],
) -> AsyncHTTPHandler:
# GenericLiteLLMParams.get uses getattr; an unset field is None, not the default.
custom_llm_provider = litellm_params.get("custom_llm_provider") or "gemini"
return client or get_async_httpx_client(
llm_provider=litellm.LlmProviders(custom_llm_provider),
params={"ssl_verify": litellm_params.get("ssl_verify", None)},
)
class InteractionsHTTPHandler(_BaseHTTPHandler):
"""
HTTP handler for Interactions API requests.
"""
def _handle_error(
self,
e: Exception,
provider_config: BaseInteractionsAPIConfig,
) -> Exception:
"""Handle errors from HTTP requests."""
if isinstance(e, httpx.HTTPStatusError):
error_message = e.response.text
status_code = e.response.status_code
headers = dict(e.response.headers)
return provider_config.get_error_class(
error_message=error_message,
status_code=status_code,
headers=headers,
)
return e
# _handle_error is inherited from _BaseHTTPHandler (accepts Any provider_config).
# AgentsHTTPHandler also extends this class and passes BaseAgentsAPIConfig, which
# is structurally compatible but a different type — keeping the override here with
# BaseInteractionsAPIConfig would cause type errors in the subclass.
# =========================================================
# CREATE INTERACTION

View file

@ -2,7 +2,17 @@
Streaming iterator for transforming Responses API stream to Interactions API stream.
"""
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, cast
from collections import deque
from typing import (
Any,
AsyncIterator,
Deque,
Dict,
Iterator,
List,
Optional,
cast,
)
from litellm.responses.streaming_iterator import (
BaseResponsesAPIStreamingIterator,
@ -15,7 +25,6 @@ from litellm.types.interactions import (
InteractionsAPIStreamingResponse,
)
from litellm.types.llms.openai import (
ContentPartAddedEvent,
OutputTextDeltaEvent,
ResponseCompletedEvent,
ResponseCreatedEvent,
@ -30,7 +39,13 @@ class LiteLLMResponsesInteractionsStreamingIterator:
This class handles both sync and async iteration, transforming Responses API
streaming events (output.text.delta, response.completed, etc.) to Interactions
API streaming events (content.delta, interaction.complete, etc.).
API streaming events.
Schema selection:
- New schema (default, use_legacy_interactions_schema=False):
interaction.created -> step.start -> step.delta ... -> step.stop -> interaction.completed
- Legacy schema (use_legacy_interactions_schema=True, remove after June 8 2026):
interaction.start -> content.start -> content.delta ... -> content.stop -> interaction.complete
"""
def __init__(
@ -42,6 +57,8 @@ class LiteLLMResponsesInteractionsStreamingIterator:
custom_llm_provider: Optional[str] = None,
litellm_metadata: Optional[Dict[str, Any]] = None,
):
import litellm
self.model = model
self.responses_stream_iterator = litellm_custom_stream_wrapper
self.request_input = request_input
@ -52,100 +69,156 @@ class LiteLLMResponsesInteractionsStreamingIterator:
self.collected_text = ""
self.sent_interaction_start = False
self.sent_content_start = False
self._pending_events: List[InteractionsAPIStreamingResponse] = []
# Capture the schema flag once at construction time so all events
# emitted by this stream use a consistent schema, even if the global
# flag is mutated mid-stream (e.g. by a config reload).
self._use_legacy: bool = litellm.use_legacy_interactions_schema
# Buffer of events that have been derived from upstream chunks but not
# yet returned to the caller. A single Responses API chunk may expand
# into multiple Interactions API events (e.g. the first text delta
# produces interaction.created + step.start + step.delta), and the
# terminal sequence on stream end may also span multiple events
# (step.stop + interaction.completed).
self._pending_events: Deque[InteractionsAPIStreamingResponse] = deque()
# Tracks whether we've already emitted a terminal completion event so
# the StopIteration fallback path doesn't double-emit.
self._sent_completion_event = False
# ID resolved from the first upstream chunk (item_id on a text delta or
# response.id on response.created). Persisted so the EOF terminal
# events stay correlated with the start events delivered earlier.
self._interaction_id: Optional[str] = None
def _transform_responses_chunk_to_interactions_chunk(
self,
responses_chunk: ResponsesAPIStreamingResponse,
) -> Optional[InteractionsAPIStreamingResponse]:
# ------------------------------------------------------------------
# Event builders
# ------------------------------------------------------------------
def _build_interaction_start_event(
self, interaction_id: str
) -> InteractionsAPIStreamingResponse:
event_type = "interaction.start" if self._use_legacy else "interaction.created"
return InteractionsAPIStreamingResponse(
event_type=event_type,
id=interaction_id,
object="interaction",
status="in_progress",
model=self.model,
)
def _build_content_start_event(
self, interaction_id: str
) -> InteractionsAPIStreamingResponse:
if self._use_legacy:
return InteractionsAPIStreamingResponse(
event_type="content.start",
id=interaction_id,
object="content",
delta={"type": "text", "text": ""},
)
return InteractionsAPIStreamingResponse(
event_type="step.start",
index=0,
step={"type": "model_output", "content": []},
)
def _build_text_delta_event(
self, interaction_id: str, delta_text: str
) -> InteractionsAPIStreamingResponse:
if self._use_legacy:
return InteractionsAPIStreamingResponse(
event_type="content.delta",
id=interaction_id,
object="content",
delta={"type": "text", "text": delta_text},
)
return InteractionsAPIStreamingResponse(
event_type="step.delta",
index=0,
delta={"type": "text", "text": delta_text},
)
def _build_content_stop_event(
self, interaction_id: Optional[str]
) -> InteractionsAPIStreamingResponse:
if self._use_legacy:
return InteractionsAPIStreamingResponse(
event_type="content.stop",
id=interaction_id,
object="content",
delta={"type": "text", "text": self.collected_text},
)
return InteractionsAPIStreamingResponse(
event_type="step.stop",
index=0,
)
def _build_completion_event(
self, response_id: str
) -> InteractionsAPIStreamingResponse:
if self._use_legacy:
return InteractionsAPIStreamingResponse(
event_type="interaction.complete",
id=response_id,
object="interaction",
status="completed",
model=self.model,
outputs=[{"type": "text", "text": self.collected_text}],
)
return InteractionsAPIStreamingResponse(
event_type="interaction.completed",
id=response_id,
object="interaction",
status="completed",
model=self.model,
steps=[
{
"type": "model_output",
"content": [{"type": "text", "text": self.collected_text}],
}
],
)
# ------------------------------------------------------------------
# Per-chunk transform (returns a list of events to enqueue)
# ------------------------------------------------------------------
def _events_for_chunk(
self, responses_chunk: ResponsesAPIStreamingResponse
) -> List[InteractionsAPIStreamingResponse]:
"""
Transform a Responses API streaming chunk to an Interactions API streaming chunk.
Translate a single upstream Responses API chunk into the list of
Interactions API events it should produce.
Responses API events:
- output.text.delta -> content.delta
- response.completed -> interaction.complete
Interactions API events:
- interaction.start
- content.start
- content.delta
- content.stop
- interaction.complete
Returning a list (rather than a single event) lets a chunk emit any
synthetic start events that haven't been sent yet *together with* the
actual delta event, so we never silently drop the chunk's payload.
"""
if not responses_chunk:
return None
return []
# Handle OutputTextDeltaEvent -> content.delta
# Text delta: emit any missing start events, then the delta itself.
if isinstance(responses_chunk, OutputTextDeltaEvent):
delta_text = (
responses_chunk.delta if isinstance(responses_chunk.delta, str) else ""
)
self.collected_text += delta_text
# Fallback: emit interaction.start, and queue content.start carrying this
# delta so the first token is preserved in the stream.
if not self.sent_interaction_start:
self.sent_interaction_start = True
self.sent_content_start = True
self._pending_events.append(
InteractionsAPIStreamingResponse(
event_type="content.start",
id=getattr(responses_chunk, "item_id", None),
object="content",
delta={"type": "text", "text": delta_text},
)
)
return InteractionsAPIStreamingResponse(
event_type="interaction.start",
id=getattr(responses_chunk, "item_id", None)
or f"interaction_{id(self)}",
object="interaction",
status="in_progress",
model=self.model,
)
# Fallback: emit content.start if ContentPartAddedEvent never arrived
if not self.sent_content_start:
self.sent_content_start = True
return InteractionsAPIStreamingResponse(
event_type="content.start",
id=getattr(responses_chunk, "item_id", None),
object="content",
delta={"type": "text", "text": delta_text},
)
# Normal path: emit content.delta with type field
return InteractionsAPIStreamingResponse(
event_type="content.delta",
id=getattr(responses_chunk, "item_id", None),
object="content",
delta={"type": "text", "text": delta_text},
interaction_id = (
getattr(responses_chunk, "item_id", None) or f"interaction_{id(self)}"
)
if self._interaction_id is None:
self._interaction_id = interaction_id
# Handle ContentPartAddedEvent -> content.start (arrives before text deltas)
if isinstance(responses_chunk, ContentPartAddedEvent):
# Fallback: emit interaction.start if ResponseCreatedEvent never arrived
events: List[InteractionsAPIStreamingResponse] = []
if not self.sent_interaction_start:
self.sent_interaction_start = True
return InteractionsAPIStreamingResponse(
event_type="interaction.start",
id=getattr(responses_chunk, "item_id", None)
or f"interaction_{id(self)}",
object="interaction",
status="in_progress",
model=self.model,
)
events.append(self._build_interaction_start_event(interaction_id))
if not self.sent_content_start:
self.sent_content_start = True
return InteractionsAPIStreamingResponse(
event_type="content.start",
id=getattr(responses_chunk, "item_id", None),
object="content",
delta={"type": "text", "text": ""},
)
return None
events.append(self._build_content_start_event(interaction_id))
events.append(self._build_text_delta_event(interaction_id, delta_text))
return events
# Handle ResponseCreatedEvent or ResponseInProgressEvent -> interaction.start
# Response created / in-progress: synthesize interaction start if we
# haven't already sent one.
if isinstance(responses_chunk, (ResponseCreatedEvent, ResponseInProgressEvent)):
if not self.sent_interaction_start:
self.sent_interaction_start = True
@ -153,177 +226,136 @@ class LiteLLMResponsesInteractionsStreamingIterator:
getattr(responses_chunk.response, "id", None)
if hasattr(responses_chunk, "response")
else None
)
return InteractionsAPIStreamingResponse(
event_type="interaction.start",
id=response_id or f"interaction_{id(self)}",
object="interaction",
status="in_progress",
model=self.model,
)
) or f"interaction_{id(self)}"
if self._interaction_id is None:
self._interaction_id = response_id
return [self._build_interaction_start_event(response_id)]
return []
# Handle ResponseCompletedEvent -> interaction.complete
# Response completed: emit step.stop (if content was started) followed
# by the terminal completion event. Prefer the interaction id already
# established by earlier events so consumers can correlate the start
# and completion events by id (response.id may differ from the item_id
# used to derive the initial id when the stream starts directly with a
# text delta).
if isinstance(responses_chunk, ResponseCompletedEvent):
self.finished = True
response = responses_chunk.response
# Send content.stop first if content was started
if self.sent_content_start:
# Note: We'll send this in the iterator, not here
pass
# Send interaction.complete
return InteractionsAPIStreamingResponse(
event_type="interaction.complete",
id=getattr(response, "id", None) or f"interaction_{id(self)}",
object="interaction",
status="completed",
model=self.model,
outputs=[
{
"type": "text",
"text": self.collected_text,
}
],
response_id = (
self._interaction_id
or getattr(response, "id", None)
or f"interaction_{id(self)}"
)
# For other event types, return None (skip)
return None
terminal: List[InteractionsAPIStreamingResponse] = []
if self.sent_content_start:
terminal.append(self._build_content_stop_event(response_id))
terminal.append(self._build_completion_event(response_id))
self._sent_completion_event = True
return terminal
return []
def _build_terminal_events_on_eof(
self,
) -> List[InteractionsAPIStreamingResponse]:
"""
Build the events to flush when the upstream stream ends without a
ResponseCompletedEvent. Ensures consumers always observe a terminal
interaction.completed/interaction.complete carrying the full text.
"""
if self._sent_completion_event:
return []
fallback_id = self._interaction_id or f"interaction_{id(self)}"
terminal: List[InteractionsAPIStreamingResponse] = []
if self.sent_content_start:
terminal.append(self._build_content_stop_event(fallback_id))
if self.sent_interaction_start or self.collected_text:
terminal.append(self._build_completion_event(fallback_id))
self._sent_completion_event = True
return terminal
# ------------------------------------------------------------------
# Iteration
# ------------------------------------------------------------------
def __iter__(self) -> Iterator[InteractionsAPIStreamingResponse]:
"""Sync iterator implementation."""
return self
def __next__(self) -> InteractionsAPIStreamingResponse:
"""Get next chunk in sync mode."""
if self._pending_events:
return self._pending_events.popleft()
if self.finished:
raise StopIteration
# Check if we have a pending interaction.complete to send
if hasattr(self, "_pending_interaction_complete"):
pending: InteractionsAPIStreamingResponse = getattr(
self, "_pending_interaction_complete"
)
delattr(self, "_pending_interaction_complete")
return pending
# Drain events queued from a prior chunk (e.g. content.start emitted alongside
# the interaction.start fallback for the first OutputTextDeltaEvent).
if self._pending_events:
return self._pending_events.pop(0)
# Use a loop instead of recursion to avoid stack overflow
sync_iterator = cast(
SyncResponsesAPIStreamingIterator, self.responses_stream_iterator
)
while True:
try:
# Get next chunk from responses API stream
chunk = next(sync_iterator)
# Transform chunk (chunk is already a ResponsesAPIStreamingResponse)
transformed = self._transform_responses_chunk_to_interactions_chunk(
chunk
)
if transformed:
# If we finished and content was started, send content.stop before interaction.complete
if (
self.finished
and self.sent_content_start
and transformed.event_type == "interaction.complete"
):
# Send content.stop first
content_stop = InteractionsAPIStreamingResponse(
event_type="content.stop",
id=transformed.id,
object="content",
delta={"type": "text", "text": self.collected_text},
)
# Store the interaction.complete to send next
self._pending_interaction_complete = transformed
return content_stop
return transformed
# If no transformation, continue to next chunk (loop continues)
except StopIteration:
self.finished = True
self._pending_events.extend(self._build_terminal_events_on_eof())
if self._pending_events:
return self._pending_events.popleft()
raise
# Send final events if needed
if self.sent_content_start:
return InteractionsAPIStreamingResponse(
event_type="content.stop",
object="content",
delta={"type": "text", "text": self.collected_text},
)
raise StopIteration
events = self._events_for_chunk(chunk)
if events:
self._pending_events.extend(events)
return self._pending_events.popleft()
def __aiter__(self) -> AsyncIterator[InteractionsAPIStreamingResponse]:
"""Async iterator implementation."""
return self
async def __anext__(self) -> InteractionsAPIStreamingResponse:
"""Get next chunk in async mode."""
if self._pending_events:
return self._pending_events.popleft()
if self.finished:
raise StopAsyncIteration
# Check if we have a pending interaction.complete to send
if hasattr(self, "_pending_interaction_complete"):
pending: InteractionsAPIStreamingResponse = getattr(
self, "_pending_interaction_complete"
)
delattr(self, "_pending_interaction_complete")
return pending
# Drain events queued from a prior chunk (e.g. content.start emitted alongside
# the interaction.start fallback for the first OutputTextDeltaEvent).
if self._pending_events:
return self._pending_events.pop(0)
# Use a loop instead of recursion to avoid stack overflow
async_iterator = cast(
ResponsesAPIStreamingIterator, self.responses_stream_iterator
)
while True:
try:
# Get next chunk from responses API stream
chunk = await async_iterator.__anext__()
# Transform chunk (chunk is already a ResponsesAPIStreamingResponse)
transformed = self._transform_responses_chunk_to_interactions_chunk(
chunk
)
if transformed:
# If we finished and content was started, send content.stop before interaction.complete
if (
self.finished
and self.sent_content_start
and transformed.event_type == "interaction.complete"
):
# Send content.stop first
content_stop = InteractionsAPIStreamingResponse(
event_type="content.stop",
id=transformed.id,
object="content",
delta={"type": "text", "text": self.collected_text},
)
# Store the interaction.complete to send next
self._pending_interaction_complete = transformed
return content_stop
return transformed
# If no transformation, continue to next chunk (loop continues)
except StopAsyncIteration:
self.finished = True
self._pending_events.extend(self._build_terminal_events_on_eof())
if self._pending_events:
return self._pending_events.popleft()
raise
# Send final events if needed
if self.sent_content_start:
return InteractionsAPIStreamingResponse(
event_type="content.stop",
object="content",
delta={"type": "text", "text": self.collected_text},
)
events = self._events_for_chunk(chunk)
if events:
self._pending_events.extend(events)
return self._pending_events.popleft()
raise StopAsyncIteration
# ------------------------------------------------------------------
# Backwards-compatible single-chunk transform (used by tests and any
# external callers that drove the iterator chunk-by-chunk pre-fix).
# ------------------------------------------------------------------
def _transform_responses_chunk_to_interactions_chunk(
self,
responses_chunk: ResponsesAPIStreamingResponse,
) -> Optional[InteractionsAPIStreamingResponse]:
"""
Compatibility shim: returns the *first* event produced for this chunk
and queues any remaining events on ``self._pending_events`` so they
are surfaced on subsequent calls/iterations.
Prefer ``_events_for_chunk`` in new code.
"""
events = self._events_for_chunk(responses_chunk)
if not events:
return None
first = events[0]
if len(events) > 1:
self._pending_events.extend(events[1:])
return first

View file

@ -226,29 +226,37 @@ class LiteLLMResponsesInteractionsConfig:
- Map status
- Extract usage
"""
# Extract text from outputs
outputs = []
# Extract text from outputs and build both `outputs` (legacy) and `steps` (new schema).
outputs: List[Dict[str, Any]] = []
steps: List[Dict[str, Any]] = []
if hasattr(responses_response, "output") and responses_response.output:
for output_item in responses_response.output:
# Use getattr with None default to safely access content
content = getattr(output_item, "content", None)
if content is not None:
content_items = content if isinstance(content, list) else [content]
model_output_contents: List[Dict[str, Any]] = []
for content_item in content_items:
# Check if content_item has text attribute
text = getattr(content_item, "text", None)
if text is not None:
outputs.append(
{
"type": "text",
"text": text,
}
)
# Use independent dict instances so mutations to one
# of `outputs` / `steps` don't leak into the other.
outputs.append({"type": "text", "text": text})
model_output_contents.append({"type": "text", "text": text})
elif (
isinstance(content_item, dict)
and content_item.get("type") == "text"
):
outputs.append(content_item)
outputs.append({**content_item})
model_output_contents.append({**content_item})
if model_output_contents:
steps.append(
{
"type": "model_output",
"content": model_output_contents,
}
)
# Convert created_at to ISO string
created_at = getattr(responses_response, "created_at", None)
@ -270,12 +278,14 @@ class LiteLLMResponsesInteractionsConfig:
else:
interactions_status = status
# Build interactions response
# Build interactions response — populate both `outputs` (legacy schema) and
# `steps` (new schema) so callers work regardless of which schema they expect.
interactions_response_dict: Dict[str, Any] = {
"id": getattr(responses_response, "id", ""),
"object": "interaction",
"status": interactions_status,
"outputs": outputs,
"steps": steps,
"model": model or getattr(responses_response, "model", ""),
"created": created,
}

View file

@ -48,6 +48,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.types.interactions import (
CancelInteractionResult,
DeleteInteractionResult,
InteractionEnvironment,
InteractionInput,
InteractionsAPIResponse,
InteractionsAPIStreamingResponse,
@ -80,6 +81,8 @@ async def acreate(
store: Optional[bool] = None,
# Background execution
background: Optional[bool] = None,
# Agent execution environment ("remote", env id, or remote config object)
environment: Optional[InteractionEnvironment] = None,
# Response format
response_modalities: Optional[List[str]] = None,
response_format: Optional[Dict[str, Any]] = None,
@ -109,6 +112,10 @@ async def acreate(
stream: Whether to stream the response
store: Whether to store the response for later retrieval
background: Whether to run in background
environment: Agent execution environment — ``"remote"``, an existing env id
string, or a config object such as
``{"type": "remote", "sources": [...]}`` /
``{"type": "remote", "network": {...}}``
response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO)
response_format: JSON schema for response format
response_mime_type: MIME type of the response
@ -144,6 +151,7 @@ async def acreate(
stream=stream,
store=store,
background=background,
environment=environment,
response_modalities=response_modalities,
response_format=response_format,
response_mime_type=response_mime_type,
@ -194,6 +202,8 @@ def create(
store: Optional[bool] = None,
# Background execution
background: Optional[bool] = None,
# Agent execution environment ("remote", env id, or remote config object)
environment: Optional[InteractionEnvironment] = None,
# Response format
response_modalities: Optional[List[str]] = None,
response_format: Optional[Dict[str, Any]] = None,
@ -231,6 +241,10 @@ def create(
stream: Whether to stream the response
store: Whether to store the response for later retrieval
background: Whether to run in background
environment: Agent execution environment — ``"remote"``, an existing env id
string, or a config object such as
``{"type": "remote", "sources": [...]}`` /
``{"type": "remote", "network": {...}}``
response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO)
response_format: JSON schema for response format
response_mime_type: MIME type of the response
@ -252,7 +266,14 @@ def create(
litellm_params = GenericLiteLLMParams(**kwargs)
if model:
# Routing logic:
# - agent provided (no model, or model accidentally set to agent name) → gemini
# - model provided → resolve provider via get_llm_provider (normal routing)
if agent and model == agent:
model = None
if agent and not model:
custom_llm_provider = custom_llm_provider or "gemini"
elif model:
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,

View file

@ -101,10 +101,14 @@ class BaseInteractionsAPIStreamingIterator:
)
)
# Store the completed response (check for status=completed)
if (
streaming_response
and getattr(streaming_response, "status", None) == "completed"
# Store the completed response.
# Legacy schema signals completion via status="completed".
# New schema (Api-Revision: 2026-05-20) uses event_type="interaction.completed".
# Remove the legacy check after June 8, 2026.
if streaming_response and (
getattr(streaming_response, "status", None) == "completed"
or getattr(streaming_response, "event_type", None)
== "interaction.completed"
):
self.completed_response = streaming_response
self._handle_logging_completed_response()

View file

@ -15,6 +15,7 @@ INTERACTIONS_API_OPTIONAL_PARAMS = {
"stream",
"store",
"background",
"environment",
"response_modalities",
"response_format",
"response_mime_type",

View file

@ -1233,6 +1233,7 @@ def infer_protocol_value(
def _gemini_tool_call_invoke_helper(
function_call_params: ChatCompletionToolCallFunctionChunk,
tool_call_id: Optional[str] = None,
) -> Optional[VertexFunctionCall]:
name = function_call_params.get("name", "") or ""
arguments = function_call_params.get("arguments", "")
@ -1248,6 +1249,10 @@ def _gemini_tool_call_invoke_helper(
name=name,
args=arguments_dict,
)
if tool_call_id:
clean_id = tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
if clean_id:
function_call["id"] = clean_id
return function_call
@ -1339,6 +1344,7 @@ def _get_dummy_thought_signature() -> str:
def convert_to_gemini_tool_call_invoke(
message: ChatCompletionAssistantMessage,
model: Optional[str] = None,
custom_llm_provider: Optional[str] = None,
) -> List[VertexPartType]:
"""
OpenAI tool invokes:
@ -1384,12 +1390,26 @@ def convert_to_gemini_tool_call_invoke(
tool_calls = message.get("tool_calls", None)
function_call = message.get("function_call", None)
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
forward_tool_call_id = bool(
model
and VertexGeminiConfig._forward_gemini_function_call_id(
model, custom_llm_provider
)
)
if tool_calls is not None:
for idx, tool in enumerate(tool_calls):
if "function" in tool:
gemini_function_call: Optional[VertexFunctionCall] = (
_gemini_tool_call_invoke_helper(
function_call_params=tool["function"]
function_call_params=tool["function"],
tool_call_id=(
tool.get("id") if forward_tool_call_id else None
),
)
)
if gemini_function_call is not None:
@ -1429,10 +1449,6 @@ def convert_to_gemini_tool_call_invoke(
thought_signature = provider_fields.get("thought_signature")
# If no signature found and model is gemini-3, use dummy signature
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
if (
not thought_signature
and model
@ -1462,6 +1478,8 @@ def convert_to_gemini_tool_call_invoke(
def convert_to_gemini_tool_call_result( # noqa: PLR0915
message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage],
last_message_with_tool_calls: Optional[dict],
model: Optional[str] = None,
custom_llm_provider: Optional[str] = None,
) -> Union[VertexPartType, List[VertexPartType]]:
"""
OpenAI message with a tool result looks like:
@ -1602,6 +1620,23 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
):
name = tool.get("function", {}).get("name", "")
# Echo the OpenAI tool_call_id on functionResponse (strip thought-signature suffix).
# Only Google AI Studio Gemini 3+ accepts `id` on function_response parts.
# Vertex AI and older Gemini models reject the field with HTTP 400.
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
gemini_call_id: Optional[str] = None
if model and VertexGeminiConfig._forward_gemini_function_call_id(
model, custom_llm_provider
):
raw_tool_call_id = message.get("tool_call_id")
if raw_tool_call_id and isinstance(raw_tool_call_id, str):
stripped_id = raw_tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
if stripped_id:
gemini_call_id = stripped_id
if not name:
raise Exception(
"Missing corresponding tool call for tool response message. Received - message={}, last_message_with_tool_calls={}".format(
@ -1632,6 +1667,8 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
name=name,
response=response_data, # type: ignore
)
if gemini_call_id:
_function_response["id"] = gemini_call_id
# Create part with function_response, and optionally inline_data for images (Computer Use)
_part: VertexPartType = {"function_response": _function_response}

View file

@ -1506,9 +1506,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["metadata"] = {"user_id": value}
elif param == "thinking":
optional_params["thinking"] = value
elif param == "reasoning_effort" and isinstance(value, str):
elif param == "reasoning_effort":
# Accept both string ("low") and dict ({"effort": "low",
# "summary": "concise"}). The Responses->Chat parser keeps the
# full dict when `summary` is set (see #25359), so a dict here
# is the standard shape Otto/OpenAI-Responses-Bridge callers
# send. Coerce to the effort string before mapping — same
# shape-tolerance the GPT-5 path already implements in
# `_normalize_reasoning_effort_for_chat_completion`.
effort_value = value
if isinstance(effort_value, dict):
effort_value = effort_value.get("effort")
if not isinstance(effort_value, str):
continue
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=value,
reasoning_effort=effort_value,
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
@ -1519,12 +1531,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["thinking"] = mapped_thinking
if AnthropicConfig._is_adaptive_thinking_model(model):
mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(
value
effort_value
)
if mapped_effort is None:
AnthropicConfig._raise_invalid_reasoning_effort(
model=model,
value=value,
value=effort_value,
llm_provider=self.custom_llm_provider or "anthropic",
)
optional_params["output_config"] = {"effort": mapped_effort}

View file

@ -1,9 +1,16 @@
from typing import Optional
from urllib.parse import parse_qs, urlparse, urlunparse
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.openai.containers.transformation import OpenAIContainerConfig
from litellm.types.router import GenericLiteLLMParams
# Endpoint-specific path suffixes that may appear in a deployment's api_base
# (e.g. the responses endpoint URL is stored as api_base for Azure models).
# Strip these before building the containers URL so we always start from the
# resource root (https://resource.cognitiveservices.azure.com).
_AZURE_ENDPOINT_PATHS = ("/openai/responses",)
class AzureContainerConfig(OpenAIContainerConfig):
"""
@ -27,6 +34,27 @@ class AzureContainerConfig(OpenAIContainerConfig):
litellm_params=GenericLiteLLMParams(api_key=api_key),
)
@staticmethod
def _normalize_api_base(api_base: Optional[str]) -> Optional[str]:
"""Strip endpoint-specific path suffixes from api_base to get the resource root."""
if not api_base:
return api_base
parsed = urlparse(api_base)
path = parsed.path.rstrip("/")
for ep in _AZURE_ENDPOINT_PATHS:
if path.endswith(ep):
return urlunparse(
(parsed.scheme, parsed.netloc, path[: -len(ep)], "", "", "")
)
return api_base
@staticmethod
def _extract_api_version(api_base: Optional[str]) -> Optional[str]:
"""Return the api-version query param from api_base if present."""
if not api_base:
return None
return parse_qs(urlparse(api_base).query).get("api-version", [None])[0]
def get_complete_url(
self,
api_base: Optional[str],
@ -39,10 +67,19 @@ class AzureContainerConfig(OpenAIContainerConfig):
{endpoint}/openai/v1/containers
when api_version is 'v1', 'latest', or 'preview'; otherwise:
{endpoint}/openai/containers
The deployment's api_base may be the responses endpoint URL
(e.g. .../openai/responses?api-version=2025-04-01-preview). We
prefer the api-version embedded there over the deployment's
api_version field, which may point to an older chat API version.
"""
effective_params = dict(litellm_params)
api_version_from_base = self._extract_api_version(api_base)
if api_version_from_base:
effective_params["api_version"] = api_version_from_base
return BaseAzureLLM._get_base_azure_url(
api_base=api_base,
litellm_params=litellm_params,
api_base=self._normalize_api_base(api_base),
litellm_params=effective_params,
route="/openai/containers",
default_api_version="v1",
)

View file

View file

@ -0,0 +1,165 @@
"""
Base transformation class for provider-side Agents API.
Providers that have a native agents CRUD API (e.g. Gemini v1beta/agents)
subclass BaseAgentsAPIConfig and implement the abstract methods.
The HTTP calls are handled by AgentsHTTPHandler — this class is pure
transform logic (same separation as BaseInteractionsAPIConfig /
InteractionsHTTPHandler).
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Tuple, Union
import httpx
from litellm.types.agents import (
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
AgentVersionsResponse,
)
class BaseAgentsAPIConfig(ABC):
"""
Minimal interface for providers that expose a native agents CRUD API.
"""
# ------------------------------------------------------------------ #
# CREATE #
# ------------------------------------------------------------------ #
@abstractmethod
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> str:
"""Return the full URL for POST /agents (create)."""
@abstractmethod
def validate_environment(
self,
headers: Dict[str, str],
litellm_params: Dict[str, Any],
) -> Dict[str, str]:
"""Validate credentials and return auth headers."""
@abstractmethod
def transform_create_request(
self,
name: str,
litellm_params: Dict[str, Any],
) -> Dict[str, Any]:
"""Map name + litellm_params to the provider's create-agent body."""
@abstractmethod
def transform_create_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentCreateResponse:
"""Parse create response. Raise on non-2xx."""
# ------------------------------------------------------------------ #
# LIST #
# ------------------------------------------------------------------ #
@abstractmethod
def transform_list_request(
self,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> Tuple[str, Dict[str, Any]]:
"""Return (url, query_params) for GET /agents."""
@abstractmethod
def transform_list_response(
self,
raw_response: httpx.Response,
) -> AgentListResponse:
"""Parse list-agents response. Raise on non-2xx."""
# ------------------------------------------------------------------ #
# GET #
# ------------------------------------------------------------------ #
@abstractmethod
def transform_get_request(
self,
name: str,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> Tuple[str, Dict[str, Any]]:
"""Return (url, query_params) for GET /agents/{name}."""
@abstractmethod
def transform_get_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentCreateResponse:
"""Parse get-agent response. Raise on non-2xx."""
# ------------------------------------------------------------------ #
# DELETE #
# ------------------------------------------------------------------ #
@abstractmethod
def transform_delete_request(
self,
name: str,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> str:
"""Return the URL for DELETE /agents/{name}."""
@abstractmethod
def transform_delete_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentDeleteResult:
"""Parse delete-agent response. Raise on non-2xx."""
# ------------------------------------------------------------------ #
# LIST VERSIONS #
# ------------------------------------------------------------------ #
@abstractmethod
def transform_list_versions_request(
self,
name: str,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> Tuple[str, Dict[str, Any]]:
"""Return (url, query_params) for GET /agents/{name}/versions."""
@abstractmethod
def transform_list_versions_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentVersionsResponse:
"""Parse list-versions response. Raise on non-2xx."""
# ------------------------------------------------------------------ #
# ERROR HANDLING #
# ------------------------------------------------------------------ #
def get_error_class(
self,
error_message: str,
status_code: int,
headers: Union[dict, httpx.Headers],
) -> Exception:
"""Map HTTP error status codes to provider-specific exceptions."""
from litellm.llms.base_llm.chat.transformation import BaseLLMException
return BaseLLMException(
status_code=status_code,
message=error_message,
headers=headers,
)

View file

@ -54,6 +54,7 @@ class OCRUsageInfo(LiteLLMPydanticObjectBase):
"""Usage information from OCR response."""
pages_processed: Optional[int] = None
credits: Optional[float] = None
doc_size_bytes: Optional[int] = None
model_config = {"extra": "allow"}

View file

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, List, Optional
import httpx
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
from litellm.litellm_core_utils.litellm_logging import verbose_logger
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_anthropic_image_obj,
)
@ -22,6 +23,7 @@ from litellm.llms.bedrock.common_utils import (
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
from litellm.utils import _supports_factory
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -169,6 +171,24 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
anthropic_request.pop("model", None)
anthropic_request.pop("stream", None)
anthropic_request.pop("output_format", None)
if not (
_supports_factory(
model=model,
custom_llm_provider="bedrock",
key="supports_output_config",
)
or AnthropicConfig._model_supports_effort_param(model)
):
if anthropic_request.pop("output_config", None) is not None:
verbose_logger.warning(
"Bedrock Invoke: stripping unsupported `output_config` for "
"model=%s — neither `supports_output_config` nor any "
"`supports_*_reasoning_effort` flag is set in "
"model_prices_and_context_window.json. Add the capability "
"flag to the model JSON entry if this model accepts "
"`output_config`.",
model,
)
if "anthropic_version" not in anthropic_request:
anthropic_request["anthropic_version"] = self.anthropic_version

View file

@ -45,6 +45,7 @@ from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import GenericStreamingChunk
from litellm.types.utils import GenericStreamingChunk as GChunk
from litellm.types.utils import ModelResponseStream
from litellm.utils import _supports_factory
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -557,7 +558,29 @@ class AmazonAnthropicClaudeMessagesConfig(
anthropic_messages_request=anthropic_messages_request,
)
# 5a. Remove `custom` field from tools (Bedrock doesn't support it)
# 5a. Bedrock Invoke supports output_config (effort) for Claude 4.6+ models,
# but older models do not — strip it to avoid request rejection.
# Ref: https://github.com/BerriAI/litellm/issues/22797
if not (
_supports_factory(
model=model,
custom_llm_provider="bedrock",
key="supports_output_config",
)
or AnthropicConfig._model_supports_effort_param(model)
):
if anthropic_messages_request.pop("output_config", None) is not None:
verbose_logger.warning(
"Bedrock Invoke: stripping unsupported `output_config` for "
"model=%s — neither `supports_output_config` nor any "
"`supports_*_reasoning_effort` flag is set in "
"model_prices_and_context_window.json. Add the capability "
"flag to the model JSON entry if this model accepts "
"`output_config`.",
model,
)
# 5b. Remove `custom` field from tools (Bedrock doesn't support it)
# Claude Code sends `custom: {defer_loading: true}` on tool definitions,
# which causes Bedrock to reject the request with "Extra inputs are not permitted"
# Ref: https://github.com/BerriAI/litellm/issues/22847

View file

@ -1,7 +1,5 @@
import json
from typing import Any, Optional
from typing import Any, Dict, Optional
from litellm.constants import STREAM_SSE_DONE_STRING
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
@ -9,13 +7,17 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
)
from litellm.llms.openai.common_utils import OpenAIError
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.responses.sse_output_recovery import (
parse_sse_json_chunk,
record_output_item_chunk,
record_output_text_chunk,
)
from litellm.types.llms.openai import (
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import CustomStreamWrapper
from ..authenticator import Authenticator
from ..common_utils import (
@ -111,86 +113,139 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
raw_response: Any,
logging_obj: Any,
):
content_type = (raw_response.headers or {}).get("content-type", "")
body_text = raw_response.text or ""
if "text/event-stream" not in content_type.lower():
trimmed_body = body_text.lstrip()
if not (
trimmed_body.startswith("event:")
or trimmed_body.startswith("data:")
or "\nevent:" in body_text
or "\ndata:" in body_text
):
return super().transform_response_api_response(
model=model,
raw_response=raw_response,
logging_obj=logging_obj,
)
if not self._should_parse_as_sse(
raw_response=raw_response, body_text=body_text
):
return super().transform_response_api_response(
model=model,
raw_response=raw_response,
logging_obj=logging_obj,
)
logging_obj.post_call(
original_response=raw_response.text,
additional_args={"complete_input_dict": {}},
)
completed_response = None
error_message = None
for chunk in body_text.splitlines():
stripped_chunk = CustomStreamWrapper._strip_sse_data_from_chunk(chunk)
if not stripped_chunk:
continue
stripped_chunk = stripped_chunk.strip()
if not stripped_chunk:
continue
if stripped_chunk == STREAM_SSE_DONE_STRING:
break
try:
parsed_chunk = json.loads(stripped_chunk)
except json.JSONDecodeError:
continue
if not isinstance(parsed_chunk, dict):
continue
event_type = parsed_chunk.get("type")
if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
response_payload = parsed_chunk.get("response")
if isinstance(response_payload, dict):
response_payload = dict(response_payload)
if "created_at" in response_payload:
response_payload["created_at"] = _safe_convert_created_field(
response_payload["created_at"]
)
try:
completed_response = ResponsesAPIResponse(**response_payload)
except Exception:
completed_response = ResponsesAPIResponse.model_construct(
**response_payload
)
break
if event_type in (
ResponsesAPIStreamEvents.RESPONSE_FAILED,
ResponsesAPIStreamEvents.ERROR,
):
error_obj = parsed_chunk.get("error") or (
parsed_chunk.get("response") or {}
).get("error")
if error_obj is not None:
if isinstance(error_obj, dict):
error_message = error_obj.get("message") or str(error_obj)
else:
error_message = str(error_obj)
completed_response, error_message = self._extract_completed_response_from_sse(
body_text=body_text
)
if completed_response is None:
raise OpenAIError(
message=error_message or raw_response.text,
status_code=raw_response.status_code,
)
self._attach_response_headers(
completed_response=completed_response, raw_response=raw_response
)
return completed_response
def _should_parse_as_sse(self, raw_response: Any, body_text: str) -> bool:
content_type = (raw_response.headers or {}).get("content-type", "")
if "text/event-stream" in content_type.lower():
return True
trimmed_body = body_text.lstrip()
return bool(
trimmed_body.startswith("event:")
or trimmed_body.startswith("data:")
or "\nevent:" in body_text
or "\ndata:" in body_text
)
def _extract_completed_response_from_sse(
self, body_text: str
) -> tuple[Optional[ResponsesAPIResponse], Optional[str]]:
completed_response = None
error_message = None
streamed_output_items: Dict[int, dict] = {}
text_only_output_items: Dict[int, dict] = {}
for chunk in body_text.splitlines():
parsed_chunk = parse_sse_json_chunk(chunk)
if parsed_chunk is None:
continue
event_type = parsed_chunk.get("type")
if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
record_output_item_chunk(
parsed_chunk=parsed_chunk,
output_items=streamed_output_items,
)
continue
if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE:
record_output_text_chunk(
parsed_chunk=parsed_chunk,
output_items=streamed_output_items,
text_only_items=text_only_output_items,
)
continue
if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
# Real OUTPUT_ITEM_DONE events take precedence at any given
# output_index, but text-only items at indices without a
# matching OUTPUT_ITEM_DONE must still be preserved (e.g.
# providers that emit only OUTPUT_TEXT_DONE for some indices).
merged_items: Dict[int, dict] = {**text_only_output_items}
merged_items.update(streamed_output_items)
completed_response = self._build_completed_response_from_chunk(
parsed_chunk=parsed_chunk,
streamed_output_items=merged_items,
)
break
if event_type in (
ResponsesAPIStreamEvents.RESPONSE_FAILED,
ResponsesAPIStreamEvents.ERROR,
):
extracted_error = self._extract_error_message(parsed_chunk)
if extracted_error is not None:
error_message = extracted_error
return completed_response, error_message
def _build_completed_response_from_chunk(
self, parsed_chunk: Dict[str, Any], streamed_output_items: Dict[int, dict]
) -> Optional[ResponsesAPIResponse]:
response_payload = parsed_chunk.get("response")
if not isinstance(response_payload, dict):
return None
response_payload = dict(response_payload)
if not response_payload.get("output") and streamed_output_items:
response_payload["output"] = [
item for _, item in sorted(streamed_output_items.items())
]
if "created_at" in response_payload:
response_payload["created_at"] = _safe_convert_created_field(
response_payload["created_at"]
)
try:
return ResponsesAPIResponse(**response_payload)
except Exception:
return ResponsesAPIResponse.model_construct(**response_payload)
def _extract_error_message(self, parsed_chunk: Dict[str, Any]) -> Optional[str]:
error_obj = parsed_chunk.get("error") or (
parsed_chunk.get("response") or {}
).get("error")
if error_obj is None:
return None
if isinstance(error_obj, dict):
return error_obj.get("message") or str(error_obj)
return str(error_obj)
def _attach_response_headers(
self,
completed_response: ResponsesAPIResponse,
raw_response: Any,
) -> None:
raw_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_headers)
if not hasattr(completed_response, "_hidden_params"):
setattr(completed_response, "_hidden_params", {})
completed_response._hidden_params["additional_headers"] = processed_headers
completed_response._hidden_params["headers"] = raw_headers
return completed_response
def get_complete_url(
self,

View file

@ -257,14 +257,19 @@ class GenericContainerHandler:
returns_binary = endpoint_config.get("returns_binary", False)
is_multipart = endpoint_config.get("is_multipart", False)
# An empty dict passed as `params` to httpx strips any existing query
# string from the URL (e.g. ?api-version=...). Use None instead so
# httpx leaves the URL's own query string intact.
effective_params = query_params or None
try:
if method == "GET":
response = http_client.get(
url=url, headers=headers, params=query_params
url=url, headers=headers, params=effective_params
)
elif method == "DELETE":
response = http_client.delete(
url=url, headers=headers, params=query_params
url=url, headers=headers, params=effective_params
)
elif method == "POST":
if is_multipart and "file" in kwargs:
@ -272,11 +277,11 @@ class GenericContainerHandler:
kwargs["file"], headers
)
response = http_client.post(
url=url, headers=headers, params=query_params, files=files
url=url, headers=headers, params=effective_params, files=files
)
else:
response = http_client.post(
url=url, headers=headers, params=query_params
url=url, headers=headers, params=effective_params
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
@ -376,14 +381,19 @@ class GenericContainerHandler:
returns_binary = endpoint_config.get("returns_binary", False)
is_multipart = endpoint_config.get("is_multipart", False)
# An empty dict passed as `params` to httpx strips any existing query
# string from the URL (e.g. ?api-version=...). Use None instead so
# httpx leaves the URL's own query string intact.
effective_params = query_params or None
try:
if method == "GET":
response = await http_client.get(
url=url, headers=headers, params=query_params
url=url, headers=headers, params=effective_params
)
elif method == "DELETE":
response = await http_client.delete(
url=url, headers=headers, params=query_params
url=url, headers=headers, params=effective_params
)
elif method == "POST":
if is_multipart and "file" in kwargs:
@ -391,11 +401,11 @@ class GenericContainerHandler:
kwargs["file"], headers
)
response = await http_client.post(
url=url, headers=headers, params=query_params, files=files
url=url, headers=headers, params=effective_params, files=files
)
else:
response = await http_client.post(
url=url, headers=headers, params=query_params
url=url, headers=headers, params=effective_params
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")

View file

@ -1409,6 +1409,8 @@ class BaseLLMHTTPHandler:
document=document,
optional_params=optional_params,
headers=headers,
api_key=api_key,
api_base=api_base,
)
# All providers return OCRRequestData
@ -1477,6 +1479,8 @@ class BaseLLMHTTPHandler:
document=document,
optional_params=optional_params,
headers=headers,
api_key=api_key,
api_base=api_base,
)
# All providers return OCRRequestData
@ -7834,7 +7838,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_list_response(
@ -7911,7 +7915,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_list_response(
@ -8001,7 +8005,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_retrieve_response(
@ -8078,7 +8082,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_retrieve_response(
@ -8168,7 +8172,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.delete(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_delete_response(
@ -8245,7 +8249,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.delete(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_delete_response(
@ -8341,7 +8345,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_file_list_response(
@ -8420,7 +8424,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_file_list_response(
@ -8508,7 +8512,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_file_content_response(
@ -8584,7 +8588,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
params=params,
params=params or None,
)
return container_provider_config.transform_container_file_content_response(

View file

@ -4,6 +4,7 @@ from typing import Any, List, Literal, Optional, Tuple, Union, cast
import httpx
import litellm
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -26,6 +27,7 @@ from litellm.types.utils import (
ProviderSpecificModelInfo,
)
from litellm.utils import (
get_model_cost_mutation_generation,
supports_function_calling,
supports_reasoning,
supports_tool_choice,
@ -112,6 +114,19 @@ class FireworksAIConfig(OpenAIGPTConfig):
# Only add tools for models that support function calling
if supports_function_calling(model=model, custom_llm_provider="fireworks_ai"):
supported_params.append("tools")
supported_params.append("parallel_tool_calls")
else:
# Historically every Fireworks model advertised tool support, so a
# JSON entry that flips `supports_function_calling` to false will
# silently drop `tools` from requests. Surface this so users can
# tell why their tool calls suddenly stop working.
verbose_logger.debug(
"fireworks_ai model %r is marked as not supporting "
"function calling in model_prices_and_context_window.json; "
"`tools` and `parallel_tool_calls` will be dropped from the "
"request.",
model,
)
# Only add tool_choice for models that explicitly support it
if supports_tool_choice(model=model, custom_llm_provider="fireworks_ai"):
@ -251,34 +266,100 @@ class FireworksAIConfig(OpenAIGPTConfig):
return messages
def get_provider_info(self, model: str) -> ProviderSpecificModelInfo:
# Models that support reasoning_effort
reasoning_supported_models = [
"qwen3-8b",
"qwen3-32b",
"qwen3-coder-480b-a35b-instruct",
"deepseek-v3p1",
"deepseek-v3p2",
"glm-4p5",
"glm-4p5-air",
"glm-4p6",
"gpt-oss-120b",
"gpt-oss-20b",
# Cached index of fireworks_ai/* entries from litellm.model_cost. Building
# this index requires a full scan of model_cost (tens of thousands of
# entries), so we memoize it. The cache key is (id(model_cost),
# mutation_generation): the generation counter is bumped on every
# register_model / reload path, so add+remove or in-place value
# replacement (which can leave id and len unchanged) still invalidates.
_fireworks_index_cache: Optional[Tuple[int, int, List[Tuple[str, dict]]]] = None
@classmethod
def _get_fireworks_index(cls) -> List[Tuple[str, dict]]:
model_cost = litellm.model_cost
signature = (id(model_cost), get_model_cost_mutation_generation())
cached = cls._fireworks_index_cache
if (
cached is not None
and cached[0] == signature[0]
and cached[1] == signature[1]
):
return cached[2]
index: List[Tuple[str, dict]] = []
for key, model_info in model_cost.items():
if not key.startswith("fireworks_ai/"):
continue
if not isinstance(model_info, dict):
continue
key_short = key[len("fireworks_ai/") :]
if key_short.startswith("accounts/fireworks/models/"):
key_short = key_short[len("accounts/fireworks/models/") :]
if not key_short:
continue
index.append((key_short, model_info))
cls._fireworks_index_cache = (signature[0], signature[1], index)
return index
@staticmethod
def _matches_on_hyphen_boundary(short_name: str, key_short: str) -> bool:
"""Return True if `key_short` appears in `short_name` aligned to
hyphen-separated word boundaries (or end-of-string). This avoids
spurious substring matches like `"some-model"` matching
`"awesome-model"`."""
if short_name == key_short:
return True
if short_name.startswith(key_short + "-"):
return True
if short_name.endswith("-" + key_short):
return True
return ("-" + key_short + "-") in short_name
def _get_model_cost_capability(self, model: str, capability: str) -> Optional[bool]:
short_name = model
if short_name.startswith("fireworks_ai/"):
short_name = short_name[len("fireworks_ai/") :]
if short_name.startswith("accounts/fireworks/models/"):
short_name = short_name[len("accounts/fireworks/models/") :]
candidate_keys = [
model,
f"fireworks_ai/{short_name}",
f"fireworks_ai/accounts/fireworks/models/{short_name}",
]
# Normalize model name - remove prefix if present
normalized_model = model
if model.startswith("fireworks_ai/"):
normalized_model = model.replace("fireworks_ai/", "")
if normalized_model.startswith("accounts/fireworks/models/"):
normalized_model = normalized_model.replace(
"accounts/fireworks/models/", ""
)
for candidate_key in candidate_keys:
model_info = litellm.model_cost.get(candidate_key)
if model_info is not None and model_info.get(capability) is not None:
return cast(Optional[bool], model_info.get(capability))
# Check if model supports reasoning
supports_reasoning_value = any(
reasoning_model in normalized_model
for reasoning_model in reasoning_supported_models
# Fallback: preserve historical substring matching for model name
# variants (e.g. fine-tuned or regionally-suffixed versions of a
# known model). Pick the *longest* matching entry so a more specific
# known model (e.g. "qwen3-8b-instruct") wins over a less specific
# one (e.g. "qwen3-8b") when the query model is more specific still.
# Use hyphen-aligned matching to avoid false positives where a short
# known model name is an unrelated substring of a longer one.
best_match_short: Optional[str] = None
best_match_value: Optional[bool] = None
for key_short, model_info in self._get_fireworks_index():
if model_info.get(capability) is None:
continue
if not self._matches_on_hyphen_boundary(short_name, key_short):
continue
if best_match_short is None or len(key_short) > len(best_match_short):
best_match_short = key_short
best_match_value = cast(Optional[bool], model_info.get(capability))
return best_match_value
def get_provider_info(self, model: str) -> ProviderSpecificModelInfo:
supports_function_calling_value = self._get_model_cost_capability(
model=model, capability="supports_function_calling"
)
supports_reasoning_value = self._get_model_cost_capability(
model=model, capability="supports_reasoning"
)
provider_specific_model_info: ProviderSpecificModelInfo = {
@ -288,9 +369,16 @@ class FireworksAIConfig(OpenAIGPTConfig):
"supports_vision": True, # via document inlining
}
if supports_function_calling_value is not None:
provider_specific_model_info["supports_function_calling"] = (
supports_function_calling_value
)
# Only include supports_reasoning if True
if supports_reasoning_value:
provider_specific_model_info["supports_reasoning"] = True
provider_specific_model_info["supports_reasoning"] = (
supports_reasoning_value
)
return provider_specific_model_info

View file

View file

@ -0,0 +1,299 @@
"""
Google AI Studio Agents API configuration.
Proxies the Gemini v1beta Agents API:
POST /v1beta/agents create
GET /v1beta/agents list
GET /v1beta/agents/{name} get
DELETE /v1beta/agents/{name} delete
GET /v1beta/agents/{name}/versions list versions
"""
from typing import Any, Dict, Optional, Tuple, Union
import httpx
from litellm._logging import verbose_logger
from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo
from litellm.types.agents import (
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
AgentVersionsResponse,
)
# Keys inside litellm_params that should be forwarded to the Gemini
# create-agent body verbatim.
_GEMINI_AGENT_BODY_KEYS = ("base_agent", "instructions", "base_environment")
# LiteLLM-internal keys that must never be forwarded to Gemini.
_LITELLM_INTERNAL_KEYS = frozenset(
{
"custom_llm_provider",
"api_key",
"api_base",
"make_public",
"cost_per_query",
"input_cost_per_token",
"output_cost_per_token",
"require_trace_id_on_calls_to_agent",
"require_trace_id_on_calls_by_agent",
"max_iterations",
"max_budget_per_session",
"guardrails",
"is_public",
"agent_name",
"agent_id",
"agent_card_params",
"provider_agent_response",
}
)
class GeminiAgentsConfig(BaseAgentsAPIConfig):
"""
Configuration for the Google AI Studio (Gemini) native Agents API.
Authentication uses x-goog-api-key, resolved from (in order):
1. litellm_params["api_key"]
2. GOOGLE_API_KEY env var
3. GEMINI_API_KEY env var
"""
@property
def api_version(self) -> str:
return "v1beta"
def _base_url(self, api_base: Optional[str]) -> str:
return f"{GeminiModelInfo.get_api_base(api_base)}/{self.api_version}"
# ------------------------------------------------------------------ #
# Shared helpers #
# ------------------------------------------------------------------ #
def get_error_class(
self,
error_message: str,
status_code: int,
headers: Union[dict, httpx.Headers],
) -> Exception:
return GeminiError(
message=error_message,
status_code=status_code,
headers=dict(headers),
)
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> str:
return f"{self._base_url(api_base)}/agents"
def validate_environment(
self,
headers: Dict[str, str],
litellm_params: Dict[str, Any],
) -> Dict[str, str]:
headers = dict(headers)
headers["Content-Type"] = "application/json"
explicit_api_key = litellm_params.get("api_key")
# SECURITY: when the caller overrides ``api_base``, refuse to fall back
# to the process-wide GOOGLE_API_KEY / GEMINI_API_KEY env vars. Otherwise
# an authenticated proxy user could set ``api_base`` to an attacker-
# controlled host and have the proxy ship its shared Gemini key in the
# ``x-goog-api-key`` header.
if litellm_params.get("api_base") and not explicit_api_key:
raise ValueError(
"When overriding api_base for Gemini agents, you must also "
"supply an explicit api_key. Falling back to GOOGLE_API_KEY / "
"GEMINI_API_KEY env vars with a custom api_base is refused "
"to prevent leaking the shared provider key to arbitrary hosts."
)
api_key = GeminiModelInfo.get_api_key(explicit_api_key)
if not api_key:
raise ValueError(
"Google API key is required. "
"Set GOOGLE_API_KEY or GEMINI_API_KEY, or pass api_key."
)
headers["x-goog-api-key"] = api_key
return headers
def _raise_for_status(self, raw_response: httpx.Response) -> None:
if not (200 <= raw_response.status_code < 300):
raise GeminiError(
message=raw_response.text,
status_code=raw_response.status_code,
headers=dict(raw_response.headers),
)
# ------------------------------------------------------------------ #
# CREATE #
# ------------------------------------------------------------------ #
def transform_create_request(
self,
name: str,
litellm_params: Dict[str, Any],
) -> Dict[str, Any]:
body: Dict[str, Any] = {"name": name}
for key in _GEMINI_AGENT_BODY_KEYS:
value = litellm_params.get(key)
if value is not None:
body[key] = value
verbose_logger.debug("GeminiAgentsConfig create body: %s", body)
return body
def transform_create_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentCreateResponse:
"""
Gemini returns:
{"id": "my-agent", "base_agent": "waverunner",
"system_instruction": "...", "base_environment": {...}}
"""
self._raise_for_status(raw_response)
try:
data: Dict[str, Any] = raw_response.json()
except Exception:
verbose_logger.warning(
"GeminiAgentsConfig: non-JSON create response (status=%d).",
raw_response.status_code,
)
data = {"id": name}
# Gemini uses "id" as the identifier; normalise to both fields.
data.setdefault("id", name)
data.setdefault("name", data["id"])
verbose_logger.debug("GeminiAgentsConfig create response: %s", data)
return AgentCreateResponse(**data)
# ------------------------------------------------------------------ #
# LIST #
# ------------------------------------------------------------------ #
def transform_list_request(
self,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> Tuple[str, Dict[str, Any]]:
url = f"{self._base_url(api_base)}/agents"
params: Dict[str, Any] = {}
if litellm_params.get("page_size"):
params["pageSize"] = litellm_params["page_size"]
if litellm_params.get("page_token"):
params["pageToken"] = litellm_params["page_token"]
return url, params
def transform_list_response(
self,
raw_response: httpx.Response,
) -> AgentListResponse:
self._raise_for_status(raw_response)
try:
data = raw_response.json()
except Exception:
data = {}
verbose_logger.debug("GeminiAgentsConfig list response: %s", data)
return AgentListResponse(
agents=data.get("agents", []),
next_page_token=data.get("nextPageToken"),
)
# ------------------------------------------------------------------ #
# GET #
# ------------------------------------------------------------------ #
def transform_get_request(
self,
name: str,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> Tuple[str, Dict[str, Any]]:
url = f"{self._base_url(api_base)}/agents/{name}"
return url, {}
def transform_get_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentCreateResponse:
"""Same shape as create response — Gemini returns "id" as identifier."""
self._raise_for_status(raw_response)
try:
data = raw_response.json()
except Exception:
data = {"id": name}
data.setdefault("id", name)
data.setdefault("name", data["id"])
verbose_logger.debug("GeminiAgentsConfig get response: %s", data)
return AgentCreateResponse(**data)
# ------------------------------------------------------------------ #
# DELETE #
# ------------------------------------------------------------------ #
def transform_delete_request(
self,
name: str,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> str:
return f"{self._base_url(api_base)}/agents/{name}"
def transform_delete_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentDeleteResult:
"""Gemini returns an empty body ``{}`` with HTTP 200 on success."""
self._raise_for_status(raw_response)
verbose_logger.debug(
"GeminiAgentsConfig delete (status=%d) agent '%s'",
raw_response.status_code,
name,
)
return AgentDeleteResult(name=name, deleted=True)
# ------------------------------------------------------------------ #
# LIST VERSIONS #
# ------------------------------------------------------------------ #
def transform_list_versions_request(
self,
name: str,
api_base: Optional[str],
litellm_params: Dict[str, Any],
) -> Tuple[str, Dict[str, Any]]:
url = f"{self._base_url(api_base)}/agents/{name}/versions"
params: Dict[str, Any] = {}
if litellm_params.get("page_size"):
params["pageSize"] = litellm_params["page_size"]
if litellm_params.get("page_token"):
params["pageToken"] = litellm_params["page_token"]
return url, params
def transform_list_versions_response(
self,
raw_response: httpx.Response,
name: str,
) -> AgentVersionsResponse:
"""
Gemini returns:
{"agentVersions": [{"agent": "waverunner", "name": "agents/.../versions/uuid", ...}]}
"""
self._raise_for_status(raw_response)
try:
data = raw_response.json()
except Exception:
data = {}
verbose_logger.debug(
"GeminiAgentsConfig list_versions response for '%s': %s", name, data
)
return AgentVersionsResponse(
agent_versions=data.get("agentVersions", []),
next_page_token=data.get("nextPageToken"),
)

View file

@ -164,5 +164,8 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
# If conversion fails, leave as is and let the API handle it
pass
return _gemini_convert_messages_with_history(
messages=messages, model=model, litellm_params=litellm_params
messages=messages,
model=model,
litellm_params=litellm_params,
custom_llm_provider="gemini",
)

View file

@ -6,13 +6,18 @@ Per OpenAPI spec (https://ai.google.dev/static/api/interactions.openapi.json):
- Get: GET https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id}
- Delete: DELETE https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id}
This is a thin wrapper - no transformation needed since we follow the spec directly.
Schema versioning:
- Default (Api-Revision: 2026-05-20): new `steps` schema.
- Legacy (Api-Revision: 2026-05-07): old `outputs` schema, controlled via
litellm.use_legacy_interactions_schema = True. Remove flag after June 8, 2026.
"""
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import httpx
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
@ -64,6 +69,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"stream",
"store",
"background",
"environment",
"response_modalities",
"response_format",
"response_mime_type",
@ -83,6 +89,15 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
api_key = GeminiModelInfo.get_api_key(litellm_params.get("api_key"))
if api_key:
headers["x-goog-api-key"] = api_key
# Inject the Api-Revision header to select the response schema.
# Default to the new `steps` schema unless the operator has opted out.
# Remove this conditional after June 8, 2026 and always use 2026-05-20.
if litellm.use_legacy_interactions_schema:
headers["Api-Revision"] = "2026-05-07"
else:
headers["Api-Revision"] = "2026-05-20"
return headers
def get_complete_url(
@ -118,8 +133,19 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
headers: dict,
) -> Dict:
"""
Build request body per OpenAPI spec - minimal transformation.
Build request body per OpenAPI spec.
When on the new schema (use_legacy_interactions_schema=False, the default):
- ``response_mime_type`` is folded into ``response_format`` and stripped from
the body (the field was removed in Api-Revision 2026-05-20).
- ``generation_config.image_config`` is moved to a ``response_format`` entry
with ``"type": "image"`` (also removed from generation_config in 2026-05-20).
When on the legacy schema (use_legacy_interactions_schema=True):
- All fields are forwarded as-is.
"""
use_legacy: bool = litellm.use_legacy_interactions_schema
request_body: Dict[str, Any] = {}
# Model or Agent (one required)
@ -134,23 +160,81 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
if input is not None:
request_body["input"] = input
# Pass through optional params directly (they match the spec)
# Pass through optional params — legacy schema keeps all fields as-is.
optional_keys = [
"tools",
"system_instruction",
"generation_config",
"stream",
"store",
"background",
"environment",
"response_modalities",
"response_format",
"response_mime_type",
"previous_interaction_id",
]
for key in optional_keys:
if optional_params.get(key) is not None:
request_body[key] = optional_params[key]
if use_legacy:
# Legacy schema: forward response_mime_type and response_format as-is.
for key in ("response_format", "response_mime_type", "generation_config"):
if optional_params.get(key) is not None:
request_body[key] = optional_params[key]
else:
# New schema (Api-Revision: 2026-05-20):
# response_mime_type is removed — fold it into response_format.
response_format = optional_params.get("response_format")
response_mime_type = optional_params.get("response_mime_type")
if (
response_mime_type
and not isinstance(response_format, list)
and (
not isinstance(response_format, dict)
or "mime_type" not in response_format
)
):
# Wrap the legacy schema into the new polymorphic format.
new_rf: Dict[str, Any] = {
"type": "text",
"mime_type": response_mime_type,
}
if response_format is not None:
new_rf["schema"] = response_format
response_format = new_rf
if response_format is not None:
request_body["response_format"] = response_format
# image_config moves out of generation_config into response_format.
generation_config: Optional[Dict[str, Any]] = optional_params.get(
"generation_config"
)
if generation_config is not None:
image_config = None
if isinstance(generation_config, dict):
generation_config = dict(
generation_config
) # avoid mutating the caller's dict
image_config = generation_config.pop("image_config", None)
if not generation_config:
generation_config = None
if generation_config is not None:
request_body["generation_config"] = generation_config
if image_config is not None:
# Move image_config to response_format with type=image.
image_rf: Dict[str, Any] = {"type": "image", **image_config}
existing_rf = request_body.get("response_format")
if existing_rf is None:
request_body["response_format"] = image_rf
elif isinstance(existing_rf, list):
request_body["response_format"] = [*existing_rf, image_rf]
else:
# Convert single entry to array for multimodal output.
request_body["response_format"] = [existing_rf, image_rf]
return request_body
def transform_response(

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,159 @@
import base64
import binascii
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, List, NoReturn, Optional, Tuple
from litellm.constants import request_timeout
REDUCTO_API_BASE = "https://platform.reducto.ai"
REDUCTO_ID_PREFIX = "reducto://"
if TYPE_CHECKING:
from litellm.llms.base_llm.ocr.transformation import OCRPage
def _normalize_api_base(api_base: Optional[str]) -> str:
return (api_base or REDUCTO_API_BASE).rstrip("/")
def _raise_bad_request(message: str, model: str) -> NoReturn:
import litellm
raise litellm.BadRequestError(
message=message,
model=model,
llm_provider="reducto",
)
def extract_file_id_or_bytes(
source_url: str,
model: str,
) -> Tuple[Optional[str], Optional[bytes], Optional[str]]:
if source_url.startswith(REDUCTO_ID_PREFIX):
return source_url, None, None
if source_url.startswith("http://") or source_url.startswith("https://"):
_raise_bad_request(
"Reducto requires type='file' (auto-uploaded) or a reducto:// id. Plain http(s) URLs are not supported; upload the file first.",
model=model,
)
if not source_url.startswith("data:"):
_raise_bad_request(
"Reducto requires a reducto:// id or a base64 data URI after OCR preprocessing.",
model=model,
)
try:
header, encoded = source_url.split(",", 1)
except ValueError:
_raise_bad_request("Invalid Reducto data URI provided.", model=model)
if ";base64" not in header:
_raise_bad_request(
"Reducto only supports base64-encoded data URIs.", model=model
)
mime = header.removeprefix("data:").split(";")[0] or "application/octet-stream"
try:
raw_bytes = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
_raise_bad_request("Invalid Reducto base64 payload provided.", model=model)
return None, raw_bytes, mime
def _extract_file_id_from_upload_response(response: Any) -> str:
try:
payload = response.json()
except ValueError as exc:
raise ValueError(
"Reducto /upload returned a non-JSON 200 response: {}".format(response.text)
) from exc
file_id = (payload or {}).get("file_id") if isinstance(payload, dict) else None
if not isinstance(file_id, str) or not file_id:
raise ValueError(
"Reducto /upload returned 200 without a file_id; got payload={}".format(
payload
)
)
return file_id
def upload_bytes_sync(
raw_bytes: bytes,
mime: Optional[str],
api_key: str,
api_base: Optional[str],
) -> str:
import litellm
response = litellm.module_level_client.post(
url="{}{}".format(_normalize_api_base(api_base), "/upload"),
headers={"Authorization": f"Bearer {api_key}"},
files={"file": ("document", raw_bytes, mime or "application/octet-stream")},
timeout=request_timeout,
)
response.raise_for_status()
return _extract_file_id_from_upload_response(response)
async def upload_bytes_async(
raw_bytes: bytes,
mime: Optional[str],
api_key: str,
api_base: Optional[str],
) -> str:
import litellm
response = await litellm.module_level_aclient.post(
url="{}{}".format(_normalize_api_base(api_base), "/upload"),
headers={"Authorization": f"Bearer {api_key}"},
files={"file": ("document", raw_bytes, mime or "application/octet-stream")},
timeout=request_timeout,
)
response.raise_for_status()
return _extract_file_id_from_upload_response(response)
def build_pages_from_reducto(result: Dict[str, Any]) -> List["OCRPage"]:
from litellm.llms.base_llm.ocr.transformation import OCRPage
chunks = result.get("chunks", []) or []
blocks_by_page: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for chunk in chunks:
for block in chunk.get("blocks", []) or []:
page_no = (block.get("bbox") or {}).get("page")
if page_no is None:
continue
try:
normalized_page = int(page_no)
except (TypeError, ValueError):
continue
blocks_by_page[normalized_page].append(block)
if not blocks_by_page:
fallback_markdown = "\n\n".join(
chunk.get("content", "") for chunk in chunks if chunk.get("content")
)
if fallback_markdown == "":
return []
return [OCRPage(index=0, markdown=fallback_markdown)]
pages: List["OCRPage"] = []
for page_no, blocks in sorted(blocks_by_page.items()):
markdown = "\n\n".join(
block.get("content", "") for block in blocks if block.get("content")
)
page_index = max(page_no - 1, 0)
page = OCRPage(
index=page_index,
markdown=markdown,
)
# OCRPage accepts extra keys at runtime; assign blocks after construction
# so static typing does not reject provider-specific metadata.
setattr(page, "blocks", blocks)
pages.append(page)
return pages

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,241 @@
from typing import Any, Dict, Optional, Tuple
import httpx
from litellm.llms.base_llm.ocr.transformation import (
BaseOCRConfig,
DocumentType,
OCRRequestData,
OCRResponse,
OCRUsageInfo,
)
from litellm.llms.reducto.common import (
REDUCTO_API_BASE,
build_pages_from_reducto,
extract_file_id_or_bytes,
upload_bytes_async,
upload_bytes_sync,
)
class _BaseReductoOCRConfig(BaseOCRConfig):
def map_ocr_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
) -> dict:
mapped_params = dict(optional_params)
supported_params = self.get_supported_ocr_params(model=model)
for param, value in non_default_params.items():
if param in supported_params:
mapped_params[param] = value
return mapped_params
def validate_environment(
self,
headers: Dict,
model: str,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
litellm_params: Optional[dict] = None,
**kwargs,
) -> Dict:
from litellm.secret_managers.main import get_secret_str
resolved_key = api_key or get_secret_str("REDUCTO_API_KEY")
if resolved_key is None:
raise ValueError(
"Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()"
)
return {
"Authorization": f"Bearer {resolved_key}",
"Content-Type": "application/json",
**headers,
}
def get_complete_url(
self,
api_base: Optional[str],
model: str,
optional_params: dict,
litellm_params: Optional[dict] = None,
**kwargs,
) -> str:
return "{}/parse".format((api_base or REDUCTO_API_BASE).rstrip("/"))
def _get_source_url(self, document: DocumentType, model: str) -> str:
source_url = document.get("document_url") or document.get("image_url")
if source_url is None:
raise ValueError(
"Reducto expected OCR preprocessing to produce document_url or image_url for model={}".format(
model
)
)
return source_url
@staticmethod
def _resolve_credentials(
api_key: Optional[str], api_base: Optional[str]
) -> Tuple[str, str]:
from litellm.secret_managers.main import get_secret_str
resolved_key = api_key or get_secret_str("REDUCTO_API_KEY")
if resolved_key is None:
raise ValueError(
"Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()"
)
resolved_base = (api_base or REDUCTO_API_BASE).rstrip("/")
return resolved_key, resolved_base
def _ensure_file_id_sync(
self,
model: str,
document: DocumentType,
api_key: Optional[str],
api_base: Optional[str],
) -> str:
source_url = self._get_source_url(document=document, model=model)
file_id, raw_bytes, mime = extract_file_id_or_bytes(source_url, model=model)
if file_id is not None:
return file_id
resolved_key, resolved_base = self._resolve_credentials(api_key, api_base)
return upload_bytes_sync(
raw_bytes=raw_bytes or b"",
mime=mime,
api_key=resolved_key,
api_base=resolved_base,
)
async def _ensure_file_id_async(
self,
model: str,
document: DocumentType,
api_key: Optional[str],
api_base: Optional[str],
) -> str:
source_url = self._get_source_url(document=document, model=model)
file_id, raw_bytes, mime = extract_file_id_or_bytes(source_url, model=model)
if file_id is not None:
return file_id
resolved_key, resolved_base = self._resolve_credentials(api_key, api_base)
return await upload_bytes_async(
raw_bytes=raw_bytes or b"",
mime=mime,
api_key=resolved_key,
api_base=resolved_base,
)
def transform_ocr_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: Any,
**kwargs,
) -> OCRResponse:
response_json = raw_response.json()
result = response_json.get("result", response_json) or {}
usage = response_json.get("usage", {}) or {}
response = OCRResponse(
pages=build_pages_from_reducto(result),
model=model,
usage_info=OCRUsageInfo(
pages_processed=usage.get("num_pages"),
credits=usage.get("credits"),
),
object="ocr",
)
response._hidden_params["reducto_raw"] = response_json
return response
class ReductoParseV3Config(_BaseReductoOCRConfig):
def get_supported_ocr_params(self, model: str) -> list:
return ["formatting", "retrieval", "settings"]
def transform_ocr_request(
self,
model: str,
document: DocumentType,
optional_params: dict,
headers: dict,
**kwargs,
) -> OCRRequestData:
file_id = self._ensure_file_id_sync(
model=model,
document=document,
api_key=kwargs.get("api_key"),
api_base=kwargs.get("api_base"),
)
return OCRRequestData(data={"input": file_id, **optional_params}, files=None)
async def async_transform_ocr_request(
self,
model: str,
document: DocumentType,
optional_params: dict,
headers: dict,
**kwargs,
) -> OCRRequestData:
file_id = await self._ensure_file_id_async(
model=model,
document=document,
api_key=kwargs.get("api_key"),
api_base=kwargs.get("api_base"),
)
return OCRRequestData(data={"input": file_id, **optional_params}, files=None)
class ReductoParseLegacyConfig(_BaseReductoOCRConfig):
def get_supported_ocr_params(self, model: str) -> list:
return ["enhance"]
def _build_legacy_body(self, file_id: str, optional_params: dict) -> Dict[str, Any]:
body: Dict[str, Any] = {"document_url": file_id}
enhance = optional_params.get("enhance")
if enhance is not None:
body["options"] = {"enhance": enhance}
return body
def transform_ocr_request(
self,
model: str,
document: DocumentType,
optional_params: dict,
headers: dict,
**kwargs,
) -> OCRRequestData:
file_id = self._ensure_file_id_sync(
model=model,
document=document,
api_key=kwargs.get("api_key"),
api_base=kwargs.get("api_base"),
)
return OCRRequestData(
data=self._build_legacy_body(
file_id=file_id, optional_params=optional_params
),
files=None,
)
async def async_transform_ocr_request(
self,
model: str,
document: DocumentType,
optional_params: dict,
headers: dict,
**kwargs,
) -> OCRRequestData:
file_id = await self._ensure_file_id_async(
model=model,
document=document,
api_key=kwargs.get("api_key"),
api_base=kwargs.get("api_base"),
)
return OCRRequestData(
data=self._build_legacy_body(
file_id=file_id, optional_params=optional_params
),
files=None,
)

View file

@ -174,7 +174,9 @@ def transform_openai_messages_to_gemini_context_caching(
)
transformed_messages = _gemini_convert_messages_with_history(
messages=new_messages, model=model
messages=new_messages,
model=model,
custom_llm_provider=custom_llm_provider,
)
model_name = "models/{}".format(model)

View file

@ -41,7 +41,7 @@ class ContextCachingEndpoints(VertexBase):
"""
def __init__(self) -> None:
pass
super().__init__()
def _get_token_and_url_context_caching(
self,

View file

@ -682,6 +682,7 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
messages: List[AllMessageValues],
model: Optional[str] = None,
litellm_params: Optional[dict] = None,
custom_llm_provider: Optional[str] = None,
) -> List[ContentType]:
"""
Converts given messages from OpenAI format to Gemini format
@ -983,7 +984,9 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
or assistant_msg.get("function_call") is not None
): # support assistant tool invoke conversion
gemini_tool_call_parts = convert_to_gemini_tool_call_invoke(
assistant_msg, model=model
assistant_msg,
model=model,
custom_llm_provider=custom_llm_provider,
)
## check if gemini_tool_call already exists in assistant_content
for gemini_tool_call_part in gemini_tool_call_parts:
@ -1042,7 +1045,10 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
and messages[msg_i]["role"] in tool_call_message_roles
):
_part = convert_to_gemini_tool_call_result(
messages[msg_i], last_message_with_tool_calls # type: ignore
messages[msg_i], # type: ignore
last_message_with_tool_calls, # type: ignore
model=model,
custom_llm_provider=custom_llm_provider,
)
msg_i += 1
# Handle both single part and list of parts (for Computer Use with images)

View file

@ -280,6 +280,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
- gemini-3-pro-preview
- gemini-3-flash
- gemini-3-flash-preview (Gemini 3 Flash)
- gemini-3.1-pro-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview
- gemini-3.5-flash
- Any future Gemini 3.x models
"""
# Check for Gemini 3 models
@ -287,6 +289,20 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return True
return False
@staticmethod
def _forward_gemini_function_call_id(
model: str, custom_llm_provider: Optional[str] = None
) -> bool:
"""
Whether to include `id` on function_call / function_response parts.
Gemini 3+ on Google AI Studio accepts (and returns) `id` for strict
tool-call matching. Vertex AI rejects the field with HTTP 400.
"""
if custom_llm_provider != "gemini":
return False
return VertexGeminiConfig._is_gemini_3_or_newer(model)
def _supports_penalty_parameters(self, model: str) -> bool:
# Gemini 3 models do not support penalty parameters
if VertexGeminiConfig._is_gemini_3_or_newer(model):
@ -300,6 +316,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
supported_params = [
"temperature",
"top_p",
"top_k",
"max_tokens",
"max_completion_tokens",
"stream",
@ -363,6 +380,66 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"""
return Tools(googleSearch={})
@staticmethod
def _search_tool_keys() -> set:
return {
VertexToolName.GOOGLE_SEARCH.value,
VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value,
VertexToolName.ENTERPRISE_WEB_SEARCH.value,
VertexToolName.URL_CONTEXT.value,
"google_search",
"google_search_retrieval",
"enterprise_web_search",
"urlContext",
}
@classmethod
def _drop_search_tools_mixed_with_functions(cls, optional_params: dict) -> None:
"""
Drop search tools from optional_params when mixed with function declarations
and include_server_side_tool_invocations is not enabled.
Runs after map_openai_params merges tools and web_search_options so both
code paths (single _map_function call vs split tools + web_search_options)
get the same conflict resolution.
"""
if optional_params.get("include_server_side_tool_invocations"):
return
tools = optional_params.get("tools")
if not isinstance(tools, list) or not tools:
return
search_tool_keys = cls._search_tool_keys()
has_function_declarations = any(
isinstance(tool, dict) and tool.get("function_declarations")
for tool in tools
)
if not has_function_declarations:
return
has_search_tools = any(
isinstance(tool, dict) and any(key in tool for key in search_tool_keys)
for tool in tools
)
if not has_search_tools:
return
verbose_logger.warning(
"Vertex AI does not support mixing function declarations with "
"search tools (googleSearch, enterpriseWebSearch, urlContext, "
"googleSearchRetrieval) in the same request. Dropping search "
"tools and keeping function declarations. To use search tools, "
"send a request without function calling tools."
)
optional_params["tools"] = [
tool
for tool in tools
if not (
isinstance(tool, dict) and any(key in tool for key in search_tool_keys)
)
]
def _map_service_tier_param(self, value: str, optional_params: dict) -> None:
"""
Map OpenAI service_tier (string) to Gemini serviceTier.
@ -884,9 +961,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
GeminiThinkingConfig with thinkingLevel and includeThoughts
"""
# Check if this is gemini-3-flash which supports MINIMAL thinking level
# Covers gemini-3-flash, gemini-3-flash-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview, etc.
# Covers gemini-3-flash, gemini-3-flash-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview,
# gemini-3.5-flash, and any future 3.x-flash variants.
is_gemini3flash = model and (
"gemini-3-flash" in model.lower() or "gemini-3.1-flash" in model.lower()
"flash" in model.lower() and "gemini-3" in model.lower()
)
is_gemini31pro = model and ("gemini-3.1-pro-preview" in model.lower())
if reasoning_effort == "minimal":
@ -982,8 +1060,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
# Follow provider defaults unless explicitly opted into legacy behavior.
if litellm.enable_gemini_default_thinking_level_low is True:
is_gemini3flash = (
"gemini-3-flash-preview" in model.lower()
or "gemini-3-flash" in model.lower()
"gemini-3" in model.lower() and "flash" in model.lower()
)
params["thinkingLevel"] = (
"minimal" if is_gemini3flash else "low"
@ -1077,6 +1154,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
model: str,
drop_params: bool,
) -> Dict:
gemini_sampling_params_warned: bool = False
for param, value in non_default_params.items():
if param == "temperature":
if VertexGeminiConfig._is_gemini_3_or_newer(model):
@ -1086,9 +1164,41 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"can cause infinite loops, degraded reasoning performance, and failure on complex tasks. "
"Strongly recommended to use temperature = 1.0 (default)."
)
if not gemini_sampling_params_warned:
verbose_logger.warning(
"DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to "
f"function for Gemini 3+ ({model}) but are planned for removal in a "
"future release. Move sampling guidance into the `system` "
"instructions instead."
)
gemini_sampling_params_warned = True
optional_params["temperature"] = value
elif param == "top_p":
if (
VertexGeminiConfig._is_gemini_3_or_newer(model)
and not gemini_sampling_params_warned
):
verbose_logger.warning(
"DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to "
f"function for Gemini 3+ ({model}) but are planned for removal in a "
"future release. Move sampling guidance into the `system` "
"instructions instead."
)
gemini_sampling_params_warned = True
optional_params["top_p"] = value
elif param == "top_k":
if (
VertexGeminiConfig._is_gemini_3_or_newer(model)
and not gemini_sampling_params_warned
):
verbose_logger.warning(
"DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to "
f"function for Gemini 3+ ({model}) but are planned for removal in a "
"future release. Move sampling guidance into the `system` "
"instructions instead."
)
gemini_sampling_params_warned = True
optional_params["top_k"] = value
elif (
param == "stream" and value is True
): # sending stream = False, can cause it to get passed unchecked and raise issues
@ -1139,11 +1249,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if _tool_choice_value is not None:
optional_params["tool_choice"] = _tool_choice_value
elif param == "parallel_tool_calls":
if value is False and not (
drop_params or litellm.drop_params
): # if drop params is True, then we should just ignore this
self.validate_parallel_tool_calls(value, non_default_params)
else:
tools_list = non_default_params.get(
"tools", non_default_params.get("functions")
)
num_tools = len(tools_list) if isinstance(tools_list, list) else 0
# Gemini does not support parallel_tool_calls=False with multiple
# tools. Drop the param instead of failing — Responses API clients
# often send parallel_tool_calls=false by default.
if not (value is False and num_tools > 1):
optional_params["parallel_tool_calls"] = value
elif param == "seed":
optional_params["seed"] = value
@ -1216,6 +1329,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if "temperature" not in optional_params:
optional_params["temperature"] = 1.0
self._drop_search_tools_mixed_with_functions(optional_params)
return optional_params
def get_mapped_special_auth_params(self) -> dict:
@ -1588,6 +1703,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
}
# Extract thought signature if present
thought_signature = part.get("thoughtSignature")
# Gemini 3.5+ returns a stable `id` per function call to enable
# strict response matching. Preserve it as the OpenAI
# tool_call_id so it can be echoed back unchanged.
gemini_call_id = part["functionCall"].get("id")
if is_function_call is True:
function_dict: Dict[str, Any] = dict(_function_chunk)
@ -1605,6 +1724,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"function": _function_chunk,
"index": cumulative_tool_call_idx,
}
# Gemini 3.5+ returns a stable native `id`; prefer it over
# the synthetic call_<uuid> so the same value can be echoed
# back on the matching `functionResponse`.
if gemini_call_id:
_tool_response_chunk["id"] = gemini_call_id
# Embed thought signature in ID for OpenAI client compatibility
if thought_signature:
_tool_response_chunk["provider_specific_fields"] = { # type: ignore
@ -2539,7 +2663,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
litellm_params: Optional[dict] = None,
) -> List[ContentType]:
return _gemini_convert_messages_with_history(
messages=messages, model=model, litellm_params=litellm_params
messages=messages,
model=model,
litellm_params=litellm_params,
custom_llm_provider="vertex_ai",
)
def get_error_class(

View file

@ -45,7 +45,7 @@ class PartnerModelPrefixes(str, Enum):
class VertexAIPartnerModels(VertexBase):
def __init__(self) -> None:
pass
super().__init__()
@staticmethod
def is_vertex_partner_model(model: str):
@ -116,9 +116,6 @@ class VertexAIPartnerModels(VertexBase):
CodestralTextCompletion,
)
from litellm.llms.openai_like.chat.handler import OpenAILikeChatHandler
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexLLM,
)
except Exception as e:
raise VertexAIError(
status_code=400,
@ -133,9 +130,7 @@ class VertexAIPartnerModels(VertexBase):
message="""Upgrade vertex ai. Run `pip install "google-cloud-aiplatform>=1.38"`""",
)
try:
vertex_httpx_logic = VertexLLM()
access_token, project_id = vertex_httpx_logic._ensure_access_token(
access_token, project_id = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
custom_llm_provider="vertex_ai",

View file

@ -31,7 +31,7 @@ from ..vertex_llm_base import VertexBase
class VertexAIGemmaModels(VertexBase):
def __init__(self) -> None:
pass
super().__init__()
def completion(
self,
@ -62,9 +62,6 @@ class VertexAIGemmaModels(VertexBase):
try:
import vertexai
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexLLM,
)
from litellm.llms.vertex_ai.vertex_gemma_models.transformation import (
VertexGemmaConfig,
)
@ -83,9 +80,8 @@ class VertexAIGemmaModels(VertexBase):
)
try:
model = get_vertex_base_model_name(model=model)
vertex_httpx_logic = VertexLLM()
access_token, project_id = vertex_httpx_logic._ensure_access_token(
access_token, project_id = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
custom_llm_provider="vertex_ai",

View file

@ -4,8 +4,10 @@ Base Vertex, Google AI Studio LLM Class
Handles Authentication and generating request urls for Vertex AI and Google AI Studio
"""
import asyncio
import json
import os
import threading
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
import litellm
@ -30,6 +32,7 @@ GOOGLE_IMPORT_ERROR_MESSAGE = (
if TYPE_CHECKING:
from google.auth.credentials import Credentials as GoogleCredentialsObject
from google.auth.credentials import TokenState
else:
GoogleCredentialsObject = Any
@ -42,10 +45,28 @@ class VertexBase:
self._credentials: Optional[GoogleCredentialsObject] = None
self._credentials_project_mapping: Dict[
Tuple[Optional[VERTEX_CREDENTIALS_TYPES], Optional[str]],
Tuple[GoogleCredentialsObject, str],
Tuple[GoogleCredentialsObject, Optional[str]],
] = {}
self.project_id: Optional[str] = None
self.async_handler: Optional[AsyncHTTPHandler] = None
# Per-credential-key asyncio.Lock for single-flight async refresh.
# Prevents thundering herd when token expires under high concurrency.
# Uses a regular dict (not WeakValueDictionary) so the lock identity is
# stable across concurrent callers — a weak reference can be GC'd
# between two coroutines arriving at the lock, breaking single-flight.
# An explicit refcount tracks the number of coroutines currently using
# each lock; the entry is pruned when the count reaches zero, so the
# dict stays bounded even in long-running high-cardinality deployments
# without depending on any private asyncio internals.
self._async_refresh_locks: Dict[tuple, asyncio.Lock] = {}
self._async_refresh_lock_refcounts: Dict[tuple, int] = {}
# Tracks in-flight background refresh tasks to avoid duplicate refreshes.
self._background_refresh_tasks: Dict[tuple, asyncio.Task] = {}
# Protects the sync get_access_token refresh path.
# Use RLock so that the reauthentication retry path (which calls
# back into get_access_token while still holding the lock) can
# re-acquire it without deadlocking the current thread.
self._sync_refresh_lock = threading.RLock()
def get_vertex_region(self, vertex_region: Optional[str], model: str) -> str:
import litellm
@ -77,7 +98,9 @@ class VertexBase:
return vertex_region or "us-central1"
def load_auth(
self, credentials: Optional[VERTEX_CREDENTIALS_TYPES], project_id: Optional[str]
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
project_id: Optional[str],
) -> Tuple[Any, str]:
if credentials is not None:
if isinstance(credentials, str):
@ -343,7 +366,241 @@ class VertexBase:
except ImportError:
raise ImportError(GOOGLE_IMPORT_ERROR_MESSAGE)
credentials.refresh(Request())
# Serialize all refreshes on this VertexBase across threads.
# ``credentials.refresh()`` is not safe to call concurrently on the
# same credentials object, and this method is invoked from three
# places that can run on different threads:
# - sync ``get_access_token`` (already holds ``_sync_refresh_lock``)
# - the async slow path (via ``asyncify`` in a worker thread)
# - the background proactive refresh task (via ``asyncify``)
# ``_sync_refresh_lock`` is an ``RLock`` so reentrant acquisition
# from the sync path is safe.
with self._sync_refresh_lock:
credentials.refresh(Request())
def _acquire_async_refresh_lock(self, credential_cache_key: tuple) -> asyncio.Lock:
"""Increment the refcount and return the lock for ``credential_cache_key``.
Every call must be paired with ``_release_async_refresh_lock`` once the
caller is done with the lock so the entry can be pruned when no other
coroutine is holding or waiting on it.
"""
lock = self._async_refresh_locks.setdefault(
credential_cache_key, asyncio.Lock()
)
self._async_refresh_lock_refcounts[credential_cache_key] = (
self._async_refresh_lock_refcounts.get(credential_cache_key, 0) + 1
)
return lock
def _release_async_refresh_lock(
self, credential_cache_key: tuple, lock: asyncio.Lock
) -> None:
"""Decrement the refcount and drop the lock entry when it reaches zero.
Must be called only after the caller has released ``lock`` (i.e. once
the surrounding ``async with`` has exited). asyncio is cooperative, so
the decrement-then-pop sequence below runs atomically with respect to
other coroutines.
"""
remaining = self._async_refresh_lock_refcounts.get(credential_cache_key, 0) - 1
if remaining > 0:
self._async_refresh_lock_refcounts[credential_cache_key] = remaining
return
self._async_refresh_lock_refcounts.pop(credential_cache_key, None)
if self._async_refresh_locks.get(credential_cache_key) is lock:
self._async_refresh_locks.pop(credential_cache_key, None)
def _try_get_cached_token(
self,
credential_cache_key: tuple,
project_id: Optional[str],
) -> Optional[Tuple[str, str]]:
"""
Look up cached credentials and return (token, project_id) if the token
is FRESH. Returns None if not cached or not fresh.
"""
from google.auth.credentials import TokenState
creds, cached_project_id = self._unpack_cached_credentials(credential_cache_key)
if (
creds is not None
and self._get_token_state(creds) == TokenState.FRESH
and creds.token is not None
and isinstance(creds.token, str)
):
resolved_project = project_id or cached_project_id
if resolved_project:
return creds.token, resolved_project
return None
def _try_get_usable_cached_token(
self,
credential_cache_key: tuple,
project_id: Optional[str],
) -> Optional[Tuple[str, str, "TokenState", Any, Optional[str]]]:
"""
Look up cached credentials and return usable token info for FRESH or
STALE tokens (both are still valid for outbound requests). STALE
tokens are returned along with their state and the underlying
credentials object so the caller can schedule a background refresh
without holding the per-key async lock.
"""
from google.auth.credentials import TokenState
creds, cached_project_id = self._unpack_cached_credentials(credential_cache_key)
if creds is None:
return None
token_state = self._get_token_state(creds)
if token_state not in (TokenState.FRESH, TokenState.STALE):
return None
if creds.token is None or not isinstance(creds.token, str):
return None
resolved_project = project_id or cached_project_id
if not resolved_project:
return None
return creds.token, resolved_project, token_state, creds, cached_project_id
def _unpack_cached_credentials(
self, credential_cache_key: tuple
) -> Tuple[Any, Optional[str]]:
"""
Return (credentials, project_id) from the cache, or (None, None) if
not cached. Handles both tuple and legacy cache formats.
"""
if credential_cache_key not in self._credentials_project_mapping:
return None, None
cached_entry = self._credentials_project_mapping[credential_cache_key]
if isinstance(cached_entry, tuple):
return cached_entry
return cached_entry, cached_entry.quota_project_id or getattr(
cached_entry, "project_id", None
)
def _get_token_state(self, credentials: Any) -> "TokenState":
"""
Return the token state using google-auth's TokenState enum.
Falls back to expired/valid checks if token_state is unavailable
(e.g. older google-auth versions or mock objects in tests).
"""
from google.auth.credentials import TokenState as _TokenState
token_state = getattr(credentials, "token_state", None)
if isinstance(token_state, _TokenState):
return token_state
# Fallback for credentials without a real token_state (e.g. mocks)
if getattr(credentials, "expired", True):
return _TokenState.INVALID
if getattr(credentials, "valid", False):
return _TokenState.FRESH
return _TokenState.INVALID
async def _load_and_cache_credentials(
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
project_id: Optional[str],
credential_cache_key: tuple,
) -> Tuple[Any, Optional[str]]:
"""Load credentials via load_auth (in thread) and cache the result."""
try:
_credentials, credential_project_id = await asyncify(self.load_auth)(
credentials=credentials,
project_id=project_id,
)
except Exception as e:
verbose_logger.exception("Failed to load vertex credentials: %s", str(e))
raise
if _credentials is None:
raise ValueError("Could not resolve credentials")
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
return _credentials, credential_project_id
async def _background_refresh_credentials(
self,
credentials: Any,
credential_cache_key: tuple,
credential_project_id: Optional[str],
) -> None:
"""
Refresh credentials in the background without blocking the calling request.
Called when the token is still valid but nearing expiry (proactive refresh).
Errors are logged but not raised — the current token is still usable.
"""
try:
verbose_logger.debug("Background proactive credential refresh")
await asyncify(self.refresh_auth)(credentials)
# Only update the cache if it still points at the credentials
# object we just refreshed. The per-key async lock is not held
# here, so a concurrent INVALID path may have already replaced
# this entry (e.g. via _handle_reauthentication_async, which
# creates a fresh credentials object). In that case our write
# would clobber the newer entry with a stale reference.
cached_creds, _ = self._unpack_cached_credentials(credential_cache_key)
if cached_creds is credentials:
self._credentials_project_mapping[credential_cache_key] = (
credentials,
credential_project_id,
)
except Exception:
verbose_logger.debug(
"Background credential refresh failed, will retry on next request",
exc_info=True,
)
async def _await_in_flight_background_refresh(
self, credential_cache_key: tuple
) -> None:
"""Wait for an in-flight background refresh to finish, if any.
google-auth's ``Credentials.refresh()`` is not safe to invoke
concurrently on the same credentials object. Coroutines that need a
blocking refresh must first drain any background refresh that was
scheduled while a previous STALE token was being served.
"""
existing_task = self._background_refresh_tasks.get(credential_cache_key)
if existing_task is None or existing_task.done():
return
try:
await existing_task
except Exception:
# Background refresh failures are already logged inside
# _background_refresh_credentials; the caller will fall through
# to its own blocking refresh.
pass
def _schedule_background_refresh(
self,
credentials: Any,
credential_cache_key: tuple,
credential_project_id: Optional[str],
) -> None:
"""Kick off a single background refresh for ``credential_cache_key``.
Skips scheduling if a refresh is already in flight. The done-callback
guards against removing a newer task that has replaced this one in the
tracking dict (done_callbacks are scheduled via ``call_soon``).
"""
existing = self._background_refresh_tasks.get(credential_cache_key)
if existing is not None and not existing.done():
return
self._background_refresh_tasks.pop(credential_cache_key, None)
task = asyncio.create_task(
self._background_refresh_credentials(
credentials, credential_cache_key, credential_project_id
)
)
def _drop_background_refresh_task(_fut: asyncio.Future[Any]) -> None:
if self._background_refresh_tasks.get(credential_cache_key) is _fut:
self._background_refresh_tasks.pop(credential_cache_key, None)
task.add_done_callback(_drop_background_refresh_task)
self._background_refresh_tasks[credential_cache_key] = task
def _ensure_access_token(
self,
@ -563,6 +820,65 @@ class VertexBase:
# Re-raise the original error for better context
raise error
async def _handle_reauthentication_async(
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
project_id: Optional[str],
credential_cache_key: Tuple,
error: Exception,
) -> Tuple[str, str]:
"""
Async reauthentication retry that stays within the per-key async lock.
"""
verbose_logger.debug(
f"Handling async reauthentication for project_id: {project_id}. "
f"Clearing cache and retrying once."
)
self._credentials_project_mapping.pop(credential_cache_key, None)
try:
_credentials, credential_project_id = (
await self._load_and_cache_credentials(
credentials=credentials,
project_id=project_id,
credential_cache_key=credential_cache_key,
)
)
if project_id is None and isinstance(credential_project_id, str):
project_id = credential_project_id
cache_credentials = (
json.dumps(credentials)
if isinstance(credentials, dict)
else credentials
)
resolved_cache_key = (cache_credentials, project_id)
# Always overwrite — any pre-existing entry at the resolved key
# references the OLD credentials object we just replaced, and
# leaving it would force the next request to do a redundant
# refresh/reauth before realizing the cached creds are stale.
self._credentials_project_mapping[resolved_cache_key] = (
_credentials,
credential_project_id,
)
if _credentials.token is None or not isinstance(_credentials.token, str):
raise ValueError(
"Could not resolve credentials token. Got None or non-string token (type={})".format(
type(_credentials.token).__name__
)
)
if project_id is None:
raise ValueError("Could not resolve project_id")
return _credentials.token, project_id
except Exception as retry_error:
verbose_logger.error(
f"Async reauthentication retry failed for project_id: {project_id}. "
f"Original error: {str(error)}. Retry error: {str(retry_error)}"
)
raise error
def get_access_token(
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
@ -646,7 +962,7 @@ class VertexBase:
)
## VALIDATE CREDENTIALS
verbose_logger.debug(f"Validating credentials for project_id: {project_id}")
verbose_logger.debug("Validating credentials")
if (
project_id is None
and credential_project_id is not None
@ -666,26 +982,27 @@ class VertexBase:
raise ValueError("Credentials are None after loading")
if _credentials.expired:
try:
verbose_logger.debug(
f"Credentials expired, refreshing for project_id: {project_id}"
)
self.refresh_auth(_credentials)
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
except Exception as e:
# if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login`
# in this case, we should try to reload the credentials by clearing the cache and retrying
if "Reauthentication is needed" in str(e) and not _retry_reauth:
return self._handle_reauthentication(
credentials=credentials,
project_id=project_id,
credential_cache_key=credential_cache_key,
error=e,
)
raise e
with self._sync_refresh_lock:
# Double-check after acquiring lock
if _credentials.expired:
try:
verbose_logger.debug("Credentials expired, refreshing")
self.refresh_auth(_credentials)
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
except Exception as e:
# if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login`
# in this case, we should try to reload the credentials by clearing the cache and retrying
if "Reauthentication is needed" in str(e) and not _retry_reauth:
return self._handle_reauthentication(
credentials=credentials,
project_id=project_id,
credential_cache_key=credential_cache_key,
error=e,
)
raise e
## VALIDATION STEP
if _credentials.token is None or not isinstance(_credentials.token, str):
@ -700,6 +1017,149 @@ class VertexBase:
return _credentials.token, project_id
async def get_access_token_async(
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
project_id: Optional[str],
) -> Tuple[str, str]:
"""
Async version of get_access_token with single-flight refresh coordination.
Prevents thundering herd: when credentials expire under high concurrency,
only one coroutine refreshes while others wait on the lock. Uses native
async refresh for service_account and authorized_user credentials.
"""
from google.auth.credentials import TokenState
cache_credentials = (
json.dumps(credentials) if isinstance(credentials, dict) else credentials
)
credential_cache_key = (cache_credentials, project_id)
# === FAST PATH (no lock) ===
# If credentials are FRESH or STALE, return immediately without
# touching the per-key async lock. STALE tokens are still usable;
# we kick off a deduplicated background refresh so subsequent
# requests get a fresh token, but we must not serialize concurrent
# callers on the lock just to schedule that refresh.
usable = self._try_get_usable_cached_token(credential_cache_key, project_id)
if usable is not None:
cached_token, resolved_project, token_state, creds, cached_project_id = (
usable
)
if token_state == TokenState.STALE:
self._schedule_background_refresh(
creds, credential_cache_key, cached_project_id
)
return cached_token, resolved_project
# === SLOW PATH (per-key lock) ===
lock = self._acquire_async_refresh_lock(credential_cache_key)
try:
async with lock:
# Double-check after acquiring lock — another coroutine may have refreshed.
cached = self._try_get_cached_token(credential_cache_key, project_id)
if cached is not None:
return cached
_credentials, credential_project_id = self._unpack_cached_credentials(
credential_cache_key
)
# Load credentials if not cached
if _credentials is None:
_credentials, credential_project_id = (
await self._load_and_cache_credentials(
credentials, project_id, credential_cache_key
)
)
# Resolve project_id from credentials if not provided
if project_id is None and isinstance(credential_project_id, str):
project_id = credential_project_id
resolved_cache_key = (cache_credentials, project_id)
# Always overwrite — a pre-existing entry at the resolved
# key may reference stale credentials (e.g. from before a
# reauth that only repopulated the unresolved key), which
# would force the next request through an unnecessary
# refresh/reauth cycle.
self._credentials_project_mapping[resolved_cache_key] = (
_credentials,
credential_project_id,
)
# Use google-auth's token_state to decide refresh strategy:
# - STALE: token is usable but within REFRESH_THRESHOLD (3:45) of
# expiry — return it immediately and refresh in the background.
# - INVALID: token is expired or missing — must block on refresh.
token_state = self._get_token_state(_credentials)
if token_state == TokenState.STALE:
if project_id is None:
raise ValueError("Could not resolve project_id")
current_token = _credentials.token
if current_token is None or not isinstance(current_token, str):
# Token is malformed despite STALE state — block on a full
# refresh using the same path as INVALID credentials.
token_state = TokenState.INVALID
else:
self._schedule_background_refresh(
_credentials,
credential_cache_key,
credential_project_id,
)
return current_token, project_id
if token_state == TokenState.INVALID:
# Drain any in-flight background refresh before invoking
# refresh_auth ourselves; google-auth's
# Credentials.refresh() is not safe to call concurrently
# on the same credentials object, and the background task
# runs outside this lock.
await self._await_in_flight_background_refresh(credential_cache_key)
cached = self._try_get_cached_token(
credential_cache_key, project_id
)
if cached is not None:
return cached
# Token is expired or missing — must block until refresh completes.
try:
verbose_logger.debug("Credentials expired, refreshing")
await asyncify(self.refresh_auth)(_credentials)
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
except Exception as e:
if "Reauthentication is needed" in str(e):
verbose_logger.debug(
"Reauthentication needed, clearing cache and retrying"
)
return await self._handle_reauthentication_async(
credentials=credentials,
project_id=project_id,
credential_cache_key=credential_cache_key,
error=e,
)
raise
# Final validation
if _credentials.token is None or not isinstance(
_credentials.token, str
):
raise ValueError(
"Could not resolve credentials token. Got None or non-string token (type={})".format(
type(_credentials.token).__name__
)
)
if project_id is None:
raise ValueError("Could not resolve project_id")
return _credentials.token, project_id
finally:
self._release_async_refresh_lock(credential_cache_key, lock)
async def _ensure_access_token_async(
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
@ -714,13 +1174,10 @@ class VertexBase:
if custom_llm_provider == "gemini":
return "", ""
else:
try:
return await asyncify(self.get_access_token)(
credentials=credentials,
project_id=project_id,
)
except Exception as e:
raise e
return await self.get_access_token_async(
credentials=credentials,
project_id=project_id,
)
def set_headers(
self, auth_header: Optional[str], extra_headers: Optional[dict]

View file

@ -57,7 +57,7 @@ def create_vertex_url(
class VertexAIModelGardenModels(VertexBase):
def __init__(self) -> None:
pass
super().__init__()
def completion(
self,
@ -89,9 +89,6 @@ class VertexAIModelGardenModels(VertexBase):
import vertexai
from litellm.llms.openai_like.chat.handler import OpenAILikeChatHandler
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexLLM,
)
except Exception as e:
raise VertexAIError(
status_code=400,
@ -107,9 +104,8 @@ class VertexAIModelGardenModels(VertexBase):
)
try:
model = get_vertex_base_model_name(model=model)
vertex_httpx_logic = VertexLLM()
access_token, project_id = vertex_httpx_logic._ensure_access_token(
access_token, project_id = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
custom_llm_provider="vertex_ai",

View file

@ -1,4 +1,4 @@
from typing import Any, AsyncIterator, Iterator, List, Optional, Tuple, Union
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple, Union
import httpx
@ -26,6 +26,7 @@ from ...openai.chat.gpt_transformation import (
class XAIChatConfig(OpenAIGPTConfig):
@property
def custom_llm_provider(self) -> Optional[str]:
return "xai"
@ -225,21 +226,57 @@ class XAIChatConfig(OpenAIGPTConfig):
verbose_logger.debug(f"Error extracting X.AI web search usage: {e}")
self._fold_reasoning_tokens_into_completion(response)
self._normalize_openai_compatible_usage_totals(getattr(response, "usage", None))
return response
@staticmethod
def _fold_reasoning_tokens_into_completion(model_response: ModelResponse) -> None:
def _fold_reasoning_tokens_into_completion(
target: Union[ModelResponse, Usage, Dict[str, Any], None],
) -> None:
"""Reconcile xAI Usage to the OpenAI invariant.
xAI accounts ``reasoning_tokens`` separately from
``completion_tokens`` while still summing them into ``total_tokens``.
OpenAI's contract (o1/o3) folds reasoning into ``completion_tokens``,
so fold here to keep ``total = prompt + completion``. Idempotent.
Accepts a ``ModelResponse`` (non-streaming), a ``Usage`` object, or a
raw usage ``dict`` (streaming chunk) so streaming and non-streaming
paths stay in sync.
"""
usage = getattr(model_response, "usage", None)
if target is None:
return
if isinstance(target, ModelResponse):
usage: Union[Usage, Dict[str, Any], None] = getattr(target, "usage", None)
else:
usage = target
if usage is None:
return
if isinstance(usage, dict):
details = usage.get("completion_tokens_details") or {}
if isinstance(details, dict):
reasoning_tokens = int(details.get("reasoning_tokens") or 0)
else:
reasoning_tokens = int(getattr(details, "reasoning_tokens", 0) or 0)
if reasoning_tokens <= 0:
return
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
total_tokens = int(usage.get("total_tokens") or 0)
if total_tokens == prompt_tokens + completion_tokens:
return
# Guard against double-counting if xAI changes accounting.
if total_tokens != prompt_tokens + completion_tokens + reasoning_tokens:
return
usage["completion_tokens"] = completion_tokens + reasoning_tokens
return
details = getattr(usage, "completion_tokens_details", None)
reasoning_tokens = (
int(getattr(details, "reasoning_tokens", 0) or 0) if details else 0
@ -284,6 +321,25 @@ class XAIChatConfig(OpenAIGPTConfig):
setattr(usage, "num_sources_used", int(num_sources_used))
verbose_logger.debug(f"X.AI web search sources used: {num_sources_used}")
@staticmethod
def _normalize_openai_compatible_usage_totals(
usage: Union[Usage, Dict[str, Any], None],
) -> None:
if usage is None:
return
if isinstance(usage, dict):
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
expected_total = prompt_tokens + completion_tokens
if int(usage.get("total_tokens") or 0) < expected_total:
usage["total_tokens"] = expected_total
return
prompt_tokens = int(usage.prompt_tokens or 0)
completion_tokens = int(usage.completion_tokens or 0)
expected_total = prompt_tokens + completion_tokens
if int(usage.total_tokens or 0) < expected_total:
usage.total_tokens = expected_total
class XAIChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler):
def chunk_parser(self, chunk: dict) -> ModelResponseStream:
@ -304,4 +360,8 @@ class XAIChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler):
# Add a dummy choice with empty delta to ensure proper processing
chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": None}]
if "usage" in chunk and chunk["usage"] is not None:
XAIChatConfig._fold_reasoning_tokens_into_completion(chunk["usage"])
XAIChatConfig._normalize_openai_compatible_usage_totals(chunk["usage"])
return super().chunk_parser(chunk)

View file

@ -1448,6 +1448,35 @@
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"jp.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
"cache_read_input_token_cost": 3.3e-07,
"input_cost_per_token": 3.3e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_read_input_token_cost": 3e-07,
@ -9602,6 +9631,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
@ -9795,6 +9825,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -9828,6 +9859,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -9861,6 +9893,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -9895,6 +9928,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -13948,6 +13982,21 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"fireworks_ai/accounts/fireworks/models/glm-5p1": {
"cache_read_input_token_cost": 2.6e-07,
"input_cost_per_token": 1.4e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 202800,
"max_output_tokens": 202800,
"max_tokens": 202800,
"mode": "chat",
"output_cost_per_token": 4.4e-06,
"source": "https://fireworks.ai/models/fireworks/glm-5p1",
"supports_function_calling": false,
"supports_reasoning": true,
"supports_response_schema": false,
"supports_tool_choice": false
},
"fireworks_ai/accounts/fireworks/models/gpt-oss-120b": {
"input_cost_per_token": 1.5e-07,
"litellm_provider": "fireworks_ai",
@ -14214,6 +14263,21 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"fireworks_ai/glm-5p1": {
"cache_read_input_token_cost": 2.6e-07,
"input_cost_per_token": 1.4e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 202800,
"max_output_tokens": 202800,
"max_tokens": 202800,
"mode": "chat",
"output_cost_per_token": 4.4e-06,
"source": "https://fireworks.ai/models/fireworks/glm-5p1",
"supports_function_calling": false,
"supports_reasoning": true,
"supports_response_schema": false,
"supports_tool_choice": false
},
"fireworks_ai/kimi-k2p5": {
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 6e-07,
@ -14883,7 +14947,65 @@
"mode": "chat",
"output_cost_per_reasoning_token": 1.5e-06,
"output_cost_per_token": 1.5e-06,
"source": "https://ai.google.dev/gemini-api/docs/models",
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true,
"supports_audio_output": false,
"supports_code_execution": true,
"supports_file_search": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query",
"supports_service_tier": true
},
"gemini-3.1-flash-lite": {
"cache_read_input_token_cost": 4.5e-08,
"cache_read_input_token_cost_per_audio_token": 9e-08,
"input_cost_per_audio_token": 9e-07,
"input_cost_per_token": 4.5e-07,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65536,
"max_pdf_size_mb": 30,
"max_tokens": 65536,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 2.7e-06,
"output_cost_per_token": 2.7e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
@ -15611,6 +15733,64 @@
},
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.5-flash": {
"cache_read_input_token_cost": 1.5e-07,
"input_cost_per_token": 1.5e-06,
"input_cost_per_audio_token": 1e-06,
"litellm_provider": "vertex_ai",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 9e-06,
"output_cost_per_token": 9e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true,
"input_cost_per_token_priority": 2.7e-06,
"input_cost_per_audio_token_priority": 1.8e-06,
"output_cost_per_token_priority": 1.62e-05,
"cache_read_input_token_cost_priority": 2.7e-07,
"supports_service_tier": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.1-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
@ -16929,6 +17109,66 @@
"web_search_billing_unit": "per_query",
"supports_service_tier": true
},
"gemini/gemini-3.1-flash-lite": {
"cache_read_input_token_cost": 4.5e-08,
"cache_read_input_token_cost_per_audio_token": 9e-08,
"input_cost_per_audio_token": 9e-07,
"input_cost_per_token": 4.5e-07,
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65536,
"max_pdf_size_mb": 30,
"max_tokens": 65536,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 2.7e-06,
"output_cost_per_token": 2.7e-06,
"rpm": 15,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true,
"supports_audio_output": false,
"supports_code_execution": true,
"supports_file_search": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true,
"tpm": 250000,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query",
"supports_service_tier": true
},
"gemini/gemini-3-flash-preview": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_audio_token": 1e-06,
@ -16988,6 +17228,67 @@
},
"web_search_billing_unit": "per_query"
},
"gemini/gemini-3.5-flash": {
"cache_read_input_token_cost": 1.5e-07,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 1.5e-06,
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 9e-06,
"output_cost_per_token": 9e-06,
"rpm": 2000,
"source": "https://ai.google.dev/pricing/gemini-3",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_audio_input": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true,
"tpm": 800000,
"input_cost_per_token_priority": 2.7e-06,
"input_cost_per_audio_token_priority": 1.8e-06,
"output_cost_per_token_priority": 1.62e-05,
"cache_read_input_token_cost_priority": 2.7e-07,
"supports_service_tier": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query"
},
"gemini/gemini-3.1-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
@ -17173,6 +17474,65 @@
},
"web_search_billing_unit": "per_query"
},
"gemini-3.5-flash": {
"cache_read_input_token_cost": 1.5e-07,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 1.5e-06,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 9e-06,
"output_cost_per_token": 9e-06,
"source": "https://ai.google.dev/pricing/gemini-3",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_audio_input": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true,
"input_cost_per_token_priority": 2.7e-06,
"input_cost_per_audio_token_priority": 1.8e-06,
"output_cost_per_token_priority": 1.62e-05,
"cache_read_input_token_cost_priority": 2.7e-07,
"supports_service_tier": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query"
},
"gemini/gemini-2.5-pro-preview-tts": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
@ -24107,6 +24467,21 @@
"supports_tool_choice": true,
"supports_vision": true
},
"mistral/ministral-8b-2512": {
"input_cost_per_token": 1.5e-07,
"litellm_provider": "mistral",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 1.5e-07,
"source": "https://mistral.ai/pricing",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"mistral/mistral-tiny": {
"input_cost_per_token": 2.5e-07,
"litellm_provider": "mistral",
@ -26951,6 +27326,58 @@
"supports_web_search": true,
"tpm": 800000
},
"openrouter/google/gemini-3.1-flash-lite": {
"cache_read_input_token_cost": 2.5e-08,
"cache_read_input_token_cost_per_audio_token": 5e-08,
"input_cost_per_audio_token": 5e-07,
"input_cost_per_token": 2.5e-07,
"litellm_provider": "openrouter",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65536,
"max_pdf_size_mb": 30,
"max_tokens": 65536,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 1.5e-06,
"output_cost_per_token": 1.5e-06,
"rpm": 2000,
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true,
"supports_audio_output": false,
"supports_code_execution": true,
"supports_file_search": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"tpm": 800000
},
"openrouter/google/gemini-3.1-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
@ -28725,6 +29152,24 @@
"supports_tool_choice": true,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"reducto/parse-legacy": {
"litellm_provider": "reducto",
"mode": "ocr",
"ocr_cost_per_credit": 0.015,
"source": "https://reducto.ai/pricing",
"supported_endpoints": [
"/v1/ocr"
]
},
"reducto/parse-v3": {
"litellm_provider": "reducto",
"mode": "ocr",
"ocr_cost_per_credit": 0.015,
"source": "https://reducto.ai/pricing",
"supported_endpoints": [
"/v1/ocr"
]
},
"recraft/recraftv2": {
"litellm_provider": "recraft",
"mode": "image_generation",
@ -33427,6 +33872,64 @@
},
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.1-flash-lite": {
"cache_read_input_token_cost": 4.5e-08,
"cache_read_input_token_cost_per_audio_token": 9e-08,
"input_cost_per_audio_token": 9e-07,
"input_cost_per_token": 4.5e-07,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65536,
"max_pdf_size_mb": 30,
"max_tokens": 65536,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 2.7e-06,
"output_cost_per_token": 2.7e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true,
"supports_audio_output": false,
"supports_code_execution": true,
"supports_file_search": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query",
"supports_service_tier": true
},
"vertex_ai/deep-research-pro-preview-12-2025": {
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,

View file

@ -1226,6 +1226,7 @@ class MCPServerManager:
tools = await self._get_tools_from_server(
server=server,
mcp_auth_header=server_auth_header,
user_api_key_auth=user_api_key_auth,
)
return tools
except Exception as e:
@ -1406,6 +1407,7 @@ class MCPServerManager:
extra_headers: Optional[Dict[str, str]] = None,
add_prefix: bool = True,
raw_headers: Optional[Dict[str, str]] = None,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
) -> List[MCPTool]:
"""
Helper method to get tools from a single MCP server with prefixed names.
@ -1432,6 +1434,46 @@ class MCPServerManager:
extra_headers = {}
extra_headers.update(server.static_headers)
# MCPJWTSigner: inject signed JWT for tools/list (list path skips pre_call_hook).
# Skip entirely when the signer is not configured (avoid an unnecessary
# dict copy on every list call), when the server has its own static
# Authorization header, when a per-user mcp_auth_header has already
# been resolved, or when the caller already supplied an Authorization
# entry in extra_headers (e.g. a per-user OAuth token resolved
# upstream) — admin-configured static auth and per-user OAuth must
# take precedence so the signer doesn't silently overwrite e.g. an
# upstream API key or a user's OAuth token (MCPClient._get_auth_headers
# applies extra_headers after writing Authorization from auth_value, so
# an injected JWT would otherwise clobber the per-user token).
if user_api_key_auth is not None and not server.spec_path:
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
get_mcp_jwt_signer,
inject_mcp_jwt_headers_for_upstream,
)
static_headers = server.static_headers or {}
has_static_authorization = any(
isinstance(k, str) and k.lower() == "authorization"
for k in static_headers.keys()
)
has_extra_authorization = bool(extra_headers) and any(
isinstance(k, str) and k.lower() == "authorization"
for k in (extra_headers or {}).keys()
)
if (
get_mcp_jwt_signer() is not None
and not has_static_authorization
and not mcp_auth_header
and not has_extra_authorization
):
extra_headers = await inject_mcp_jwt_headers_for_upstream(
user_api_key_dict=user_api_key_auth,
extra_headers=extra_headers,
raw_headers=raw_headers,
for_list_tools=True,
)
stdio_env = self._build_stdio_env(server, raw_headers)
client = await self._create_mcp_client(
@ -2791,6 +2833,112 @@ class MCPServerManager:
return cast(CallToolResult, result)
def _resolve_mcp_server_for_tool_call(
self,
server_name: str,
name: str,
) -> MCPServer:
"""Resolve MCP server for call_tool (prefixed name, registry, fallback)."""
prefixed_tool_name = add_server_prefix_to_name(name, server_name)
mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name)
resolved_by_server_name_only = False
normalized_server_name = normalize_server_name(server_name)
def _candidate_matches_server_name(candidate: MCPServer) -> bool:
for identifier in (
candidate.alias,
candidate.server_name,
candidate.name,
):
if identifier and normalize_server_name(identifier) == (
normalized_server_name
):
return True
return False
if mcp_server is None:
for candidate in self.get_registry().values():
if _candidate_matches_server_name(candidate):
mcp_server = candidate
resolved_by_server_name_only = True
break
if mcp_server is None:
fallback = self._get_mcp_server_from_tool_name(name)
if fallback is not None and (
not server_name or _candidate_matches_server_name(fallback)
):
mcp_server = fallback
if mcp_server is None:
raise ValueError(f"Tool {name} not found")
if resolved_by_server_name_only:
tool_known = (
name in self.tool_name_to_mcp_server_name_mapping
or prefixed_tool_name in self.tool_name_to_mcp_server_name_mapping
)
if not tool_known:
raise ValueError(f"Tool {name} not found")
return mcp_server
async def _resolve_oauth2_headers_for_tool_call(
self,
mcp_server: MCPServer,
oauth2_headers: Optional[Dict[str, str]],
user_api_key_auth: Optional[UserAPIKeyAuth],
) -> Optional[Dict[str, str]]:
"""Look up per-user OAuth headers when the client did not supply a token."""
if (
not mcp_server.needs_user_oauth_token
or oauth2_headers
or user_api_key_auth is None
):
return oauth2_headers
user_id = getattr(user_api_key_auth, "user_id", None)
if not user_id:
return oauth2_headers
try:
from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415
_get_user_oauth_extra_headers_from_db,
)
stored_headers = await _get_user_oauth_extra_headers_from_db(
server=mcp_server,
user_api_key_auth=user_api_key_auth,
)
if stored_headers:
return stored_headers
except Exception as _lookup_exc:
verbose_logger.debug(
"call_tool: per-user token lookup failed for " "user=%s server=%s: %s",
user_id,
mcp_server.server_id,
_lookup_exc,
)
return oauth2_headers
async def _gather_openapi_tool_tasks(
self,
tasks: List[Any],
proxy_logging_obj: Optional[ProxyLogging],
) -> CallToolResult:
"""Await OpenAPI tool tasks and return the tool call result."""
try:
mcp_responses = await asyncio.gather(*tasks)
result_index = 1 if proxy_logging_obj else 0
return cast(CallToolResult, mcp_responses[result_index])
except (
BlockedPiiEntityError,
GuardrailRaisedException,
HTTPException,
) as e:
verbose_logger.error(
f"Guardrail blocked MCP tool call during result check: {str(e)}"
)
raise e
async def call_tool(
self,
server_name: str,
@ -2821,12 +2969,7 @@ class MCPServerManager:
CallToolResult from the MCP server
"""
start_time = datetime.datetime.now()
# Get the MCP server
prefixed_tool_name = add_server_prefix_to_name(name, server_name)
mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name)
if mcp_server is None:
raise ValueError(f"Tool {name} not found")
mcp_server = self._resolve_mcp_server_for_tool_call(server_name, name)
#########################################################
# Pre MCP Tool Call Hook
@ -2860,36 +3003,9 @@ class MCPServerManager:
)
tasks.append(during_hook_task)
# For per-user OAuth servers: if the client didn't supply a token in
# oauth2_headers, look up the stored token from Redis / DB. This is the
# call_tool equivalent of _get_user_oauth_extra_headers_from_db used in
# list_tools.
if (
mcp_server.needs_user_oauth_token
and not oauth2_headers
and user_api_key_auth is not None
):
user_id = getattr(user_api_key_auth, "user_id", None)
if user_id:
try:
from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415
_get_user_oauth_extra_headers_from_db,
)
stored_headers = await _get_user_oauth_extra_headers_from_db(
server=mcp_server,
user_api_key_auth=user_api_key_auth,
)
if stored_headers:
oauth2_headers = stored_headers
except Exception as _lookup_exc:
verbose_logger.debug(
"call_tool: per-user token lookup failed for "
"user=%s server=%s: %s",
user_id,
mcp_server.server_id,
_lookup_exc,
)
oauth2_headers = await self._resolve_oauth2_headers_for_tool_call(
mcp_server, oauth2_headers, user_api_key_auth
)
# For OpenAPI servers, call the tool handler directly instead of via MCP client
if mcp_server.spec_path:
@ -2925,26 +3041,7 @@ class MCPServerManager:
hook_extra_headers=hook_result.get("extra_headers"),
)
# For OpenAPI tools, await outside the client context
try:
mcp_responses = await asyncio.gather(*tasks)
# If proxy_logging_obj is None, the tool call result is at index 0
# If proxy_logging_obj is not None, the tool call result is at index 1 (after the during hook task)
result_index = 1 if proxy_logging_obj else 0
result = mcp_responses[result_index]
return cast(CallToolResult, result)
except (
BlockedPiiEntityError,
GuardrailRaisedException,
HTTPException,
) as e:
# Re-raise guardrail exceptions to properly fail the MCP call
verbose_logger.error(
f"Guardrail blocked MCP tool call during result check: {str(e)}"
)
raise e
return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj)
#########################################################
# End of Methods that call the upstream MCP servers

View file

@ -29,6 +29,16 @@ _DEFAULT_PORTS = {"http": 80, "https": 443}
# subdomain. HTTPS only.
_TRUSTED_REDIRECT_ORIGINS_ENV = "MCP_TRUSTED_REDIRECT_ORIGINS"
# Comma-separated private-use URI allowlist for native MCP clients.
# A trailing ``*`` is a prefix match; end the prefix with ``/`` (e.g.
# ``myapp://host/oauth/*``) so ``.../oauth/callback*`` does not also
# match ``.../oauth/callback-2``.
_TRUSTED_NATIVE_REDIRECT_URIS_ENV = "MCP_TRUSTED_NATIVE_REDIRECT_URIS"
# Default allowlist for trusted native redirect URIs.
_DEFAULT_NATIVE_REDIRECT_URIS: List[str] = [
"cursor://anysphere.cursor-mcp/oauth/callback",
]
_warned_invalid_proxy_base_url: Optional[str] = None
@ -212,10 +222,82 @@ def _matches_trusted_origin_entry(netloc: str, entry: str) -> bool:
return netloc == entry
def _normalize_native_redirect_uri(
parsed,
) -> str:
"""Lowercase scheme, netloc, and path for allowlist comparison."""
return urlunparse(
(
(parsed.scheme or "").lower(),
(parsed.netloc or "").lower(),
(parsed.path or "").lower(),
"",
"",
"",
)
)
def _parse_trusted_native_redirect_uris() -> List[str]:
"""Built-in native MCP callbacks plus ``MCP_TRUSTED_NATIVE_REDIRECT_URIS``."""
entries: List[str] = [uri.lower() for uri in _DEFAULT_NATIVE_REDIRECT_URIS]
raw = os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV, "").strip()
if not raw:
return entries
for token in raw.split(","):
entry = token.strip().lower()
if entry and entry not in entries:
entries.append(entry)
return entries
def _native_wildcard_prefix_matches(normalized: str, prefix: str) -> bool:
"""Prefix match for ``entry*`` allowlist rows.
When the prefix does not end with ``/``, only exact matches or
deeper path segments (``prefix/...``) are accepted — not siblings
like ``prefix-2``.
"""
if not normalized.startswith(prefix):
return False
suffix = normalized[len(prefix) :]
if not suffix:
return True
if prefix.endswith("/"):
return True
return suffix[0] == "/"
def _matches_trusted_native_redirect_uri(parsed) -> bool:
"""Allowlisted private-use / custom-scheme OAuth callbacks for native MCP clients."""
if parsed.fragment:
return False
# Query strings are not part of registered redirect_uris (RFC 6749 §3.1.2).
# Rejecting them prevents allowlist bypass via ``.../callback?injected=...``.
if parsed.query:
return False
if not parsed.netloc:
return False
if parsed.username is not None or parsed.password is not None:
return False
if "\\" in parsed.netloc:
return False
normalized = _normalize_native_redirect_uri(parsed)
for entry in _parse_trusted_native_redirect_uris():
if entry.endswith("*"):
if _native_wildcard_prefix_matches(normalized, entry[:-1]):
return True
elif normalized == entry:
return True
return False
def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
"""Accept ``redirect_uri`` when it is (a) same-origin with the
proxy's own request origin, (b) loopback, or (c) listed in the
``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist.
proxy's own request origin, (b) loopback, (c) listed in the
``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist, or (d) a built-in /
env-configured native MCP client callback (e.g. ``cursor://``).
Same-origin is VERIA-57's threat-model-safe equivalent of loopback:
an attacker who can host content on the proxy's own HTTPS origin
@ -239,6 +321,8 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
except ValueError:
raise HTTPException(status_code=400, detail="invalid_request")
if parsed.scheme not in ("http", "https"):
if _matches_trusted_native_redirect_uri(parsed):
return
raise HTTPException(status_code=400, detail="invalid_request")
if parsed.fragment:
raise HTTPException(status_code=400, detail="invalid_request")
@ -310,9 +394,12 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
"Inbound headers: X-Forwarded-Proto=%r X-Forwarded-Host=%r "
"X-Forwarded-Port=%r Host=%r. "
"Trusted-redirect-origins env=%r. "
"Trusted-native-redirect-uris env=%r. "
"If this should be accepted, either align ingress X-Forwarded-* "
"with the browser URL, set PROXY_BASE_URL to your public origin, "
"or add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS.",
"add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS, or "
"for native MCP clients (cursor://, etc.) add the full redirect_uri "
"to MCP_TRUSTED_NATIVE_REDIRECT_URIS.",
redirect_uri,
proxy_base,
os.environ.get("PROXY_BASE_URL"),
@ -321,5 +408,6 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
request.headers.get("X-Forwarded-Port"),
request.headers.get("Host"),
os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV),
os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV),
)
raise HTTPException(status_code=400, detail="invalid_request")

View file

@ -1,6 +1,17 @@
import importlib
from datetime import datetime
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Set, Union
from typing import (
Any,
Awaitable,
Callable,
Dict,
List,
Literal,
Optional,
Set,
Tuple,
Union,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@ -231,11 +242,32 @@ if MCP_AVAILABLE:
)
return mcp_auth_header, mcp_server_auth_headers, raw_headers
def _resolve_mcp_server_id_for_rest(
server_id: str,
allowed_server_ids: Union[Set[str], List[str]],
client_ip: Optional[str] = None,
) -> str:
"""
Map REST ``server_id`` (UUID, server_name, or alias) to canonical server_id.
tools/list already did this; tools/call must match so clients can pass
server names like ``order_status_mcp`` instead of only UUIDs.
"""
allowed = set(allowed_server_ids)
if server_id in allowed:
return server_id
by_name = global_mcp_server_manager.get_mcp_server_by_name(
server_id, client_ip=client_ip
)
if by_name is not None and by_name.server_id in allowed:
return by_name.server_id
return server_id
async def _resolve_allowed_mcp_servers_with_ip_filter(
request: Request,
user_api_key_dict: UserAPIKeyAuth,
server_id: str,
) -> List[MCPServer]:
) -> Tuple[List[MCPServer], str]:
"""
Resolve allowed MCP servers for a tool call with IP filtering.
@ -245,10 +277,10 @@ if MCP_AVAILABLE:
server_id: The server ID to validate access for
Returns:
List of allowed MCPServer objects
Tuple of (allowed MCPServer objects, canonical server_id)
Raises:
HTTPException: If the server_id is not allowed
HTTPException: If the server_id is not allowed or not found
"""
# Get all auth contexts
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
@ -268,8 +300,41 @@ if MCP_AVAILABLE:
)
)
# Check if the specified server_id is allowed
if server_id not in allowed_server_ids_set:
canonical_server_id = _resolve_mcp_server_id_for_rest(
server_id, allowed_server_ids_set, _rest_client_ip
)
if canonical_server_id not in allowed_server_ids_set:
_server = global_mcp_server_manager.get_mcp_server_by_id(
server_id
) or global_mcp_server_manager.get_mcp_server_by_name(server_id)
if (
_server is not None
and _rest_client_ip is not None
and not global_mcp_server_manager._is_server_accessible_from_ip(
_server, _rest_client_ip
)
):
raise HTTPException(
status_code=403,
detail={
"error": "ip_filtering",
"message": (
f"MCP server '{server_id}' is not accessible from your IP address "
f"({_rest_client_ip}). This server is restricted to internal "
"networks only. To make it externally accessible, set "
"'available_on_public_internet: true' in the server configuration."
),
},
)
if _server is None:
raise HTTPException(
status_code=404,
detail={
"error": "server_not_found",
"message": f"MCP server '{server_id}' was not found",
},
)
raise HTTPException(
status_code=403,
detail={
@ -285,7 +350,7 @@ if MCP_AVAILABLE:
if server is not None:
allowed_mcp_servers.append(server)
return allowed_mcp_servers
return allowed_mcp_servers, canonical_server_id
async def _get_tools_for_single_server(
server,
@ -301,6 +366,7 @@ if MCP_AVAILABLE:
extra_headers=extra_headers,
add_prefix=False,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
)
# Filter tools based on allowed_tools configuration
@ -753,7 +819,7 @@ if MCP_AVAILABLE:
},
)
tool_arguments = data.get("arguments")
tool_arguments = data.get("arguments") or {}
proxy_base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
(
@ -786,14 +852,18 @@ if MCP_AVAILABLE:
data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"]
# Resolve allowed MCP servers with IP filtering
allowed_mcp_servers = await _resolve_allowed_mcp_servers_with_ip_filter(
(
allowed_mcp_servers,
canonical_server_id,
) = await _resolve_allowed_mcp_servers_with_ip_filter(
request, user_api_key_dict, server_id
)
# Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
user_oauth_extra_headers: Optional[Dict[str, str]] = None
target_server = next(
(s for s in allowed_mcp_servers if s.server_id == server_id), None
(s for s in allowed_mcp_servers if s.server_id == canonical_server_id),
None,
)
if target_server is not None:
user_oauth_extra_headers = await _get_user_oauth_extra_headers(
@ -812,6 +882,7 @@ if MCP_AVAILABLE:
oauth2_headers=user_oauth_extra_headers or data.get("oauth2_headers"),
raw_headers=data.get("raw_headers"),
litellm_logging_obj=data.get("litellm_logging_obj"),
requested_server_id=canonical_server_id,
)
return result
except BlockedPiiEntityError as e:

View file

@ -1368,6 +1368,7 @@ if MCP_AVAILABLE:
extra_headers=extra_headers,
add_prefix=True, # Always add server prefix
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
@ -2074,6 +2075,7 @@ if MCP_AVAILABLE:
"""
# Track resolved MCP server for both permission checks and dispatch
mcp_server: Optional[MCPServer] = None
requested_server_id: Optional[str] = kwargs.get("requested_server_id")
# If the client called with a display-name override (e.g. "Get Pet"),
# translate it back to the original prefixed name before any routing.
@ -2082,14 +2084,55 @@ if MCP_AVAILABLE:
# Remove prefix from tool name for logging and processing
original_tool_name, server_name = split_server_prefix_from_name(name)
requested_server: Optional[MCPServer] = None
if requested_server_id:
requested_server = next(
(s for s in allowed_mcp_servers if s.server_id == requested_server_id),
None,
)
# Resolve the actual MCP server up-front so the permission check uses
# the canonical server.name even when the tool name is prefixed with a
# short ID (LITELLM_USE_SHORT_MCP_TOOL_PREFIX) that doesn't match the
# server's display name directly.
mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
if mcp_server is None and requested_server is not None:
# REST callers may pass the raw tool name (no prefix) plus a
# ``requested_server_id``. The mapping might only contain the
# prefixed form, so retry the lookup with every known prefix of
# the requested server before treating the tool as unresolved —
# otherwise the tool_server_mismatch guard below is silently
# bypassed.
for known_prefix in iter_known_server_prefixes(requested_server):
candidate = global_mcp_server_manager._get_mcp_server_from_tool_name(
add_server_prefix_to_name(name, known_prefix)
)
if candidate is not None:
mcp_server = candidate
break
if mcp_server is not None:
server_name = mcp_server.name
# REST /mcp-rest/tools/call passes server_id — tool must belong to that server
if requested_server is not None:
if (
mcp_server is not None
and mcp_server.server_id != requested_server.server_id
):
raise HTTPException(
status_code=403,
detail={
"error": "tool_server_mismatch",
"message": (
f"Tool '{name}' belongs to MCP server '{mcp_server.name}' "
f"but request specified server_id for '{requested_server.name}'."
),
},
)
if mcp_server is None:
mcp_server = requested_server
server_name = requested_server.name
# Only enforce server-level permissions when we can resolve a server
if server_name:
if not MCPRequestHandler.is_tool_allowed(

File diff suppressed because one or more lines are too long

View file

@ -84,6 +84,11 @@ LAZY_FEATURES: Tuple[LazyFeature, ...] = (
module_path="litellm.proxy.agent_endpoints.endpoints",
path_prefixes=("/v1/agents", "/agents", "/agent/"),
),
LazyFeature(
name="gemini_agents",
module_path="litellm.proxy.google_endpoints.agents_endpoints",
path_prefixes=("/v1beta/agents",),
),
LazyFeature(
name="a2a",
module_path="litellm.proxy.agent_endpoints.a2a_endpoints",

View file

@ -3171,7 +3171,7 @@
]
},
"post": {
"description": "Create a new agent\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/agents\" \\\n -H \"Authorization: Bearer <your_api_key>\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"agent\": {\n \"agent_name\": \"my-custom-agent\",\n \"agent_card_params\": {\n \"protocolVersion\": \"1.0\",\n \"name\": \"Hello World Agent\",\n \"description\": \"Just a hello world agent\",\n \"url\": \"http://localhost:9999/\",\n \"version\": \"1.0.0\",\n \"defaultInputModes\": [\"text\"],\n \"defaultOutputModes\": [\"text\"],\n \"capabilities\": {\n \"streaming\": true\n },\n \"skills\": [\n {\n \"id\": \"hello_world\",\n \"name\": \"Returns hello world\",\n \"description\": \"just returns hello world\",\n \"tags\": [\"hello world\"],\n \"examples\": [\"hi\", \"hello world\"]\n }\n ]\n },\n \"litellm_params\": {\n \"make_public\": true\n }\n }\n }'\n```",
"description": "Create a new agent\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/v1/agents\" \\\n -H \"Authorization: Bearer <your_api_key>\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"agent_name\": \"my-custom-agent\",\n \"agent_card_params\": {\n \"protocolVersion\": \"1.0\",\n \"name\": \"Hello World Agent\",\n \"description\": \"Just a hello world agent\",\n \"url\": \"http://localhost:9999/\",\n \"version\": \"1.0.0\",\n \"defaultInputModes\": [\"text\"],\n \"defaultOutputModes\": [\"text\"],\n \"capabilities\": {\n \"streaming\": true\n },\n \"skills\": [\n {\n \"id\": \"hello_world\",\n \"name\": \"Returns hello world\",\n \"description\": \"just returns hello world\",\n \"tags\": [\"hello world\"],\n \"examples\": [\"hi\", \"hello world\"]\n }\n ]\n },\n \"litellm_params\": {\n \"make_public\": true\n }\n }'\n```",
"operationId": "create_agent_v1_agents_post",
"requestBody": {
"content": {

View file

@ -481,6 +481,10 @@ class LiteLLMRoutes(enum.Enum):
"/v1beta/interactions/{interaction_id}",
"/interactions/{interaction_id}/cancel",
"/v1beta/interactions/{interaction_id}/cancel",
# Google Managed Agents API
"/v1beta/agents",
"/v1beta/agents/{name}",
"/v1beta/agents/{name}/versions",
]
apply_guardrail_routes = [
@ -2357,6 +2361,30 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
database_connection_timeout: Optional[float] = Field(
60, description="default timeout for a connection to the database"
)
database_connect_timeout: Optional[float] = Field(
None,
description=(
"Prisma `connect_timeout` URL param (seconds). Bounds how long the "
"engine waits to establish a new connection before failing. Defaults "
"to Prisma's built-in value when unset."
),
)
database_socket_timeout: Optional[float] = Field(
None,
description=(
"Prisma `socket_timeout` URL param (seconds). When set, an idle/slow "
"connection that has not produced data within this window is closed. "
"This is the main knob for capping idle DB connections from LiteLLM."
),
)
database_extra_connection_params: Optional[Dict[str, Any]] = Field(
None,
description=(
"Escape hatch: extra key/value pairs appended verbatim to the Prisma "
"DATABASE_URL / DIRECT_URL query string (e.g. `sslmode`, `pgbouncer`, "
"`statement_cache_size`). Keys here override any default LiteLLM sets."
),
)
database_type: Optional[Literal["dynamo_db"]] = Field(
None, description="to use dynamodb instead of postgres db"
)

View file

@ -2,6 +2,12 @@
from typing import Dict, Mapping, Optional
# Re-export from the canonical SDK location so the proxy and SDK always
# share the same provider-config lookup logic.
from litellm.interactions.agents.utils import ( # noqa: F401
get_provider_agents_api_config,
)
def merge_agent_headers(
*,

View file

@ -1187,6 +1187,127 @@ async def get_end_user_object(
return None
_END_USER_VALIDATION_NEGATIVE_TTL = 60
_END_USER_VALIDATION_POSITIVE_TTL = 300
async def resolve_and_validate_end_user_id(
raw_end_user_id: Optional[str],
prisma_client: Optional[PrismaClient],
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Optional[Span] = None,
proxy_logging_obj: Optional[ProxyLogging] = None,
route: str = "",
) -> Optional[str]:
"""Optionally drop end-user ids that don't resolve to a known DB row.
Default: pass-through. LiteLLM's documented pattern is that the `user`
field is an arbitrary caller-supplied identifier, so validation is
opt-in behind ``litellm.validate_end_user_id_in_db`` to preserve
backwards compatibility.
When the flag is set: accept the id when it matches any of
- LiteLLM_EndUserTable.user_id
- LiteLLM_UserTable.user_id
- LiteLLM_UserTable.user_email (case-insensitive)
If the id doesn't match but ``litellm.max_end_user_budget_id`` is set,
we still preserve the id so the default end-user budget is applied
downstream; otherwise we return None.
DB lookups reuse ``get_end_user_object`` / ``get_user_object`` so they
share the same cache as the rest of the auth path instead of adding new
raw Prisma queries.
"""
if raw_end_user_id is None:
return None
if not litellm.validate_end_user_id_in_db:
return raw_end_user_id
if prisma_client is None:
return raw_end_user_id
cache_key = f"end_user_validation:{raw_end_user_id}"
cached = await user_api_key_cache.async_get_cache(key=cache_key)
if cached == "valid":
return raw_end_user_id
if cached == "invalid":
return raw_end_user_id if litellm.max_end_user_budget_id else None
is_valid = await _end_user_id_exists_in_db(
end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
await user_api_key_cache.async_set_cache(
key=cache_key,
value="valid" if is_valid else "invalid",
ttl=(
_END_USER_VALIDATION_POSITIVE_TTL
if is_valid
else _END_USER_VALIDATION_NEGATIVE_TTL
),
)
if is_valid:
return raw_end_user_id
# Preserve id so the caller can still apply litellm.max_end_user_budget_id.
if litellm.max_end_user_budget_id:
return raw_end_user_id
return None
async def _end_user_id_exists_in_db(
end_user_id: str,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Optional[Span] = None,
proxy_logging_obj: Optional[ProxyLogging] = None,
route: str = "",
) -> bool:
"""True when the id matches an EndUser, User, or user_email row."""
try:
end_user_obj = await get_end_user_object(
end_user_id=end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
if end_user_obj is not None:
return True
except litellm.BudgetExceededError:
raise
except Exception as e:
verbose_proxy_logger.debug(
f"end_user validation: get_end_user_object lookup failed: {e}"
)
try:
user_obj = await get_user_object(
user_id=end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
check_db_only=False,
user_email=end_user_id if "@" in end_user_id else None,
)
if user_obj is not None:
return True
except Exception as e:
verbose_proxy_logger.debug(
f"end_user validation: get_user_object lookup failed: {e}"
)
return False
@log_db_metrics
async def get_tag_objects_batch(
tag_names: List[str],

View file

@ -10,6 +10,7 @@ import litellm
from litellm import Router, provider_list
from litellm._logging import verbose_proxy_logger
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
from litellm.proxy._types import *
from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS
@ -1008,12 +1009,47 @@ def _get_customer_id_from_standard_headers(
for standard_header in STANDARD_CUSTOMER_ID_HEADERS:
for header_name, header_value in request_headers.items():
if header_name.lower() == standard_header.lower():
user_id_str = str(header_value) if header_value is not None else ""
if user_id_str.strip():
user_id_str = _coerce_user_id_to_str(header_value)
if user_id_str:
return user_id_str
return None
def _coerce_user_id_to_str(value: Any) -> Optional[str]:
"""Return a usable end-user identifier string, or None if the value isn't one.
Always drops non-string structured values (dict/list/tuple/set) because
stringifying them produces garbage spend-log rows like
``"{'device_id': ...}"``. Strings that *decode* to a structured payload
are only rejected when ``litellm.validate_end_user_id_in_db`` is enabled
— operators who currently pass JSON-encoded identifiers keep their
existing behavior until they opt in. See
auth_utils.py:get_end_user_id_from_request_body for the extraction chain.
"""
if value is None:
return None
if isinstance(value, bool):
# bool is an int subclass; handle explicitly to avoid "True"/"False".
return None
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return None
# Reject strings that decode to a structured payload (JSON object/array)
# only when the operator has opted into end-user validation. Gating
# behind the flag preserves backwards compatibility for deployments
# that intentionally pass JSON-encoded user identifiers.
if litellm.validate_end_user_id_in_db and stripped[:1] in ("{", "["):
parsed = safe_json_loads(stripped)
if isinstance(parsed, (dict, list)):
return None
return stripped
# dict, list, tuple, set, arbitrary objects -> drop.
return None
def get_end_user_id_from_request_body(
request_body: dict, request_headers: Optional[dict] = None
) -> Optional[str]:
@ -1052,23 +1088,22 @@ def get_end_user_id_from_request_body(
if isinstance(custom_header_name_to_check, list):
headers_lower = {k.lower(): v for k, v in request_headers.items()}
for expected_header in custom_header_name_to_check:
header_value = headers_lower.get(expected_header)
if header_value is not None:
user_id_str = str(header_value)
if user_id_str.strip():
return user_id_str
user_id_str = _coerce_user_id_to_str(headers_lower.get(expected_header))
if user_id_str:
return user_id_str
elif isinstance(custom_header_name_to_check, str):
for header_name, header_value in request_headers.items():
if header_name.lower() == custom_header_name_to_check.lower():
user_id_str = str(header_value) if header_value is not None else ""
if user_id_str.strip():
user_id_str = _coerce_user_id_to_str(header_value)
if user_id_str:
return user_id_str
# Check 3: 'user' field in request_body (commonly OpenAI)
if "user" in request_body and request_body["user"] is not None:
user_from_body_user_field = request_body["user"]
return str(user_from_body_user_field)
if "user" in request_body:
user_id_str = _coerce_user_id_to_str(request_body["user"])
if user_id_str:
return user_id_str
def _as_dict(value: Any) -> dict:
# metadata / litellm_metadata can arrive as JSON strings from
@ -1077,32 +1112,30 @@ def get_end_user_id_from_request_body(
if isinstance(value, dict):
return value
if isinstance(value, str):
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
parsed = safe_json_loads(value)
return parsed if isinstance(parsed, dict) else {}
return {}
# Check 4: 'litellm_metadata.user' in request_body (commonly Anthropic)
litellm_metadata = _as_dict(request_body.get("litellm_metadata"))
user_from_litellm_metadata = litellm_metadata.get("user")
if user_from_litellm_metadata is not None:
return str(user_from_litellm_metadata)
user_id_str = _coerce_user_id_to_str(litellm_metadata.get("user"))
if user_id_str:
return user_id_str
# Check 5: 'metadata.user_id' in request_body (another common pattern)
metadata_dict = _as_dict(request_body.get("metadata"))
user_id_from_metadata_field = metadata_dict.get("user_id")
if user_id_from_metadata_field is not None:
return str(user_id_from_metadata_field)
user_id_str = _coerce_user_id_to_str(metadata_dict.get("user_id"))
if user_id_str:
return user_id_str
# Check 6: 'safety_identifier' in request body (OpenAI Responses API parameter)
# SECURITY NOTE: safety_identifier can be set by any caller in the request body.
# Only use this for end-user identification in trusted environments where you control
# the calling application. For untrusted callers, prefer using headers or server-side
# middleware to set the end_user_id to prevent impersonation.
if request_body.get("safety_identifier") is not None:
user_from_body_user_field = request_body["safety_identifier"]
return str(user_from_body_user_field)
user_id_str = _coerce_user_id_to_str(request_body.get("safety_identifier"))
if user_id_str:
return user_id_str
return None

View file

@ -4,13 +4,17 @@ from typing import Dict, List, Optional, Set
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth
from litellm.router import Router
from litellm.router_utils.fallback_event_handlers import get_fallback_model_group
from litellm.types.router import LiteLLM_Params
from litellm.types.router import CredentialLiteLLMParams, LiteLLM_Params
from litellm.utils import get_valid_models
_CREDENTIAL_LITELLM_PARAM_FIELDS = set(CredentialLiteLLMParams.model_fields)
def _check_wildcard_routing(model: str) -> bool:
"""
Returns True if a model is a provider wildcard.
@ -178,6 +182,7 @@ def get_complete_model_list(
model_access_groups: Dict[str, List[str]] = {},
include_model_access_groups: Optional[bool] = False,
only_model_access_groups: Optional[bool] = False,
team_id: Optional[str] = None,
) -> List[str]:
"""Logic for returning complete model list for a given key + team pair"""
@ -222,6 +227,7 @@ def get_complete_model_list(
unique_models=unique_models,
return_wildcard_routes=return_wildcard_routes,
llm_router=llm_router,
team_id=team_id,
)
complete_model_list = unique_models + all_wildcard_models
@ -229,6 +235,29 @@ def get_complete_model_list(
return complete_model_list
def _hydrate_litellm_credential_name(
litellm_params: Optional[LiteLLM_Params],
) -> Optional[LiteLLM_Params]:
if litellm_params is None or litellm_params.litellm_credential_name is None:
return litellm_params
credential_values = CredentialAccessor.get_credential_values(
litellm_params.litellm_credential_name
)
if not credential_values:
return litellm_params
litellm_params = litellm_params.model_copy()
for key, value in credential_values.items():
if (
key in _CREDENTIAL_LITELLM_PARAM_FIELDS
and getattr(litellm_params, key, None) is None
):
setattr(litellm_params, key, value)
litellm_params.litellm_credential_name = None
return litellm_params
def get_known_models_from_wildcard(
wildcard_model: str, litellm_params: Optional[LiteLLM_Params] = None
) -> List[str]:
@ -247,7 +276,7 @@ def get_known_models_from_wildcard(
else:
provider = wildcard_provider_prefix
# get all known provider models
litellm_params = _hydrate_litellm_credential_name(litellm_params)
wildcard_models = get_provider_models(
provider=provider, litellm_params=litellm_params
@ -285,6 +314,7 @@ def _get_wildcard_models(
unique_models: List[str],
return_wildcard_routes: Optional[bool] = False,
llm_router: Optional[Router] = None,
team_id: Optional[str] = None,
) -> List[str]:
models_to_remove = set()
all_wildcard_models = []
@ -297,7 +327,9 @@ def _get_wildcard_models(
## get litellm params from model
if llm_router is not None:
model_list = llm_router.get_model_list(model_name=model)
model_list = llm_router.get_model_list(
model_name=model, team_id=team_id
)
if model_list:
for router_model in model_list:
wildcard_models = get_known_models_from_wildcard(

View file

@ -12,7 +12,7 @@ import fnmatch
import re
import secrets
from datetime import datetime, timezone
from typing import Any, Iterator, List, Optional, Tuple, Union, cast
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast
import fastapi
from fastapi import HTTPException, Request, WebSocket, status
@ -44,6 +44,7 @@ from litellm.proxy.auth.auth_checks import (
get_team_object,
get_user_object,
is_valid_fallback_model,
resolve_and_validate_end_user_id,
)
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler
from litellm.proxy.auth.auth_utils import (
@ -333,8 +334,22 @@ def _apply_budget_limits_to_end_user_params(
async def user_api_key_auth_websocket(websocket: WebSocket):
# Accept the WebSocket connection
scope_headers = list(websocket.scope.get("headers") or [])
request = Request(scope={"type": "http", "headers": scope_headers})
ws_scope = websocket.scope or {}
scope_headers = list(ws_scope.get("headers") or [])
# ``get_request_route`` falls back to ``request.url.path`` when
# ``scope["path"]`` is absent. On WebSockets that fallback reads
# ``websocket.url``, which Starlette reconstructs from the (poisonable)
# Host header. Carry the ASGI scope's path / root_path so the lookup
# never reaches the fallback.
synthetic_scope: Dict[str, Any] = {
"type": "http",
"headers": scope_headers,
"path": ws_scope.get("path", ""),
}
for key in ("root_path", "app_root_path"):
if key in ws_scope:
synthetic_scope[key] = ws_scope[key]
request = Request(scope=synthetic_scope)
request._url = websocket.url
@ -1057,9 +1072,17 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
_end_user_object = None
end_user_params = {}
end_user_id = get_end_user_id_from_request_body(
raw_end_user_id = get_end_user_id_from_request_body(
request_data, _safe_get_request_headers(request)
)
end_user_id = await resolve_and_validate_end_user_id(
raw_end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
if end_user_id:
try:
end_user_params["end_user_id"] = end_user_id
@ -1745,7 +1768,7 @@ def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamTableCached
@tracer.wrap()
async def _run_centralized_common_checks(
async def _run_centralized_common_checks( # noqa: PLR0915
user_api_key_auth_obj: UserAPIKeyAuth,
request: Request,
request_data: dict,
@ -1823,9 +1846,23 @@ async def _run_centralized_common_checks(
return
parent_otel_span = user_api_key_auth_obj.parent_otel_span
end_user_id = get_end_user_id_from_request_body(
request_data, _safe_get_request_headers(request)
)
# In the integrated auth flow ``_user_api_key_auth_builder`` has already
# resolved the end-user id and attached it here. Reuse that to avoid a
# second extraction pass; fall back to extracting locally when the
# function is invoked in isolation (e.g. in direct unit tests).
end_user_id = user_api_key_auth_obj.end_user_id
if end_user_id is None:
raw_end_user_id = get_end_user_id_from_request_body(
request_data, _safe_get_request_headers(request)
)
end_user_id = await resolve_and_validate_end_user_id(
raw_end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
fetch_coros = []
if user_api_key_auth_obj.team_id is not None:
@ -2156,11 +2193,33 @@ async def user_api_key_auth(
api_key=api_key,
)
end_user_id = get_end_user_id_from_request_body(
request_data, _safe_get_request_headers(request)
)
if end_user_id is not None:
user_api_key_auth_obj.end_user_id = end_user_id
# Defense-in-depth: ``_user_api_key_auth_builder`` has multiple early-return
# paths (no master key, /user/auth route, JWT short-circuits) that bypass
# the end-user resolution block. If those paths produced an auth obj
# without an ``end_user_id`` set, fall back to extracting from the request
# body so spend logs are still attributed correctly. Validation honours
# ``litellm.validate_end_user_id_in_db``.
if user_api_key_auth_obj.end_user_id is None:
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
raw_end_user_id = get_end_user_id_from_request_body(
request_data, _safe_get_request_headers(request)
)
if raw_end_user_id is not None:
resolved_end_user_id = await resolve_and_validate_end_user_id(
raw_end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
if resolved_end_user_id is not None:
user_api_key_auth_obj.end_user_id = resolved_end_user_id
user_api_key_auth_obj.request_route = normalize_request_route(route)
return user_api_key_auth_obj

View file

@ -523,6 +523,10 @@ async def retrieve_batch( # noqa: PLR0915
custom_llm_provider=custom_llm_provider, **data # type: ignore
)
response = await proxy_logging_obj.post_call_success_hook(
data=data, user_api_key_dict=user_api_key_dict, response=response
)
# FIX: Update the database with the latest state from provider
await update_batch_in_database(
batch_id=batch_id,
@ -533,19 +537,9 @@ async def retrieve_batch( # noqa: PLR0915
verbose_proxy_logger=verbose_proxy_logger,
db_batch_object=db_batch_object,
operation="retrieve",
user_api_key_dict=user_api_key_dict,
)
### CALL HOOKS ### - modify outgoing data
response = await proxy_logging_obj.post_call_success_hook(
data=data, user_api_key_dict=user_api_key_dict, response=response
)
# Fix: bug_feb14_batch_retrieve_returns_raw_input_file_id
# Resolve raw provider file IDs (input, output, error) to unified IDs.
if unified_batch_id:
await resolve_input_file_id_to_unified(response, prisma_client)
await resolve_output_file_ids_to_unified(response, prisma_client)
### ALERTING ###
asyncio.create_task(
proxy_logging_obj.update_request_status(
@ -917,10 +911,14 @@ async def cancel_batch(
**_cancel_batch_data,
)
# FIX: Update the database with the new cancelled state
managed_files_obj = proxy_logging_obj.get_proxy_hook("managed_files")
from litellm.proxy.proxy_server import prisma_client
response = await proxy_logging_obj.post_call_success_hook(
data=data, user_api_key_dict=user_api_key_dict, response=response
)
# FIX: Update the database with the new cancelled state
await update_batch_in_database(
batch_id=batch_id,
unified_batch_id=unified_batch_id,
@ -929,11 +927,7 @@ async def cancel_batch(
prisma_client=prisma_client,
verbose_proxy_logger=verbose_proxy_logger,
operation="cancel",
)
### CALL HOOKS ### - modify outgoing data
response = await proxy_logging_obj.post_call_success_hook(
data=data, user_api_key_dict=user_api_key_dict, response=response
user_api_key_dict=user_api_key_dict,
)
### ALERTING ###

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