Merge branch 'litellm_internal_staging' into litellm_mcp-rest-jwt-fixes

Pulls in CI fixes from the base branch so the PR's CI passes:
- 92de7423ef fix(tests): replace shut-down gpt-4o-audio-preview with gpt-audio-1.5
- 99a63d5180 adds mistral/ministral-8b-2512 to the cost map (Mistral
  rotated mistral-tiny upstream to return ministral-8b-2512)
This commit is contained in:
Claude 2026-05-20 17:40:17 +00:00
commit 97d17c3166
No known key found for this signature in database
63 changed files with 7160 additions and 2242 deletions

View file

@ -215,6 +215,7 @@ 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_ui_path_detection.py

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

@ -1288,6 +1288,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

@ -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 {}
@ -1537,6 +1617,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
value=guardrail_information.get("guardrail_response"),
)
self._set_team_attributes_from_kwargs(guardrail_span, kwargs)
guardrail_span.end(end_time=self._to_ns(end_time_datetime))
def _handle_failure(self, kwargs, response_obj, start_time, end_time):

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

@ -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

@ -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
@ -1384,12 +1389,23 @@ 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._is_gemini_3_or_newer(model)
)
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 +1445,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 +1474,7 @@ 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,
) -> Union[VertexPartType, List[VertexPartType]]:
"""
OpenAI message with a tool result looks like:
@ -1602,6 +1615,21 @@ 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 Gemini 3+ accepts (and returns) an `id` on function_response parts;
# older Gemini models reject the field with a 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._is_gemini_3_or_newer(model):
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 +1660,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

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

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

@ -64,6 +64,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"stream",
"store",
"background",
"environment",
"response_modalities",
"response_format",
"response_mime_type",
@ -142,6 +143,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"stream",
"store",
"background",
"environment",
"response_modalities",
"response_format",
"response_mime_type",

View file

@ -1042,7 +1042,9 @@ 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,
)
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
@ -300,6 +302,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
supported_params = [
"temperature",
"top_p",
"top_k",
"max_tokens",
"max_completion_tokens",
"stream",
@ -363,6 +366,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 +947,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 +1046,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 +1140,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 +1150,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 +1235,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 +1315,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 +1689,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 +1710,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

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,
@ -14883,7 +14917,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 +15703,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 +17079,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 +17198,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 +17444,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 +24437,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",
@ -33427,6 +33772,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

@ -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

@ -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 = [

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

@ -807,6 +807,11 @@ class ProxyBaseLLMRequestProcessing:
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
"acreate_agent",
"alist_agents",
"aget_agent",
"adelete_agent",
"alist_agent_versions",
"asend_message",
"call_mcp_tool",
"acreate_eval",
@ -1074,6 +1079,11 @@ class ProxyBaseLLMRequestProcessing:
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
"acreate_agent",
"alist_agents",
"aget_agent",
"adelete_agent",
"alist_agent_versions",
"asend_message",
"call_mcp_tool",
"acreate_eval",

View file

@ -0,0 +1,445 @@
"""
Google AI Studio Managed Agents API Proxy Endpoints.
Exposes Gemini's /v1beta/agents surface through the LiteLLM proxy so that
user curl commands transfer 1-to-1 by swapping the host + auth header.
Routes:
POST /v1beta/agents -> acreate_agent
GET /v1beta/agents -> alist_agents
GET /v1beta/agents/{name} -> aget_agent
DELETE /v1beta/agents/{name} -> adelete_agent
GET /v1beta/agents/{name}/versions -> alist_agent_versions
These are distinct from the A2A agent registry at /v1/agents.
"""
import json
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import ORJSONResponse
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.common_utils.http_parsing_utils import (
_read_request_body,
_safe_get_request_query_params,
)
router = APIRouter(tags=["gemini managed agents"])
def _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
return (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
def _enforce_caller_supplied_provider_key(
data: dict,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""
SECURITY: refuse to use the proxy's shared GOOGLE_API_KEY / GEMINI_API_KEY
env fallback for non-admin callers on Gemini managed-agent CRUD endpoints.
These endpoints are part of ``llm_api_routes`` so any authenticated LLM key
can reach them, but unlike ``/v1beta/models/...:generateContent`` they are
*not* routed through ``model_list`` — the only credential source is either
the per-request ``litellm_params_template`` or the env var fallback. Without
this guard, any ordinary proxy user could list, create, or delete managed
agents inside the operator's Gemini project using the operator's key.
Proxy admins (master key) keep the env-fallback convenience for ops use.
"""
if _is_proxy_admin(user_api_key_dict):
return
if data.get("api_key"):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
"Gemini managed-agent endpoints require a caller-supplied "
"Gemini api_key (via 'litellm_params_template'). Falling back to "
"the proxy's GOOGLE_API_KEY / GEMINI_API_KEY env vars is only "
"permitted for proxy admins."
),
)
def _merge_query_params_into_data(data: dict, request: Request) -> dict:
"""
For GET/DELETE endpoints that cannot carry a JSON body, read a
JSON-encoded ``litellm_params_template`` query parameter and merge its
contents into *data*, without overwriting keys that are already present
(e.g. path params like ``name`` or the fixed ``custom_llm_provider``).
This mirrors the ``litellm_params_template`` handling in
``create_gemini_agent`` and is the supported way for multi-tenant
callers to supply per-request credentials on non-POST endpoints:
.. code-block:: bash
curl "http://localhost:4000/v1beta/agents?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\
-H "Authorization: Bearer sk-..."
Credentials MUST NOT be passed as plain flat query parameters (e.g.
``?api_key=AIza...``) because URL query strings appear verbatim in
web-server access logs, CDN edge logs, browser history, and Referer
headers. Use the ``litellm_params_template`` JSON body field on POST
requests, or the JSON-encoded query parameter above for GET/DELETE.
"""
query_params = _safe_get_request_query_params(request)
if not query_params:
return data
raw_template = query_params.get("litellm_params_template")
if raw_template:
try:
template = (
json.loads(raw_template)
if isinstance(raw_template, str)
else raw_template
)
except (json.JSONDecodeError, ValueError):
template = {}
if isinstance(template, dict):
for key, value in template.items():
data.setdefault(key, value)
return data
def _proxy_server_imports():
from litellm.proxy.proxy_server import ( # noqa: PLC0415
general_settings,
llm_router,
proxy_config,
proxy_logging_obj,
select_data_generator,
user_api_base,
user_max_tokens,
user_model,
user_request_timeout,
user_temperature,
version,
)
return dict(
general_settings=general_settings,
llm_router=llm_router,
proxy_config=proxy_config,
proxy_logging_obj=proxy_logging_obj,
select_data_generator=select_data_generator,
user_api_base=user_api_base,
user_max_tokens=user_max_tokens,
user_model=user_model,
user_request_timeout=user_request_timeout,
user_temperature=user_temperature,
version=version,
)
@router.post(
"/v1beta/agents",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
)
async def create_gemini_agent(
request: Request,
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Create a named custom agent on the Gemini side.
Example:
```bash
curl -X POST "http://localhost:4000/v1beta/agents" \\
-H "Authorization: Bearer sk-..." \\
-H "Content-Type: application/json" \\
-d '{
"name": "my-custom-slides-agent",
"base_agent": "waverunner",
"instructions": "You are a helpful assistant that creates slides.",
"base_environment": {
"type": "remote",
"sources": [
{"type": "gcs", "source": "gs://eap-templates/slides-skill",
"target": "/.agents/skills/slides-skill"}
]
}
}'
```
"""
srv = _proxy_server_imports()
data = await _read_request_body(request=request)
# Merge litellm_params_template (e.g. custom_llm_provider, api_key) into the request
litellm_params_template = data.pop("litellm_params_template", None) or {}
if isinstance(litellm_params_template, dict):
for key, value in litellm_params_template.items():
if key not in data:
data[key] = value
data.setdefault("custom_llm_provider", "gemini")
_enforce_caller_supplied_provider_key(data, user_api_key_dict)
processor = ProxyBaseLLMRequestProcessing(data=data)
try:
return await processor.base_process_llm_request(
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
route_type="acreate_agent",
proxy_logging_obj=srv["proxy_logging_obj"],
llm_router=srv["llm_router"],
general_settings=srv["general_settings"],
proxy_config=srv["proxy_config"],
select_data_generator=srv["select_data_generator"],
model=None,
user_model=srv["user_model"],
user_temperature=srv["user_temperature"],
user_request_timeout=srv["user_request_timeout"],
user_max_tokens=srv["user_max_tokens"],
user_api_base=srv["user_api_base"],
version=srv["version"],
)
except Exception as e:
raise await processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=srv["proxy_logging_obj"],
version=srv["version"],
)
@router.get(
"/v1beta/agents",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
)
async def list_gemini_agents(
request: Request,
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
List all custom agents on the Gemini side.
Pass per-request Gemini credentials via the JSON-encoded
``litellm_params_template`` query parameter. Flat query parameters
(e.g. ``?api_key=AIza...``) are intentionally ignored — see
``_merge_query_params_into_data`` for the rationale.
```bash
curl "http://localhost:4000/v1beta/agents?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\
-H "Authorization: Bearer sk-..."
```
"""
srv = _proxy_server_imports()
data: dict = {"custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
_enforce_caller_supplied_provider_key(data, user_api_key_dict)
processor = ProxyBaseLLMRequestProcessing(data=data)
try:
return await processor.base_process_llm_request(
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
route_type="alist_agents",
proxy_logging_obj=srv["proxy_logging_obj"],
llm_router=srv["llm_router"],
general_settings=srv["general_settings"],
proxy_config=srv["proxy_config"],
select_data_generator=srv["select_data_generator"],
model=None,
user_model=srv["user_model"],
user_temperature=srv["user_temperature"],
user_request_timeout=srv["user_request_timeout"],
user_max_tokens=srv["user_max_tokens"],
user_api_base=srv["user_api_base"],
version=srv["version"],
)
except Exception as e:
raise await processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=srv["proxy_logging_obj"],
version=srv["version"],
)
@router.get(
"/v1beta/agents/{name}",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
)
async def get_gemini_agent(
request: Request,
name: str,
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get a specific custom agent by name.
Pass per-request Gemini credentials via the JSON-encoded
``litellm_params_template`` query parameter. Flat query parameters
(e.g. ``?api_key=AIza...``) are intentionally ignored — see
``_merge_query_params_into_data`` for the rationale.
```bash
curl "http://localhost:4000/v1beta/agents/my-custom-slides-agent?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\
-H "Authorization: Bearer sk-..."
```
"""
srv = _proxy_server_imports()
data = {"name": name, "custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
_enforce_caller_supplied_provider_key(data, user_api_key_dict)
processor = ProxyBaseLLMRequestProcessing(data=data)
try:
return await processor.base_process_llm_request(
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
route_type="aget_agent",
proxy_logging_obj=srv["proxy_logging_obj"],
llm_router=srv["llm_router"],
general_settings=srv["general_settings"],
proxy_config=srv["proxy_config"],
select_data_generator=srv["select_data_generator"],
model=None,
user_model=srv["user_model"],
user_temperature=srv["user_temperature"],
user_request_timeout=srv["user_request_timeout"],
user_max_tokens=srv["user_max_tokens"],
user_api_base=srv["user_api_base"],
version=srv["version"],
)
except Exception as e:
raise await processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=srv["proxy_logging_obj"],
version=srv["version"],
)
@router.delete(
"/v1beta/agents/{name}",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
)
async def delete_gemini_agent(
request: Request,
name: str,
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Delete a custom agent by name.
Pass per-request Gemini credentials via the JSON-encoded
``litellm_params_template`` query parameter. Flat query parameters
(e.g. ``?api_key=AIza...``) are intentionally ignored — see
``_merge_query_params_into_data`` for the rationale.
```bash
curl -X DELETE "http://localhost:4000/v1beta/agents/my-custom-slides-agent?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\
-H "Authorization: Bearer sk-..."
```
"""
srv = _proxy_server_imports()
data = {"name": name, "custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
_enforce_caller_supplied_provider_key(data, user_api_key_dict)
processor = ProxyBaseLLMRequestProcessing(data=data)
try:
return await processor.base_process_llm_request(
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
route_type="adelete_agent",
proxy_logging_obj=srv["proxy_logging_obj"],
llm_router=srv["llm_router"],
general_settings=srv["general_settings"],
proxy_config=srv["proxy_config"],
select_data_generator=srv["select_data_generator"],
model=None,
user_model=srv["user_model"],
user_temperature=srv["user_temperature"],
user_request_timeout=srv["user_request_timeout"],
user_max_tokens=srv["user_max_tokens"],
user_api_base=srv["user_api_base"],
version=srv["version"],
)
except Exception as e:
raise await processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=srv["proxy_logging_obj"],
version=srv["version"],
)
@router.get(
"/v1beta/agents/{name}/versions",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
)
async def list_gemini_agent_versions(
request: Request,
name: str,
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
List versions of a custom agent.
Pass per-request Gemini credentials via the JSON-encoded
``litellm_params_template`` query parameter. Flat query parameters
(e.g. ``?api_key=AIza...``) are intentionally ignored — see
``_merge_query_params_into_data`` for the rationale.
```bash
curl "http://localhost:4000/v1beta/agents/my-custom-slides-agent/versions?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\
-H "Authorization: Bearer sk-..."
```
"""
srv = _proxy_server_imports()
data = {"name": name, "custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
_enforce_caller_supplied_provider_key(data, user_api_key_dict)
processor = ProxyBaseLLMRequestProcessing(data=data)
try:
return await processor.base_process_llm_request(
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
route_type="alist_agent_versions",
proxy_logging_obj=srv["proxy_logging_obj"],
llm_router=srv["llm_router"],
general_settings=srv["general_settings"],
proxy_config=srv["proxy_config"],
select_data_generator=srv["select_data_generator"],
model=None,
user_model=srv["user_model"],
user_temperature=srv["user_temperature"],
user_request_timeout=srv["user_request_timeout"],
user_max_tokens=srv["user_max_tokens"],
user_api_base=srv["user_api_base"],
version=srv["version"],
)
except Exception as e:
raise await processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=srv["proxy_logging_obj"],
version=srv["version"],
)

View file

@ -285,7 +285,7 @@ async def create_interaction(
general_settings=general_settings,
proxy_config=proxy_config,
select_data_generator=select_data_generator,
model=data.get("model") or data.get("agent"),
model=data.get("model"),
user_model=user_model,
user_temperature=user_temperature,
user_request_timeout=user_request_timeout,

View file

@ -94,6 +94,12 @@ ROUTE_ENDPOINT_MAPPING = {
"aget_interaction": "/interactions/{interaction_id}",
"adelete_interaction": "/interactions/{interaction_id}",
"acancel_interaction": "/interactions/{interaction_id}/cancel",
# Google Managed Agents API routes
"acreate_agent": "/v1beta/agents",
"alist_agents": "/v1beta/agents",
"aget_agent": "/v1beta/agents/{name}",
"adelete_agent": "/v1beta/agents/{name}",
"alist_agent_versions": "/v1beta/agents/{name}/versions",
# OpenAI Evals API routes
"acreate_eval": "/evals",
"alist_evals": "/evals",
@ -311,6 +317,11 @@ async def route_request( # noqa: PLR0915 - Complex routing function, refactorin
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
"acreate_agent",
"alist_agents",
"aget_agent",
"adelete_agent",
"alist_agent_versions",
"asend_message",
"call_mcp_tool",
"acancel_batch",
@ -468,6 +479,15 @@ async def route_request( # noqa: PLR0915 - Complex routing function, refactorin
"acancel_interaction",
]:
return getattr(llm_router, f"{route_type}")(**data)
# Managed Agents API: these don't need model routing
if route_type in [
"acreate_agent",
"alist_agents",
"aget_agent",
"adelete_agent",
"alist_agent_versions",
]:
return getattr(llm_router, f"{route_type}")(**data)
if route_type in [
"avideo_list",
"avideo_status",

View file

@ -1579,6 +1579,44 @@ class Router:
cancel_interaction, call_type="cancel_interaction"
)
def _initialize_managed_agents_endpoints(self):
"""Initialize Google Managed Agents API endpoints (v1beta/agents)."""
from litellm.interactions.agents import acreate as acreate_agent
from litellm.interactions.agents import adelete as adelete_agent
from litellm.interactions.agents import aget as aget_agent
from litellm.interactions.agents import alist as alist_agents
from litellm.interactions.agents import alist_versions as alist_agent_versions
from litellm.interactions.agents import create as create_agent
from litellm.interactions.agents import delete as delete_agent
from litellm.interactions.agents import get as get_agent
from litellm.interactions.agents import list as list_agents
from litellm.interactions.agents import list_versions as list_agent_versions
self.acreate_agent = self.factory_function(
acreate_agent, call_type="acreate_agent"
)
self.create_agent = self.factory_function(
create_agent, call_type="create_agent"
)
self.alist_agents = self.factory_function(
alist_agents, call_type="alist_agents"
)
self.list_agents = self.factory_function(list_agents, call_type="list_agents")
self.aget_agent = self.factory_function(aget_agent, call_type="aget_agent")
self.get_agent = self.factory_function(get_agent, call_type="get_agent")
self.adelete_agent = self.factory_function(
adelete_agent, call_type="adelete_agent"
)
self.delete_agent = self.factory_function(
delete_agent, call_type="delete_agent"
)
self.alist_agent_versions = self.factory_function(
alist_agent_versions, call_type="alist_agent_versions"
)
self.list_agent_versions = self.factory_function(
list_agent_versions, call_type="list_agent_versions"
)
def _initialize_specialized_endpoints(self):
"""Helper to initialize specialized router endpoints (vector store, OCR, search, video, container, skills, interactions)."""
self._initialize_vector_store_endpoints()
@ -1591,6 +1629,7 @@ class Router:
self._initialize_container_endpoints()
self._initialize_skills_endpoints()
self._initialize_interactions_endpoints()
self._initialize_managed_agents_endpoints()
def initialize_router_endpoints(self):
self._initialize_core_endpoints()
@ -5322,6 +5361,16 @@ class Router:
"delete_interaction",
"acancel_interaction",
"cancel_interaction",
"acreate_agent",
"create_agent",
"alist_agents",
"list_agents",
"aget_agent",
"get_agent",
"adelete_agent",
"delete_agent",
"alist_agent_versions",
"list_agent_versions",
] = "assistants",
):
"""
@ -5406,6 +5455,27 @@ class Router:
return vector_store_file_sync_wrapper
if call_type in (
"create_agent",
"list_agents",
"get_agent",
"delete_agent",
"list_agent_versions",
):
def managed_agents_sync_wrapper(
custom_llm_provider: Optional[str] = None,
client: Optional[Any] = None,
**kwargs,
):
if custom_llm_provider and "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = custom_llm_provider
if "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = "gemini"
return original_function(**kwargs)
return managed_agents_sync_wrapper
# Handle asynchronous call types
async def async_wrapper(
custom_llm_provider: Optional[str] = None,
@ -5469,8 +5539,6 @@ class Router:
"alist_skills",
"aget_skill",
"adelete_skill",
"acreate_interaction",
"create_interaction",
):
return await self._ageneric_api_call_with_fallbacks(
original_function=original_function,
@ -5530,6 +5598,8 @@ class Router:
**kwargs,
)
elif call_type in (
"acreate_interaction",
"create_interaction",
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
@ -5539,6 +5609,18 @@ class Router:
custom_llm_provider=custom_llm_provider,
**kwargs,
)
elif call_type in (
"acreate_agent",
"alist_agents",
"aget_agent",
"adelete_agent",
"alist_agent_versions",
):
return await self._init_managed_agents_api_endpoints(
original_function=original_function,
custom_llm_provider=custom_llm_provider,
**kwargs,
)
return async_wrapper
@ -5643,6 +5725,34 @@ class Router:
if custom_llm_provider and "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = custom_llm_provider
# Default to gemini for interactions API
if "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = "gemini"
# If the proxy accidentally passed agent name as model, clear it
if kwargs.get("agent") and kwargs.get("model") == kwargs.get("agent"):
kwargs["model"] = None
# Model-based interactions use deployment routing + fallbacks; agent-only calls
# must not enter model-group lookup (agent name is not a LiteLLM deployment).
if kwargs.get("model"):
return await self._ageneric_api_call_with_fallbacks(
original_function=original_function,
**kwargs,
)
return await original_function(**kwargs)
async def _init_managed_agents_api_endpoints(
self,
original_function: Callable,
custom_llm_provider: Optional[str] = None,
**kwargs,
):
"""
Initialize the Managed Agents API endpoints on the router (v1beta/agents).
CRUD operations for Gemini managed agents don't need model-based routing,
so we call the original function directly with the custom_llm_provider.
"""
if custom_llm_provider and "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = custom_llm_provider
if "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = "gemini"
return await original_function(**kwargs)

View file

@ -228,6 +228,66 @@ class ListAgentsResponse(BaseModel):
agents: List[AgentResponse]
class AgentCreateResponse(LiteLLMPydanticObjectBase):
"""
Response from a provider-side agent creation or get call (e.g. Gemini v1beta/agents).
Gemini returns ``"id"`` as the agent identifier; we surface both ``id``
(Gemini's value) and ``name`` (the user-supplied name, equal to ``id`` for
Gemini) so callers can use either. All extra fields returned by the
provider (e.g. ``base_agent``, ``system_instruction``, ``base_environment``)
are preserved via extra="allow".
"""
id: Optional[str] = None
name: Optional[str] = None
model_config = {"extra": "allow"}
_hidden_params: dict = PrivateAttr(default_factory=dict)
class AgentDeleteResult(LiteLLMPydanticObjectBase):
"""Result of a provider-side agent deletion (e.g. Gemini DELETE /v1beta/agents/{name}).
Gemini returns an empty body ``{}`` on success; we synthesise ``name`` and
``deleted`` so callers always get a consistent response object.
"""
name: str
deleted: bool = True
model_config = {"extra": "allow"}
_hidden_params: dict = PrivateAttr(default_factory=dict)
class AgentListResponse(LiteLLMPydanticObjectBase):
"""Response from listing agents on the provider side (e.g. Gemini GET /v1beta/agents).
Gemini returns ``{"agents": [{"id": "..."}, ...]}``; each item is kept as
a plain dict so no fields are silently dropped.
"""
agents: List[Dict[str, Any]] = []
next_page_token: Optional[str] = None
model_config = {"extra": "allow"}
_hidden_params: dict = PrivateAttr(default_factory=dict)
class AgentVersionsResponse(LiteLLMPydanticObjectBase):
"""Response from listing versions of an agent (e.g. Gemini GET /v1beta/agents/{name}/versions).
Gemini returns ``{"agentVersions": [...]}``; each version has a ``name``
field of the form ``agents/{agent_id}/versions/{uuid}``.
"""
agent_versions: List[Dict[str, Any]] = []
next_page_token: Optional[str] = None
model_config = {"extra": "allow"}
_hidden_params: dict = PrivateAttr(default_factory=dict)
class AgentMakePublicResponse(BaseModel):
message: str
public_agent_groups: List[str]

View file

@ -37,6 +37,7 @@ from litellm.types.interactions.generated import (
ImageContent,
Interaction,
InteractionEvent,
InteractionEnvironment,
InteractionInput,
InteractionsAPIOptionalRequestParams,
InteractionsAPIResponse,
@ -115,6 +116,7 @@ __all__ = [
"ResponseModality",
"Annotation",
# LiteLLM types
"InteractionEnvironment",
"InteractionInput",
"InteractionsAPIResponse",
"InteractionsAPIStreamingResponse",

View file

@ -1257,3 +1257,6 @@ class CancelInteractionResult(BaseLiteLLMOpenAIResponseObject):
InteractionTool = Tool
InteractionToolChoiceConfig = ToolChoiceConfig
InteractionsAPIOptionalRequestParams = Dict[str, Any]
# Agent interaction execution environment
InteractionEnvironment = Union[str, Dict[str, Any]]

View file

@ -14,13 +14,19 @@ from litellm.types.llms.openai import EmbeddingInput
GeminiEmbeddingInput = Union[EmbeddingInput, List[List[str]]]
class FunctionResponse(TypedDict):
name: str
class FunctionResponse(TypedDict, total=False):
# `id` correlates this response with the originating `functionCall` part.
# Required by Gemini 3.5+ for strict function-calling response matching.
id: str
name: Required[str]
response: Optional[dict]
class FunctionCall(TypedDict):
name: str
class FunctionCall(TypedDict, total=False):
# `id` is returned by Gemini 3.5+ to correlate the corresponding
# `functionResponse`. Older Gemini models omit this field.
id: str
name: Required[str]
args: Optional[dict]
@ -45,8 +51,11 @@ class PartType(TypedDict, total=False):
media_resolution: Literal["low", "medium", "high"]
class HttpxFunctionCall(TypedDict):
name: str
class HttpxFunctionCall(TypedDict, total=False):
# `id` is returned by Gemini 3.5+ to correlate the corresponding
# `functionResponse`. Older Gemini models omit this field.
id: str
name: Required[str]
args: dict

View file

@ -14957,6 +14957,64 @@
"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",
"/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
},
"deep-research-pro-preview-12-2025": {
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
@ -15645,6 +15703,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,
@ -16963,6 +17079,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,
@ -17022,6 +17198,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,
@ -17207,6 +17444,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,
@ -24141,6 +24437,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",
@ -33461,6 +33772,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

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.86.0"
version = "1.87.0"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.14"
@ -56,7 +56,7 @@ proxy = [
"azure-identity==1.25.2",
"azure-storage-blob==12.28.0",
"mcp==1.26.0",
"litellm-proxy-extras==0.4.72",
"litellm-proxy-extras==0.4.73",
"litellm-enterprise==0.1.41",
"RestrictedPython==8.1",
"rich==13.9.4",
@ -251,7 +251,7 @@ source-exclude = [
profile = "black"
[tool.commitizen]
version = "1.86.0"
version = "1.87.0"
version_files = [
"pyproject.toml:^version",
]

View file

@ -59,7 +59,7 @@ async def test_audio_output_from_model(stream):
litellm.set_verbose = False
try:
completion = await litellm.acompletion(
model="gpt-4o-audio-preview",
model="gpt-audio-1.5",
modalities=["text", "audio"],
audio={"voice": "alloy", "format": "pcm16"},
messages=[{"role": "user", "content": "response in 1 word - yes or no"}],
@ -69,8 +69,14 @@ async def test_audio_output_from_model(stream):
print(e)
pytest.skip("Skipping test due to timeout")
except Exception as e:
if "openai-internal" in str(e):
pytest.skip("Skipping test due to openai-internal error")
err = str(e).lower()
if (
"model_not_found" in err
or "does not exist" in err
or "openai-internal" in err
):
pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}")
raise
if stream is True:
await check_streaming_response(completion)
@ -85,7 +91,7 @@ async def test_audio_output_from_model(stream):
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [True, False])
@pytest.mark.parametrize("model", ["gpt-4o-audio-preview"]) # "gpt-4o-audio-preview",
@pytest.mark.parametrize("model", ["gpt-audio-1.5"])
async def test_audio_input_to_model(stream, model):
# Fetch the audio file and convert it to a base64 encoded string
audio_format = "pcm16"
@ -121,9 +127,14 @@ async def test_audio_input_to_model(stream, model):
print(e)
pytest.skip("Skipping test due to timeout")
except Exception as e:
if "openai-internal" in str(e):
pytest.skip("Skipping test due to openai-internal error")
raise e
err = str(e).lower()
if (
"model_not_found" in err
or "does not exist" in err
or "openai-internal" in err
):
pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}")
raise
if stream is True:
await check_streaming_response(completion)
else:

View file

@ -22,6 +22,20 @@ sys.path.insert(
) # Adds the parent directory to the system path
import litellm
# ``litellm.model_cost`` is loaded at import time from the URL pinned to
# ``main`` (``LITELLM_MODEL_COST_MAP_URL``). The in-tree backup ships with
# this branch and can include pricing entries that main has not yet picked
# up (e.g. an upstream provider rotates a model id and the test cassette
# records the new name). Backfill any entries that are missing from the
# remote-fetched map so cost-calculator lookups in tests succeed against
# the cassette state the branch is being tested with.
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
_local_cost_map = GetModelCostMap.load_local_model_cost_map()
for _k, _v in _local_cost_map.items():
litellm.model_cost.setdefault(_k, _v)
del _local_cost_map
from tests._vcr_conftest_common import ( # noqa: E402,F401
VerboseReporterState,
_pin_multipart_boundary,

View file

@ -1125,7 +1125,7 @@ def test_standard_logging_payload_audio(turn_off_message_logging, stream):
) as mock_client:
try:
response = litellm.completion(
model="gpt-4o-audio-preview",
model="gpt-audio-1.5",
modalities=["text", "audio"],
audio={"voice": "alloy", "format": "pcm16"},
messages=[
@ -1134,8 +1134,14 @@ def test_standard_logging_payload_audio(turn_off_message_logging, stream):
stream=stream,
)
except Exception as e:
if "openai-internal" in str(e):
pytest.skip("Skipping test due to openai-internal error")
err = str(e).lower()
if (
"model_not_found" in err
or "does not exist" in err
or "openai-internal" in err
):
pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}")
raise
if stream:
for chunk in response:

View file

@ -649,7 +649,7 @@ def test_stream_chunk_builder_openai_audio_output_usage():
try:
completion = client.chat.completions.create(
model="gpt-4o-audio-preview",
model="gpt-audio-1.5",
modalities=["text", "audio"],
audio={"voice": "alloy", "format": "pcm16"},
messages=[{"role": "user", "content": "response in 1 word - yes or no"}],
@ -657,8 +657,14 @@ def test_stream_chunk_builder_openai_audio_output_usage():
stream_options={"include_usage": True},
)
except Exception as e:
if "openai-internal" in str(e):
pytest.skip("Skipping test due to openai-internal error")
err = str(e).lower()
if (
"model_not_found" in err
or "does not exist" in err
or "openai-internal" in err
):
pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}")
raise
chunks = []
for chunk in completion:

View file

@ -0,0 +1,519 @@
"""
Unit tests for litellm/proxy/google_endpoints/agents_endpoints.py
Focus: verify that list_gemini_agents, get_gemini_agent, delete_gemini_agent,
and list_gemini_agent_versions correctly forward per-request credentials
(api_key, api_base, …) supplied via the JSON-encoded litellm_params_template
query parameter. Flat credential query params (e.g. ?api_key=…) are no
longer accepted — they would appear in server logs.
"""
import json
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request
from fastapi.datastructures import Headers, QueryParams
sys.path.insert(0, os.path.abspath("../.."))
from litellm.proxy.google_endpoints.agents_endpoints import (
_merge_query_params_into_data,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_request(query_string: str = "") -> MagicMock:
"""Build a minimal mock Request whose query_params match *query_string*."""
req = MagicMock(spec=Request)
req.query_params = QueryParams(query_string)
req.headers = Headers({})
return req
# ---------------------------------------------------------------------------
# _merge_query_params_into_data – unit tests for the helper
# ---------------------------------------------------------------------------
class TestMergeQueryParamsIntoData:
def test_no_query_params_leaves_data_unchanged(self):
data = {"custom_llm_provider": "gemini"}
request = _make_request("")
result = _merge_query_params_into_data(data, request)
assert result == {"custom_llm_provider": "gemini"}
def test_flat_api_key_is_ignored(self):
"""Flat credential params must NOT be merged (they leak into server logs)."""
data = {"custom_llm_provider": "gemini"}
request = _make_request("api_key=AIzaSyTest123")
_merge_query_params_into_data(data, request)
assert "api_key" not in data
assert data["custom_llm_provider"] == "gemini"
def test_flat_params_are_silently_dropped(self):
"""Flat params (including name injection attempts) are ignored entirely."""
data = {"name": "my-agent", "custom_llm_provider": "gemini"}
request = _make_request("name=INJECTED&api_key=AIzaSyTest")
_merge_query_params_into_data(data, request)
assert data["name"] == "my-agent"
assert "api_key" not in data
def test_litellm_params_template_json_is_expanded(self):
template = json.dumps(
{"api_key": "AIzaFromTemplate", "api_base": "https://example.com"}
)
from urllib.parse import quote
request = _make_request(f"litellm_params_template={quote(template)}")
data = {"custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
assert data["api_key"] == "AIzaFromTemplate"
assert data["api_base"] == "https://example.com"
# The raw template key itself must NOT appear in data
assert "litellm_params_template" not in data
def test_litellm_params_template_does_not_overwrite_existing(self):
template = json.dumps(
{"api_key": "FromTemplate", "custom_llm_provider": "openai"}
)
from urllib.parse import quote
request = _make_request(f"litellm_params_template={quote(template)}")
data = {"custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
# custom_llm_provider was already set; template must not override it
assert data["custom_llm_provider"] == "gemini"
assert data["api_key"] == "FromTemplate"
def test_invalid_litellm_params_template_json_is_ignored(self):
request = _make_request("litellm_params_template=NOT_VALID_JSON")
data = {"custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
# Bad JSON is silently skipped; other data stays intact
assert data == {"custom_llm_provider": "gemini"}
def test_template_only_no_flat_params_merged(self):
"""Only litellm_params_template is expanded; unknown flat params are dropped."""
template = json.dumps({"api_key": "FromTemplate"})
from urllib.parse import quote
qs = f"litellm_params_template={quote(template)}&vertex_project=my-project"
request = _make_request(qs)
data = {"custom_llm_provider": "gemini"}
_merge_query_params_into_data(data, request)
assert data["api_key"] == "FromTemplate"
# flat vertex_project is ignored since it wasn't in litellm_params_template
assert "vertex_project" not in data
assert "litellm_params_template" not in data
# ---------------------------------------------------------------------------
# Endpoint-level smoke tests: data dict is populated before the processor call
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_srv():
"""Patch _proxy_server_imports to return lightweight fakes."""
srv = {
"general_settings": {},
"llm_router": MagicMock(),
"proxy_config": MagicMock(),
"proxy_logging_obj": MagicMock(),
"select_data_generator": MagicMock(),
"user_api_base": None,
"user_max_tokens": None,
"user_model": None,
"user_request_timeout": None,
"user_temperature": None,
"version": "0.0.0",
}
with patch(
"litellm.proxy.google_endpoints.agents_endpoints._proxy_server_imports",
return_value=srv,
):
yield srv
@pytest.fixture
def user_api_key_dict():
from litellm.proxy._types import UserAPIKeyAuth
return UserAPIKeyAuth(api_key="test-key")
def _make_endpoint_request(query_string: str = "") -> MagicMock:
req = MagicMock(spec=Request)
req.query_params = QueryParams(query_string)
req.headers = Headers({})
req.scope = {}
async def _body():
return b""
req.body = _body
return req
@pytest.mark.asyncio
async def test_list_gemini_agents_passes_api_key_to_processor(
mock_srv, user_api_key_dict
):
from urllib.parse import quote
from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents
template = json.dumps({"api_key": "AIzaListTest"})
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request(f"litellm_params_template={quote(template)}")
await list_gemini_agents(
request=request,
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
init_data = MockProcessor.call_args[1]["data"]
assert init_data.get("api_key") == "AIzaListTest"
assert init_data.get("custom_llm_provider") == "gemini"
@pytest.mark.asyncio
async def test_get_gemini_agent_passes_api_key_to_processor(
mock_srv, user_api_key_dict
):
from urllib.parse import quote
from litellm.proxy.google_endpoints.agents_endpoints import get_gemini_agent
template = json.dumps({"api_key": "AIzaGetTest"})
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request(f"litellm_params_template={quote(template)}")
await get_gemini_agent(
request=request,
name="my-agent",
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
init_data = MockProcessor.call_args[1]["data"]
assert init_data.get("api_key") == "AIzaGetTest"
assert init_data.get("name") == "my-agent"
assert init_data.get("custom_llm_provider") == "gemini"
@pytest.mark.asyncio
async def test_delete_gemini_agent_passes_api_key_to_processor(
mock_srv, user_api_key_dict
):
from urllib.parse import quote
from litellm.proxy.google_endpoints.agents_endpoints import delete_gemini_agent
template = json.dumps({"api_key": "AIzaDeleteTest"})
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request(f"litellm_params_template={quote(template)}")
await delete_gemini_agent(
request=request,
name="my-agent",
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
init_data = MockProcessor.call_args[1]["data"]
assert init_data.get("api_key") == "AIzaDeleteTest"
assert init_data.get("name") == "my-agent"
assert init_data.get("custom_llm_provider") == "gemini"
@pytest.mark.asyncio
async def test_list_gemini_agent_versions_passes_api_key_to_processor(
mock_srv, user_api_key_dict
):
from urllib.parse import quote
from litellm.proxy.google_endpoints.agents_endpoints import (
list_gemini_agent_versions,
)
template = json.dumps({"api_key": "AIzaVersionsTest"})
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request(f"litellm_params_template={quote(template)}")
await list_gemini_agent_versions(
request=request,
name="my-agent",
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
init_data = MockProcessor.call_args[1]["data"]
assert init_data.get("api_key") == "AIzaVersionsTest"
assert init_data.get("name") == "my-agent"
assert init_data.get("custom_llm_provider") == "gemini"
@pytest.mark.asyncio
async def test_get_gemini_agent_name_not_overwritten_by_query_param(
mock_srv, user_api_key_dict
):
"""Path-param ``name`` must not be replaced by an attacker-controlled query param."""
from urllib.parse import quote
from litellm.proxy.google_endpoints.agents_endpoints import get_gemini_agent
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
# Even if a caller tries to inject "name" via flat query param, it is
# ignored (flat params are not merged). The path-param name wins.
# ``api_key`` is supplied via the JSON template (required for non-admin
# callers — see test_*_non_admin_without_api_key_is_rejected below).
template = json.dumps({"api_key": "AIzaTest"})
request = _make_endpoint_request(
f"name=INJECTED&litellm_params_template={quote(template)}"
)
await get_gemini_agent(
request=request,
name="real-agent",
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
init_data = MockProcessor.call_args[1]["data"]
assert init_data["name"] == "real-agent"
@pytest.mark.asyncio
async def test_list_agents_template_via_query_param(mock_srv, user_api_key_dict):
"""litellm_params_template in query string is expanded."""
from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents
from urllib.parse import quote
template = json.dumps({"api_key": "TemplateKey", "vertex_project": "proj-x"})
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request(f"litellm_params_template={quote(template)}")
await list_gemini_agents(
request=request,
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
init_data = MockProcessor.call_args[1]["data"]
assert init_data["api_key"] == "TemplateKey"
assert init_data["vertex_project"] == "proj-x"
assert "litellm_params_template" not in init_data
# ---------------------------------------------------------------------------
# Security guards (veria-flagged findings)
# ---------------------------------------------------------------------------
@pytest.fixture
def proxy_admin_user_api_key_dict():
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
return UserAPIKeyAuth(
api_key="sk-admin",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
@pytest.mark.asyncio
async def test_list_agents_non_admin_without_api_key_is_rejected(
mock_srv, user_api_key_dict
):
"""Non-admin callers must supply an explicit api_key — the proxy must not
silently fall back to the operator's shared GOOGLE_API_KEY/GEMINI_API_KEY.
"""
from fastapi import HTTPException
from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request("")
with pytest.raises(HTTPException) as excinfo:
await list_gemini_agents(
request=request,
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
assert excinfo.value.status_code == 401
# Processor must never be invoked
instance.base_process_llm_request.assert_not_called()
@pytest.mark.asyncio
async def test_delete_agent_non_admin_without_api_key_is_rejected(
mock_srv, user_api_key_dict
):
from fastapi import HTTPException
from litellm.proxy.google_endpoints.agents_endpoints import delete_gemini_agent
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request("")
with pytest.raises(HTTPException) as excinfo:
await delete_gemini_agent(
request=request,
name="my-agent",
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
assert excinfo.value.status_code == 401
instance.base_process_llm_request.assert_not_called()
@pytest.mark.asyncio
async def test_create_agent_non_admin_without_api_key_is_rejected(
mock_srv, user_api_key_dict
):
from fastapi import HTTPException
from litellm.proxy.google_endpoints.agents_endpoints import create_gemini_agent
with (
patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor,
patch(
"litellm.proxy.google_endpoints.agents_endpoints._read_request_body",
new=AsyncMock(return_value={"name": "agent-1", "base_agent": "waverunner"}),
),
):
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request("")
with pytest.raises(HTTPException) as excinfo:
await create_gemini_agent(
request=request,
fastapi_response=MagicMock(),
user_api_key_dict=user_api_key_dict,
)
assert excinfo.value.status_code == 401
instance.base_process_llm_request.assert_not_called()
@pytest.mark.asyncio
async def test_list_agents_proxy_admin_may_use_env_fallback(
mock_srv, proxy_admin_user_api_key_dict
):
"""Proxy admins (master key) keep the env-fallback convenience."""
from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents
with patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
instance = MockProcessor.return_value
instance.base_process_llm_request = AsyncMock(return_value=MagicMock())
request = _make_endpoint_request("")
await list_gemini_agents(
request=request,
fastapi_response=MagicMock(),
user_api_key_dict=proxy_admin_user_api_key_dict,
)
init_data = MockProcessor.call_args[1]["data"]
assert "api_key" not in init_data
instance.base_process_llm_request.assert_awaited_once()
def test_validate_environment_rejects_api_base_override_without_explicit_key(
monkeypatch,
):
"""SECURITY: caller-supplied api_base must be paired with an explicit
api_key — otherwise the proxy's shared GOOGLE_API_KEY leaks to the
attacker-controlled host via the x-goog-api-key header.
"""
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
# Even if env-fallback is available, api_base override must require api_key.
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSharedSecret")
cfg = GeminiAgentsConfig()
with pytest.raises(ValueError, match="api_base"):
cfg.validate_environment(
headers={},
litellm_params={"api_base": "https://attacker.example"},
)
def test_validate_environment_allows_api_base_with_explicit_key(monkeypatch):
"""api_base override is OK when paired with an explicit api_key."""
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
cfg = GeminiAgentsConfig()
headers = cfg.validate_environment(
headers={},
litellm_params={
"api_base": "https://my-gemini-proxy.example",
"api_key": "AIzaCallerOwned",
},
)
assert headers["x-goog-api-key"] == "AIzaCallerOwned"
def test_validate_environment_env_fallback_when_no_api_base_override(monkeypatch):
"""Without api_base override, env fallback continues to work for SDK use."""
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaFromEnv")
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
cfg = GeminiAgentsConfig()
headers = cfg.validate_environment(headers={}, litellm_params={})
assert headers["x-goog-api-key"] == "AIzaFromEnv"

View file

@ -88,6 +88,149 @@ class TestOpenTelemetryGuardrails(unittest.TestCase):
otel.tracer.start_span.assert_not_called()
class TestOpenTelemetryTeamAttributesOnChildSpans(unittest.TestCase):
"""team_id / team_alias must land on every child span of a
litellm_request trace, not only the root litellm_request span."""
def _slo_metadata(self):
return {
"user_api_key_team_id": "team-123",
"user_api_key_team_alias": "my-team",
}
@patch("litellm.integrations.opentelemetry.datetime")
def test_guardrail_span_has_team_attributes(self, mock_datetime):
otel = OpenTelemetry()
otel.tracer = MagicMock()
mock_span = MagicMock()
otel.tracer.start_span.return_value = mock_span
guardrail_info = {
"guardrail_name": "test_guardrail",
"guardrail_mode": "input",
"guardrail_response": "filtered_content",
"start_time": 1609459200.0,
"end_time": 1609459201.0,
}
kwargs = {
"standard_logging_object": {
"guardrail_information": [guardrail_info],
"metadata": self._slo_metadata(),
}
}
otel._create_guardrail_span(kwargs=kwargs, context=None)
mock_span.set_attribute.assert_any_call(
"metadata.user_api_key_team_id", "team-123"
)
mock_span.set_attribute.assert_any_call(
"metadata.user_api_key_team_alias", "my-team"
)
@patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""})
@patch("litellm.turn_off_message_logging", False)
def test_raw_request_span_has_team_attributes(self):
otel = OpenTelemetry()
otel.message_logging = True
mock_tracer = MagicMock()
mock_span = MagicMock()
mock_tracer.start_span.return_value = mock_span
otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer)
otel.set_raw_request_attributes = MagicMock()
otel._to_ns = MagicMock(return_value=1234567890)
kwargs = {
"litellm_params": {"metadata": {}},
"standard_logging_object": {"metadata": self._slo_metadata()},
}
otel._maybe_log_raw_request(
kwargs, {}, datetime.now(), datetime.now(), MagicMock()
)
mock_span.set_attribute.assert_any_call(
"metadata.user_api_key_team_id", "team-123"
)
mock_span.set_attribute.assert_any_call(
"metadata.user_api_key_team_alias", "my-team"
)
def test_helper_skips_when_team_values_missing(self):
otel = OpenTelemetry()
mock_span = MagicMock()
otel._set_team_attributes_on_span(span=mock_span, team_id=None, team_alias=None)
mock_span.set_attribute.assert_not_called()
def test_helper_skips_when_team_values_are_empty_strings(self):
"""A master-key / team-less request carries user_api_key_team_id=''
in metadata. Propagating '' to every span is noise that makes
traces look mis-instrumented; treat empty as absent."""
otel = OpenTelemetry()
mock_span = MagicMock()
otel._set_team_attributes_on_span(span=mock_span, team_id="", team_alias="")
mock_span.set_attribute.assert_not_called()
def test_helper_reads_metadata_from_kwargs(self):
otel = OpenTelemetry()
mock_span = MagicMock()
otel._set_team_attributes_from_kwargs(
mock_span,
{"standard_logging_object": {"metadata": self._slo_metadata()}},
)
mock_span.set_attribute.assert_any_call(
"metadata.user_api_key_team_id", "team-123"
)
mock_span.set_attribute.assert_any_call(
"metadata.user_api_key_team_alias", "my-team"
)
def test_helper_handles_missing_standard_logging_object(self):
otel = OpenTelemetry()
mock_span = MagicMock()
otel._set_team_attributes_from_kwargs(mock_span, {})
mock_span.set_attribute.assert_not_called()
def test_failure_hook_exception_span_has_team_attributes(self):
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer(__name__)
otel = OpenTelemetry()
otel.tracer = tracer
server_span = tracer.start_span("Received Proxy Server Request")
user_api_key_dict = MagicMock()
user_api_key_dict.parent_otel_span = server_span
user_api_key_dict.team_id = "team-123"
user_api_key_dict.team_alias = "my-team"
asyncio.run(
otel.async_post_call_failure_hook(
request_data={},
original_exception=ValueError("boom"),
user_api_key_dict=user_api_key_dict,
traceback_str="trace",
)
)
finished = {s.name: s for s in exporter.get_finished_spans()}
exception_span = finished["Failed Proxy Server Request"]
assert exception_span.attributes["metadata.user_api_key_team_id"] == "team-123"
assert (
exception_span.attributes["metadata.user_api_key_team_alias"] == "my-team"
)
class TestOpenTelemetryCostBreakdown(unittest.TestCase):
def test_cost_breakdown_emitted_to_otel_span(self):
"""

View file

@ -0,0 +1,285 @@
"""
Matrix test: team_id / team_alias must land on EVERY span of a proxy
request trace, for a representative set of endpoints x HTTP outcomes.
Endpoints
- /v1/chat/completions (OpenAI-format LLM path)
- /v1/messages (Anthropic-format LLM path)
- /team/info (management/admin path)
Outcomes
- 2xx success
- 3xx redirect (LLM endpoints never 3xx -> N/A; admin too)
- 4xx client error (auth / validation failure)
- 5xx server error (upstream / DB failure)
Strategy
These assertions exercise the real OpenTelemetry callback the proxy
invokes for each path, with a SERVER parent span (as
``user_api_key_auth`` creates) and an in-memory exporter. Each cell
drives the path, then asserts team attributes on every span that path
actually emits.
- success path -> ``log_success_event`` -> litellm_request +
raw_gen_ai_request + guardrail child spans.
- failure path -> ``async_post_call_failure_hook`` -> Failed Proxy
Server Request exception child span.
Admin endpoints do not run the LLM success callback, so their only
trace surface is the SERVER span (success) or the exception child span
(failure) -- the cells below assert exactly that.
"""
import asyncio
import os
import sys
import unittest
from datetime import datetime
from unittest.mock import MagicMock
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
sys.path.insert(0, os.path.abspath("../.."))
from litellm.integrations.opentelemetry import (
LITELLM_PROXY_REQUEST_SPAN_NAME,
OpenTelemetry,
)
TEAM_ID = "team-123"
TEAM_ALIAS = "my-team"
TEAM_ID_ATTR = "metadata.user_api_key_team_id"
TEAM_ALIAS_ATTR = "metadata.user_api_key_team_alias"
def _make_otel():
"""OTel callback whose every span lands in an in-memory exporter."""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
otel = OpenTelemetry()
otel.tracer = provider.get_tracer(__name__)
# raw_gen_ai_request sub-span is gated on message logging.
otel.message_logging = True
return otel, exporter
def _server_span(otel):
"""Mirror the SERVER span user_api_key_auth opens per request."""
return otel.create_litellm_proxy_request_started_span(
start_time=datetime.now(), headers={}
)
def _slo(call_type, with_guardrail=False):
"""standard_logging_object the proxy attaches, carrying team metadata."""
md = {
"user_api_key_team_id": TEAM_ID,
"user_api_key_team_alias": TEAM_ALIAS,
}
slo = {"metadata": md, "call_type": call_type}
if with_guardrail:
slo["guardrail_information"] = [
{
"guardrail_name": "test_guardrail",
"guardrail_mode": "input",
"guardrail_response": "ok",
"start_time": 1609459200.0,
"end_time": 1609459201.0,
}
]
return slo
def _success_kwargs(call_type, server_span, with_guardrail=True):
"""kwargs the success callback receives for an LLM proxy request."""
return {
"model": "gpt-4.1-mini",
"litellm_call_id": "call-abc",
"call_type": call_type,
"litellm_params": {
"metadata": {
"litellm_parent_otel_span": server_span,
"user_api_key_team_id": TEAM_ID,
"user_api_key_team_alias": TEAM_ALIAS,
}
},
"standard_logging_object": _slo(call_type, with_guardrail=with_guardrail),
"messages": [{"role": "user", "content": "hi"}],
}
def _team_user_api_key_dict(server_span):
d = MagicMock()
d.parent_otel_span = server_span
d.team_id = TEAM_ID
d.team_alias = TEAM_ALIAS
return d
def _spans_by_name(exporter):
return {s.name: s for s in exporter.get_finished_spans()}
def _assert_team_attrs(span, where):
assert span.attributes.get(TEAM_ID_ATTR) == TEAM_ID, (
f"{where}: missing/blank {TEAM_ID_ATTR} "
f"(got {span.attributes.get(TEAM_ID_ATTR)!r})"
)
assert span.attributes.get(TEAM_ALIAS_ATTR) == TEAM_ALIAS, (
f"{where}: missing/blank {TEAM_ALIAS_ATTR} "
f"(got {span.attributes.get(TEAM_ALIAS_ATTR)!r})"
)
class _Boom(Exception):
"""Upstream/DB style 5xx."""
status_code = 500
class _ClientErr(Exception):
"""Auth/validation style 4xx."""
status_code = 401
# ---------------------------------------------------------------------------
# LLM success cells: litellm_request + raw_gen_ai_request + guardrail spans
# ---------------------------------------------------------------------------
class TestLLMSuccessCells(unittest.TestCase):
def _run_success(self, call_type):
otel, exporter = _make_otel()
server_span = _server_span(otel)
kwargs = _success_kwargs(call_type, server_span)
now = datetime.now()
otel.log_success_event(kwargs, {"id": "resp-1"}, now, now)
return _spans_by_name(exporter)
def test_chat_completions_2xx(self):
spans = self._run_success("completion")
for name in (
LITELLM_PROXY_REQUEST_SPAN_NAME,
"litellm_request",
"raw_gen_ai_request",
"guardrail",
):
assert name in spans, f"chat/completions 2xx: missing span {name}"
_assert_team_attrs(spans[name], f"chat/completions 2xx [{name}]")
def test_v1_messages_2xx(self):
spans = self._run_success("anthropic_messages")
for name in (
LITELLM_PROXY_REQUEST_SPAN_NAME,
"litellm_request",
"raw_gen_ai_request",
"guardrail",
):
assert name in spans, f"v1/messages 2xx: missing span {name}"
_assert_team_attrs(spans[name], f"v1/messages 2xx [{name}]")
# ---------------------------------------------------------------------------
# LLM failure cells: Failed Proxy Server Request exception child span
# ---------------------------------------------------------------------------
class TestLLMFailureCells(unittest.TestCase):
def _run_failure(self, exc):
"""Drive the failure hook, then close the SERVER span (the proxy
closes it after the hook in real flow) so both the exception child
span and the SERVER root span are asserted."""
otel, exporter = _make_otel()
server_span = _server_span(otel)
asyncio.run(
otel.async_post_call_failure_hook(
request_data={},
original_exception=exc,
user_api_key_dict=_team_user_api_key_dict(server_span),
traceback_str="tb",
)
)
server_span.end()
return _spans_by_name(exporter)
def _assert_all(self, spans, where):
for name in ("Failed Proxy Server Request", LITELLM_PROXY_REQUEST_SPAN_NAME):
assert name in spans, f"{where}: missing span {name}"
_assert_team_attrs(spans[name], f"{where} [{name}]")
def test_chat_completions_4xx(self):
self._assert_all(
self._run_failure(_ClientErr("bad key")), "chat/completions 4xx"
)
def test_chat_completions_5xx(self):
self._assert_all(
self._run_failure(_Boom("upstream blew up")), "chat/completions 5xx"
)
def test_v1_messages_4xx(self):
self._assert_all(
self._run_failure(_ClientErr("bad anthropic key")), "v1/messages 4xx"
)
def test_v1_messages_5xx(self):
self._assert_all(
self._run_failure(_Boom("anthropic upstream timeout")), "v1/messages 5xx"
)
# ---------------------------------------------------------------------------
# Admin /team/info cells.
# 2xx: admin path never runs the LLM success callback -> its only trace
# surface is the SERVER span; no child spans are emitted.
# 3xx: management endpoints do not redirect -> N/A (documented, no run).
# 4xx/5xx: proxy_logging post_call_failure_hook -> exception child span.
# ---------------------------------------------------------------------------
class TestAdminTeamInfoCells(unittest.TestCase):
def _run_admin_failure(self, exc):
otel, exporter = _make_otel()
server_span = _server_span(otel)
asyncio.run(
otel.async_post_call_failure_hook(
request_data={},
original_exception=exc,
user_api_key_dict=_team_user_api_key_dict(server_span),
traceback_str="tb",
)
)
server_span.end()
return _spans_by_name(exporter)
def test_team_info_4xx(self):
spans = self._run_admin_failure(_ClientErr("team not found"))
for name in ("Failed Proxy Server Request", LITELLM_PROXY_REQUEST_SPAN_NAME):
_assert_team_attrs(spans[name], f"/team/info 4xx [{name}]")
def test_team_info_5xx(self):
spans = self._run_admin_failure(_Boom("db connection lost"))
for name in ("Failed Proxy Server Request", LITELLM_PROXY_REQUEST_SPAN_NAME):
_assert_team_attrs(spans[name], f"/team/info 5xx [{name}]")
def test_team_info_2xx_only_server_span_no_orphan_children(self):
"""Admin success path emits no LLM child spans; nothing to stamp
beyond the SERVER span. This pins that contract so a future
regression that starts emitting child spans here without team
attrs is caught."""
otel, exporter = _make_otel()
server_span = _server_span(otel)
server_span.end()
spans = _spans_by_name(exporter)
assert set(spans) == {
LITELLM_PROXY_REQUEST_SPAN_NAME
}, f"/team/info 2xx: unexpected child spans {set(spans)}"
def test_team_info_3xx_not_applicable(self):
"""Management endpoints return JSON, never a 3xx redirect."""
self.skipTest("/team/info has no 3xx redirect path (N/A)")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,587 @@
"""
Unit tests for litellm/interactions/agents/http_handler.py
These tests exercise both the sync and async branches of every CRUD method
on AgentsHTTPHandler using stub httpx clients, plus the _is_async dispatch
branches, error mapping, and pre/post logging hooks.
No real HTTP traffic is made.
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
sys.path.insert(0, os.path.abspath("../../.."))
from litellm.interactions.agents.http_handler import (
AgentsHTTPHandler,
agents_http_handler,
)
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
from litellm.llms.gemini.common_utils import GeminiError
from litellm.types.agents import (
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
AgentVersionsResponse,
)
from litellm.types.router import GenericLiteLLMParams
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_response(status_code: int = 200, json_data=None, text: str = "") -> MagicMock:
"""Build a stub httpx-like response."""
response = MagicMock()
response.status_code = status_code
response.text = text or (str(json_data) if json_data is not None else "")
response.headers = {}
if json_data is not None:
response.json.return_value = json_data
else:
response.json.return_value = {}
return response
def _make_sync_client() -> MagicMock:
client = MagicMock(spec=HTTPHandler)
return client
def _make_async_client() -> MagicMock:
client = MagicMock(spec=AsyncHTTPHandler)
client.post = AsyncMock()
client.get = AsyncMock()
client.delete = AsyncMock()
return client
def _make_logging_obj() -> MagicMock:
return MagicMock()
@pytest.fixture
def handler() -> AgentsHTTPHandler:
return AgentsHTTPHandler()
@pytest.fixture
def config() -> GeminiAgentsConfig:
return GeminiAgentsConfig()
@pytest.fixture
def litellm_params() -> GenericLiteLLMParams:
return GenericLiteLLMParams(api_key="AIza-test")
# ---------------------------------------------------------------------------
# Module-level singleton sanity check
# ---------------------------------------------------------------------------
def test_module_singleton_is_agents_http_handler_instance():
assert isinstance(agents_http_handler, AgentsHTTPHandler)
# ---------------------------------------------------------------------------
# CREATE
# ---------------------------------------------------------------------------
class TestCreateAgent:
def test_sync_returns_parsed_create_response(self, handler, config, litellm_params):
client = _make_sync_client()
client.post.return_value = _make_response(
200, json_data={"id": "agent-x", "base_agent": "gemini-2.5-flash"}
)
logging_obj = _make_logging_obj()
result = handler.create_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers={"X-Test": "1"},
extra_body={"foo": "bar"},
client=client,
)
assert isinstance(result, AgentCreateResponse)
assert result.id == "agent-x"
client.post.assert_called_once()
kwargs = client.post.call_args.kwargs
assert kwargs["url"].endswith("/v1beta/agents")
assert kwargs["json"]["name"] == "agent-x"
assert kwargs["json"]["foo"] == "bar"
assert kwargs["headers"]["X-Test"] == "1"
logging_obj.pre_call.assert_called_once()
logging_obj.post_call.assert_called_once()
def test_sync_dispatches_to_async_when_is_async(
self, handler, config, litellm_params
):
client = _make_sync_client()
result = handler.create_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
_is_async=True,
)
import asyncio
assert asyncio.iscoroutine(result)
result.close()
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
client = _make_sync_client()
bad = _make_response(404, text="not found")
client.post.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
handler.create_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
@pytest.mark.asyncio
async def test_async_returns_parsed_create_response(
self, handler, config, litellm_params
):
client = _make_async_client()
client.post.return_value = _make_response(
200, json_data={"id": "agent-y", "base_agent": "gemini-2.5-flash"}
)
result = await handler.async_create_agent(
agents_api_config=config,
name="agent-y",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
extra_body={"baz": "qux"},
client=client,
)
assert isinstance(result, AgentCreateResponse)
assert result.id == "agent-y"
client.post.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_maps_http_error_via_config(
self, handler, config, litellm_params
):
client = _make_async_client()
bad = _make_response(500, text="server error")
client.post.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
await handler.async_create_agent(
agents_api_config=config,
name="agent-y",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
# ---------------------------------------------------------------------------
# LIST
# ---------------------------------------------------------------------------
class TestListAgents:
def test_sync_returns_list_response(self, handler, config, litellm_params):
client = _make_sync_client()
client.get.return_value = _make_response(
200,
json_data={
"agents": [{"id": "a-1"}, {"id": "a-2"}],
"nextPageToken": "tok",
},
)
result = handler.list_agents(
agents_api_config=config,
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentListResponse)
assert len(result.agents) == 2
assert result.next_page_token == "tok"
client.get.assert_called_once()
def test_sync_dispatches_to_async_when_is_async(
self, handler, config, litellm_params
):
client = _make_sync_client()
result = handler.list_agents(
agents_api_config=config,
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
_is_async=True,
)
import asyncio
assert asyncio.iscoroutine(result)
result.close()
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
client = _make_sync_client()
bad = _make_response(403, text="forbidden")
client.get.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
handler.list_agents(
agents_api_config=config,
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
@pytest.mark.asyncio
async def test_async_returns_list_response(self, handler, config, litellm_params):
client = _make_async_client()
client.get.return_value = _make_response(
200, json_data={"agents": [{"id": "a-1"}]}
)
result = await handler.async_list_agents(
agents_api_config=config,
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentListResponse)
assert len(result.agents) == 1
client.get.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_maps_http_error_via_config(
self, handler, config, litellm_params
):
client = _make_async_client()
bad = _make_response(429, text="rate limited")
client.get.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
await handler.async_list_agents(
agents_api_config=config,
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
# ---------------------------------------------------------------------------
# GET
# ---------------------------------------------------------------------------
class TestGetAgent:
def test_sync_returns_get_response(self, handler, config, litellm_params):
client = _make_sync_client()
client.get.return_value = _make_response(200, json_data={"id": "agent-x"})
result = handler.get_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentCreateResponse)
assert result.id == "agent-x"
kwargs = client.get.call_args.kwargs
assert kwargs["url"].endswith("/v1beta/agents/agent-x")
def test_sync_dispatches_to_async_when_is_async(
self, handler, config, litellm_params
):
result = handler.get_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=_make_sync_client(),
_is_async=True,
)
import asyncio
assert asyncio.iscoroutine(result)
result.close()
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
client = _make_sync_client()
bad = _make_response(404, text="not found")
client.get.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
handler.get_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
@pytest.mark.asyncio
async def test_async_returns_get_response(self, handler, config, litellm_params):
client = _make_async_client()
client.get.return_value = _make_response(200, json_data={"id": "agent-y"})
result = await handler.async_get_agent(
agents_api_config=config,
name="agent-y",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentCreateResponse)
assert result.id == "agent-y"
@pytest.mark.asyncio
async def test_async_maps_http_error_via_config(
self, handler, config, litellm_params
):
client = _make_async_client()
bad = _make_response(404, text="not found")
client.get.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
await handler.async_get_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
# ---------------------------------------------------------------------------
# DELETE
# ---------------------------------------------------------------------------
class TestDeleteAgent:
def test_sync_returns_delete_result(self, handler, config, litellm_params):
client = _make_sync_client()
client.delete.return_value = _make_response(200, json_data={})
result = handler.delete_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentDeleteResult)
assert result.name == "agent-x"
assert result.deleted is True
kwargs = client.delete.call_args.kwargs
assert kwargs["url"].endswith("/v1beta/agents/agent-x")
def test_sync_dispatches_to_async_when_is_async(
self, handler, config, litellm_params
):
result = handler.delete_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=_make_sync_client(),
_is_async=True,
)
import asyncio
assert asyncio.iscoroutine(result)
result.close()
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
client = _make_sync_client()
bad = _make_response(403, text="forbidden")
client.delete.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
handler.delete_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
@pytest.mark.asyncio
async def test_async_returns_delete_result(self, handler, config, litellm_params):
client = _make_async_client()
client.delete.return_value = _make_response(200, json_data={})
result = await handler.async_delete_agent(
agents_api_config=config,
name="agent-y",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentDeleteResult)
assert result.name == "agent-y"
assert result.deleted is True
@pytest.mark.asyncio
async def test_async_maps_http_error_via_config(
self, handler, config, litellm_params
):
client = _make_async_client()
bad = _make_response(500, text="server error")
client.delete.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
await handler.async_delete_agent(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
# ---------------------------------------------------------------------------
# LIST VERSIONS
# ---------------------------------------------------------------------------
class TestListAgentVersions:
def test_sync_returns_versions_response(self, handler, config, litellm_params):
client = _make_sync_client()
client.get.return_value = _make_response(
200,
json_data={
"agentVersions": [
{"agent": "agent-x", "name": "agents/agent-x/versions/v1"}
],
"nextPageToken": "tok",
},
)
result = handler.list_agent_versions(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentVersionsResponse)
assert len(result.agent_versions) == 1
assert result.next_page_token == "tok"
kwargs = client.get.call_args.kwargs
assert kwargs["url"].endswith("/v1beta/agents/agent-x/versions")
def test_sync_dispatches_to_async_when_is_async(
self, handler, config, litellm_params
):
result = handler.list_agent_versions(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=_make_sync_client(),
_is_async=True,
)
import asyncio
assert asyncio.iscoroutine(result)
result.close()
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
client = _make_sync_client()
bad = _make_response(404, text="not found")
client.get.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
handler.list_agent_versions(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
@pytest.mark.asyncio
async def test_async_returns_versions_response(
self, handler, config, litellm_params
):
client = _make_async_client()
client.get.return_value = _make_response(200, json_data={"agentVersions": []})
result = await handler.async_list_agent_versions(
agents_api_config=config,
name="agent-y",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)
assert isinstance(result, AgentVersionsResponse)
assert result.agent_versions == []
@pytest.mark.asyncio
async def test_async_maps_http_error_via_config(
self, handler, config, litellm_params
):
client = _make_async_client()
bad = _make_response(500, text="server error")
client.get.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=bad
)
with pytest.raises(GeminiError):
await handler.async_list_agent_versions(
agents_api_config=config,
name="agent-x",
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
)

View file

@ -0,0 +1,354 @@
"""
Unit tests for litellm/interactions/agents/utils.py and main.py
focused on the managed agents SDK surface added in the
"Gemini managed agents support" PR.
The tests mock the underlying HTTP handler so they cover the public
sync + async create/list/get/delete/list_versions entry points and the
small helper utilities without touching the network.
"""
import asyncio
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(0, os.path.abspath("../../.."))
import litellm
from litellm.interactions.agents import (
acreate,
adelete,
aget,
alist,
alist_versions,
create,
delete,
get,
list as list_agents,
list_versions,
)
from litellm.interactions.agents.main import (
_get_agents_api_config,
_make_logging_obj,
)
from litellm.interactions.agents.utils import get_provider_agents_api_config
from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
_HANDLER_PATH = "litellm.interactions.agents.main.agents_http_handler"
# ---------------------------------------------------------------------------
# utils.get_provider_agents_api_config
# ---------------------------------------------------------------------------
class TestGetProviderAgentsApiConfig:
def test_returns_gemini_config_for_gemini(self):
cfg = get_provider_agents_api_config("gemini")
assert isinstance(cfg, GeminiAgentsConfig)
assert isinstance(cfg, BaseAgentsAPIConfig)
@pytest.mark.parametrize(
"provider", ["openai", "anthropic", "bedrock", "vertex_ai", "unknown"]
)
def test_returns_none_for_non_gemini(self, provider):
assert get_provider_agents_api_config(provider) is None
def test_returns_none_for_none(self):
assert get_provider_agents_api_config(None) is None
# ---------------------------------------------------------------------------
# main._get_agents_api_config
# ---------------------------------------------------------------------------
class TestGetAgentsApiConfig:
def test_returns_config_for_gemini(self):
cfg = _get_agents_api_config("gemini")
assert isinstance(cfg, GeminiAgentsConfig)
def test_raises_bad_request_for_unsupported_provider(self):
with pytest.raises(litellm.BadRequestError) as excinfo:
_get_agents_api_config("openai")
assert "does not have a native" in str(excinfo.value)
# ---------------------------------------------------------------------------
# main._make_logging_obj
# ---------------------------------------------------------------------------
class TestMakeLoggingObj:
def test_calls_update_from_kwargs_and_returns_same_obj(self):
logging_obj = MagicMock()
kwargs = {"litellm_logging_obj": logging_obj, "litellm_call_id": "abc-123"}
returned = _make_logging_obj(
kwargs=kwargs,
model="my-agent",
custom_llm_provider="gemini",
call_type="create_agent",
optional_params={"foo": "bar"},
)
assert returned is logging_obj
logging_obj.update_from_kwargs.assert_called_once()
kwargs_call = logging_obj.update_from_kwargs.call_args.kwargs
assert kwargs_call["model"] == "my-agent"
assert kwargs_call["optional_params"] == {"foo": "bar"}
assert kwargs_call["custom_llm_provider"] == "gemini"
assert kwargs_call["litellm_params"]["litellm_call_id"] == "abc-123"
# ---------------------------------------------------------------------------
# Sync entry points: create / list / get / delete / list_versions
# ---------------------------------------------------------------------------
def _stub_handler(return_value):
"""Build a stub AgentsHTTPHandler whose CRUD methods return *return_value*."""
handler = MagicMock()
handler.create_agent.return_value = return_value
handler.list_agents.return_value = return_value
handler.get_agent.return_value = return_value
handler.delete_agent.return_value = return_value
handler.list_agent_versions.return_value = return_value
return handler
class TestSyncEntryPoints:
def test_create_passes_args_to_handler(self):
sentinel = MagicMock(name="create_response")
with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler:
response = create(
name="waverunner",
base_agent="gemini-2.5-flash",
instructions="be helpful",
base_environment={"type": "remote"},
custom_llm_provider="gemini",
api_key="AIza-test",
extra_headers={"X-Test": "1"},
extra_body={"foo": "bar"},
)
assert response is sentinel
handler.create_agent.assert_called_once()
kw = handler.create_agent.call_args.kwargs
assert kw["name"] == "waverunner"
assert kw["_is_async"] is False
assert kw["extra_headers"] == {"X-Test": "1"}
assert kw["extra_body"] == {"foo": "bar"}
assert isinstance(kw["agents_api_config"], GeminiAgentsConfig)
def test_create_defaults_custom_llm_provider_to_gemini(self):
sentinel = MagicMock(name="create_response")
with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler:
create(name="agent-x", api_key="AIza")
assert handler.create_agent.call_args.kwargs["_is_async"] is False
cfg = handler.create_agent.call_args.kwargs["agents_api_config"]
assert isinstance(cfg, GeminiAgentsConfig)
def test_create_raises_for_unsupported_provider(self):
with pytest.raises(litellm.exceptions.BadRequestError):
create(name="agent-x", custom_llm_provider="openai", api_key="sk-x")
def test_list_passes_args_to_handler(self):
sentinel = MagicMock(name="list_response")
with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler:
response = list_agents(custom_llm_provider="gemini", api_key="AIza")
assert response is sentinel
handler.list_agents.assert_called_once()
assert handler.list_agents.call_args.kwargs["_is_async"] is False
def test_get_passes_args_to_handler(self):
sentinel = MagicMock(name="get_response")
with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler:
response = get(name="waverunner", api_key="AIza")
assert response is sentinel
kw = handler.get_agent.call_args.kwargs
assert kw["name"] == "waverunner"
assert kw["_is_async"] is False
def test_delete_passes_args_to_handler(self):
sentinel = MagicMock(name="delete_response")
with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler:
response = delete(name="waverunner", api_key="AIza")
assert response is sentinel
kw = handler.delete_agent.call_args.kwargs
assert kw["name"] == "waverunner"
assert kw["_is_async"] is False
def test_list_versions_passes_args_to_handler(self):
sentinel = MagicMock(name="versions_response")
with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler:
response = list_versions(name="waverunner", api_key="AIza")
assert response is sentinel
kw = handler.list_agent_versions.call_args.kwargs
assert kw["name"] == "waverunner"
assert kw["_is_async"] is False
# ---------------------------------------------------------------------------
# Async entry points
# ---------------------------------------------------------------------------
class TestAsyncEntryPoints:
"""Async entry points delegate to their sync counterparts via run_in_executor."""
@pytest.mark.asyncio
async def test_acreate_dispatches_with_async_flag(self):
sentinel = MagicMock(name="acreate_response")
def fake_create_agent(**kwargs):
assert kwargs["_is_async"] is True
assert kwargs["name"] == "waverunner"
return sentinel
handler = MagicMock()
handler.create_agent.side_effect = fake_create_agent
with patch(_HANDLER_PATH, handler):
response = await acreate(
name="waverunner",
base_agent="gemini-2.5-flash",
api_key="AIza",
)
assert response is sentinel
@pytest.mark.asyncio
async def test_acreate_awaits_coroutine_result(self):
async def _coro():
return "async-value"
handler = MagicMock()
handler.create_agent.return_value = _coro()
with patch(_HANDLER_PATH, handler):
response = await acreate(name="waverunner", api_key="AIza")
assert response == "async-value"
@pytest.mark.asyncio
async def test_alist_dispatches_with_async_flag(self):
sentinel = MagicMock(name="alist_response")
def fake_list_agents(**kwargs):
assert kwargs["_is_async"] is True
return sentinel
handler = MagicMock()
handler.list_agents.side_effect = fake_list_agents
with patch(_HANDLER_PATH, handler):
response = await alist(api_key="AIza")
assert response is sentinel
@pytest.mark.asyncio
async def test_aget_dispatches_with_async_flag(self):
sentinel = MagicMock(name="aget_response")
def fake_get_agent(**kwargs):
assert kwargs["_is_async"] is True
assert kwargs["name"] == "waverunner"
return sentinel
handler = MagicMock()
handler.get_agent.side_effect = fake_get_agent
with patch(_HANDLER_PATH, handler):
response = await aget(name="waverunner", api_key="AIza")
assert response is sentinel
@pytest.mark.asyncio
async def test_adelete_dispatches_with_async_flag(self):
sentinel = MagicMock(name="adelete_response")
def fake_delete_agent(**kwargs):
assert kwargs["_is_async"] is True
assert kwargs["name"] == "waverunner"
return sentinel
handler = MagicMock()
handler.delete_agent.side_effect = fake_delete_agent
with patch(_HANDLER_PATH, handler):
response = await adelete(name="waverunner", api_key="AIza")
assert response is sentinel
@pytest.mark.asyncio
async def test_alist_versions_dispatches_with_async_flag(self):
sentinel = MagicMock(name="alist_versions_response")
def fake_versions(**kwargs):
assert kwargs["_is_async"] is True
assert kwargs["name"] == "waverunner"
return sentinel
handler = MagicMock()
handler.list_agent_versions.side_effect = fake_versions
with patch(_HANDLER_PATH, handler):
response = await alist_versions(name="waverunner", api_key="AIza")
assert response is sentinel
# ---------------------------------------------------------------------------
# Async error wrapping: exception_type must be invoked
# ---------------------------------------------------------------------------
class TestAsyncErrorWrapping:
"""If the underlying handler raises, async entry points re-raise via
litellm.exception_type so users get a normalised provider error."""
@pytest.mark.asyncio
async def test_acreate_wraps_exception(self):
handler = MagicMock()
handler.create_agent.side_effect = RuntimeError("kaboom")
with patch(_HANDLER_PATH, handler):
with pytest.raises(Exception):
await acreate(name="waverunner", api_key="AIza")
@pytest.mark.asyncio
async def test_aget_wraps_exception(self):
handler = MagicMock()
handler.get_agent.side_effect = RuntimeError("kaboom")
with patch(_HANDLER_PATH, handler):
with pytest.raises(Exception):
await aget(name="waverunner", api_key="AIza")
@pytest.mark.asyncio
async def test_alist_wraps_exception(self):
handler = MagicMock()
handler.list_agents.side_effect = RuntimeError("kaboom")
with patch(_HANDLER_PATH, handler):
with pytest.raises(Exception):
await alist(api_key="AIza")
@pytest.mark.asyncio
async def test_adelete_wraps_exception(self):
handler = MagicMock()
handler.delete_agent.side_effect = RuntimeError("kaboom")
with patch(_HANDLER_PATH, handler):
with pytest.raises(Exception):
await adelete(name="waverunner", api_key="AIza")
@pytest.mark.asyncio
async def test_alist_versions_wraps_exception(self):
handler = MagicMock()
handler.list_agent_versions.side_effect = RuntimeError("kaboom")
with patch(_HANDLER_PATH, handler):
with pytest.raises(Exception):
await alist_versions(name="waverunner", api_key="AIza")

View file

@ -122,6 +122,56 @@ class TestGetCompleteUrl:
)
class TestTransformRequest:
def test_passes_environment_to_request_body(self, config):
request_body = config.transform_request(
model=None,
agent="my-custom-slides-agent",
input=[{"type": "text", "text": "Create a 5-slide presentation about AI trends."}],
optional_params={
"environment": "remote",
"stream": False,
},
litellm_params=GenericLiteLLMParams(api_key="test-api-key"),
headers={},
)
assert request_body["agent"] == "my-custom-slides-agent"
assert request_body["environment"] == "remote"
assert request_body["stream"] is False
assert request_body["input"] == [
{"type": "text", "text": "Create a 5-slide presentation about AI trends."}
]
def test_passes_environment_object_to_request_body(self, config):
environment_config = {
"type": "remote",
"sources": [{"type": "gcs", "uri": "gs://bucket/skills.zip"}],
"network": {"egress": "allow_all"},
}
request_body = config.transform_request(
model=None,
agent="waverunner",
input="What is 2 + 2?",
optional_params={"environment": environment_config},
litellm_params=GenericLiteLLMParams(api_key="test-api-key"),
headers={},
)
assert request_body["environment"] == environment_config
def test_passes_existing_environment_id_to_request_body(self, config):
env_id = "env-abc123"
request_body = config.transform_request(
model=None,
agent="my-custom-slides-agent",
input="Continue the presentation.",
optional_params={"environment": env_id},
litellm_params=GenericLiteLLMParams(api_key="test-api-key"),
headers={},
)
assert request_body["environment"] == env_id
class TestStreamingIterator:
def _make_iterator(self) -> LiteLLMResponsesInteractionsStreamingIterator:
return LiteLLMResponsesInteractionsStreamingIterator(

View file

@ -179,7 +179,10 @@ class TestResponseCompliance:
# `status` is an output-only field; validate against the response schema.
schema = spec_dict["components"]["schemas"]["Interaction"]
status_prop = schema["properties"]["status"]
# Google Interactions API uses lowercase status values (updated Feb 2026)
# Google Interactions API uses lowercase status values (updated Feb 2026).
# Keep this an exact match: this test intentionally breaks CI when
# Google changes the live spec — that breakage is how we get notified
# to review the change.
expected_statuses = [
"in_progress",
"requires_action",
@ -187,6 +190,7 @@ class TestResponseCompliance:
"failed",
"cancelled",
"incomplete",
"budget_exceeded",
]
assert status_prop["enum"] == expected_statuses
print(f"✓ Status enum values: {expected_statuses}")

View file

@ -437,13 +437,38 @@ def test_gpt_4o_token_counter():
@pytest.mark.parametrize(
"img_url",
[
"https://blog.purpureus.net/assets/blog/personal_key_rotation/simplified-asset-graph.jpg",
"https://example.com/test-image.png",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAL0AAAC9CAMAAADRCYwCAAAAh1BMVEX///8AAAD8/Pz5+fkEBAT39/cJCQn09PRNTU3y8vIMDAwzMzPe3t7v7+8QEBCOjo7FxcXR0dHn5+elpaWGhoYYGBivr686OjocHBy0tLQtLS1TU1PY2Ni6urpaWlpERER3d3ecnJxoaGiUlJRiYmIlJSU4ODhBQUFycnKAgIDBwcFnZ2chISE7EjuwAAAI/UlEQVR4nO1caXfiOgz1bhJIyAJhX1JoSzv8/9/3LNlpYd4rhX6o4/N8Z2lKM2cURZau5JsQEhERERERERERERERERERERHx/wBjhDPC3OGN8+Cc5JeMuheaETSdO8vZFyCScHtmz2CsktoeMn7rLM1u3h0PMAEhyYX7v/Q9wQvoGdB0hlbzm45lEq/wd6y6G9aezvBk9AXwp1r3LHJIRsh6s2maxaJpmvqgvkC7WFS3loUnaFJtKRVUCEoV/RpCnHRvAsesVQ1hw+vd7Mpo+424tLs72NplkvQgcdrsvXkW/zJWqH/fA0FT84M/xnQJt4to3+ZLuanbM6X5lfXKHosO9COgREqpCR5i86pf2zPS7j9tTj+9nO7bQz3+xGEyGW9zqgQ1tyQ/VsxEDvce/4dcUPNb5OD9yXvR4Z2QisuP0xiGWPnemgugU5q/troHhGEjIF5sTOyW648aC0TssuaaCEsYEIkGzjWXOp3A0vVsf6kgRyqaDk+T7DIVWrb58b2tT5xpUucKwodOD/5LbrZC1ws6YSaBZJ/8xlh+XZSYXaMJ2ezNqjB3IPXuehPcx2U6b4t1dS/xNdFzguUt8ie7arnPeyCZroxLHzGgGdqVcspwafizPWEXBee+9G1OaufGdvNng/9C+gwgZ3PH3r87G6zXTZ5D5De2G2DeFoANXfbACkT+fxBQ22YFsTTJF9hjFVO6VbqxZXko4WJ8s52P4PnuxO5KRzu0/hlix1ySt8iXjgaQ+4IHPA9nVzNkdduM9LFT/Aacj4FtKrHA7iAw602Vnht6R8Vq1IOS+wNMKLYqayAYfRuufQPGeGb7sZogQQoLZrGPgZ6KoYn70Iw30O92BNEDpvwouCFn6wH2uS+EhRb3WF/HObZk3HuxfRQM3Y/Of/VH0n4MKNHZDiZvO9+m/ABALfkOcuar/7nOo7B95ACGVAFaz4jMiJwJhdaHBkySmzlGTu82gr6FSTik2kJvLnY9nOd/D90qcH268m3I/cgI1xg1maE5CuZYaWLH+UHANCIck0yt7Mx5zBm5vVHXHwChsZ35kKqUpmo5Svq5/fzfAI5g2vDtFPYo1HiEA85QrDeGm9g//LG7K0scO3sdpj2CBDgCa+0OFs0bkvVgnnM/QBDwllOMm+cN7vMSHlB7Uu4haHKaTwgGkv8tlK+hP8fzmFuK/RQTpaLPWvbd58yWIo66HHM0OsPoPhVqmtaEVL7N+wYcTLTbb0DLdgp23Eyy2VYJ2N7bkLFAAibtoLPe5sLt6Oa2bvU+zyeMa8wrixO0gRTn9tO9NCSThTLGqcqtsDvphlfmx/cPBZVvw24jg1LE2lPuEo35Mhi58U0I/Ga8n5w+NS8i34MAQLos5B1u0xL1ZvCVYVRw/Fs2q53KLaXJMWwOZZ/4MPYV19bAHmgGDKB6f01xoeJKFbl63q9J34KdaVNPJWztQyRkzA3KNs1AdAEDowMxh10emXTCx75CkurtbY/ZpdNDGdsn2UcHKHsQ8Ai3WZi48IfkvtjOhsLpuIRSKZTX9FA4o+0d6o/zOWqQzVJMynL9NsxhSJOaourq6nBVQBueMSyubsX2xHrmuABZN2Ns9jr5nwLFlLF/2R6atjW/67Yd11YQ1Z+kA9Zk9dPTM/o6dVo6HHVgC0JR8oUfmI93T9u3gvTG94bAH02Y5xeqRcjuwnKCK6Q2+ajl8KXJ3GSh22P3Zfx6S+n008ROhJn+JRIUVu6o7OXl8w1SeyhuqNDwNI7SjbK08QrqPxS95jy4G7nCXVq6G3HNu0LtK5J0e226CfC005WKK9sVvfxI0eUbcnzutfhWe3rpZHM0nZ/ny/N8tanKYlQ6VEW5Xuym8yV1zZX58vwGhZp/5tFfhybZabdbrQYOs8F+xEhmPsb0/nki6kIyVvzZzUASiOrTfF+Sj9bXC7DoJxeiV8tjQL6loSd0yCx7YyB6rPdLx31U2qCG3F/oXIuDuqd6LFO+4DNIJuxFZqSsU0ea88avovFnWKRYFYRQDfCfcGaBCLn4M4A1ntJ5E57vicwqq2enaZEF5nokCYu9TbKqCC5yCDfL+GhLxT4w4xEJs+anqgou8DOY2q8FMryjb2MehC1dRJ9s4g9NXeTwPkWON4RH+FhIe0AWR/S9ekvQ+t70XHeimGF78LzuU7d7PwrswdIG2VpgF8C53qVQsTDtBJc4CdnkQPbnZY9mbPdDFra3PCXBBQ5QBn2aQqtyhvlyYM4Hb2/mdhsxCUen04GZVvIJZw5PAamMOmjzq8Q+dzAKLXDQ3RUZItWsg4t7W2DP+JDrJDymoMH7E5zQtuEpG03GTIjGCW3LQqOYEsXgFc78x76NeRwY6SNM+IfQoh6myJKRBIcLYxZcwscJ/gI2isTBty2Po9IkYzP0/SS4hGlxRjFAG5z1Jt1LckiB57yWvo35EaolbvA+6fBa24xodL2YjsPpTnj3JgJOqhcgOeLVsYYwoK0wjY+m1D3rGc40CukkaHnkEjarlXrF1B9M6ECQ6Ow0V7R7N4G3LfOHAXtymoyXOb4QhaYHJ/gNBJUkxclpSs7DNcgWWDDmM7Ke5MJpGuioe7w5EOvfTunUKRzOh7G2ylL+6ynHrD54oQO3//cN3yVO+5qMVsPZq0CZIOx4TlcJ8+Vz7V5waL+7WekzUpRFMTnnTlSCq3X5usi8qmIleW/rit1+oQZn1WGSU/sKBYEqMNh1mBOc6PhK8yCfKHdUNQk8o/G19ZPTs5MYfai+DLs5vmee37zEyyH48WW3XA6Xw6+Az8lMhci7N/KleToo7PtTKm+RA887Kqc6E9dyqL/QPTugzMHLbLZtJKqKLFfzVWRNJ63c+95uWT/F7R0U5dDVvuS409AJXhJvD0EwWaWdW8UN11u/7+umaYjT8mJtzZwP/MD4r57fihiHlC5fylHfaqnJdro+Dr7DajvO+vi2EwyD70s8nCH71nzIO1l5Zl+v1DMCb5ebvCMkGHvobXy/hPumGLyX0218/3RyD1GRLOuf9u/OGQyDmto32yMiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv7GP8YjWPR/czH2AAAAAElFTkSuQmCC",
],
)
def test_img_url_token_counter(img_url):
def test_img_url_token_counter(img_url, monkeypatch):
"""
Verify get_image_dimensions returns valid (width, height) for both an
HTTPS URL and a base64 data URI. The HTTPS branch is exercised with a
mocked HTTP fetch so the test is hermetic - it can't break when a
third-party image URL goes away.
"""
import base64
from litellm.litellm_core_utils.token_counter import get_image_dimensions
# Minimal valid 1x1 PNG, served by the mocked safe_get for the URL case.
_tiny_png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
if img_url.startswith(("http://", "https://")):
class _FakeResponse:
headers = {"Content-Length": str(len(_tiny_png))}
def read(self):
return _tiny_png
monkeypatch.setattr(
"litellm.litellm_core_utils.token_counter.safe_get",
lambda client, url, **kw: _FakeResponse(),
)
width, height = get_image_dimensions(data=img_url)
print(width, height)

View file

@ -2959,6 +2959,38 @@ def test_vertex_ai_gemini3_tool_combination_no_drop():
assert len(tools) == 3
def test_vertex_ai_mixed_tools_and_web_search_options_drops_search():
"""
When function tools and web_search_options are sent separately (Codex-style),
search tools are dropped unless include_server_side_tool_invocations is set.
"""
v = VertexGeminiConfig()
optional_params: dict = {}
non_default_params = {
"tools": [
{
"type": "function",
"function": {"name": "exec_command", "description": "Run a command"},
}
],
"web_search_options": {},
}
result = v.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gemini-3.5-flash",
drop_params=True,
)
assert not result.get("include_server_side_tool_invocations")
tool_keys = set()
for tool in result.get("tools", []):
tool_keys.update(tool.keys())
assert "function_declarations" in tool_keys
assert "googleSearch" not in tool_keys
def test_vertex_ai_openai_web_search_tool_transformation():
"""
Test that OpenAI-style web_search and web_search_preview tools are transformed to googleSearch.

View file

@ -1517,39 +1517,31 @@ def test_vertex_parallel_tool_calls_true():
assert "tools" in optional_params
def test_vertex_parallel_tool_calls_false_multiple_tools_error():
def test_vertex_parallel_tool_calls_false_multiple_tools_dropped():
"""
Test that parallel_tool_calls = False with multiple tools raises UnsupportedParamsError
when drop_params is False.
parallel_tool_calls=False with multiple tools is dropped for Gemini
(unsupported upstream). Request should succeed without the param.
"""
tools = [
{"type": "function", "function": {"name": "get_weather"}},
{"type": "function", "function": {"name": "get_time"}},
]
with pytest.raises(litellm.utils.UnsupportedParamsError) as excinfo:
get_optional_params(
model="gemini-1.5-pro",
custom_llm_provider="vertex_ai",
tools=tools,
parallel_tool_calls=False,
)
assert (
"`parallel_tool_calls=False` is not supported by Gemini when multiple tools are"
in str(excinfo.value)
optional_params = get_optional_params(
model="gemini-1.5-pro",
custom_llm_provider="vertex_ai",
tools=tools,
parallel_tool_calls=False,
)
assert "parallel_tool_calls" not in optional_params
assert "tools" in optional_params
# works when specified as "functions"
with pytest.raises(litellm.utils.UnsupportedParamsError) as excinfo:
get_optional_params(
model="gemini-1.5-pro",
custom_llm_provider="vertex_ai",
functions=tools,
parallel_tool_calls=False,
)
assert (
"`parallel_tool_calls=False` is not supported by Gemini when multiple tools are"
in str(excinfo.value)
optional_params = get_optional_params(
model="gemini-1.5-pro",
custom_llm_provider="vertex_ai",
functions=tools,
parallel_tool_calls=False,
)
assert "parallel_tool_calls" not in optional_params
def test_vertex_parallel_tool_calls_false_single_tool():

View file

@ -1,75 +1,103 @@
"""
Test for interactions endpoint agent parameter handling.
Tests for managed-agent interaction routing.
Tests that the /v1beta/interactions endpoint correctly extracts
the `agent` parameter as a fallback when `model` is not provided.
Custom Gemini agents are identified by ``agent`` (name/id), not ``model``.
The proxy must not pass the agent name as ``model`` or LiteLLM may route to
openai/* wildcards instead of Gemini interactions.
"""
from unittest.mock import MagicMock, patch
import pytest
class TestInteractionsAgentParameter:
"""Test agent parameter handling in interactions endpoint."""
"""Proxy endpoint must keep agent and model separate."""
def test_agent_parameter_fallback_logic(self):
"""
Test the core logic: model or agent extraction.
This tests the fix in endpoints.py line ~267:
model=data.get("model") or data.get("agent")
"""
# Case 1: Only agent provided (Deep Research use case)
def test_create_interaction_uses_model_only_from_body(self):
"""POST /v1beta/interactions: model kwarg is only the request's model field."""
data = {
"agent": "deep-research-pro-preview-12-2025",
"input": "Research quantum computing",
"background": True,
"agent": "mqy-custom-slides-agent",
"input": "hello",
}
model = data.get("model") or data.get("agent")
assert model == "deep-research-pro-preview-12-2025"
# Fixed behavior: do NOT fall back agent → model
model_for_routing = data.get("model")
assert model_for_routing is None
assert data.get("agent") == "mqy-custom-slides-agent"
# Case 2: Only model provided (normal use case)
def test_model_field_still_used_when_present(self):
data = {
"model": "gemini-2.5-flash",
"input": "Hello world",
"input": "hello",
}
model = data.get("model") or data.get("agent")
assert model == "gemini-2.5-flash"
model_for_routing = data.get("model")
assert model_for_routing == "gemini-2.5-flash"
# Case 3: Both provided (model takes precedence)
data = {
"model": "gemini-2.5-flash",
"agent": "deep-research-pro-preview-12-2025",
"input": "Test",
}
model = data.get("model") or data.get("agent")
assert model == "gemini-2.5-flash"
# Case 4: Neither provided
data = {
"input": "Test",
}
model = data.get("model") or data.get("agent")
assert model is None
class TestInteractionsAgentOnlyProviderRouting:
"""SDK: agent-only create must not call get_llm_provider on the agent name."""
def test_route_type_in_skip_model_routing_list(self):
"""
Test that acreate_interaction is in the list of routes
that skip model-based routing.
@patch("litellm.interactions.main.interactions_http_handler")
@patch("litellm.interactions.main.get_provider_interactions_api_config")
@patch("litellm.get_llm_provider")
def test_agent_only_skips_get_llm_provider(
self,
mock_get_llm_provider,
mock_get_config,
mock_handler,
):
from litellm.interactions.main import create
from litellm.types.interactions import InteractionsAPIResponse
This tests the fix in route_llm_request.py.
"""
# The list of routes that skip model routing for interactions
skip_model_routing_routes = [
"acreate_interaction",
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
]
mock_get_config.return_value = MagicMock()
mock_handler.create_interaction.return_value = InteractionsAPIResponse(
id="int-1",
status="completed",
object="interaction",
)
# acreate_interaction should be in the list (this is the fix)
assert "acreate_interaction" in skip_model_routing_routes
logging_obj = MagicMock()
create(
agent="mqy-custom-slides-agent",
input="test",
custom_llm_provider="gemini",
litellm_logging_obj=logging_obj,
)
# All interaction routes should be covered
assert "aget_interaction" in skip_model_routing_routes
assert "adelete_interaction" in skip_model_routing_routes
assert "acancel_interaction" in skip_model_routing_routes
mock_get_llm_provider.assert_not_called()
call_kwargs = mock_handler.create_interaction.call_args.kwargs
assert call_kwargs["agent"] == "mqy-custom-slides-agent"
assert call_kwargs["model"] is None
assert call_kwargs["custom_llm_provider"] == "gemini"
@patch("litellm.interactions.main.interactions_http_handler")
@patch("litellm.interactions.main.get_provider_interactions_api_config")
@patch("litellm.get_llm_provider")
def test_proxy_mistake_model_equals_agent_is_corrected(
self,
mock_get_llm_provider,
mock_get_config,
mock_handler,
):
"""If model was wrongly set to the agent name, clear it before the HTTP call."""
from litellm.interactions.main import create
from litellm.types.interactions import InteractionsAPIResponse
mock_get_config.return_value = MagicMock()
mock_handler.create_interaction.return_value = InteractionsAPIResponse(
id="int-1",
status="completed",
object="interaction",
)
logging_obj = MagicMock()
create(
model="mqy-custom-slides-agent",
agent="mqy-custom-slides-agent",
input="test",
custom_llm_provider="gemini",
litellm_logging_obj=logging_obj,
)
mock_get_llm_provider.assert_not_called()
assert mock_handler.create_interaction.call_args.kwargs["model"] is None

View file

@ -0,0 +1,199 @@
"""
Tests verifying that managed-agent proxy endpoints never pass the agent name
as the ``model`` parameter to ``base_process_llm_request``.
Passing ``model=<agent_name>`` would cause ``common_processing_pre_call_logic``
to write the agent name into ``self.data["model"]``, which triggers spurious
model-alias mapping, rate-limiting lookups, and logging tied to a
non-existent model deployment. The agent name is already carried in
``data["name"]`` and must not pollute the ``model`` slot.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _build_agents_client():
"""Build a TestClient whose auth dependency is overridden to a PROXY_ADMIN
user. Using ``dependency_overrides`` is the only reliable way to bypass the
real ``user_api_key_auth`` for FastAPI route tests — patching the module-
level name does not affect the function reference captured by ``Depends``.
The PROXY_ADMIN role also bypasses the caller-supplied-api_key guard so
these tests can focus on the ``model=None`` invariant.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.google_endpoints.agents_endpoints import router as agents_router
app = FastAPI()
app.include_router(agents_router)
async def _fake_user_api_key_auth():
return UserAPIKeyAuth(
api_key="sk-test",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
app.dependency_overrides[user_api_key_auth] = _fake_user_api_key_auth
return TestClient(app)
def _patch_proxy_server_imports(client=None):
"""Return a context-manager that stubs _proxy_server_imports so tests
don't need a running proxy."""
mock_srv = {
"general_settings": {},
"llm_router": MagicMock(),
"proxy_config": MagicMock(),
"proxy_logging_obj": MagicMock(),
"select_data_generator": None,
"user_api_base": None,
"user_max_tokens": None,
"user_model": None,
"user_request_timeout": None,
"user_temperature": None,
"version": "0.0.0",
}
return patch(
"litellm.proxy.google_endpoints.agents_endpoints._proxy_server_imports",
return_value=mock_srv,
)
def _patch_base_process(return_value=None):
if return_value is None:
return_value = {"name": "agents/my-agent", "displayName": "My Agent"}
return patch(
"litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing.base_process_llm_request",
new_callable=AsyncMock,
return_value=return_value,
)
def _patch_auth():
"""Deprecated no-op kept for call-site compatibility.
``_build_agents_client`` now installs a FastAPI ``dependency_overrides``
entry that injects a PROXY_ADMIN ``UserAPIKeyAuth``, so individual tests
no longer need to patch the module-level ``user_api_key_auth`` name.
"""
return patch("os.getpid")
class TestManagedAgentsModelParam:
"""Endpoints must pass model=None, not the agent name, to base_process_llm_request."""
def test_create_agent_passes_model_none(self):
"""POST /v1beta/agents: model kwarg must be None, not the name field."""
try:
client = _build_agents_client()
except ImportError as exc:
pytest.skip(f"Skipping: missing dependency {exc}")
with (
_patch_proxy_server_imports(),
_patch_base_process() as mock_process,
_patch_auth(),
):
client.post(
"/v1beta/agents",
json={
"name": "my-custom-slides-agent",
"base_agent": "waverunner",
"instructions": "Be helpful.",
},
)
mock_process.assert_called_once()
kwargs = mock_process.call_args.kwargs
assert kwargs["model"] is None, (
f"create_gemini_agent must not pass model={kwargs['model']!r}; "
"the agent name must stay in data['name'], not pollute data['model']"
)
assert kwargs["route_type"] == "acreate_agent"
def test_get_agent_passes_model_none(self):
"""GET /v1beta/agents/{name}: model kwarg must be None."""
try:
client = _build_agents_client()
except ImportError as exc:
pytest.skip(f"Skipping: missing dependency {exc}")
with (
_patch_proxy_server_imports(),
_patch_base_process() as mock_process,
_patch_auth(),
):
client.get("/v1beta/agents/my-custom-slides-agent")
mock_process.assert_called_once()
kwargs = mock_process.call_args.kwargs
assert (
kwargs["model"] is None
), f"get_gemini_agent must not pass model={kwargs['model']!r}"
assert kwargs["route_type"] == "aget_agent"
def test_delete_agent_passes_model_none(self):
"""DELETE /v1beta/agents/{name}: model kwarg must be None."""
try:
client = _build_agents_client()
except ImportError as exc:
pytest.skip(f"Skipping: missing dependency {exc}")
with (
_patch_proxy_server_imports(),
_patch_base_process() as mock_process,
_patch_auth(),
):
client.delete("/v1beta/agents/my-custom-slides-agent")
mock_process.assert_called_once()
kwargs = mock_process.call_args.kwargs
assert (
kwargs["model"] is None
), f"delete_gemini_agent must not pass model={kwargs['model']!r}"
assert kwargs["route_type"] == "adelete_agent"
def test_list_agent_versions_passes_model_none(self):
"""GET /v1beta/agents/{name}/versions: model kwarg must be None."""
try:
client = _build_agents_client()
except ImportError as exc:
pytest.skip(f"Skipping: missing dependency {exc}")
with (
_patch_proxy_server_imports(),
_patch_base_process() as mock_process,
_patch_auth(),
):
client.get("/v1beta/agents/my-custom-slides-agent/versions")
mock_process.assert_called_once()
kwargs = mock_process.call_args.kwargs
assert (
kwargs["model"] is None
), f"list_gemini_agent_versions must not pass model={kwargs['model']!r}"
assert kwargs["route_type"] == "alist_agent_versions"
def test_list_agents_already_passes_model_none(self):
"""GET /v1beta/agents: existing list endpoint already passes model=None — keep it so."""
try:
client = _build_agents_client()
except ImportError as exc:
pytest.skip(f"Skipping: missing dependency {exc}")
with (
_patch_proxy_server_imports(),
_patch_base_process(return_value={"agents": []}) as mock_process,
_patch_auth(),
):
client.get("/v1beta/agents")
mock_process.assert_called_once()
kwargs = mock_process.call_args.kwargs
assert kwargs["model"] is None
assert kwargs["route_type"] == "alist_agents"

View file

@ -140,3 +140,159 @@ class TestInitInteractionsApiEndpoints:
custom_llm_provider="vertex_ai",
)
assert result == {"result": "success"}
@pytest.mark.asyncio
async def test_init_interactions_api_endpoints_clears_model_when_equals_agent(
self,
):
"""Managed agent interactions must not pass agent name as model to the SDK."""
router = Router(model_list=[])
mock_function = AsyncMock(return_value={"result": "success"})
await router._init_interactions_api_endpoints(
original_function=mock_function,
agent="mqy-custom-slides-agent",
model="mqy-custom-slides-agent",
input="hello",
)
mock_function.assert_called_once_with(
custom_llm_provider="gemini",
agent="mqy-custom-slides-agent",
model=None,
input="hello",
)
class TestRouterCreateInteractionRouting:
"""acreate_interaction routing: agent-only vs model + fallbacks."""
@pytest.mark.asyncio
async def test_acreate_interaction_agent_only_uses_init_interactions(self):
"""Agent-only create must not use model-group fallback lookup."""
router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {"model": "gpt-4"},
}
]
)
with (
patch.object(
router,
"_init_interactions_api_endpoints",
new_callable=AsyncMock,
return_value={"id": "int-1"},
) as mock_init,
patch.object(
router,
"_ageneric_api_call_with_fallbacks",
new_callable=AsyncMock,
) as mock_generic,
):
result = await router.acreate_interaction(
agent="mqy-custom-slides-agent",
input="hello",
custom_llm_provider="gemini",
)
mock_init.assert_called_once()
mock_generic.assert_not_called()
assert result == {"id": "int-1"}
@pytest.mark.asyncio
async def test_init_interactions_model_uses_generic_fallbacks(self):
"""Model-based create uses _ageneric_api_call_with_fallbacks inside _init_interactions."""
router = Router(model_list=[])
with patch.object(
router,
"_ageneric_api_call_with_fallbacks",
new_callable=AsyncMock,
return_value={"id": "int-1"},
) as mock_generic:
result = await router._init_interactions_api_endpoints(
original_function=AsyncMock(),
model="gemini-2.5-flash",
input="hello",
custom_llm_provider="gemini",
)
mock_generic.assert_called_once()
assert result == {"id": "int-1"}
class TestInitializeManagedAgentsEndpoints:
"""Tests for _initialize_managed_agents_endpoints."""
def test_initialize_managed_agents_endpoints_creates_methods(self):
router = Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4"},
}
]
)
for method_name in (
"acreate_agent",
"alist_agents",
"aget_agent",
"adelete_agent",
"alist_agent_versions",
):
assert hasattr(router, method_name), f"missing {method_name}"
assert callable(getattr(router, method_name)), f"{method_name} not callable"
def test_initialize_managed_agents_endpoints_can_be_called_directly(self):
router = Router(model_list=[])
router._initialize_managed_agents_endpoints()
assert callable(router.acreate_agent)
assert callable(router.alist_agents)
class TestInitManagedAgentsApiEndpoints:
"""Tests for _init_managed_agents_api_endpoints."""
@pytest.mark.asyncio
async def test_init_managed_agents_api_endpoints_defaults_to_gemini(self):
router = Router(model_list=[])
mock_fn = AsyncMock(return_value={"agents": []})
await router._init_managed_agents_api_endpoints(
original_function=mock_fn,
)
call_kwargs = mock_fn.call_args.kwargs
assert call_kwargs["custom_llm_provider"] == "gemini"
@pytest.mark.asyncio
async def test_init_managed_agents_api_endpoints_passes_custom_provider(self):
router = Router(model_list=[])
mock_fn = AsyncMock(return_value={"agents": []})
await router._init_managed_agents_api_endpoints(
original_function=mock_fn,
custom_llm_provider="vertex_ai",
)
call_kwargs = mock_fn.call_args.kwargs
assert call_kwargs["custom_llm_provider"] == "vertex_ai"
@pytest.mark.asyncio
async def test_init_managed_agents_api_endpoints_does_not_override_existing_provider(
self,
):
router = Router(model_list=[])
mock_fn = AsyncMock(return_value={"agents": []})
await router._init_managed_agents_api_endpoints(
original_function=mock_fn,
custom_llm_provider="vertex_ai",
)
mock_fn.assert_called_once_with(custom_llm_provider="vertex_ai")

View file

@ -2059,6 +2059,25 @@ def test_openrouter_gemini_3_1_flash_lite_preview_pricing():
assert model_info["max_output_tokens"] == 65536
def test_gemini_3_1_flash_lite_pricing():
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
for model_name in (
"gemini-3.1-flash-lite",
"gemini/gemini-3.1-flash-lite",
"vertex_ai/gemini-3.1-flash-lite",
):
model_info = litellm.model_cost.get(model_name)
assert model_info is not None, f"Missing model pricing entry: {model_name}"
assert model_info["input_cost_per_token"] == 4.5e-07
assert model_info["input_cost_per_audio_token"] == 9e-07
assert model_info["output_cost_per_token"] == 2.7e-06
assert model_info["output_cost_per_reasoning_token"] == 2.7e-06
assert model_info["cache_read_input_token_cost"] == 4.5e-08
assert model_info["max_input_tokens"] == 1048576
def test_custom_pricing_applies_cache_read_input_cost():
"""
Bug 1 reproduction: custom_cost_per_token with cache_read_input_token_cost

View file

@ -172,12 +172,7 @@ describe("LogDetailContent", () => {
});
it("should display loading state when isLoadingDetails is true", () => {
render(
<LogDetailContent
logEntry={createLogEntry()}
isLoadingDetails={true}
/>,
);
render(<LogDetailContent logEntry={createLogEntry()} isLoadingDetails={true} />);
expect(screen.getByText("Loading request & response data...")).toBeInTheDocument();
});
@ -298,6 +293,37 @@ describe("LogDetailContent", () => {
expect(screen.getByText("42.50 ms")).toBeInTheDocument();
});
it("should not display LiteLLM Overhead when litellm_overhead_time_ms is absent from metadata", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success" } })} />);
expect(screen.queryByText("LiteLLM Overhead")).not.toBeInTheDocument();
});
const retriesItem = () => screen.getByText("Retries").closest(".ant-descriptions-item") as HTMLElement;
it("should display attempted_retries / max_retries for Retries when attempted_retries > 0", () => {
render(
<LogDetailContent
logEntry={createLogEntry({ metadata: { status: "success", attempted_retries: 2, max_retries: 3 } })}
/>,
);
expect(within(retriesItem()).getByText("2 / 3")).toBeInTheDocument();
});
it("should display a green 'None' tag for Retries when attempted_retries is 0", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success", attempted_retries: 0 } })} />);
const noneTag = within(retriesItem()).getByText("None");
expect(noneTag.closest(".ant-tag")).toHaveClass("ant-tag-green");
});
it("should display '-' for Retries when attempted_retries is absent from metadata", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success" } })} />);
expect(within(retriesItem()).getByText("-")).toBeInTheDocument();
});
it("should display start and end time in ISO format", () => {
render(
<LogDetailContent

View file

@ -0,0 +1,243 @@
import moment from "moment";
import { useEffect, useRef, useState } from "react";
import { SyncOutlined } from "@ant-design/icons";
import { Button, Switch } from "antd";
import { QUICK_SELECT_OPTIONS } from "./constants";
import { getTimeRangeDisplay } from "./logs_utils";
import type { PaginatedResponse } from "./log_filter_logic";
interface LogsTableToolbarProps {
searchTerm: string;
onSearchChange: (value: string) => void;
startTime: string;
onStartTimeChange: (value: string) => void;
endTime: string;
onEndTimeChange: (value: string) => void;
isCustomDate: boolean;
onIsCustomDateChange: (value: boolean) => void;
selectedTimeInterval: { value: number; unit: string };
onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void;
isLiveTail: boolean;
onIsLiveTailChange: (value: boolean) => void;
currentPage: number;
onCurrentPageChange: (updater: number | ((prev: number) => number)) => void;
pageSize: number;
isLoading: boolean;
isButtonLoading: boolean;
onRefetch: () => void;
filteredLogs: PaginatedResponse;
}
export function LogsTableToolbar({
searchTerm,
onSearchChange,
startTime,
onStartTimeChange,
endTime,
onEndTimeChange,
isCustomDate,
onIsCustomDateChange,
selectedTimeInterval,
onSelectedTimeIntervalChange,
isLiveTail,
onIsLiveTailChange,
currentPage,
onCurrentPageChange,
pageSize,
isLoading,
isButtonLoading,
onRefetch,
filteredLogs,
}: LogsTableToolbarProps) {
const [quickSelectOpen, setQuickSelectOpen] = useState(false);
const quickSelectRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (quickSelectRef.current && !quickSelectRef.current.contains(event.target as Node)) {
setQuickSelectOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const selectedOption = QUICK_SELECT_OPTIONS.find(
(option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit,
);
const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label;
return (
<>
<div className="border-b px-6 py-4 w-full max-w-full box-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0 w-full max-w-full box-border">
<div className="flex flex-wrap items-center gap-3 w-full max-w-full box-border">
<div className="relative w-64 min-w-0 flex-shrink-0">
<input
type="text"
placeholder="Search by Request ID"
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<div className="flex items-center gap-2 min-w-0 flex-shrink">
<div className="relative z-50" ref={quickSelectRef}>
<button
onClick={() => setQuickSelectOpen(!quickSelectOpen)}
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{displayLabel}
</button>
{quickSelectOpen && (
<div className="absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border p-2 z-50">
<div className="space-y-1">
{QUICK_SELECT_OPTIONS.map((option) => (
<button
key={option.label}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${displayLabel === option.label ? "bg-blue-50 text-blue-600" : ""}`}
onClick={() => {
onCurrentPageChange(1);
onEndTimeChange(moment().format("YYYY-MM-DDTHH:mm"));
onStartTimeChange(
moment()
.subtract(option.value, option.unit as any)
.format("YYYY-MM-DDTHH:mm"),
);
onSelectedTimeIntervalChange({ value: option.value, unit: option.unit });
onIsCustomDateChange(false);
setQuickSelectOpen(false);
}}
>
{option.label}
</button>
))}
<div className="border-t my-2" />
<button
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${isCustomDate ? "bg-blue-50 text-blue-600" : ""}`}
onClick={() => onIsCustomDateChange(!isCustomDate)}
>
Custom Range
</button>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">Live Tail</span>
<Switch checked={isLiveTail} defaultChecked={true} onChange={onIsLiveTailChange} />
</div>
<Button
type="default"
icon={<SyncOutlined spin={isButtonLoading} />}
onClick={onRefetch}
disabled={isButtonLoading}
title="Fetch data"
>
{isButtonLoading ? "Fetching" : "Fetch"}
</Button>
</div>
{isCustomDate && (
<div className="flex items-center gap-2">
<div>
<input
type="datetime-local"
value={startTime}
onChange={(e) => {
onStartTimeChange(e.target.value);
onCurrentPageChange(1);
}}
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<span className="text-gray-500">to</span>
<div>
<input
type="datetime-local"
value={endTime}
onChange={(e) => {
onEndTimeChange(e.target.value);
onCurrentPageChange(1);
}}
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
)}
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700 whitespace-nowrap">
Showing {isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "}
{isLoading
? "..."
: filteredLogs
? Math.min(currentPage * pageSize, filteredLogs.total)
: 0}{" "}
of {isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} results
</span>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700 min-w-[90px]">
Page {isLoading ? "..." : currentPage} of{" "}
{isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1}
</span>
<button
onClick={() => onCurrentPageChange((p: number) => Math.max(1, p - 1))}
disabled={isLoading || currentPage === 1}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => onCurrentPageChange((p: number) => Math.min(filteredLogs.total_pages || 1, p + 1))}
disabled={isLoading || currentPage === (filteredLogs.total_pages || 1)}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
{isLiveTail && currentPage === 1 && (
<div className="mb-4 px-4 py-2 bg-green-50 border border-green-200 rounded-md flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>
</div>
<button
onClick={() => onIsLiveTailChange(false)}
className="text-sm text-green-600 hover:text-green-800"
>
Stop
</button>
</div>
)}
</>
);
}

View file

@ -0,0 +1,77 @@
import FilterTeamDropdown from "../common_components/FilterTeamDropdown";
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect";
import { FilterOption } from "../molecules/filter";
import { allEndUsersCall } from "../networking";
import { ERROR_CODE_OPTIONS } from "./constants";
import { FILTER_KEYS } from "./log_filter_logic";
export function getLogFilterOptions(accessToken: string): FilterOption[] {
return [
{
name: "Team ID",
label: "Team ID",
customComponent: FilterTeamDropdown,
},
{
name: "Status",
label: "Status",
isSearchable: false,
options: [
{ label: "Success", value: "success" },
{ label: "Failure", value: "failure" },
],
},
{
name: "Model",
label: "Model",
customComponent: PaginatedModelSelect,
},
{
name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL,
label: "Public model / search tool",
isSearchable: false,
},
{
name: "Key Alias",
label: "Key Alias",
customComponent: PaginatedKeyAliasSelect,
},
{
name: "End User",
label: "End User",
isSearchable: true,
searchFn: async (searchText: string) => {
const data = await allEndUsersCall(accessToken);
const users = data?.map((u: any) => u.user_id) || [];
const filtered = users.filter((u: string) => u.toLowerCase().includes(searchText.toLowerCase()));
return filtered.map((u: string) => ({ label: u, value: u }));
},
},
{
name: "Error Code",
label: "Error Code",
isSearchable: true,
searchFn: async (searchText: string) => {
if (!searchText) return ERROR_CODE_OPTIONS;
const lower = searchText.toLowerCase();
const filtered = ERROR_CODE_OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower));
const isExactValue = ERROR_CODE_OPTIONS.some((opt) => opt.value === searchText.trim());
if (!isExactValue && searchText.trim()) {
filtered.push({ label: `Use custom code: ${searchText.trim()}`, value: searchText.trim() });
}
return filtered;
},
},
{
name: "Key Hash",
label: "Key Hash",
isSearchable: false,
},
{
name: "Error Message",
label: "Error Message",
isSearchable: false,
},
];
}

View file

@ -1,12 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpendLogsTable, { RequestViewer } from "./index";
import type { LogEntry } from "./columns";
import type { Row } from "@tanstack/react-table";
import SpendLogsTable from "./index";
import { renderWithProviders } from "../../../tests/test-utils";
import { uiSpendLogsCall } from "../networking";
const mockHandleFilterResetFromHook = vi.fn();
vi.mock("./log_filter_logic", async (importOriginal) => {
@ -14,14 +10,8 @@ vi.mock("./log_filter_logic", async (importOriginal) => {
return {
...actual,
useLogFilterLogic: vi.fn(() => ({
filters: {},
filteredLogs: {
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 1,
},
logsQuery: { isLoading: false, isFetching: false, refetch: vi.fn() },
filteredLogs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 },
allTeams: [],
handleFilterChange: vi.fn(),
handleFilterReset: mockHandleFilterResetFromHook,
@ -50,139 +40,6 @@ vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
const baseLogEntry: LogEntry = {
request_id: "chatcmpl-test-id",
api_key: "api-key",
team_id: "team-id",
model: "gpt-4",
model_id: "gpt-4",
call_type: "chat",
spend: 0,
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
startTime: "2025-11-14T00:00:00Z",
endTime: "2025-11-14T00:00:00Z",
cache_hit: "miss",
request_duration_ms: 1000,
messages: [{ role: "user", content: "hello" }],
response: { status: "ok" },
metadata: {
status: "success",
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
request_tags: {},
custom_llm_provider: "openai",
api_base: "https://api.example.com",
};
const createRow = (overrides: Partial<LogEntry> = {}): Row<LogEntry> =>
({
original: {
...baseLogEntry,
...overrides,
},
}) as unknown as Row<LogEntry>;
describe("Request Viewer", () => {
it("renders the request details heading", () => {
render(<RequestViewer row={createRow()} />);
expect(screen.getByText("Request Details")).toBeInTheDocument();
});
it("should truncate the request id if it is longer than 64 characters", () => {
const LONG_REQUEST_ID = "a".repeat(128);
const TRUNCATED_REQUEST_ID = `${"a".repeat(64)}...`;
render(
<RequestViewer
row={createRow({
request_id: LONG_REQUEST_ID,
})}
/>,
);
expect(screen.getByText(TRUNCATED_REQUEST_ID)).toBeInTheDocument();
});
it("should display LiteLLM Overhead when litellm_overhead_time_ms is present in metadata", () => {
render(
<RequestViewer
row={createRow({
metadata: {
status: "success",
litellm_overhead_time_ms: 150,
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
})}
/>,
);
expect(screen.getByText("LiteLLM Overhead:")).toBeInTheDocument();
expect(screen.getByText("150 ms")).toBeInTheDocument();
});
it("should not display LiteLLM Overhead when litellm_overhead_time_ms is not present in metadata", () => {
render(<RequestViewer row={createRow()} />);
expect(screen.queryByText("LiteLLM Overhead:")).not.toBeInTheDocument();
});
it("should display retry count when attempted_retries > 0 in metadata", () => {
render(
<RequestViewer
row={createRow({
metadata: {
status: "success",
attempted_retries: 2,
max_retries: 3,
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
})}
/>,
);
expect(screen.getByText("Retries:")).toBeInTheDocument();
expect(screen.getByText("2 / 3")).toBeInTheDocument();
});
it("should display green 'None' tag when attempted_retries is 0", () => {
render(
<RequestViewer
row={createRow({
metadata: {
status: "success",
attempted_retries: 0,
max_retries: 3,
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
})}
/>,
);
expect(screen.getByText("Retries:")).toBeInTheDocument();
expect(screen.getByText("None")).toBeInTheDocument();
});
it("should display '-' for Retries when attempted_retries is not present in metadata", () => {
render(<RequestViewer row={createRow()} />);
expect(screen.getByText("Retries:")).toBeInTheDocument();
expect(screen.getByText("-")).toBeInTheDocument();
});
});
describe("SpendLogsTable", () => {
const defaultProps = {
accessToken: "test-token",
@ -215,7 +72,9 @@ describe("SpendLogsTable", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} />);
// Open the time range quick select dropdown (button shows current range like "Last 24 Hours")
const quickSelectButton = screen.getByRole("button", { name: /Last 24 Hours|Last 15 Minutes|Last Hour|Last 4 Hours|Last 7 Days/i });
const quickSelectButton = screen.getByRole("button", {
name: /Last 24 Hours|Last 15 Minutes|Last Hour|Last 4 Hours|Last 7 Days/i,
});
await user.click(quickSelectButton);
// Click "Custom Range" to enable custom date selection
@ -241,51 +100,19 @@ describe("SpendLogsTable", () => {
});
});
describe("Quick Select time range", () => {
const waitForWindowSeconds = async (minMinutes: number) => {
let diff = -1;
await waitFor(() => {
const lastCall = vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0];
if (!lastCall) throw new Error("uiSpendLogsCall was not called");
diff = moment
.utc(lastCall.end_date, "YYYY-MM-DD HH:mm:ss")
.diff(moment.utc(lastCall.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds");
// start_date is rounded down to the minute boundary; end_date is current time
expect(diff).toBeGreaterThanOrEqual(minMinutes * 60);
expect(diff).toBeLessThan((minMinutes + 1) * 60);
});
return diff;
};
describe("auth-not-ready guard", () => {
it("shows a loading spinner when credentials are not yet resolved", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} accessToken={null} />);
it("should pass a ~1-minute window to uiSpendLogsCall when 'Last Minute' is selected", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last Minute" }));
await waitForWindowSeconds(1);
expect(document.querySelector(".ant-spin")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reset Filters" })).not.toBeInTheDocument();
});
it("should pass a ~15-minute window to uiSpendLogsCall when 'Last 15 Minutes' is selected", async () => {
const user = userEvent.setup();
it("renders the table (no spinner) once all credentials are present", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" }));
await waitForWindowSeconds(15);
});
it("should update the time-range button label to 'Last Minute' after selecting it", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last Minute" }));
expect(screen.getByRole("button", { name: "Last Minute" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Last 24 Hours/i })).not.toBeInTheDocument();
expect(document.querySelector(".ant-spin")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument();
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,28 @@
import moment from "moment";
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { uiSpendLogsCall } from "../networking";
import { Team } from "../key_team_helpers/key_list";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
import { debounce } from "lodash";
import { defaultPageSize } from "../constants";
import { PaginatedResponse } from ".";
import type { LogsSortField } from "./columns";
import type { LogEntry, LogsSortField } from "./columns";
export interface PaginatedResponse {
data: LogEntry[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
function useDebouncedValue<T>(value: T, delayMs: number): [T, React.Dispatch<React.SetStateAction<T>>] {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return [debounced, setDebounced];
}
/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */
export const FILTER_KEYS = {
@ -28,324 +43,188 @@ export const FILTER_KEYS = {
export type FilterKey = keyof typeof FILTER_KEYS;
export type LogFilterState = Record<(typeof FILTER_KEYS)[FilterKey], string>;
// Keys whose UI is a free-form text input; only these need debouncing.
const TEXT_FILTER_KEYS: readonly (keyof LogFilterState)[] = [
FILTER_KEYS.KEY_HASH,
FILTER_KEYS.ERROR_MESSAGE,
FILTER_KEYS.REQUEST_ID,
FILTER_KEYS.USER_ID,
FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL,
];
// Live-tail polls every 15s, but only on page 1 (newest) while live tail is on.
export const LIVE_TAIL_INTERVAL_MS = 15000;
export const getLiveTailRefetchInterval = (isLiveTail: boolean, currentPage: number): number | false =>
isLiveTail && currentPage === 1 ? LIVE_TAIL_INTERVAL_MS : false;
export const defaultFilters: LogFilterState = {
[FILTER_KEYS.TEAM_ID]: "",
[FILTER_KEYS.KEY_HASH]: "",
[FILTER_KEYS.REQUEST_ID]: "",
[FILTER_KEYS.MODEL]: "",
[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "",
[FILTER_KEYS.USER_ID]: "",
[FILTER_KEYS.END_USER]: "",
[FILTER_KEYS.STATUS]: "",
[FILTER_KEYS.KEY_ALIAS]: "",
[FILTER_KEYS.ERROR_CODE]: "",
[FILTER_KEYS.ERROR_MESSAGE]: "",
};
export function useLogFilterLogic({
logs,
accessToken,
startTime, // Receive from SpendLogsTable
endTime, // Receive from SpendLogsTable
token,
userRole,
userID,
filters,
setFilters,
filterByCurrentUser,
activeTab,
isLiveTail,
startTime,
endTime,
pageSize = defaultPageSize,
isCustomDate,
setCurrentPage,
userID,
userRole,
sortBy = "startTime",
sortOrder = "desc",
currentPage = 1,
}: {
logs: PaginatedResponse;
accessToken: string | null;
token: string | null;
userRole: string | null;
userID: string | null;
filters: LogFilterState;
setFilters: React.Dispatch<React.SetStateAction<LogFilterState>>;
filterByCurrentUser: boolean | null;
activeTab: string;
isLiveTail: boolean;
startTime: string;
endTime: string;
pageSize?: number;
isCustomDate: boolean;
setCurrentPage: (page: number) => void;
userID: string | null;
userRole: string | null;
sortBy?: LogsSortField;
sortOrder?: "asc" | "desc";
currentPage?: number;
}) {
const defaultFilters = useMemo<LogFilterState>(
() => ({
[FILTER_KEYS.TEAM_ID]: "",
[FILTER_KEYS.KEY_HASH]: "",
[FILTER_KEYS.REQUEST_ID]: "",
[FILTER_KEYS.MODEL]: "",
[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "",
[FILTER_KEYS.USER_ID]: "",
[FILTER_KEYS.END_USER]: "",
[FILTER_KEYS.STATUS]: "",
[FILTER_KEYS.KEY_ALIAS]: "",
[FILTER_KEYS.ERROR_CODE]: "",
[FILTER_KEYS.ERROR_MESSAGE]: "",
}),
[],
);
const [debouncedFilters, setDebouncedFilters] = useDebouncedValue(filters, 300);
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
const [backendFilteredLogs, setBackendFilteredLogs] = useState<PaginatedResponse | null>(null);
const lastSearchTimestamp = useRef(0);
// Live values for dropdown keys, debounced for text keys.
const effectiveFilters = useMemo(() => {
const merged = { ...filters };
for (const k of TEXT_FILTER_KEYS) {
merged[k] = debouncedFilters[k];
}
return merged;
}, [filters, debouncedFilters]);
// Refs that always hold the latest filters and hasBackendFilters values.
// The sort/page/time effect below intentionally omits these from its dep array
// to avoid double-fetches when a filter changes; reading from refs instead of
// the closure prevents stale-closure bugs (e.g. the effect using a snapshot of
// filters taken before the user selected Key Alias).
const filtersRef = useRef(filters);
const hasBackendFiltersRef = useRef(false);
const performSearch = useCallback(
async (filters: LogFilterState, page = 1) => {
if (!accessToken) return;
console.log("Filters being sent to API:", filters);
const currentTimestamp = Date.now();
lastSearchTimestamp.current = currentTimestamp;
const logsQuery = useQuery<PaginatedResponse>({
queryKey: [
"logs",
"table",
currentPage,
pageSize,
startTime,
endTime,
isCustomDate,
effectiveFilters,
filterByCurrentUser ? userID : null,
sortBy,
sortOrder,
],
queryFn: async () => {
if (!accessToken || !token || !userRole || !userID) {
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss");
const formattedEndTime = isCustomDate
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
try {
const response = await uiSpendLogsCall({
accessToken,
start_date: formattedStartTime,
end_date: formattedEndTime,
page,
page_size: pageSize,
params: {
api_key: filters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: filters[FILTER_KEYS.TEAM_ID] || undefined,
request_id: filters[FILTER_KEYS.REQUEST_ID] || undefined,
user_id: filters[FILTER_KEYS.USER_ID] || undefined,
end_user: filters[FILTER_KEYS.END_USER] || undefined,
status_filter: filters[FILTER_KEYS.STATUS] || undefined,
model_id: filters[FILTER_KEYS.MODEL] || undefined,
model: filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined,
key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined,
error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined,
error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
sort_by: sortBy,
sort_order: sortOrder,
},
});
const response = await uiSpendLogsCall({
accessToken,
start_date: formattedStartTime,
end_date: formattedEndTime,
page: currentPage,
page_size: pageSize,
params: {
api_key: effectiveFilters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: effectiveFilters[FILTER_KEYS.TEAM_ID] || undefined,
request_id: effectiveFilters[FILTER_KEYS.REQUEST_ID] || undefined,
user_id: effectiveFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined),
end_user: effectiveFilters[FILTER_KEYS.END_USER] || undefined,
status_filter: effectiveFilters[FILTER_KEYS.STATUS] || undefined,
model_id: effectiveFilters[FILTER_KEYS.MODEL] || undefined,
model: effectiveFilters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined,
key_alias: effectiveFilters[FILTER_KEYS.KEY_ALIAS] || undefined,
error_code: effectiveFilters[FILTER_KEYS.ERROR_CODE] || undefined,
error_message: effectiveFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
sort_by: sortBy,
sort_order: sortOrder,
},
});
if (currentTimestamp === lastSearchTimestamp.current) {
setBackendFilteredLogs({
...response,
data: response.data ?? [],
});
}
} catch (error) {
console.error("Error searching users:", error);
setBackendFilteredLogs({
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
});
}
return response;
},
[accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder],
);
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
refetchInterval: getLiveTailRefetchInterval(isLiveTail, currentPage),
placeholderData: keepPreviousData,
// Only live-tail-poll while the tab is visible.
refetchIntervalInBackground: false,
});
const debouncedSearch = useMemo(
() => debounce((filters: LogFilterState, page: number) => performSearch(filters, page), 300),
[performSearch],
);
const filteredLogs: PaginatedResponse = logsQuery.data ?? {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
useEffect(() => {
return () => debouncedSearch.cancel();
}, [debouncedSearch]);
// Determine when backend filters are active (server-side filtering)
const hasBackendFilters = useMemo(
() =>
!!(
filters[FILTER_KEYS.KEY_ALIAS] ||
filters[FILTER_KEYS.KEY_HASH] ||
filters[FILTER_KEYS.REQUEST_ID] ||
filters[FILTER_KEYS.USER_ID] ||
filters[FILTER_KEYS.END_USER] ||
filters[FILTER_KEYS.ERROR_CODE] ||
filters[FILTER_KEYS.ERROR_MESSAGE] ||
filters[FILTER_KEYS.MODEL] ||
filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]
),
[filters],
);
// Keep refs in sync on every render so the sort/page/time effect always reads
// the latest values without those values being in its dep array.
useEffect(() => {
filtersRef.current = filters;
hasBackendFiltersRef.current = hasBackendFilters;
}, [filters, hasBackendFilters]);
// Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query)
useEffect(() => {
if (hasBackendFiltersRef.current && accessToken) {
// Cancel any pending debounced search to prevent it from overwriting this page's results
debouncedSearch.cancel();
performSearch(filtersRef.current, currentPage);
}
// filters / hasBackendFilters are read via refs — avoids stale-closure bugs
// when sort/page/time changes after a filter (e.g. Key Alias) was set.
// debouncedSearch / performSearch: filter changes go through handleFilterChange
// → debouncedSearch; adding them here would cause double-fetches on filter apply.
// accessToken: stable across sort/page/time changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]);
// Compute client-side filtered logs directly from incoming logs and filters
const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => {
if (!logs || !logs.data) {
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
// If backend filters are on, don't perform client-side filtering here
if (hasBackendFilters) {
return logs;
}
let filteredData = [...logs.data];
if (filters[FILTER_KEYS.TEAM_ID]) {
filteredData = filteredData.filter((log) => log.team_id === filters[FILTER_KEYS.TEAM_ID]);
}
if (filters[FILTER_KEYS.STATUS]) {
filteredData = filteredData.filter((log) => {
if (filters[FILTER_KEYS.STATUS] === "success") {
return !log.status || log.status === "success";
}
return log.status === filters[FILTER_KEYS.STATUS];
});
}
if (filters[FILTER_KEYS.MODEL]) {
filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]);
}
if (filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]) {
const m = filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL];
filteredData = filteredData.filter((log) => log.model === m);
}
if (filters[FILTER_KEYS.KEY_HASH]) {
filteredData = filteredData.filter((log) => log.api_key === filters[FILTER_KEYS.KEY_HASH]);
}
if (filters[FILTER_KEYS.END_USER]) {
filteredData = filteredData.filter((log) => log.end_user === filters[FILTER_KEYS.END_USER]);
}
if (filters[FILTER_KEYS.ERROR_CODE]) {
filteredData = filteredData.filter((log) => {
const metadata = log.metadata || {};
const errorInfo = metadata.error_information;
return errorInfo && errorInfo.error_code === filters[FILTER_KEYS.ERROR_CODE];
});
}
return {
data: filteredData,
total: logs.total,
page: logs.page,
page_size: logs.page_size,
total_pages: logs.total_pages,
};
}, [logs, filters, hasBackendFilters]);
// Choose which filtered logs to expose: backend result when active, otherwise client-derived
const filteredLogs: PaginatedResponse = useMemo(() => {
if (hasBackendFilters) {
// When backend filters are active, only show backend results.
// If search hasn't completed yet (null), show empty state rather than
// falling back to unfiltered logs — that caused filtered views to
// display mismatched data when the filter matched zero rows.
if (backendFilteredLogs !== null) {
return backendFilteredLogs;
}
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
return clientDerivedFilteredLogs;
}, [hasBackendFilters, backendFilteredLogs, clientDerivedFilteredLogs]);
// Fetch all teams and users for potential filter dropdowns (optional, can be adapted)
const { data: allTeams } = useQuery<Team[], Error>({
queryKey: ["allTeamsForLogFilters", accessToken],
queryFn: async () => {
if (!accessToken) return [];
// Use fetchAllTeams helper function for consistency and abstraction
// Assuming fetchAllTeams returns Team[] directly
const teamsData = await fetchAllTeams(accessToken);
return teamsData || []; // Ensure it returns an array
return teamsData || [];
},
enabled: !!accessToken,
});
// Update filters state
const handleFilterChange = (newFilters: Partial<LogFilterState>) => {
setFilters((prev) => {
const updatedFilters = { ...prev, ...newFilters };
// Ensure all keys in LogFilterState are present, defaulting to '' if not in newFilters
for (const key of Object.keys(defaultFilters) as Array<keyof LogFilterState>) {
if (!(key in updatedFilters)) {
updatedFilters[key] = defaultFilters[key];
}
}
// Only call debouncedSearch if filters have actually changed
if (JSON.stringify(updatedFilters) !== JSON.stringify(prev)) {
setCurrentPage(1);
setBackendFilteredLogs(null);
debouncedSearch(updatedFilters, 1);
}
return updatedFilters as LogFilterState;
});
};
const handleFilterReset = () => {
// Reset filters state
setFilters(defaultFilters);
// Clear backend filtered logs to ensure fresh render
setBackendFilteredLogs(null);
// Cancel any in-flight debounced search
debouncedSearch.cancel();
// Reset to first page so the unfiltered view starts at page 1
setDebouncedFilters(defaultFilters);
setCurrentPage(1);
};
// Expose a filter-aware refetch so callers (e.g. the manual Fetch button) can
// refresh results while keeping all active backend filters intact. The plain
// `logs.refetch()` in the parent only re-runs the main TanStack Query, which
// does not carry key_alias or other backend-only filter params.
const refetchWithFilters = useCallback(
(page = currentPage) => {
if (hasBackendFilters && accessToken) {
debouncedSearch.cancel();
performSearch(filters, page);
}
},
[hasBackendFilters, accessToken, filters, currentPage, performSearch, debouncedSearch],
);
return {
filters,
logsQuery,
filteredLogs,
hasBackendFilters,
allTeams,
handleFilterChange,
handleFilterReset,
refetchWithFilters,
};
}

View file

@ -0,0 +1,45 @@
import moment from "moment";
import { describe, expect, it } from "vitest";
import { getTimeRangeDisplay } from "./logs_utils";
// startTime built relative to "now"; getTimeRangeDisplay computes now() internally.
const ago = (amount: number, unit: moment.unitOfTime.DurationConstructor) =>
moment().subtract(amount, unit).toISOString();
describe("getTimeRangeDisplay", () => {
it("labels a ~1-minute window as 'Last 1 Minute'", () => {
expect(getTimeRangeDisplay(false, ago(1, "minutes"), "")).toBe("Last 1 Minute");
});
it("labels a ~10-minute window as 'Last 15 Minutes'", () => {
expect(getTimeRangeDisplay(false, ago(10, "minutes"), "")).toBe("Last 15 Minutes");
});
it("labels a ~30-minute window as 'Last Hour'", () => {
expect(getTimeRangeDisplay(false, ago(30, "minutes"), "")).toBe("Last Hour");
});
it("labels a ~2-hour window as 'Last 4 Hours'", () => {
expect(getTimeRangeDisplay(false, ago(2, "hours"), "")).toBe("Last 4 Hours");
});
it("labels a ~10-hour window as 'Last 24 Hours'", () => {
expect(getTimeRangeDisplay(false, ago(10, "hours"), "")).toBe("Last 24 Hours");
});
it("labels a ~3-day window as 'Last 7 Days'", () => {
expect(getTimeRangeDisplay(false, ago(3, "days"), "")).toBe("Last 7 Days");
});
it("falls back to a 'MMM D - MMM D' range beyond 7 days", () => {
const label = getTimeRangeDisplay(false, ago(30, "days"), "");
expect(label).toMatch(/^[A-Z][a-z]{2} \d{1,2} - [A-Z][a-z]{2} \d{1,2}$/);
});
it("renders an explicit start - end range when isCustomDate is true", () => {
const start = "2025-01-02T03:04:00Z";
const end = "2025-01-05T06:07:00Z";
const expected = `${moment(start).format("MMM D, h:mm A")} - ${moment(end).format("MMM D, h:mm A")}`;
expect(getTimeRangeDisplay(true, start, end)).toBe(expected);
});
});

View file

@ -1,62 +0,0 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useLogFilterLogic } from "../../src/components/view_logs/log_filter_logic";
// Minimal mocks to avoid real network during hook init
vi.mock("../../src/components/key_team_helpers/filter_helpers", () => ({
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
const createQueryClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
function Harness({ logs }: { logs: any }) {
const { filteredLogs } = useLogFilterLogic({
logs,
accessToken: "token",
startTime: "2025-01-01 00:00:00",
endTime: "2025-01-02 00:00:00",
pageSize: 50,
isCustomDate: true,
setCurrentPage: () => {},
userID: "user-1",
userRole: "admin",
});
return <div data-testid="count">{filteredLogs.data.length}</div>;
}
describe("useLogFilterLogic (minimal)", () => {
it("useLogFilterLogic minimal: updates filteredLogs when logs change", async () => {
const qc = createQueryClient();
const logsA = { data: [{ request_id: "a" }], total: 1, page: 1, page_size: 50, total_pages: 1 };
const logsB = {
data: [{ request_id: "a" }, { request_id: "b" }],
total: 2,
page: 1,
page_size: 50,
total_pages: 1,
};
const { rerender } = render(
<QueryClientProvider client={qc}>
<Harness logs={logsA} />
</QueryClientProvider>,
);
expect(await screen.findByTestId("count")).toHaveTextContent("1");
rerender(
<QueryClientProvider client={qc}>
<Harness logs={logsB} />
</QueryClientProvider>,
);
expect(await screen.findByTestId("count")).toHaveTextContent("2");
});
});

4
uv.lock generated
View file

@ -3189,7 +3189,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.86.0"
version = "1.87.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@ -3539,7 +3539,7 @@ source = { editable = "enterprise" }
[[package]]
name = "litellm-proxy-extras"
version = "0.4.72"
version = "0.4.73"
source = { editable = "litellm-proxy-extras" }
[[package]]