Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_mcp_server_env_vars

This commit is contained in:
mateo-berri 2026-06-04 15:11:50 +00:00
commit fe20b4b904
No known key found for this signature in database
25 changed files with 3683 additions and 210 deletions

View file

@ -36,3 +36,79 @@ jobs:
- name: Build
run: npm run build
frontend-lint:
runs-on: ubuntu-latest
timeout-minutes: 8
defaults:
run:
working-directory: ui/litellm-dashboard
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
- name: Collect changed files
id: changed
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
: > "$RUNNER_TEMP/prettier_files.txt"
: > "$RUNNER_TEMP/eslint_files.txt"
while IFS= read -r f; do
[ -f "$f" ] || continue
case "$f" in
*.js | *.jsx | *.ts | *.tsx | *.mjs | *.cjs)
printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt"
printf '%s\n' "$f" >> "$RUNNER_TEMP/eslint_files.txt" ;;
*.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html)
printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;;
esac
done < <(git diff --name-only --diff-filter=ACMR --relative "$BASE_SHA"...HEAD -- .)
if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then
echo "has_files=true" >> "$GITHUB_OUTPUT"
else
echo "has_files=false" >> "$GITHUB_OUTPUT"
echo "No lintable UI files changed in this PR; nothing to check."
fi
- name: Setup Node.js
if: steps.changed.outputs.has_files == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ui/litellm-dashboard/package-lock.json
- name: Install dependencies
if: steps.changed.outputs.has_files == 'true'
run: npm ci
- name: Lint changed files (prettier + eslint)
if: steps.changed.outputs.has_files == 'true'
run: |
prettier_files=()
eslint_files=()
while IFS= read -r f; do prettier_files+=("$f"); done < "$RUNNER_TEMP/prettier_files.txt"
while IFS= read -r f; do eslint_files+=("$f"); done < "$RUNNER_TEMP/eslint_files.txt"
status=0
if [ ${#prettier_files[@]} -gt 0 ]; then
echo "::group::Prettier (${#prettier_files[@]} files)"
npx prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; }
echo "::endgroup::"
fi
if [ ${#eslint_files[@]} -gt 0 ]; then
echo "::group::ESLint (${#eslint_files[@]} files)"
npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1
echo "::endgroup::"
fi
exit $status
- name: Check lint budgets
if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
run: |
npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true
node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json

View file

@ -678,6 +678,7 @@ OPENAI_CHAT_COMPLETION_PARAMS = [
"extra_headers",
"thinking",
"web_search_options",
"include_server_side_tool_invocations",
"service_tier",
"prompt_cache_key",
"prompt_cache_retention",
@ -739,6 +740,7 @@ DEFAULT_CHAT_COMPLETION_PARAM_VALUES = {
"verbosity": None,
"thinking": None,
"web_search_options": None,
"include_server_side_tool_invocations": None,
"service_tier": None,
"safety_identifier": None,
"prompt_cache_key": None,

View file

@ -92,8 +92,27 @@ class RealTimeStreaming:
# Track whether we have already sent the guardrail turn-detection update
# that disables provider auto-response for transcription guardrails.
self._guardrail_turn_detection_update_sent: bool = False
# Deferred Gemini Live setup: Pipecat may stream audio before session.update.
# Buffer client audio until the backend acknowledges setup (setupComplete).
self._backend_setup_complete: bool = (
provider_config is None or provider_config.requires_session_configuration()
)
self._flushing_pending_messages_until_setup: bool = False
self._pending_messages_until_setup: List[str] = []
self._pending_messages_byte_total: int = 0
# Per-connection caps for pre-setup audio frames (message count + total bytes).
_MAX_BUFFERED_MESSAGES: int = 200
_MAX_BUFFERED_BYTES: int = 10 * 1024 * 1024 # 10 MB
_SESSION_EVENT_TYPES = frozenset(["session.created", "session.updated"])
_CLIENT_AUDIO_BUFFER_TYPES = frozenset(
[
"input_audio_buffer.append",
"input_audio_buffer.commit",
"input_audio_buffer.clear",
]
)
_AUDIO_FORMAT_MAP: Dict[str, Dict[str, Any]] = {
"pcm16": {"type": "audio/pcm", "rate": 24000},
"g711_ulaw": {"type": "audio/G711-ulaw", "rate": 8000},
@ -285,6 +304,86 @@ class RealTimeStreaming:
await self.backend_ws.send(message) # type: ignore[union-attr, attr-defined]
return True
def _uses_deferred_backend_setup(self) -> bool:
"""True when setup is deferred until the client's first session.update."""
if self.provider_config is None:
return False
return not self.provider_config.requires_session_configuration()
def _should_buffer_client_message_until_setup(self, message: str) -> bool:
if not self._uses_deferred_backend_setup():
return False
if (
self._backend_setup_complete
and not self._flushing_pending_messages_until_setup
):
return False
try:
msg_obj = json.loads(message)
except (json.JSONDecodeError, TypeError):
return False
return msg_obj.get("type") in RealTimeStreaming._CLIENT_AUDIO_BUFFER_TYPES
def _buffer_pending_message_until_setup(self, message: str) -> None:
msg_bytes = len(message.encode("utf-8"))
if (
len(self._pending_messages_until_setup)
< RealTimeStreaming._MAX_BUFFERED_MESSAGES
and self._pending_messages_byte_total + msg_bytes
<= RealTimeStreaming._MAX_BUFFERED_BYTES
):
self._pending_messages_until_setup.append(message)
self._pending_messages_byte_total += msg_bytes
else:
verbose_logger.warning(
"Pre-setup buffer full (%d messages / %d bytes); dropping frame",
len(self._pending_messages_until_setup),
self._pending_messages_byte_total,
)
async def _flush_pending_messages_until_setup(self) -> bool:
pending = self._pending_messages_until_setup
self._pending_messages_until_setup = []
self._pending_messages_byte_total = 0
for idx, message in enumerate(pending):
try:
await self._send_to_backend(message)
except Exception as e:
unsent = pending[idx:]
self._pending_messages_until_setup = (
unsent + self._pending_messages_until_setup
)
self._pending_messages_byte_total = sum(
len(msg.encode("utf-8"))
for msg in self._pending_messages_until_setup
)
verbose_logger.debug(
"Failed to flush buffered client message after setup: %s "
"(%d buffered message(s) retained)",
e,
len(unsent),
)
return False
return True
async def _send_event_to_client(self, event: Any, event_str: str) -> bool:
if self._client_wants_beta and isinstance(event, dict):
try:
translated = self._translate_event_to_beta(event)
if translated is None:
return False
await self.websocket.send_text(json.dumps(translated))
return True
except Exception as e:
verbose_logger.warning(
"Failed to translate %s to beta protocol, forwarding "
"untranslated event to client: %s",
event.get("type"),
e,
)
await self.websocket.send_text(event_str)
return True
def _cache_session_configuration_request(self, transformed_message: str) -> None:
"""Store setup payload once sent to backend.
@ -547,6 +646,19 @@ class RealTimeStreaming:
isinstance(event, dict) and event.get("type") == "session.created"
)
if is_session_created_event:
if (
self._uses_deferred_backend_setup()
and not self._backend_setup_complete
):
self._backend_setup_complete = True
self._flushing_pending_messages_until_setup = True
try:
while self._pending_messages_until_setup:
flushed = await self._flush_pending_messages_until_setup()
if not flushed:
break
finally:
self._flushing_pending_messages_until_setup = False
if self._session_created_sent_to_client:
# A synthetic session.created (with placeholder defaults) was
# already forwarded to the client when we connected. The
@ -569,7 +681,7 @@ class RealTimeStreaming:
## update if a prior attempt was dropped by the provider transform.
if is_session_created_event and self._has_audio_transcription_guardrails():
self.store_message(event_str)
await self.websocket.send_text(event_str)
await self._send_event_to_client(event, event_str)
await self._maybe_send_guardrail_turn_detection_update()
continue
## GUARDRAIL: run on transcription events in provider_config path too
@ -581,7 +693,7 @@ class RealTimeStreaming:
transcript = event.get("transcript", "")
self._collect_user_input_from_backend_event(cast(dict, event))
self.store_message(event_str)
await self.websocket.send_text(event_str)
await self._send_event_to_client(event, event_str)
blocked = await self.run_realtime_guardrails(
cast(str, transcript),
item_id=cast(Optional[str], event.get("item_id")),
@ -591,7 +703,7 @@ class RealTimeStreaming:
continue
## LOGGING
self.store_message(event_str)
await self.websocket.send_text(event_str)
await self._send_event_to_client(event, event_str)
async def _handle_raw_backend_message(self, raw_response) -> bool:
"""Process a backend message without provider_config (raw path).
@ -880,6 +992,7 @@ class RealTimeStreaming:
## GUARDRAIL: intercept conversation.item.create for text-based injection.
guardrail_turn_detection_injected = False
msg_type: Optional[str] = None
try:
msg_obj = json.loads(message)
msg_type = msg_obj.get("type")
@ -1081,6 +1194,29 @@ class RealTimeStreaming:
# actually forward to the backend.
self.store_input(message=message)
if self._should_buffer_client_message_until_setup(message):
self._buffer_pending_message_until_setup(message)
continue
if self._pending_messages_until_setup:
should_send_setup_before_buffered_messages = (
not self._backend_setup_complete
and not self._flushing_pending_messages_until_setup
and msg_type == "session.update"
)
if not should_send_setup_before_buffered_messages:
self._buffer_pending_message_until_setup(message)
if (
self._backend_setup_complete
and not self._flushing_pending_messages_until_setup
):
await self._flush_pending_messages_until_setup()
continue
if self._flushing_pending_messages_until_setup:
self._buffer_pending_message_until_setup(message)
continue
## FORWARD TO BACKEND
# Only mark the guardrail turn_detection update as sent after the
# backend actually accepted the message. Setting the flag earlier

View file

@ -93,6 +93,7 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
"modalities",
"parallel_tool_calls",
"web_search_options",
"include_server_side_tool_invocations",
"service_tier",
]
if supports_reasoning(model, custom_llm_provider="gemini"):

View file

@ -27,7 +27,6 @@ from litellm.types.llms.gemini import (
)
from litellm.types.llms.openai import (
OpenAIRealtimeContentPartDone,
OpenAIRealtimeConversationItemCreated,
OpenAIRealtimeDoneEvent,
OpenAIRealtimeEvents,
OpenAIRealtimeEventTypes,
@ -79,6 +78,12 @@ _KNOWN_GEMINI_TOP_LEVEL_KEYS: set = {
map_key.split(".", 1)[0] for map_key in MAP_GEMINI_FIELD_TO_OPENAI_EVENT
}
# Gemini Live native-audio model ids carry this marker (e.g.
# ``gemini-2.5-flash-native-audio-preview-09-2025``). These models reject a
# ``speechConfig`` on ``setup`` with a 1007 invalid-argument error, so it is
# stripped in ``_finalize_gemini_live_setup``.
_GEMINI_NATIVE_AUDIO_MODEL_MARKER = "native-audio"
class GeminiRealtimeConfig(BaseRealtimeConfig):
# Cap the LRU of in-flight tool calls so long sessions with many tool
@ -98,6 +103,33 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
# bypassing spend and budget accounting.
self._pending_usage_metadata: Optional[dict] = None
@staticmethod
def _usage_detail_alias(details: Any, defaults: Dict[str, int]) -> Dict[str, Any]:
if not isinstance(details, dict):
return dict(defaults)
return {
**defaults,
**{key: value for key, value in details.items() if value is not None},
}
@staticmethod
def _add_pipecat_usage_detail_aliases(usage_dict: Dict[str, Any]) -> Dict[str, Any]:
usage_dict.setdefault(
"input_token_details",
GeminiRealtimeConfig._usage_detail_alias(
usage_dict.get("input_tokens_details"),
{"cached_tokens": 0, "text_tokens": 0, "audio_tokens": 0},
),
)
usage_dict.setdefault(
"output_token_details",
GeminiRealtimeConfig._usage_detail_alias(
usage_dict.get("output_tokens_details"),
{"text_tokens": 0, "audio_tokens": 0},
),
)
return usage_dict
def validate_environment(
self, headers: dict, model: str, api_key: Optional[str] = None
) -> dict:
@ -173,9 +205,25 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
def map_automatic_turn_detection(
self, value: OpenAIRealtimeTurnDetection
) -> AutomaticActivityDetection:
"""Map OpenAI ``server_vad`` to Gemini ``automaticActivityDetection``.
OpenAI ``semantic_vad`` has no Gemini Live equivalent — return an empty
dict so callers omit ``realtimeInputConfig`` (mapping it with
``disabled: true`` breaks native-audio sessions).
"""
if (
isinstance(value, dict)
and value.get("type") == "semantic_vad"
and "create_response" not in value
):
return AutomaticActivityDetection()
automatic_activity_dection = AutomaticActivityDetection()
if "create_response" in value and isinstance(value["create_response"], bool):
automatic_activity_dection["disabled"] = not value["create_response"]
elif isinstance(value, dict) and value.get("type") == "server_vad":
# OpenAI server VAD enables activity detection by default.
automatic_activity_dection["disabled"] = False
else:
automatic_activity_dection["disabled"] = True
if "prefix_padding_ms" in value and isinstance(value["prefix_padding_ms"], int):
@ -197,6 +245,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"tools",
"input_audio_transcription",
"turn_detection",
"voice",
]
def map_openai_params(
@ -231,17 +280,33 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
optional_params["inputAudioTranscription"] = {}
elif key == "turn_detection":
value_typed = cast(OpenAIRealtimeTurnDetection, value)
if (
isinstance(value_typed, dict)
and value_typed.get("type") == "semantic_vad"
and "create_response" not in value_typed
):
# Pipecat/OpenAI GA semantic VAD — skip; Gemini uses its own VAD.
# Only skip when there is no create_response override so that
# a guardrail-injected create_response:false is not dropped.
continue
transformed_audio_activity_config = self.map_automatic_turn_detection(
value_typed
)
if (
len(transformed_audio_activity_config) > 0
): # if the config is not empty, add it to the optional params
if transformed_audio_activity_config:
optional_params["realtimeInputConfig"] = (
BidiGenerateContentRealtimeInputConfig(
automaticActivityDetection=transformed_audio_activity_config
)
)
elif key == "voice":
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
vertex_gemini_config = VertexGeminiConfig()
speech_config = vertex_gemini_config._map_audio_params({"voice": value})
if speech_config:
optional_params["generationConfig"]["speechConfig"] = speech_config
if len(optional_params["generationConfig"]) == 0:
optional_params.pop("generationConfig")
return optional_params
@ -297,6 +362,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
and "transcription" in input_cfg
):
normalized["input_audio_transcription"] = input_cfg["transcription"]
output_cfg = audio.get("output")
if isinstance(output_cfg, dict) and output_cfg.get("voice"):
normalized["voice"] = output_cfg["voice"]
extracted_turn_detection = GeminiRealtimeConfig._extract_turn_detection(
normalized
@ -308,6 +376,18 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
return normalized
@staticmethod
def _finalize_gemini_live_setup(
model: str, setup: Dict[str, Any]
) -> Dict[str, Any]:
"""Drop fields Gemini Live native-audio rejects on ``setup``."""
if _GEMINI_NATIVE_AUDIO_MODEL_MARKER not in model.lower():
return setup
generation_config = setup.get("generationConfig")
if isinstance(generation_config, dict):
generation_config.pop("speechConfig", None)
return setup
def _handle_session_update(
self,
json_message: dict,
@ -351,7 +431,11 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
verbose_logger.debug(
"Gemini Realtime: Sending initial setup with tools to backend"
)
return [json.dumps({"setup": new_overrides})]
return [
json.dumps(
{"setup": self._finalize_gemini_live_setup(model, new_overrides)}
)
]
if not new_overrides:
verbose_logger.debug(
@ -420,7 +504,15 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
verbose_logger.debug(
"Gemini Realtime: Forwarding session.update as follow-up setup"
)
return [json.dumps({"setup": follow_up_setup})]
return [
json.dumps(
{
"setup": self._finalize_gemini_live_setup(
model, cast(Dict[str, Any], follow_up_setup)
)
}
)
]
def _handle_conversation_item(self, json_message: dict) -> List[str]:
"""
@ -666,6 +758,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"object": "realtime.response",
"id": response_id,
"status": "in_progress",
"status_details": None,
"output": [],
"conversation_id": conversation_id,
"modalities": _modalities,
@ -675,9 +768,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
)
response_items.append(response_created)
## - return response.output_item.added ← adds ‘item_id’ same for all subsequent events
## - return response.output_item.added
response_output_item_added = OpenAIRealtimeStreamResponseOutputItemAdded(
type="response.output_item.added",
event_id="event_{}".format(uuid.uuid4()),
response_id=response_id,
output_index=0,
item={
@ -690,20 +784,28 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
},
)
response_items.append(response_output_item_added)
## - return conversation.item.created
conversation_item_created = OpenAIRealtimeConversationItemCreated(
type="conversation.item.created",
event_id="event_{}".format(uuid.uuid4()),
item={
"id": output_item_id,
"object": "realtime.item",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
## - return conversation.item.added
# Pipecat 1.3.x handles "conversation.item.added" (not ".created").
# Sending ".created" raises "Unimplemented server event type" which
# kills the receive task handler.
response_items.append(
cast(
OpenAIRealtimeEvents,
{
"type": "conversation.item.added",
"event_id": "event_{}".format(uuid.uuid4()),
"previous_item_id": None,
"item": {
"id": output_item_id,
"object": "realtime.item",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
},
)
)
response_items.append(conversation_item_created)
## - return response.content_part.added
response_content_part_added = OpenAIRealtimeResponseContentPartAdded(
type="response.content_part.added",
@ -749,9 +851,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
return OpenAIRealtimeResponseDelta(
type=(
"response.text.delta"
"response.output_text.delta"
if delta_type == "text"
else "response.audio.delta"
else "response.output_audio.delta"
),
content_index=0,
event_id="event_{}".format(uuid.uuid4()),
@ -778,7 +880,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
current_response_id = "resp_{}".format(uuid.uuid4())
if delta_type == "text":
return OpenAIRealtimeResponseTextDone(
type="response.text.done",
type="response.output_text.done",
content_index=0,
event_id="event_{}".format(uuid.uuid4()),
item_id=current_output_item_id,
@ -788,7 +890,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
)
elif delta_type == "audio":
return OpenAIRealtimeResponseAudioDone(
type="response.audio.done",
type="response.output_audio.done",
content_index=0,
event_id="event_{}".format(uuid.uuid4()),
item_id=current_output_item_id,
@ -914,7 +1016,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
events: List[OpenAIRealtimeFunctionCallArgumentsDone] = []
for idx, fc in enumerate(function_calls):
call_id = fc.get("id", "")
call_id = fc.get("id", "") or f"call_{uuid.uuid4().hex[:16]}"
name = fc.get("name", "")
# Store call_id → name mapping for round-trip. Use an LRU so
@ -962,7 +1064,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
current_delta_chunks = []
any_delta_chunk = False
for event in transformed_message:
if event["type"] == "response.text.delta":
if event["type"] == "response.output_text.delta":
current_delta_chunks.append(
cast(OpenAIRealtimeResponseDelta, event)
)
@ -973,7 +1075,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
)
else:
if (
transformed_message["type"] == "response.text.delta"
transformed_message["type"] == "response.output_text.delta"
): # ONLY ACCUMULATE TEXT DELTA CHUNKS - AUDIO WILL CAUSE SERVER MEMORY ISSUES
if current_delta_chunks is None:
current_delta_chunks = []
@ -1067,6 +1169,8 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
responses_api_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
_chat_completion_usage,
)
_usage_dict = responses_api_usage.model_dump()
self._add_pipecat_usage_detail_aliases(_usage_dict)
response_done_event = OpenAIRealtimeDoneEvent(
type="response.done",
event_id="event_{}".format(uuid.uuid4()),
@ -1074,6 +1178,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
object="realtime.response",
id=current_response_id,
status="completed",
status_details=None, # type: ignore[typeddict-item]
output=(
[output_item["item"] for output_item in output_items]
if output_items
@ -1081,7 +1186,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
),
conversation_id=current_conversation_id,
modalities=_modalities,
usage=responses_api_usage.model_dump(),
usage=_usage_dict,
),
)
if temperature is not None:
@ -1294,19 +1399,36 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
output_tx = server_content.get("outputTranscription")
if isinstance(output_tx, dict) and output_tx.get("text"):
if current_response_id is None:
current_response_id = "resp_{}".format(uuid.uuid4())
if current_output_item_id is None:
current_output_item_id = "item_{}".format(uuid.uuid4())
current_conversation_id = (
current_conversation_id or "conv_{}".format(uuid.uuid4())
)
returned_message.extend(
self.return_new_content_delta_events(
session_configuration_request=session_configuration_request,
response_id=current_response_id,
output_item_id=current_output_item_id,
conversation_id=current_conversation_id,
delta_type="audio",
)
)
# Emit as the GA event name; _GA_TO_BETA_EVENT_TYPES translates
# this back to response.audio_transcript.delta for beta clients.
returned_message.append(
cast(
OpenAIRealtimeEvents,
{
"type": "response.audio_transcript.delta",
"type": "response.output_audio_transcript.delta",
"event_id": "event_{}".format(uuid.uuid4()),
"delta": output_tx["text"],
"item_id": current_output_item_id
or "item_{}".format(uuid.uuid4()),
"response_id": current_response_id
or "resp_{}".format(uuid.uuid4()),
"output_index": 0,
"transcript": output_tx["text"],
"item_id": current_output_item_id,
"content_index": 0,
"output_index": 0,
"response_id": current_response_id,
"delta": output_tx["text"],
},
)
)
@ -1416,6 +1538,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"object": "realtime.response",
"id": current_response_id,
"status": "in_progress",
"status_details": None,
"output": [],
"conversation_id": current_conversation_id,
"modalities": tool_call_modalities,
@ -1460,6 +1583,29 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
},
)
)
# conversation.item.added — Pipecat 1.3.x registers the
# call_id into _pending_function_calls inside
# _handle_evt_conversation_item_added, which is triggered
# by this event (NOT by response.output_item.added and NOT
# by the old conversation.item.created which Pipecat 1.3.x
# does not handle). Without this event the subsequent
# response.function_call_arguments.done finds an empty
# pending-calls dict and drops the tool invocation silently.
returned_message.append(
cast(
OpenAIRealtimeEvents,
{
"type": "conversation.item.added",
"event_id": f"event_{uuid.uuid4()}",
"previous_item_id": None,
"item": {
**function_call_item,
"status": "in_progress",
"arguments": "",
},
},
)
)
# response.function_call_arguments.delta — Gemini delivers
# the full arguments string in a single toolCall frame
# rather than streaming partial chunks, so emit one delta
@ -1496,14 +1642,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
item={**function_call_item},
)
)
# conversation.item.created
returned_message.append(
OpenAIRealtimeConversationItemCreated(
type="conversation.item.created",
event_id=f"event_{uuid.uuid4()}",
item={**function_call_item},
)
)
# response.done - close the response so clients can submit tool
# results. Mirror the non-tool-call RESPONSE_DONE path: if Gemini
@ -1537,6 +1675,8 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
tool_call_responses_api_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
_tool_call_chat_completion_usage,
)
_tool_usage_dict = tool_call_responses_api_usage.model_dump()
self._add_pipecat_usage_detail_aliases(_tool_usage_dict)
tool_call_done_event = OpenAIRealtimeDoneEvent(
type="response.done",
event_id=f"event_{uuid.uuid4()}",
@ -1544,6 +1684,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
id=current_response_id,
object="realtime.response",
status="completed",
status_details=None, # type: ignore[typeddict-item]
output=[
{
"id": te["item_id"],
@ -1558,7 +1699,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
],
conversation_id=current_conversation_id,
modalities=tool_call_modalities,
usage=tool_call_responses_api_usage.model_dump(),
usage=_tool_usage_dict,
),
)
tool_call_temperature = tool_call_generation_config.get("temperature")

View file

@ -1121,6 +1121,61 @@ def _pop_and_merge_extra_body(data: RequestBody, optional_params: dict) -> None:
data_dict[k] = v
def _has_google_maps_tool(tools: Optional[Any]) -> bool:
"""Return True if any tool object in the list has a 'googleMaps' key."""
if not isinstance(tools, list):
return False
return any(
isinstance(t, dict) and VertexToolName.GOOGLE_MAPS.value in t for t in tools
)
def _rewrite_mime_type_to_response_format(generation_config: GenerationConfig) -> None:
"""
Convert response_mime_type + response_json_schema/response_schema to the newer
responseFormat structure when googleMaps is present in tools.
The Gemini API rejects the combination of googleMaps + response_mime_type:
'application/json' with the error:
"Google Maps tool with a response mime type: 'application/json' is unsupported"
The newer responseFormat field supports this combination on both the Gemini API
(generativelanguage.googleapis.com) and Vertex AI endpoints.
Before:
generationConfig: {
response_mime_type: "application/json",
response_json_schema: {...}
}
After:
generationConfig: {
responseFormat: {
"text": {"mimeType": "APPLICATION_JSON", "schema": {...}}
}
}
"""
schema = generation_config.pop("response_json_schema", None) # type: ignore[misc]
if schema is None:
schema = generation_config.pop("response_schema", None) # type: ignore[misc]
generation_config.pop("response_mime_type", None) # type: ignore[misc]
response_format: Dict[str, Any] = {"text": {"mimeType": "APPLICATION_JSON"}}
if schema is not None:
response_format["text"]["schema"] = schema
generation_config["responseFormat"] = response_format # type: ignore[typeddict-unknown-key]
def _rewrite_google_maps_response_format(data: RequestBody) -> None:
generation_config = cast(Optional[GenerationConfig], data.get("generationConfig"))
if (
isinstance(generation_config, dict)
and _has_google_maps_tool(data.get("tools"))
and generation_config.get("response_mime_type") == "application/json"
):
_rewrite_mime_type_to_response_format(generation_config)
def _transform_request_body( # noqa: PLR0915
messages: List[AllMessageValues],
model: str,
@ -1246,6 +1301,7 @@ def _transform_request_body( # noqa: PLR0915
if labels and custom_llm_provider != LlmProviders.GEMINI:
data["labels"] = labels
_pop_and_merge_extra_body(data, optional_params)
_rewrite_google_maps_response_format(data)
except Exception as e:
raise e

View file

@ -1147,6 +1147,26 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return cast(dict, speech_config)
@staticmethod
def _apply_include_server_side_tool_invocations(
non_default_params: Dict,
optional_params: Dict,
) -> None:
"""
Set include_server_side_tool_invocations before tools are mapped.
map_openai_params iterates non_default_params in request order; if tools
appear before this flag, _resolve_search_tool_conflict would drop search
tools before the flag is applied.
"""
for key in (
"include_server_side_tool_invocations",
"includeServerSideToolInvocations",
):
if non_default_params.get(key) is True or optional_params.get(key) is True:
optional_params["include_server_side_tool_invocations"] = True
return
def map_openai_params( # noqa: PLR0915
self,
non_default_params: Dict,
@ -1154,6 +1174,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
model: str,
drop_params: bool,
) -> Dict:
self._apply_include_server_side_tool_invocations(
non_default_params, optional_params
)
gemini_sampling_params_warned: bool = False
for param, value in non_default_params.items():
if param == "temperature":

View file

@ -437,6 +437,7 @@ async def acompletion( # noqa: PLR0915
# Optional liteLLM function params
thinking: Optional[AnthropicThinkingParam] = None,
web_search_options: Optional[OpenAIWebSearchOptions] = None,
include_server_side_tool_invocations: Optional[bool] = None,
# Session management
shared_session: Optional["ClientSession"] = None,
# Per-request JSON schema validation (overrides litellm.enable_json_schema_validation)
@ -584,6 +585,7 @@ async def acompletion( # noqa: PLR0915
"acompletion": True, # assuming this is a required parameter
"thinking": thinking,
"web_search_options": web_search_options,
"include_server_side_tool_invocations": include_server_side_tool_invocations,
"shared_session": shared_session,
"enable_json_schema_validation": enable_json_schema_validation,
}
@ -1116,6 +1118,7 @@ def completion( # type: ignore # noqa: PLR0915
top_logprobs: Optional[int] = None,
parallel_tool_calls: Optional[bool] = None,
web_search_options: Optional[OpenAIWebSearchOptions] = None,
include_server_side_tool_invocations: Optional[bool] = None,
deployment_id=None,
extra_headers: Optional[dict] = None,
safety_identifier: Optional[str] = None,
@ -1550,6 +1553,11 @@ def completion( # type: ignore # noqa: PLR0915
"reasoning_effort": reasoning_effort,
"thinking": thinking,
"web_search_options": web_search_options,
"include_server_side_tool_invocations": (
include_server_side_tool_invocations
if include_server_side_tool_invocations is not None
else kwargs.get("include_server_side_tool_invocations")
),
"safety_identifier": safety_identifier,
"service_tier": service_tier,
"allowed_openai_params": kwargs.get("allowed_openai_params"),

View file

@ -59,7 +59,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
_safe_get_request_headers,
)
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.utils import get_server_root_path, normalize_route_for_root_path
from litellm.proxy.utils import normalize_route_for_root_path
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
@ -2499,20 +2499,16 @@ class InitPassThroughEndpointHelpers:
return list(_registered_pass_through_routes.keys())
@staticmethod
def _build_full_path_with_root(path: str) -> str:
def _route_for_registry_lookup(route: str) -> str:
"""
Build full path by prepending server root path if needed.
Normalize an incoming route to the bare path stored in the registry.
Args:
path: The relative path to build
Returns:
Full path with server root prepended (if root is not "/")
Registry keys store root-stripped paths. Callers should pass routes from
``get_request_route()`` (already stripped); prefixed ``request.url.path``
values are stripped via ``normalize_route_for_root_path``.
"""
root_path = get_server_root_path()
if root_path == "/":
return path
return f"{root_path}{path}"
normalized_route = normalize_route_for_root_path(route)
return normalized_route if normalized_route is not None else route
@staticmethod
def is_registered_pass_through_route(route: str) -> bool:
@ -2535,6 +2531,10 @@ class InitPassThroughEndpointHelpers:
if normalized_route.startswith(mapped_route):
return True
comparison_route = InitPassThroughEndpointHelpers._route_for_registry_lookup(
route
)
# Fast path: check if any registered route key contains this path
# Keys are in format: "{endpoint_id}:exact:{path}:{methods}" or "{endpoint_id}:subpath:{path}:{methods}"
# For backward compatibility, also support old format: "{endpoint_id}:exact:{path}" or "{endpoint_id}:subpath:{path}"
@ -2543,14 +2543,13 @@ class InitPassThroughEndpointHelpers:
parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?]
if len(parts) >= 3:
route_type = parts[1]
registered_path = (
InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2])
)
if route_type == "exact" and route == registered_path:
registered_path = parts[2]
if route_type == "exact" and comparison_route == registered_path:
return True
elif route_type == "subpath":
if route == registered_path or route.startswith(
registered_path + "/"
if (
comparison_route == registered_path
or comparison_route.startswith(registered_path + "/")
):
return True
@ -2561,13 +2560,14 @@ class InitPassThroughEndpointHelpers:
route: str, method: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Get passthrough params for a given route and optionally filter by HTTP method"""
comparison_route = InitPassThroughEndpointHelpers._route_for_registry_lookup(
route
)
for key in _registered_pass_through_routes.keys():
parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?]
if len(parts) >= 3:
route_type = parts[1]
registered_path = (
InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2])
)
registered_path = parts[2]
# Get the methods for this route. Prefer the registered metadata,
# but keep supporting test fixtures / older registry entries that
@ -2581,11 +2581,12 @@ class InitPassThroughEndpointHelpers:
# Check if path matches
path_matches = False
if route_type == "exact" and route == registered_path:
if route_type == "exact" and comparison_route == registered_path:
path_matches = True
elif route_type == "subpath":
if route == registered_path or route.startswith(
registered_path + "/"
if (
comparison_route == registered_path
or comparison_route.startswith(registered_path + "/")
):
path_matches = True

View file

@ -246,6 +246,7 @@ class GenerationConfig(TypedDict, total=False):
response_mime_type: Literal["text/plain", "application/json"]
response_schema: dict
response_json_schema: dict
responseFormat: dict
seed: int
responseLogprobs: bool
logprobs: int

View file

@ -246,6 +246,38 @@ async def test__transform_request_body_image_config_with_image_size():
assert rb["generationConfig"]["imageConfig"]["imageSize"] == "4K"
def test__transform_request_body_google_maps_json_schema_uses_response_format():
"""googleMaps + JSON schema must use responseFormat, not response_mime_type."""
messages = [{"role": "user", "content": "Find restaurants in Mumbai"}]
schema = {
"type": "object",
"properties": {"places": {"type": "array"}},
"required": ["places"],
}
optional_params = {
"tools": [{"googleMaps": {}}],
"response_mime_type": "application/json",
"response_json_schema": schema,
}
transform_request_params = {
"messages": messages,
"model": "gemini/gemini-3.1-flash-lite",
"optional_params": optional_params,
"custom_llm_provider": "gemini",
"litellm_params": {},
"cached_content": None,
}
rb: RequestBody = transformation._transform_request_body(**transform_request_params)
gen = rb["generationConfig"]
assert "responseFormat" in gen
assert gen["responseFormat"]["text"]["mimeType"] == "APPLICATION_JSON"
assert gen["responseFormat"]["text"]["schema"] == schema
assert "response_mime_type" not in gen
assert "response_json_schema" not in gen
def test_map_function_google_search_snake_case():
"""
Test that google_search tool (snake_case) is properly mapped to googleSearch.

View file

@ -293,6 +293,51 @@ def test_translate_event_to_beta_drops_conversation_item_done():
)
@pytest.mark.asyncio
async def test_provider_config_path_translates_ga_events_for_beta_clients():
client_ws = MagicMock()
client_ws.scope = {"headers": [(b"openai-beta", b"realtime=v1")]}
client_ws.send_text = AsyncMock()
backend_ws = MagicMock()
logging_obj = MagicMock()
provider_config = MagicMock()
provider_config.transform_realtime_response = MagicMock(
return_value={
"response": [
{
"type": "response.output_text.delta",
"event_id": "event_1",
"delta": "hello",
},
{"type": "conversation.item.done", "event_id": "event_2"},
],
"current_output_item_id": None,
"current_response_id": None,
"current_delta_chunks": [],
"current_conversation_id": None,
"current_item_chunks": [],
"current_delta_type": None,
"session_configuration_request": None,
}
)
streaming = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=provider_config,
model="gemini-2.5-flash",
)
await streaming._handle_provider_config_message("{}")
assert client_ws.send_text.await_count == 1
sent = json.loads(client_ws.send_text.await_args.args[0])
assert sent["type"] == "response.text.delta"
assert sent["delta"] == "hello"
def test_client_sent_openai_beta_realtime_header_detects_header():
ws = MagicMock()
ws.scope = {"headers": [(b"openai-beta", b"realtime=v1")]}
@ -1770,3 +1815,298 @@ async def test_follow_up_setup_updates_cached_session_configuration_request():
await streaming.client_ack_messages()
assert streaming.session_configuration_request == follow_up_setup
@pytest.mark.asyncio
async def test_deferred_setup_buffers_audio_until_backend_setup_complete(monkeypatch):
"""Pipecat may send audio before session.update when setup is deferred."""
monkeypatch.setattr(litellm, "gemini_live_defer_setup", True, raising=False)
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
client_ws = MagicMock()
audio_msg = json.dumps({"type": "input_audio_buffer.append", "audio": "AA=="})
client_ws.receive_text = AsyncMock(
side_effect=[audio_msg, ConnectionClosed(None, None)]
)
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
logging_obj = MagicMock()
config = GeminiRealtimeConfig()
streaming = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=config,
model="gemini-live-2.5-flash-native-audio",
)
assert streaming._backend_setup_complete is False
await streaming.client_ack_messages()
backend_ws.send.assert_not_called()
assert len(streaming._pending_messages_until_setup) == 1
streaming._backend_setup_complete = True
await streaming._flush_pending_messages_until_setup()
assert backend_ws.send.call_count == 1
@pytest.mark.asyncio
async def test_deferred_setup_sends_session_update_before_buffered_audio(monkeypatch):
monkeypatch.setattr(litellm, "gemini_live_defer_setup", True, raising=False)
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
client_ws = MagicMock()
audio_msg = json.dumps({"type": "input_audio_buffer.append", "audio": "AA=="})
session_update = json.dumps(
{"type": "session.update", "session": {"modalities": ["audio"]}}
)
client_ws.receive_text = AsyncMock(
side_effect=[audio_msg, session_update, ConnectionClosed(None, None)]
)
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
logging_obj = MagicMock()
config = GeminiRealtimeConfig()
streaming = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=config,
model="gemini-live-2.5-flash-native-audio",
)
await streaming.client_ack_messages()
assert backend_ws.send.await_count == 1
sent_payload = json.loads(backend_ws.send.await_args_list[0].args[0])
assert "setup" in sent_payload
assert "realtimeInput" not in sent_payload
assert streaming._pending_messages_until_setup == [audio_msg]
@pytest.mark.asyncio
async def test_deferred_setup_flush_buffers_audio_received_during_flush():
import asyncio
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
new_audio_msg = json.dumps(
{"type": "input_audio_buffer.append", "audio": "new-audio"}
)
client_ws.receive_text = AsyncMock(
side_effect=[new_audio_msg, ConnectionClosed(None, None)]
)
backend_ws = MagicMock()
logging_obj = MagicMock()
provider_config = MagicMock()
provider_config.requires_session_configuration = MagicMock(return_value=False)
provider_config.transform_realtime_response = MagicMock(
return_value={
"response": {
"type": "session.created",
"event_id": "event_1",
"session": {"id": "sess_1", "modalities": ["audio"]},
},
"current_output_item_id": None,
"current_response_id": None,
"current_delta_chunks": [],
"current_conversation_id": None,
"current_item_chunks": [],
"current_delta_type": None,
"session_configuration_request": None,
}
)
streaming = RealTimeStreaming(
websocket=client_ws,
backend_ws=backend_ws,
logging_obj=logging_obj,
provider_config=provider_config,
model="gemini-live-2.5-flash-native-audio",
)
old_audio_msg = json.dumps(
{"type": "input_audio_buffer.append", "audio": "old-audio"}
)
streaming._pending_messages_until_setup = [old_audio_msg]
streaming._pending_messages_byte_total = len(old_audio_msg.encode("utf-8"))
first_flush_started = asyncio.Event()
release_flush = asyncio.Event()
sent_messages = []
async def send_to_backend(message):
sent_messages.append(message)
if message == old_audio_msg:
first_flush_started.set()
await release_flush.wait()
return True
streaming._send_to_backend = send_to_backend # type: ignore[method-assign]
setup_task = asyncio.create_task(
streaming._handle_provider_config_message(json.dumps({"setupComplete": {}}))
)
await asyncio.wait_for(first_flush_started.wait(), timeout=1)
await streaming.client_ack_messages()
assert sent_messages == [old_audio_msg]
assert streaming._pending_messages_until_setup == [new_audio_msg]
release_flush.set()
await asyncio.wait_for(setup_task, timeout=1)
assert sent_messages == [old_audio_msg, new_audio_msg]
assert streaming._pending_messages_until_setup == []
@pytest.mark.asyncio
async def test_deferred_setup_flush_retains_unsent_messages_after_send_failure():
client_ws = MagicMock()
backend_ws = MagicMock()
logging_obj = MagicMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
buffered_messages = [
json.dumps({"type": "input_audio_buffer.append", "audio": "AA=="}),
json.dumps({"type": "input_audio_buffer.commit"}),
]
streaming._pending_messages_until_setup = list(buffered_messages)
streaming._pending_messages_byte_total = sum(
len(message.encode("utf-8")) for message in buffered_messages
)
streaming._send_to_backend = AsyncMock( # type: ignore[method-assign]
side_effect=Exception("transient")
)
await streaming._flush_pending_messages_until_setup()
assert streaming._pending_messages_until_setup == buffered_messages
assert streaming._pending_messages_byte_total == sum(
len(message.encode("utf-8")) for message in buffered_messages
)
streaming._send_to_backend = AsyncMock(return_value=True) # type: ignore[method-assign]
await streaming._flush_pending_messages_until_setup()
assert streaming._pending_messages_until_setup == []
assert streaming._pending_messages_byte_total == 0
assert streaming._send_to_backend.await_count == 2
@pytest.mark.asyncio
async def test_deferred_setup_flushes_audio_on_backend_session_created(monkeypatch):
"""Buffered audio is released when Gemini setupComplete becomes session.created."""
monkeypatch.setattr(litellm, "gemini_live_defer_setup", True, raising=False)
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({"setupComplete": {}}).encode(),
ConnectionClosed(None, None),
]
)
logging_obj = MagicMock()
logging_obj.litellm_trace_id = "trace_defer"
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
config = GeminiRealtimeConfig()
streaming = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=config,
model="gemini-live-2.5-flash-native-audio",
)
streaming._pending_messages_until_setup.append(
json.dumps({"type": "input_audio_buffer.append", "audio": "AA=="})
)
await streaming.backend_to_client_send_messages()
assert streaming._backend_setup_complete is True
assert streaming._pending_messages_until_setup == []
assert backend_ws.send.call_count == 1
@pytest.mark.asyncio
async def test_deferred_setup_caps_non_audio_buffered_messages(monkeypatch):
"""A client that withholds session.update cannot grow the pre-setup buffer
without bound by streaming non-audio frames after the first audio frame."""
monkeypatch.setattr(litellm, "gemini_live_defer_setup", True, raising=False)
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
cap = RealTimeStreaming._MAX_BUFFERED_MESSAGES
audio_msg = json.dumps({"type": "input_audio_buffer.append", "audio": "AA=="})
flood_msg = json.dumps({"type": "foo", "data": "x" * 1024})
client_ws = MagicMock()
client_ws.receive_text = AsyncMock(
side_effect=[audio_msg]
+ [flood_msg] * (cap + 50)
+ [ConnectionClosed(None, None)]
)
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
logging_obj = MagicMock()
streaming = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=GeminiRealtimeConfig(),
model="gemini-live-2.5-flash-native-audio",
)
assert streaming._backend_setup_complete is False
await streaming.client_ack_messages()
backend_ws.send.assert_not_called()
assert len(streaming._pending_messages_until_setup) == cap
assert (
streaming._pending_messages_byte_total <= RealTimeStreaming._MAX_BUFFERED_BYTES
)
@pytest.mark.asyncio
async def test_deferred_setup_caps_non_audio_buffered_bytes(monkeypatch):
"""Non-audio frames appended after the first audio frame honor the byte budget."""
monkeypatch.setattr(litellm, "gemini_live_defer_setup", True, raising=False)
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
audio_msg = json.dumps({"type": "input_audio_buffer.append", "audio": "AA=="})
big_non_audio = json.dumps(
{"type": "foo", "data": "x" * (RealTimeStreaming._MAX_BUFFERED_BYTES + 1)}
)
client_ws = MagicMock()
client_ws.receive_text = AsyncMock(
side_effect=[audio_msg, big_non_audio, ConnectionClosed(None, None)]
)
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
logging_obj = MagicMock()
streaming = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=GeminiRealtimeConfig(),
model="gemini-live-2.5-flash-native-audio",
)
await streaming.client_ack_messages()
assert streaming._pending_messages_until_setup == [audio_msg]
assert (
streaming._pending_messages_byte_total <= RealTimeStreaming._MAX_BUFFERED_BYTES
)

View file

@ -235,12 +235,73 @@ def test_gemini_realtime_transformation_audio_delta():
contains_audio_delta = False
for response in responses:
if response["type"] == OpenAIRealtimeEventTypes.RESPONSE_AUDIO_DELTA.value:
if (
response["type"]
== OpenAIRealtimeEventTypes.RESPONSE_OUTPUT_AUDIO_DELTA.value
):
contains_audio_delta = True
break
assert contains_audio_delta, "Expected audio delta event"
def test_gemini_output_audio_transcript_delta_uses_active_response_ids():
config = GeminiRealtimeConfig()
session_configuration_request = {
"setup": {
"model": "gemini-1.5-flash",
"generationConfig": {"responseModalities": ["AUDIO"]},
}
}
session_configuration_request_str = json.dumps(session_configuration_request)
event = {
"serverContent": {
"outputTranscription": {"text": "Hello from Gemini."},
"modelTurn": {
"parts": [
{"inlineData": {"mimeType": "audio/pcm", "data": "my-audio-data"}}
]
},
}
}
result = config.transform_realtime_response(
json.dumps(event),
"gemini-1.5-flash",
MagicMock(),
realtime_response_transform_input={
"session_configuration_request": session_configuration_request_str,
"current_output_item_id": None,
"current_response_id": None,
"current_conversation_id": None,
"current_delta_chunks": [],
"current_item_chunks": [],
"current_delta_type": None,
},
)
responses = result["response"]
response_created = next(
response for response in responses if response["type"] == "response.created"
)
transcript_delta = next(
response
for response in responses
if response["type"] == "response.output_audio_transcript.delta"
)
audio_delta = next(
response
for response in responses
if response["type"] == "response.output_audio.delta"
)
assert transcript_delta["response_id"] == response_created["response"]["id"]
assert transcript_delta["response_id"] == audio_delta["response_id"]
assert transcript_delta["item_id"] == audio_delta["item_id"]
assert result["current_response_id"] == transcript_delta["response_id"]
assert result["current_output_item_id"] == transcript_delta["item_id"]
def test_gemini_realtime_transformation_generation_complete():
from litellm.types.llms.openai import OpenAIRealtimeEventTypes
@ -278,7 +339,10 @@ def test_gemini_realtime_transformation_generation_complete():
contains_audio_done_event = False
for response in responses:
if response["type"] == OpenAIRealtimeEventTypes.RESPONSE_AUDIO_DONE.value:
if (
response["type"]
== OpenAIRealtimeEventTypes.RESPONSE_OUTPUT_AUDIO_DONE.value
):
contains_audio_done_event = True
break
assert contains_audio_done_event, "Expected audio done event"
@ -735,7 +799,14 @@ def test_gemini_tool_call_emits_response_created_preamble():
)
responses = result["response"]
# Should have: response.created, output_item.added, function_call_arguments.delta, function_call_arguments.done, output_item.done, conversation.item.created, response.done
# Expected sequence:
# 0: response.created
# 1: response.output_item.added (item status=in_progress)
# 2: conversation.item.added (registers call_id in Pipecat's _pending_function_calls)
# 3: response.function_call_arguments.delta
# 4: response.function_call_arguments.done
# 5: response.output_item.done
# 6: response.done
assert len(responses) >= 7
assert responses[0]["type"] == "response.created"
assert "response" in responses[0]
@ -749,14 +820,14 @@ def test_gemini_tool_call_emits_response_created_preamble():
assert responses[1]["type"] == "response.output_item.added"
assert responses[1]["item"]["type"] == "function_call"
assert responses[1]["item"]["status"] == "in_progress"
assert responses[2]["type"] == "response.function_call_arguments.delta"
assert responses[2]["call_id"] == "call_123"
assert responses[2]["delta"] == responses[3]["arguments"]
assert responses[3]["type"] == "response.function_call_arguments.done"
assert responses[4]["type"] == "response.output_item.done"
assert responses[4]["item"]["type"] == "function_call"
assert responses[4]["item"]["status"] == "completed"
assert responses[5]["type"] == "conversation.item.created"
assert responses[2]["type"] == "conversation.item.added"
assert responses[2]["item"]["type"] == "function_call"
assert responses[2]["item"]["call_id"] == "call_123"
assert responses[3]["type"] == "response.function_call_arguments.delta"
assert responses[3]["call_id"] == "call_123"
assert responses[3]["delta"] == responses[4]["arguments"]
assert responses[4]["type"] == "response.function_call_arguments.done"
assert responses[5]["type"] == "response.output_item.done"
assert responses[5]["item"]["type"] == "function_call"
assert responses[5]["item"]["status"] == "completed"
assert responses[6]["type"] == "response.done"
@ -930,6 +1001,12 @@ def test_gemini_tool_call_response_done_includes_usage_from_sibling_metadata():
"promptTokenCount": 17,
"responseTokenCount": 4,
"totalTokenCount": 21,
"promptTokensDetails": [
{"modality": "TEXT", "tokenCount": 17},
],
"responseTokensDetails": [
{"modality": "TEXT", "tokenCount": 4},
],
},
}
),
@ -953,6 +1030,8 @@ def test_gemini_tool_call_response_done_includes_usage_from_sibling_metadata():
assert usage["input_tokens"] == 17
assert usage["output_tokens"] == 4
assert usage["total_tokens"] == 21
assert usage["input_token_details"]["text_tokens"] == 17
assert usage["output_token_details"]["text_tokens"] == 4
def test_gemini_tool_call_response_done_falls_back_to_empty_usage():
@ -1120,6 +1199,90 @@ def test_gemini_subsequent_session_update_forwards_tools_merged_with_original_se
assert follow_up["inputAudioTranscription"] == {}
def test_gemini_realtime_pipecat_ga_session_voice_and_tools():
"""Pipecat OpenAIRealtimeSessionProperties: output_modalities, nested tools,
and audio.output.voice (e.g. Kore) must map into Gemini setup."""
config = GeminiRealtimeConfig()
session_update = {
"type": "session.update",
"session": {
"output_modalities": ["audio"],
"instructions": "Follow system instructions.",
"tools": [
{
"type": "function",
"function": {
"name": "terminate_call",
"description": "End the call.",
"parameters": {"type": "object", "properties": {}},
},
}
],
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 24000},
"turn_detection": {"type": "server_vad"},
},
"output": {
"format": {"type": "audio/pcm", "rate": 24000},
"voice": "Kore",
},
},
"temperature": 0,
},
}
messages = config.transform_realtime_request(
json.dumps(session_update),
"gemini-2.5-flash-native-audio",
session_configuration_request=None,
)
assert len(messages) == 1
setup = json.loads(messages[0])["setup"]
assert setup["generationConfig"]["responseModalities"] == ["AUDIO"]
# Native-audio Live rejects speechConfig on setup (see _finalize_gemini_live_setup).
assert "speechConfig" not in setup.get("generationConfig", {})
assert setup["tools"][0]["function_declarations"][0]["name"] == "terminate_call"
assert (
setup["realtimeInputConfig"]["automaticActivityDetection"]["disabled"] is False
)
def test_gemini_realtime_pipecat_semantic_vad_omits_realtime_input_config():
"""Pipecat SemanticTurnDetection (semantic_vad) must not map to disabled VAD."""
config = GeminiRealtimeConfig()
session_update = {
"type": "session.update",
"session": {
"output_modalities": ["audio"],
"instructions": "test",
"audio": {
"input": {"turn_detection": {"type": "semantic_vad"}},
},
"tools": [
{
"type": "function",
"function": {
"name": "terminate_call",
"description": "End call.",
"parameters": {"type": "object", "properties": {}},
},
}
],
},
}
messages = config.transform_realtime_request(
json.dumps(session_update),
"gemini-live-2.5-flash-native-audio",
session_configuration_request=None,
)
setup = json.loads(messages[0])["setup"]
assert "realtimeInputConfig" not in setup
assert setup["tools"][0]["function_declarations"][0]["name"] == "terminate_call"
def test_gemini_subsequent_session_update_with_turn_detection_only_preserves_original_tools():
"""A subsequent session.update carrying only turn_detection (the
guardrail-injected disable) must keep the original tools/generationConfig."""
@ -1407,6 +1570,12 @@ def test_gemini_standalone_usage_metadata_is_attributed_to_next_response_done():
"promptTokenCount": 5,
"responseTokenCount": 11,
"totalTokenCount": 16,
"promptTokensDetails": [
{"modality": "TEXT", "tokenCount": 5},
],
"responseTokensDetails": [
{"modality": "TEXT", "tokenCount": 11},
],
}
}
),
@ -1447,6 +1616,8 @@ def test_gemini_standalone_usage_metadata_is_attributed_to_next_response_done():
assert usage["input_tokens"] == 5
assert usage["output_tokens"] == 11
assert usage["total_tokens"] == 16
assert usage["input_token_details"]["text_tokens"] == 5
assert usage["output_token_details"]["text_tokens"] == 11
assert config._pending_usage_metadata is None

View file

@ -285,6 +285,80 @@ def test_extra_body_tags_not_forwarded_to_vertex_ai():
assert result["custom_param"] == "allowed"
def test_extra_body_google_maps_rewrites_json_response_format():
messages = [{"role": "user", "content": "test"}]
optional_params = {
"response_mime_type": "application/json",
"response_schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
},
"extra_body": {
"tools": [{"googleMaps": {}}],
},
}
result = _transform_request_body(
messages=messages,
model="gemini-2.5-pro",
optional_params=optional_params,
custom_llm_provider="vertex_ai",
litellm_params={},
cached_content=None,
)
generation_config = result["generationConfig"]
assert "response_mime_type" not in generation_config
assert generation_config["responseFormat"] == {
"text": {
"mimeType": "APPLICATION_JSON",
"schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
},
}
}
def test_extra_body_generation_config_cannot_restore_google_maps_json_mime_type():
messages = [{"role": "user", "content": "test"}]
optional_params = {
"tools": [{"googleMaps": {}}],
"response_mime_type": "application/json",
"extra_body": {
"generationConfig": {
"response_mime_type": "application/json",
"response_json_schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
},
},
},
}
result = _transform_request_body(
messages=messages,
model="gemini-2.5-pro",
optional_params=optional_params,
custom_llm_provider="vertex_ai",
litellm_params={},
cached_content=None,
)
generation_config = result["generationConfig"]
assert "response_mime_type" not in generation_config
assert "response_json_schema" not in generation_config
assert generation_config["responseFormat"] == {
"text": {
"mimeType": "APPLICATION_JSON",
"schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
},
}
}
def test_metadata_to_labels_vertex_only():
"""Test that metadata->labels conversion only happens for Vertex AI"""
messages = [{"role": "user", "content": "test"}]

View file

@ -3078,6 +3078,83 @@ def test_vertex_ai_gemini3_tool_combination_no_drop():
assert len(tools) == 3
def test_get_optional_params_keeps_google_search_with_server_side_flag():
"""
include_server_side_tool_invocations must be in non_default_params before
map_openai_params runs (not only via add_provider_specific_params after).
"""
from litellm.utils import get_optional_params
optional_params = get_optional_params(
model="gemini-3.1-pro-preview",
custom_llm_provider="gemini",
tools=[
{"google_search": {}},
{
"type": "function",
"function": {
"name": "send_message",
"description": "Send a message back",
"parameters": {
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
},
},
],
include_server_side_tool_invocations=True,
)
assert optional_params.get("include_server_side_tool_invocations") is True
tool_keys = set()
for tool in optional_params.get("tools", []):
tool_keys.update(tool.keys())
assert "function_declarations" in tool_keys
assert "googleSearch" in tool_keys
def test_map_openai_params_tools_before_include_server_side_flag():
"""
Request bodies often list tools before include_server_side_tool_invocations.
Search tools must not be dropped when the flag is present later in the dict.
"""
v = VertexGeminiConfig()
optional_params: dict = {}
non_default_params = {
"tools": [
{"google_search": {}},
{
"type": "function",
"function": {
"name": "send_message",
"description": "Send a message back",
"parameters": {
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
},
},
],
"include_server_side_tool_invocations": True,
}
result = v.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gemini-3.1-pro-preview",
drop_params=True,
)
assert result.get("include_server_side_tool_invocations") is True
tool_keys = set()
for tool in result.get("tools", []):
tool_keys.update(tool.keys())
assert "function_declarations" in tool_keys
assert "googleSearch" in tool_keys
def test_vertex_ai_mixed_tools_and_web_search_options_drops_search():
"""
When function tools and web_search_options are sent separately (Codex-style),

View file

@ -278,8 +278,8 @@ async def test_vertex_realtime_text_in_text_out():
assert session_created_msgs, "Expected session.created to be sent to client"
# At least one text delta should have been forwarded
text_delta_msgs = [m for m in sent_to_client if '"response.text.delta"' in m]
assert text_delta_msgs, "Expected response.text.delta to be sent to client"
text_delta_msgs = [m for m in sent_to_client if '"response.output_text.delta"' in m]
assert text_delta_msgs, "Expected response.output_text.delta to be sent to client"
# Verify the delta contains the model's text
delta_obj = json.loads(text_delta_msgs[0])

View file

@ -233,7 +233,7 @@ async def test_find_team_with_model_access_uses_request_method_for_passthrough_a
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):

View file

@ -733,7 +733,7 @@ def test_virtual_key_llm_api_routes_allows_registered_pass_through_endpoints():
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):
@ -799,7 +799,7 @@ def test_virtual_key_llm_api_routes_allows_non_auth_enforced_pass_through_endpoi
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):
@ -849,7 +849,7 @@ def test_virtual_key_llm_api_routes_denies_auth_pass_through_without_allowlist()
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):
@ -893,7 +893,7 @@ def test_virtual_key_llm_api_routes_uses_method_specific_auth_setting():
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):
@ -948,7 +948,7 @@ def test_non_proxy_admin_denies_auth_pass_through_without_allowlist():
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):
@ -987,7 +987,7 @@ def test_non_proxy_admin_allows_auth_pass_through_with_team_allowlist():
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):
@ -1021,7 +1021,7 @@ def test_virtual_key_without_llm_api_routes_cannot_access_pass_through():
mock_registered_routes,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
"litellm.proxy.utils.get_server_root_path",
return_value="/",
),
):

View file

@ -1386,10 +1386,6 @@ async def test_create_pass_through_endpoint_auth_true_enforces_allowlist():
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
registry,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
return_value="/",
),
):
mock_get_config.return_value = ConfigFieldInfo(
field_name="pass_through_endpoints", field_value=[]
@ -1485,10 +1481,6 @@ async def test_update_pass_through_endpoint_auth_true_enforces_allowlist():
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
registry,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
return_value="/",
),
):
mock_get_config.return_value = ConfigFieldInfo(
field_name="pass_through_endpoints", field_value=existing_endpoints
@ -1570,10 +1562,6 @@ async def test_update_pass_through_endpoint_preserves_auth_false():
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
registry,
),
patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
return_value="/",
),
):
mock_get_config.return_value = ConfigFieldInfo(
field_name="pass_through_endpoints", field_value=existing_endpoints
@ -2866,70 +2854,10 @@ async def test_create_pass_through_route_no_custom_body_falls_back():
assert call_kwargs["custom_body"] == request_parsed_body
def test_build_full_path_with_root_default():
"""
Test _build_full_path_with_root with default root path (/)
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
) as mock_get_root:
# Test with default root path
mock_get_root.return_value = "/"
result = InitPassThroughEndpointHelpers._build_full_path_with_root(
"/api/v1/endpoint"
)
assert result == "/api/v1/endpoint"
def test_build_full_path_with_root_custom():
"""
Test _build_full_path_with_root with custom root path
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
) as mock_get_root:
# Test with custom root path /proxy
mock_get_root.return_value = "/proxy"
result = InitPassThroughEndpointHelpers._build_full_path_with_root(
"/api/v1/endpoint"
)
assert result == "/proxy/api/v1/endpoint"
def test_build_full_path_with_root_nested():
"""
Test _build_full_path_with_root with nested root path
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
) as mock_get_root:
# Test with nested root path /api/v2
mock_get_root.return_value = "/api/v2"
result = InitPassThroughEndpointHelpers._build_full_path_with_root("/endpoint")
assert result == "/api/v2/endpoint"
def test_is_registered_pass_through_route_with_custom_root():
"""
Test is_registered_pass_through_route correctly handles server root path
When server has a custom root path like /proxy, the registered path
should be constructed by prepending the root to match incoming routes.
Registry stores bare paths; incoming routes may be bare (get_request_route)
or prefixed (request.url.path). Both should resolve via normalization.
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
@ -2948,32 +2876,13 @@ def test_is_registered_pass_through_route_with_custom_root():
"headers": {},
}
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
) as mock_get_root:
# Test with custom root path /proxy
mock_get_root.return_value = "/proxy"
# Should match when request route includes the root path
with patch("litellm.proxy.utils.get_server_root_path", return_value="/proxy"):
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/proxy/api/endpoint"
)
is True
)
# Should not match when request route doesn't include root path
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/api/endpoint"
)
is False
)
# Test with default root path
mock_get_root.return_value = "/"
# Should match with default root
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/api/endpoint"
@ -2981,7 +2890,13 @@ def test_is_registered_pass_through_route_with_custom_root():
is True
)
# Should not match with root prepended when root is /
with patch("litellm.proxy.utils.get_server_root_path", return_value="/"):
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/api/endpoint"
)
is True
)
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/proxy/api/endpoint"
@ -2995,10 +2910,8 @@ def test_is_registered_pass_through_route_with_custom_root():
def test_get_registered_pass_through_route_with_custom_root():
"""
Test get_registered_pass_through_route correctly handles server root path
When server has a custom root path, the method should return the correct
endpoint configuration by matching the full path including the root.
get_registered_pass_through_route matches bare registry paths against
bare or SERVER_ROOT_PATH-prefixed incoming routes.
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
@ -3019,13 +2932,8 @@ def test_get_registered_pass_through_route_with_custom_root():
route_key = f"{endpoint_id}:exact:{path}"
_registered_pass_through_routes[route_key] = target_config
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
) as mock_get_root:
# Test with custom root path /litellm
mock_get_root.return_value = "/litellm"
# Should return config when request route includes root path
with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"):
# Prefixed incoming route
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route(
"/litellm/chat/completions"
)
@ -3033,16 +2941,14 @@ def test_get_registered_pass_through_route_with_custom_root():
assert result["target"] == "http://api.example.com/v1/chat/completions"
assert result["headers"]["Authorization"] == "Bearer token123"
# Should return None when route doesn't match
# Bare incoming route (get_request_route convention)
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route(
"/chat/completions"
)
assert result is None
assert result is not None
assert result["target"] == "http://api.example.com/v1/chat/completions"
# Test with default root path
mock_get_root.return_value = "/"
# Should return config with default root
with patch("litellm.proxy.utils.get_server_root_path", return_value="/"):
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route(
"/chat/completions"
)
@ -3053,6 +2959,62 @@ def test_get_registered_pass_through_route_with_custom_root():
_registered_pass_through_routes.clear()
@pytest.mark.parametrize(
"server_root_path,route_type,incoming_route,should_match",
[
("", "subpath", "/ml/api/v1/time-series-forecast/predict", True),
("", "exact", "/ml", True),
("", "exact", "/ml/extra", False),
("/llmproxy", "subpath", "/ml/api/v1/time-series-forecast/predict", True),
(
"/llmproxy",
"subpath",
"/llmproxy/ml/api/v1/time-series-forecast/predict",
True,
),
("/llmproxy", "exact", "/ml", True),
("/llmproxy", "exact", "/llmproxy/ml", True),
("/llmproxy", "subpath", "/other/api", False),
],
)
def test_db_registered_pass_through_route_bare_path_convention(
server_root_path, route_type, incoming_route, should_match
):
"""
Regression: #28547 / SERVER_ROOT_PATH — registry stores bare /ml paths;
get_request_route() supplies bare paths; prefixed url.path must still match.
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
_registered_pass_through_routes,
)
_registered_pass_through_routes.clear()
endpoint_id = "customer-ml"
path = "/ml"
route_key = f"{endpoint_id}:{route_type}:{path}:GET,POST"
_registered_pass_through_routes[route_key] = {
"endpoint_id": endpoint_id,
"path": path,
"type": route_type,
"target": "https://example.com",
"methods": ["GET", "POST"],
}
with patch(
"litellm.proxy.utils.get_server_root_path",
return_value=server_root_path,
):
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
incoming_route
)
is should_match
)
_registered_pass_through_routes.clear()
def test_mapped_pass_through_routes_with_server_root_path():
"""
Mapped passthrough routes (vertex_ai, bedrock, etc) should match
@ -3064,9 +3026,7 @@ def test_mapped_pass_through_routes_with_server_root_path():
InitPassThroughEndpointHelpers,
)
with patch("litellm.proxy.utils.get_server_root_path") as mock_get_root:
mock_get_root.return_value = "/litellm"
with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"):
# prefixed route should match mapped routes like /vertex_ai
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(

View file

@ -8,4 +8,5 @@ build
.turbo
.next-static
*.min.js
coverage/
coverage/
eslint-suppressions.json

View file

@ -1 +1,3 @@
Never put LiteLLM tokens or API keys in `localStorage`. `localStorage` survives browser close. Prefer `httpOnly` cookies, or `sessionStorage` at most, understanding that any web storage is readable by injected scripts (XSS), and only httpOnly cookies are not
When you fix lint violations that are grandfathered in `eslint-suppressions.json`, run `eslint . --prune-suppressions` and commit the updated baseline so the gate ratchets down instead of leaving a stale suppression

View file

@ -0,0 +1,5 @@
{
"@typescript-eslint/no-explicit-any": { "max": 2040, "target": 1500 },
"complexity": { "max": 140, "target": 80 },
"max-depth": { "max": 70, "target": 30 }
}

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@ const eslintConfig = [
plugins: { "unused-imports": unusedImports },
rules: {
"unused-imports/no-unused-imports": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/ban-ts-comment": "off",
@ -25,7 +25,31 @@ const eslintConfig = [
"no-prototype-builtins": "off",
"no-useless-catch": "off",
"no-useless-escape": "off",
"no-self-assign": "off",
"no-self-assign": "error",
"no-var": "error",
"react/no-danger": "error",
complexity: ["warn", 20],
"max-depth": ["warn", 4],
"max-params": ["error", 4],
"max-nested-callbacks": ["error", 4],
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.name='fetch']",
message: "Use React Query (@tanstack/react-query) for data fetching instead of a raw fetch().",
},
],
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@tremor/react", "@tremor/react/*"],
message: "@tremor/react is being phased out; build new UI with antd instead of adding tremor imports.",
},
],
},
],
},
},
];

View file

@ -0,0 +1,30 @@
import { readFileSync } from "fs";
const [, , reportPath, budgetsPath] = process.argv;
const report = JSON.parse(readFileSync(reportPath, "utf8"));
const budgets = JSON.parse(readFileSync(budgetsPath, "utf8"));
const counts = {};
for (const file of report) {
for (const message of file.messages) {
if (message.ruleId in budgets) {
counts[message.ruleId] = (counts[message.ruleId] || 0) + 1;
}
}
}
let failed = false;
for (const [rule, { max, target }] of Object.entries(budgets)) {
const count = counts[rule] || 0;
const note = count > max ? "OVER BUDGET" : count <= target ? "at target" : `${max - count} of headroom`;
console.log(`${rule}: ${count} | max: ${max} | target: ${target} | ${note}`);
if (count > max) {
console.error(
`::error::${rule} budget exceeded (${count} > ${max}). Reduce usage; lower max in eslint-budgets.json as the count drops.`,
);
failed = true;
}
}
process.exit(failed ? 1 : 0);