mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
* feat(bedrock): add bedrock mantle gemma 4 models (#30264) * feat(bedrock): add bedrock mantle gemma 4 models * test(bedrock): harden mantle local cost fixture * feat(responses): enable the responses API for the Tensormesh provider (#30209) * feat(responses): enable the responses API for the Tensormesh provider * Update litellm/llms/openai_like/providers.json Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(langfuse_otel): mark LLM spans as generations (#30250) * fix(bedrock): stop stream_chunk_size leaking into invoke request bodies (#30240) stream_chunk_size is a LiteLLM-internal knob for re-chunking the HTTP response stream. The invoke transformations splat optional_params into the provider request body without dropping it, and Bedrock rejects unknown fields, so any bedrock/invoke request that sets the parameter fails with ValidationException: stream_chunk_size: Extra inputs are not permitted. Drop it in the invoke dispatcher (covers cohere, titan, mistral, meta, ai21) and in the Claude messages-format request builder (the route used for bedrock/invoke Anthropic models) * fix(bedrock): stop buffering streamed tool-call argument deltas (#30231) * fix(bedrock): stop buffering streamed tool-call argument deltas Two issues made Bedrock tool-use streaming arrive as a single end-of-stream burst through LiteLLM while plain text streamed fine. First, the anthropic-beta allowlist mapped fine-grained-tool-streaming-2025-05-14 to null for bedrock and bedrock_converse, so the header was silently stripped. Without that beta, Anthropic models on Bedrock buffer tool input server-side and emit all toolUse.input deltas at once (verified against converse-stream and invoke-with-response-stream directly). Bedrock accepts the beta via additionalModelRequestFields.anthropic_beta, so it is now forwarded. Second, the streaming reads re-chunked the AWS event stream with iter_bytes(chunk_size=1024). httpx's ByteChunker only releases full 1024-byte blocks, so the small early events (messageStart, contentBlockStart, first deltas) sat in the buffer until enough bytes accumulated, pushing time-to-first-byte from ~1.4s to ~8.5s on buffered tool-use streams. The default is now no re-chunking; an explicit stream_chunk_size is still honored. * test(bedrock): cover explicit stream_chunk_size on sync invoke path * test(bedrock): cover stream_chunk_size plumbing through converse completion * test(bedrock): cover stream_chunk_size default in legacy BedrockLLM streaming * test(bedrock): merge converse handler tests into existing mapped test file pytest imports test modules by basename in non-package test dirs, so the new tests/test_litellm/llms/bedrock/chat/test_converse_handler.py collided with the pre-existing tests/test_litellm/llms/chat/test_converse_handler.py and broke collection in CI. Move the new tests into the existing file * feat(otel): emit v2 cost breakdown + stamp tracer scope version (#30156) Read the StandardLoggingPayload cost_breakdown into a typed LLMCost on LLMCallSpanData and emit each component under litellm.cost.* (absent components omitted, so spans stay sparse). Stamp litellm.__version__ as the instrumentation scope version so every v2 span carries a deterministic scope.version. Tests under tests/test_litellm/integrations/otel/. * fix(proxy): cancel in-flight upstream LLM request on client disconnect (opt-in) (#30223) * fix(proxy): cancel in-flight upstream LLM request on client disconnect (opt-in) On the non-streaming path, base_process_llm_request awaited the LLM call with no disconnect monitoring; when the HTTP client went away the upstream request kept running until completion or request_timeout (6000s default), holding a backend slot (e.g. a vLLM GPU slot) for output nobody would read Add an opt-in general_settings.cancel_on_disconnect flag, default off, so the default code path is unchanged. When enabled, a receive-based watcher task observes http.disconnect and cancels the asyncio.gather driving the upstream call. The resulting CancelledError is converted to HTTPException 499 only when the disconnect event is set, so server-initiated cancellations still propagate as-is. The 499 then flows through _handle_llm_api_exception like any other failure, meaning post_call_failure_hook still releases max_parallel_requests slots and fires spend and alerting callbacks; it is logged at info level instead of a full traceback Also removes the dead check_request_disconnection helper in proxy_server.py (zero call sites) along with its behavior-pin tests Builds on the receive-based design from #25776 Addresses #13774. Re-fixes #22805 (regressed after the #14295 revert) Co-authored-by: CreateRandom <18438707+CreateRandom@users.noreply.github.com> * fix(proxy): scope 499 quiet logging to disconnects and harden watcher Address the two P2 findings from the Greptile review on #30223. The info-level logging in _log_llm_api_exception now applies only to the disconnect-specific HTTPException (status 499 plus the shared _CLIENT_DISCONNECT_DETAIL message), so any other 499 raised by hooks or guardrails keeps its full traceback. The disconnect watcher now catches exceptions from request.receive() (e.g. a transport reset) and logs a warning instead of dying silently, making the degradation to no-op visible; a test pins that the LLM call is not cancelled in that case --------- Co-authored-by: kursad <kursad.lacin@brado.net> Co-authored-by: CreateRandom <18438707+CreateRandom@users.noreply.github.com> * fix(bedrock): grant aws-external-anthropic:* in OIDC session policy for claude_platform (#30200) (#30205) The inline STS session policy passed to assume_role_with_web_identity acts as an IAM PERMISSION CEILING — effective permissions are the intersection of the role's identity policies and this policy. Any action not listed is silently denied even when the IAM role grants it. #27678 added the bedrock/claude_platform/<model> route but its service-side action namespace is aws-external-anthropic:*, not bedrock:*. Without a matching statement here, every claude_platform request via OIDC (GCP federation, EKS Pod Identity webhook, etc.) 403s with 'no session policy allows the aws-external-anthropic:CreateInference action' — even with a fully permissive identity policy. Add a second ClaudePlatformLiteLLM statement covering CreateInference, CreateBatchInference, CancelBatchInference, DeleteBatchInference, CountTokens, Get*, List*. Keep aws:SecureTransport=true parity with the bedrock statement. Static creds + IRSA flow through different code paths and are not affected. Fixes #30200 * fix(proxy): set Retry-After header on RouterRateLimitError 429 responses (#30098) * Set Retry-After header on RouterRateLimitError responses When all deployments for a model are in cooldown, the proxy returns a 429 whose cooldown timing is only available by parsing the error message string. RouterRateLimitError already carries cooldown_time, so expose it as a standard retry-after header in _handle_llm_api_exception. The value is rounded up so clients never retry before the cooldown window ends. Fixes #27823. * Set Retry-After after response-headers hook so cooldown wins The cooldown-derived retry-after was assigned before the post_call_response_headers_hook merge, so a callback returning a retry-after key (including a stale or empty value) silently clobbered it. Move the RouterRateLimitError block after the callback merge so the cooldown value is authoritative for this error type. * fix(router): route aspeech through async_function_with_fallbacks (#30104) * fix(router): route aspeech through async_function_with_fallbacks Router.aspeech selected a deployment and awaited litellm.aspeech directly, so TTS requests got no retry on failure and no failover to backup deployments; the except block only fired an exception alert and re-raised. Every other router endpoint (acompletion, aembedding, atranscription, arerank) already delegates to async_function_with_fallbacks Mirror the atranscription pattern: move deployment selection and the litellm.aspeech call into a private _aspeech method, then have the public aspeech set kwargs["original_function"] = self._aspeech and await self.async_function_with_fallbacks(**kwargs). _aspeech also picks up the shared _get_async_openai_model_client helper and the same total/success/fail call accounting the sibling endpoints use Fixes #27778. * fix(router): apply deployment kwargs and rpm semaphore in _aspeech Bring _aspeech fully in line with _atranscription: call _update_kwargs_with_deployment so deployment metadata, model_info, timeout, and default litellm params flow into the request, and wrap the litellm.aspeech call with the max_parallel_requests semaphore plus async_routing_strategy_pre_call_checks so TTS respects rpm limits the same way the other router endpoints do Also add a unit test that exercises _aspeech directly and asserts the deployment metadata reaches the underlying call * fix(slack_alerting): stop false-positive hanging request alerts for requests below the alerting threshold (#30106) * fix(slack_alerting): skip hanging request alerts below the threshold The hanging request check alerted on any cached request whose completion status was not yet recorded, with no minimum age check. Since the background loop runs every alerting_threshold / 2 seconds, any request that happened to be in flight at a check fired a "hanging - Ns+ request time" alert even if it was only seconds old, producing a steady stream of false positives. Add a created_at timestamp to HangingRequestData, stamped when the request enters the hanging request cache, and skip requests younger than alerting_threshold without evicting them, so a later check can still alert if they never complete. Extend the cache TTL from threshold + 60s to 1.5x threshold + 60s; with the age check, entries only become alertable after threshold seconds, and the check period is threshold / 2, so the old TTL could evict a genuinely hanging request before any check saw it cross the threshold. Fixes #27855. * fix(slack_alerting): alert once per hanging request The min-age gate stops false positives for young in-flight requests, but a genuinely hanging request still re-alerted on every checker tick within the cache TTL. With the wider TTL (1.5x threshold + 60s) that is 1-2 extra Slack notifications per stuck request at the default 600s threshold. Flag a HangingRequestData entry as alerted once its alert fires and skip flagged entries on later ticks, so each hang produces exactly one alert. The cache reference is mutated in place, so the TTL is untouched and still handles cleanup. Adds a regression test asserting one alert across multiple ticks. Fixes #27855. * fix(health): treat all-proxy-models keys as unrestricted in /health (#30087) * fix(health): treat all-proxy-models keys as unrestricted in /health A key granted all model permissions stores the literal "all-proxy-models" marker in its models list. The /health access filter compared that marker against real model_names, so the model list filtered down to nothing and the WebUI health check returned healthy_count=0, unhealthy_count=0 with HTTP 503. Skip the filter (both the live path and the background-cache model_id scoping) when the marker is present, matching how auth_checks treats SpecialModelNames.all_proxy_models. Fixes #29744. * fix(health): resolve all-team-models sentinel to the team allowlist Same failure shape as the all-proxy-models case: a key carrying the literal "all-team-models" entry matches no real model_name, so the /health access filter would zero out the model list. Resolve the sentinel to the key's team models when team_id is set, matching get_key_models in model_checks.py. Without a team_id the sentinel stays unresolved and matches nothing, denying rather than widening access, mirroring _resolve_key_models_for_auth_check. * feat(proxy): auto-enable drop_params for Claude Code requests (#30218) * feat(proxy): auto-enable drop_params for Claude Code requests Claude Code identifies itself with a claude-cli/<version> user agent and sends Anthropic-specific params (top_k, thinking, etc.) on every request. When the proxy routes those requests to a non-Anthropic provider, the unsupported params fail the call unless drop_params is configured. Detect the Claude Code user agent in add_litellm_data_to_request and default drop_params to true for those requests, without overriding an explicit drop_params value sent by the caller. * feat(proxy): respect operator litellm_settings drop_params over Claude Code default An explicit drop_params in the operator's litellm_settings (true or false) now suppresses the Claude Code user agent default, so an operator who deliberately configured drop_params: false keeps strict param validation for Claude Code clients too. The auto-default only fills the gap when neither the request body nor the config sets a value. * fix(snowflake): migrate to native endpoints with auto-routing for Claude models (#29964) * fix(snowflake): migrate to native Cortex REST API endpoints Replaces the legacy /api/v2/cortex/inference:complete endpoint with the native OpenAI-compatible /api/v2/cortex/v1/chat/completions endpoint, fixing error 390142 (Incoming request does not contain a valid payload) when using model: snowflake/<model> in LiteLLM proxy. Changes: - litellm/llms/snowflake/chat/transformation.py: route to native /cortex/v1/chat/completions, remove Snowflake-specific tool_spec payload transformation, remove content_list response handling, add stream to supported params - litellm/llms/snowflake/anthropic/transformation.py (new): SnowflakeCortexAnthropicConfig routes Claude models to /cortex/v1/messages with anthropic-version header and Anthropic->OpenAI response transform - tests: 29 unit tests covering URL routing, auth headers, payload format, and response parsing * fix(snowflake): map max_tokens to max_completion_tokens for native endpoint * fix: handle multi-turn tool conversations and OpenAI→Anthropic tool format conversion - _extract_system_and_messages now preserves tool_calls from assistant messages and converts them to Anthropic tool_use content blocks - tool role messages are converted to user role with tool_result content blocks (as required by Anthropic Messages API) - Added _transform_tools_to_anthropic() to convert OpenAI tool format (type/function/parameters) to Anthropic format (name/input_schema) - Added comprehensive tests for multi-turn tool conversations Addresses review feedback on PR #29964 * test: add coverage for malformed JSON and non-string tool arguments * fix(tests): update chat transformation tests for native OpenAI-compatible endpoint * style: apply black formatting * fix: resolve mypy type errors in anthropic transformation * fix: correct mypy type: ignore error codes (attr-defined) * fix: use max_tokens instead of max_completion_tokens for Snowflake endpoint compatibility * refactor: merge Anthropic config into unified SnowflakeConfig with auto-routing - Remove separate SnowflakeCortexAnthropicConfig and anthropic/ directory - SnowflakeConfig now auto-routes based on model name: - Claude models → /messages endpoint (Anthropic format) - All others → /chat/completions endpoint (OpenAI format) - No new provider needed (stays as SNOWFLAKE = 'snowflake') - Tool message transformation for Claude: tool_calls → tool_use blocks, tool role → user with tool_result - OpenAI → Anthropic tool format conversion (parameters → input_schema) - Addresses Greptile feedback about unwired SnowflakeCortexAnthropicConfig * fix: use max_completion_tokens for /chat/completions (Snowflake deprecated max_tokens on this endpoint) * fix(tests): update assertions for Claude auto-routing to /messages endpoint * fix(snowflake): add tool_choice conversion and preserve max_completion_tokens in Anthropic path * fix(snowflake): use ChatCompletionMessageToolCall objects and strip model prefix on OpenAI path * fix(snowflake): collect multiple system messages to prevent guardrail override * chore: remove committed .pyc files and add __pycache__ to .gitignore * fix: remove unused Union import * fix: restore original .gitignore (accidentally replaced in earlier commit) * feat(snowflake): add streaming response handler for both Anthropic and OpenAI SSE formats * fix: remove unused AsyncIterator and Iterator imports * fix: add missing total_tokens to ChatCompletionUsageBlock * fix(snowflake): coalesce consecutive tool results into single user message for Anthropic * fix(snowflake): handle message_start event for streaming input_tokens tracking * fix: evict last deleted model in multi-instance deployments (#28608) * fix: evict last deleted model in multi-instance deployments _delete_deployment had an early return when db_models was empty, preventing eviction of the last deleted model during reconciliation. - Remove len(db_models)==0 early return from _delete_deployment - Return None (not []) from _get_models_from_db on DB failure so callers can distinguish a transient failure from a genuinely empty DB - Guard _update_llm_router against None to skip updates on DB failure Fixes #28443 * test: remove dead MagicMock assignment in type_mismatch test * fix: update test to pass [] not None to _update_llm_router test_ProxyConfig__update_llm_router_bad_proxy_logging_raises was passing None as new_models to get through to the proxy_logging_obj check, but the None guard we added now returns early before reaching that path. Pass [] instead so the test exercises the intended AttributeError case. Signed-off-by: Rudra Dudhat <contact.rdudhat@gmail.com> * chore: regenerate API types to sync schema.d.ts with proxy OpenAPI spec Signed-off-by: Rudra Dudhat <contact.rdudhat@gmail.com> --------- Signed-off-by: Rudra Dudhat <contact.rdudhat@gmail.com> * fix: invalidate Redis spend counter on /key/reset_spend (#29694) * fix: set Redis spend counter to reset_to value on /key/reset_spend Previously, the Redis spend counter was always set to 0.0 after a reset, even when reset_to was a non-zero value (partial reset). This caused the budget to be under-enforced for up to 60 seconds until the counter expired and fell through to the DB. Now the counter is set to the actual reset_to value, so partial resets are reflected correctly and budget enforcement is consistent. * test: update reset_key_spend test to match direct cache set The implementation now sets spend_counter_cache directly instead of calling _invalidate_spend_counter. Update the test to verify the in_memory_cache.set_cache call with the correct key, value, and ttl. --------- Co-authored-by: michaelxer <michaelxer@users.noreply.github.com> * fix: add scaleway models pricing (#27659) * fix: Add embeddings support for Scaleway provider * fix: resolve merge conflicts * fix(main): clarify backend route handling for Swagger static assets (#30196) * fix(main): clarify backend route handling for Swagger static assets * fix(allowlist): add BACKEND_MOUNT_PATHS for Swagger static assets * fix(voyage): route multimodal embeddings to correct endpoint (#30193) * fix(voyage): route multimodal embeddings to correct endpoint * test(voyage): cover multimodal embedding edge cases * test(voyage): cover api key fallback * fix(voyage): raise early on missing api key and malformed image url * test(voyage): cover utils routing and helper * fix(voyage): route supported openai params for multimodal models * style: apply black formatting * fix(ui): infer Azure API version from API base (#30204) * fix(ui): infer Azure API version from API base * fix(ui): address Azure API version feedback * Update litellm/llms/snowflake/chat/transformation.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * feat(datadog): add team-scoped Datadog callback support (#29947) Enable teams to configure their own Datadog credentials via POST /team/{team_id}/callback, following the same pattern as Langfuse. * Merge pull request #29528 from aanchal22/litellm_byok-alias-merge fix(proxy): atomic merge for team model aliases and team.models on BYOK create * feat: add EmpirioLabs as an OpenAI-compatible provider (#30278) Co-authored-by: Adam Dalloul <adam.d.developer@gmail.com> * fix: resolve failing tests and lint in snowflake/team endpoints - Black-format snowflake/chat/transformation.py to fix lint failure - Update Anthropic config test to expect default max_tokens of 4096 (matches implementation) - Add AsyncMock + execute_raw mock to team_model_add cache-refresh pin test - Add model_dump mock and patch cache/logging in test_uses_atomic_array_append_with_dedup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(test): update test_db_error_new_model_check for new _delete_deployment logic _delete_deployment no longer short-circuits on empty db_models — it now treats [] as a valid empty-DB state and proceeds to check config models. Mock get_config to return the two router deployments so they appear in combined_id_list and are protected, which matches the real-world scenario where a DB error occurs but the models are config-backed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(proxy): register cancel_on_disconnect in ConfigGeneralSettings and config list (#30295) * feat(proxy): register cancel_on_disconnect in ConfigGeneralSettings and config list Follow-up to #30223 per maintainer review: documents the flag in ConfigGeneralSettings with a short description and adds it to allowed_args in get_config_list so the UI and /config/list expose it. A test pins that /config/list returns the field with type Boolean, which requires both registrations to be present * chore(ui): regenerate schema.d.ts for cancel_on_disconnect --------- Co-authored-by: kursad <kursad.lacin@brado.net> * fix(datadog): never fall back to env DD_API_KEY for caller-supplied destinations Team/key-scoped Datadog loggers could be pointed at an arbitrary dd_agent_host or dd_site while omitting dd_api_key, causing the proxy's global DD_API_KEY to be sent as the DD-API-KEY header to that destination. Gate the env-var fallback behind an allow_env_credentials flag, set to False when the destination is caller-supplied, mirroring the existing langfuse/langsmith pattern. --------- Signed-off-by: Rudra Dudhat <contact.rdudhat@gmail.com> Co-authored-by: Emerson Gomes <emerson.gomes@thalesgroup.com> Co-authored-by: daitran-tensormesh <dai@tensormesh.ai> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Muspi Merol <me@promplate.dev> Co-authored-by: fangkang <fangkangm@gmail.com> Co-authored-by: Chris Hoogeboom <chris.hoogeboom@gmail.com> Co-authored-by: kursadlacin <kursadlacin@gmail.com> Co-authored-by: kursad <kursad.lacin@brado.net> Co-authored-by: CreateRandom <18438707+CreateRandom@users.noreply.github.com> Co-authored-by: hcl <chenglunhu@gmail.com> Co-authored-by: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com> Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: sfc-gh-nashukla <navnit.shukla@snowflake.com> Co-authored-by: Rudra Dudhat <contact.rdudhat@gmail.com> Co-authored-by: Michael <52305679+michaelxer@users.noreply.github.com> Co-authored-by: michaelxer <michaelxer@users.noreply.github.com> Co-authored-by: Quentin Champenois <26109239+Quentinchampenois@users.noreply.github.com> Co-authored-by: mauriceberentsen <mauriceberentsen@live.nl> Co-authored-by: lost9999 <56498264+lost9999@users.noreply.github.com> Co-authored-by: GaetanVDB07 <86427581+GaetanVDB07@users.noreply.github.com> Co-authored-by: Aanchal Khandelwal <aan2210khandelwal@gmail.com> Co-authored-by: Adam Dalloul <adam_dalloul@icloud.com> Co-authored-by: Adam Dalloul <adam.d.developer@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1385 lines
42 KiB
Python
1385 lines
42 KiB
Python
import sys
|
|
import os
|
|
import json
|
|
import traceback
|
|
from typing import Optional
|
|
from dotenv import load_dotenv
|
|
from fastapi import Request
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../..")
|
|
) # Adds the parent directory to the system path
|
|
from litellm import Router, CustomLogger
|
|
from litellm.types.utils import StandardLoggingPayload
|
|
|
|
## Get the current directory of the file being run
|
|
pwd = os.path.dirname(os.path.realpath(__file__))
|
|
print(pwd)
|
|
|
|
file_path = os.path.join(pwd, "gettysburg.wav")
|
|
|
|
audio_file = open(file_path, "rb")
|
|
from pathlib import Path
|
|
import litellm
|
|
import pytest
|
|
import asyncio
|
|
|
|
|
|
@pytest.fixture
|
|
def model_list():
|
|
return [
|
|
{
|
|
"model_name": "gpt-5-mini",
|
|
"litellm_params": {
|
|
"model": "gpt-5-mini",
|
|
"api_key": os.getenv("OPENAI_API_KEY"),
|
|
},
|
|
},
|
|
{
|
|
"model_name": "gpt-5.5",
|
|
"litellm_params": {
|
|
"model": "gpt-5.5",
|
|
"api_key": os.getenv("OPENAI_API_KEY"),
|
|
},
|
|
},
|
|
{
|
|
"model_name": "gpt-image-1",
|
|
"litellm_params": {
|
|
"model": "gpt-image-1",
|
|
"api_key": os.getenv("OPENAI_API_KEY"),
|
|
},
|
|
},
|
|
{
|
|
"model_name": "cohere-rerank",
|
|
"litellm_params": {
|
|
"model": "cohere/rerank-english-v3.0",
|
|
"api_key": os.getenv("COHERE_API_KEY"),
|
|
},
|
|
},
|
|
{
|
|
"model_name": "claude-sonnet-4-5-20250929",
|
|
"litellm_params": {
|
|
"model": "gpt-5-mini",
|
|
"mock_response": "hi this is macintosh.",
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
# This file includes the custom callbacks for LiteLLM Proxy
|
|
# Once defined, these can be passed in proxy_config.yaml
|
|
class MyCustomHandler(CustomLogger):
|
|
def __init__(self):
|
|
self.openai_client = None
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
try:
|
|
# init logging config
|
|
print("logging a transcript kwargs: ", kwargs)
|
|
print("openai client=", kwargs.get("client"))
|
|
self.openai_client = kwargs.get("client")
|
|
self.standard_logging_object: Optional[StandardLoggingPayload] = kwargs.get(
|
|
"standard_logging_object"
|
|
)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Set litellm.callbacks = [proxy_handler_instance] on the proxy
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.flaky(retries=6, delay=10)
|
|
async def test_transcription_on_router():
|
|
proxy_handler_instance = MyCustomHandler()
|
|
litellm.set_verbose = True
|
|
litellm.callbacks = [proxy_handler_instance]
|
|
print("\n Testing async transcription on router\n")
|
|
try:
|
|
model_list = [
|
|
{
|
|
"model_name": "whisper",
|
|
"litellm_params": {
|
|
"model": "whisper-1",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "whisper",
|
|
"litellm_params": {
|
|
"model": "azure/azure-whisper",
|
|
"api_base": "https://my-endpoint-europe-berri-992.openai.azure.com/",
|
|
"api_key": os.getenv("AZURE_EUROPE_API_KEY"),
|
|
"api_version": "2024-02-15-preview",
|
|
},
|
|
},
|
|
]
|
|
|
|
router = Router(model_list=model_list)
|
|
|
|
router_level_clients = []
|
|
for deployment in router.model_list:
|
|
_deployment_openai_client = router._get_client(
|
|
deployment=deployment,
|
|
kwargs={"model": "whisper-1"},
|
|
client_type="async",
|
|
)
|
|
|
|
router_level_clients.append(str(_deployment_openai_client))
|
|
|
|
## test 1: user facing function
|
|
response = await router.atranscription(
|
|
model="whisper",
|
|
file=audio_file,
|
|
)
|
|
|
|
## test 2: underlying function
|
|
response = await router._atranscription(
|
|
model="whisper",
|
|
file=audio_file,
|
|
)
|
|
print(response)
|
|
|
|
# PROD Test
|
|
# Ensure we ONLY use OpenAI/Azure client initialized on the router level
|
|
await asyncio.sleep(5)
|
|
print("OpenAI Client used= ", proxy_handler_instance.openai_client)
|
|
print("all router level clients= ", router_level_clients)
|
|
assert proxy_handler_instance.openai_client in router_level_clients
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
pytest.fail(f"Error occurred: {e}")
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["iterator"]) # "file",
|
|
@pytest.mark.asyncio
|
|
async def test_audio_speech_router(mode):
|
|
litellm.set_verbose = True
|
|
test_logger = MyCustomHandler()
|
|
litellm.callbacks = [test_logger]
|
|
from litellm import Router
|
|
|
|
client = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "tts",
|
|
"litellm_params": {
|
|
"model": "openai/tts-1",
|
|
},
|
|
},
|
|
]
|
|
)
|
|
|
|
response = await client.aspeech(
|
|
model="tts",
|
|
voice="alloy",
|
|
input="the quick brown fox jumped over the lazy dogs",
|
|
api_base=None,
|
|
api_key=None,
|
|
organization=None,
|
|
project=None,
|
|
max_retries=1,
|
|
timeout=600,
|
|
client=None,
|
|
optional_params={},
|
|
)
|
|
|
|
await asyncio.sleep(3)
|
|
|
|
from litellm.llms.openai.openai import HttpxBinaryResponseContent
|
|
|
|
assert isinstance(response, HttpxBinaryResponseContent)
|
|
|
|
assert test_logger.standard_logging_object is not None
|
|
print(
|
|
"standard_logging_object=",
|
|
json.dumps(test_logger.standard_logging_object, indent=4),
|
|
)
|
|
assert test_logger.standard_logging_object["model_group"] == "tts"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aspeech_fallbacks_on_deployment_failure():
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "tts-main",
|
|
"litellm_params": {"model": "openai/tts-1", "api_key": "fake-key"},
|
|
},
|
|
{
|
|
"model_name": "tts-backup",
|
|
"litellm_params": {"model": "openai/tts-1-hd", "api_key": "fake-key"},
|
|
},
|
|
],
|
|
fallbacks=[{"tts-main": ["tts-backup"]}],
|
|
num_retries=0,
|
|
)
|
|
|
|
called_models = []
|
|
|
|
async def mock_aspeech(*args, **kwargs):
|
|
called_models.append(kwargs["model"])
|
|
if kwargs["model"] == "openai/tts-1":
|
|
raise litellm.InternalServerError(
|
|
message="deployment down",
|
|
llm_provider="openai",
|
|
model="tts-1",
|
|
)
|
|
return MagicMock()
|
|
|
|
with patch("litellm.aspeech", side_effect=mock_aspeech):
|
|
response = await router.aspeech(
|
|
model="tts-main",
|
|
input="the quick brown fox jumped over the lazy dogs",
|
|
voice="alloy",
|
|
)
|
|
|
|
assert response is not None
|
|
assert called_models == ["openai/tts-1", "openai/tts-1-hd"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aspeech_success_returns_response():
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "tts",
|
|
"litellm_params": {"model": "openai/tts-1", "api_key": "fake-key"},
|
|
},
|
|
]
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
with patch("litellm.aspeech", return_value=mock_response) as mock_aspeech:
|
|
response = await router.aspeech(
|
|
model="tts",
|
|
input="the quick brown fox jumped over the lazy dogs",
|
|
voice="alloy",
|
|
)
|
|
|
|
assert response is mock_response
|
|
mock_aspeech.assert_called_once()
|
|
assert mock_aspeech.call_args.kwargs["model"] == "openai/tts-1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aspeech_sets_deployment_metadata():
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "tts",
|
|
"litellm_params": {"model": "openai/tts-1", "api_key": "fake-key"},
|
|
},
|
|
]
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
with patch("litellm.aspeech", return_value=mock_response) as mock_aspeech:
|
|
response = await router._aspeech(
|
|
model="tts",
|
|
input="the quick brown fox jumped over the lazy dogs",
|
|
voice="alloy",
|
|
)
|
|
|
|
assert response is mock_response
|
|
metadata = mock_aspeech.call_args.kwargs["metadata"]
|
|
assert metadata["deployment"] == "openai/tts-1"
|
|
assert metadata["deployment_model_name"] == "tts"
|
|
assert metadata["model_info"]["id"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_rerank_endpoint(model_list):
|
|
from litellm.types.utils import RerankResponse
|
|
|
|
router = Router(model_list=model_list)
|
|
|
|
## Test 1: user facing function
|
|
response = await router.arerank(
|
|
model="cohere-rerank",
|
|
query="hello",
|
|
documents=["hello", "world"],
|
|
top_n=3,
|
|
)
|
|
|
|
## Test 2: underlying function
|
|
response = await router._arerank(
|
|
model="cohere-rerank",
|
|
query="hello",
|
|
documents=["hello", "world"],
|
|
top_n=3,
|
|
)
|
|
|
|
print("async re rank response: ", response)
|
|
|
|
assert response.id is not None
|
|
assert response.results is not None
|
|
|
|
RerankResponse.model_validate(response)
|
|
|
|
|
|
@pytest.mark.asyncio()
|
|
@pytest.mark.parametrize(
|
|
"model", ["omni-moderation-latest", "openai/omni-moderation-latest", None]
|
|
)
|
|
async def test_moderation_endpoint(model):
|
|
litellm.set_verbose = True
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "openai/*",
|
|
"litellm_params": {
|
|
"model": "openai/*",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "*",
|
|
"litellm_params": {
|
|
"model": "openai/*",
|
|
},
|
|
},
|
|
]
|
|
)
|
|
|
|
if model is None:
|
|
response = await router.amoderation(input="hello this is a test")
|
|
else:
|
|
response = await router.amoderation(model=model, input="hello this is a test")
|
|
|
|
print("moderation response: ", response)
|
|
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_moderation_endpoint_with_api_base():
|
|
"""
|
|
Test that the moderation endpoint respects api_base configuration
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
custom_api_base = "https://us.api.openai.com/v1"
|
|
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "openai/omni-moderation-latest",
|
|
"litellm_params": {
|
|
"model": "openai/omni-moderation-latest",
|
|
"api_base": custom_api_base,
|
|
"api_key": "test-key",
|
|
},
|
|
},
|
|
]
|
|
)
|
|
|
|
# Mock the OpenAI client to verify api_base is passed
|
|
with patch(
|
|
"litellm.main.openai_chat_completions._get_openai_client"
|
|
) as mock_get_client:
|
|
mock_client = AsyncMock()
|
|
mock_response = MagicMock()
|
|
mock_response.model_dump.return_value = {
|
|
"id": "modr-123",
|
|
"model": "omni-moderation-latest",
|
|
"results": [
|
|
{
|
|
"flagged": False,
|
|
"categories": {},
|
|
"category_scores": {},
|
|
"category_applied_input_types": {},
|
|
}
|
|
],
|
|
}
|
|
mock_client.moderations.create = AsyncMock(return_value=mock_response)
|
|
mock_get_client.return_value = mock_client
|
|
|
|
response = await router.amoderation(
|
|
model="openai/omni-moderation-latest", input="hello this is a test"
|
|
)
|
|
|
|
# Verify that _get_openai_client was called with the custom api_base
|
|
mock_get_client.assert_called()
|
|
call_kwargs = mock_get_client.call_args.kwargs
|
|
assert (
|
|
call_kwargs.get("api_base") == custom_api_base
|
|
), f"Expected api_base to be {custom_api_base}, but got {call_kwargs.get('api_base')}"
|
|
|
|
print(f"✓ Moderation endpoint correctly uses api_base: {custom_api_base}")
|
|
|
|
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_aaaaatext_completion_endpoint(model_list, sync_mode):
|
|
router = Router(model_list=model_list)
|
|
|
|
if sync_mode:
|
|
response = router.text_completion(
|
|
model="gpt-5-mini",
|
|
prompt="Hello, how are you?",
|
|
mock_response="I'm fine, thank you!",
|
|
)
|
|
else:
|
|
## Test 1: user facing function
|
|
response = await router.atext_completion(
|
|
model="gpt-5-mini",
|
|
prompt="Hello, how are you?",
|
|
mock_response="I'm fine, thank you!",
|
|
)
|
|
|
|
## Test 2: underlying function
|
|
response_2 = await router._atext_completion(
|
|
model="gpt-5-mini",
|
|
prompt="Hello, how are you?",
|
|
mock_response="I'm fine, thank you!",
|
|
)
|
|
assert response_2.choices[0].text == "I'm fine, thank you!"
|
|
|
|
assert response.choices[0].text == "I'm fine, thank you!"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_router_with_empty_choices(model_list):
|
|
"""
|
|
https://github.com/BerriAI/litellm/issues/8306
|
|
"""
|
|
router = Router(model_list=model_list)
|
|
mock_response = litellm.ModelResponse(
|
|
choices=[],
|
|
usage=litellm.Usage(
|
|
prompt_tokens=10,
|
|
completion_tokens=10,
|
|
total_tokens=20,
|
|
),
|
|
model="gpt-5-mini",
|
|
object="chat.completion",
|
|
created=1723081200,
|
|
).model_dump()
|
|
response = await router.acompletion(
|
|
model="gpt-5-mini",
|
|
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
|
mock_response=mock_response,
|
|
)
|
|
assert response is not None
|
|
|
|
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
def test_generic_api_call_with_fallbacks_basic(sync_mode):
|
|
"""
|
|
Test both the sync and async versions of generic_api_call_with_fallbacks with a basic successful call
|
|
"""
|
|
# Create a mock function that will be passed to generic_api_call_with_fallbacks
|
|
if sync_mode:
|
|
from unittest.mock import Mock
|
|
|
|
mock_function = Mock()
|
|
mock_function.__name__ = "test_function"
|
|
else:
|
|
mock_function = AsyncMock()
|
|
mock_function.__name__ = "test_function"
|
|
|
|
# Create a mock response
|
|
mock_response = {
|
|
"id": "resp_123456",
|
|
"role": "assistant",
|
|
"content": "This is a test response",
|
|
"model": "test-model",
|
|
"usage": {"input_tokens": 10, "output_tokens": 20},
|
|
}
|
|
mock_function.return_value = mock_response
|
|
|
|
# Create a router with a test model
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model-alias",
|
|
"litellm_params": {
|
|
"model": "anthropic/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
# Call the appropriate generic_api_call_with_fallbacks method
|
|
if sync_mode:
|
|
response = router._generic_api_call_with_fallbacks(
|
|
model="test-model-alias",
|
|
original_function=mock_function,
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
)
|
|
else:
|
|
response = asyncio.run(
|
|
router._ageneric_api_call_with_fallbacks(
|
|
model="test-model-alias",
|
|
original_function=mock_function,
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
)
|
|
)
|
|
|
|
# Verify the mock function was called
|
|
mock_function.assert_called_once()
|
|
|
|
# Verify the response
|
|
assert response == mock_response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aadapter_completion():
|
|
"""
|
|
Test the aadapter_completion method which uses async_function_with_fallbacks
|
|
"""
|
|
# Create a mock for the _aadapter_completion method
|
|
mock_response = {
|
|
"id": "adapter_resp_123",
|
|
"object": "adapter.completion",
|
|
"created": 1677858242,
|
|
"model": "test-model-with-adapter",
|
|
"choices": [
|
|
{
|
|
"text": "This is a test adapter response",
|
|
"index": 0,
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
|
}
|
|
|
|
# Create a router with a patched _aadapter_completion method
|
|
with patch.object(
|
|
Router, "_aadapter_completion", new_callable=AsyncMock
|
|
) as mock_method:
|
|
mock_method.return_value = mock_response
|
|
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-adapter-model",
|
|
"litellm_params": {
|
|
"model": "anthropic/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
# Replace the async_function_with_fallbacks with a mock
|
|
router.async_function_with_fallbacks = AsyncMock(return_value=mock_response)
|
|
|
|
# Call the aadapter_completion method
|
|
response = await router.aadapter_completion(
|
|
adapter_id="test-adapter-id",
|
|
model="test-adapter-model",
|
|
prompt="This is a test prompt",
|
|
max_tokens=100,
|
|
)
|
|
|
|
# Verify the response
|
|
assert response == mock_response
|
|
|
|
# Verify async_function_with_fallbacks was called with the right parameters
|
|
router.async_function_with_fallbacks.assert_called_once()
|
|
call_kwargs = router.async_function_with_fallbacks.call_args.kwargs
|
|
assert call_kwargs["adapter_id"] == "test-adapter-id"
|
|
assert call_kwargs["model"] == "test-adapter-model"
|
|
assert call_kwargs["prompt"] == "This is a test prompt"
|
|
assert call_kwargs["max_tokens"] == 100
|
|
assert call_kwargs["original_function"] == router._aadapter_completion
|
|
assert "metadata" in call_kwargs
|
|
assert call_kwargs["metadata"]["model_group"] == "test-adapter-model"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test__aadapter_completion():
|
|
"""
|
|
Test the _aadapter_completion method directly
|
|
"""
|
|
# Create a mock response for litellm.aadapter_completion
|
|
mock_response = {
|
|
"id": "adapter_resp_123",
|
|
"object": "adapter.completion",
|
|
"created": 1677858242,
|
|
"model": "test-model-with-adapter",
|
|
"choices": [
|
|
{
|
|
"text": "This is a test adapter response",
|
|
"index": 0,
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
|
}
|
|
|
|
# Create a router with a mocked litellm.aadapter_completion
|
|
with patch(
|
|
"litellm.aadapter_completion", new_callable=AsyncMock
|
|
) as mock_adapter_completion:
|
|
mock_adapter_completion.return_value = mock_response
|
|
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-adapter-model",
|
|
"litellm_params": {
|
|
"model": "anthropic/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
# Mock the async_get_available_deployment method
|
|
router.async_get_available_deployment = AsyncMock(
|
|
return_value={
|
|
"model_name": "test-adapter-model",
|
|
"litellm_params": {
|
|
"model": "test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
"model_info": {
|
|
"id": "test-unique-id",
|
|
},
|
|
}
|
|
)
|
|
|
|
# Mock the async_routing_strategy_pre_call_checks method
|
|
router.async_routing_strategy_pre_call_checks = AsyncMock()
|
|
|
|
# Call the _aadapter_completion method
|
|
response = await router._aadapter_completion(
|
|
adapter_id="test-adapter-id",
|
|
model="test-adapter-model",
|
|
prompt="This is a test prompt",
|
|
max_tokens=100,
|
|
)
|
|
|
|
# Verify the response
|
|
assert response == mock_response
|
|
|
|
# Verify litellm.aadapter_completion was called with the right parameters
|
|
mock_adapter_completion.assert_called_once()
|
|
call_kwargs = mock_adapter_completion.call_args.kwargs
|
|
assert call_kwargs["adapter_id"] == "test-adapter-id"
|
|
assert call_kwargs["model"] == "test-model"
|
|
assert call_kwargs["prompt"] == "This is a test prompt"
|
|
assert call_kwargs["max_tokens"] == 100
|
|
assert call_kwargs["api_key"] == "fake-api-key"
|
|
assert call_kwargs["caching"] == router.cache_responses
|
|
|
|
# Verify the success call was recorded
|
|
assert router.success_calls["test-model"] == 1
|
|
assert router.total_calls["test-model"] == 1
|
|
|
|
# Verify async_routing_strategy_pre_call_checks was called
|
|
router.async_routing_strategy_pre_call_checks.assert_called_once()
|
|
|
|
|
|
def test_initialize_router_endpoints():
|
|
"""
|
|
Test that initialize_router_endpoints correctly sets up all router endpoints
|
|
"""
|
|
# Create a router with a basic model
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "anthropic/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
# Explicitly call initialize_router_endpoints
|
|
router.initialize_router_endpoints()
|
|
|
|
# Verify all expected endpoints are initialized
|
|
assert hasattr(router, "amoderation")
|
|
assert hasattr(router, "aanthropic_messages")
|
|
assert hasattr(router, "aresponses")
|
|
assert hasattr(router, "responses")
|
|
assert hasattr(router, "aget_responses")
|
|
assert hasattr(router, "adelete_responses")
|
|
# Verify the endpoints are callable
|
|
assert callable(router.amoderation)
|
|
assert callable(router.aanthropic_messages)
|
|
assert callable(router.aresponses)
|
|
assert callable(router.responses)
|
|
assert callable(router.aget_responses)
|
|
assert callable(router.adelete_responses)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_responses_api_endpoints():
|
|
"""
|
|
A simpler test for _init_responses_api_endpoints that focuses on the basic functionality
|
|
"""
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
# Create a router with a basic model
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
# Just mock the _ageneric_api_call_with_fallbacks method
|
|
router._ageneric_api_call_with_fallbacks = AsyncMock()
|
|
|
|
# Add a mock implementation of _get_model_id_from_response_id to the Router instance
|
|
ResponsesAPIRequestUtils.get_model_id_from_response_id = MagicMock(
|
|
return_value=None
|
|
)
|
|
|
|
# Call without a response_id (no model extraction should happen)
|
|
await router._init_responses_api_endpoints(
|
|
original_function=AsyncMock(), thread_id="thread_xyz"
|
|
)
|
|
|
|
# Verify _ageneric_api_call_with_fallbacks was called but model wasn't changed
|
|
first_call_kwargs = router._ageneric_api_call_with_fallbacks.call_args.kwargs
|
|
assert "model" not in first_call_kwargs
|
|
assert first_call_kwargs["thread_id"] == "thread_xyz"
|
|
|
|
# Reset the mock
|
|
router._ageneric_api_call_with_fallbacks.reset_mock()
|
|
|
|
# Change the return value for the second call
|
|
ResponsesAPIRequestUtils.get_model_id_from_response_id.return_value = (
|
|
"claude-3-sonnet"
|
|
)
|
|
|
|
# Call with a response_id
|
|
await router._init_responses_api_endpoints(
|
|
original_function=AsyncMock(), response_id="resp_claude_123"
|
|
)
|
|
|
|
# Verify model was updated in the kwargs
|
|
second_call_kwargs = router._ageneric_api_call_with_fallbacks.call_args.kwargs
|
|
assert second_call_kwargs["model"] == "claude-3-sonnet"
|
|
assert second_call_kwargs["response_id"] == "resp_claude_123"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_vector_store_api_endpoints():
|
|
"""
|
|
Test that _init_vector_store_api_endpoints correctly passes custom_llm_provider to kwargs
|
|
"""
|
|
# Create a router with a basic model
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
# Mock the original function
|
|
mock_original_function = AsyncMock(return_value={"status": "success"})
|
|
|
|
# Call without custom_llm_provider
|
|
result = await router._init_vector_store_api_endpoints(
|
|
original_function=mock_original_function, vector_store_id="test-store"
|
|
)
|
|
|
|
# Verify original function was called with correct kwargs
|
|
mock_original_function.assert_called_once_with(vector_store_id="test-store")
|
|
assert result == {"status": "success"}
|
|
|
|
# Reset the mock
|
|
mock_original_function.reset_mock()
|
|
|
|
# Call with custom_llm_provider
|
|
await router._init_vector_store_api_endpoints(
|
|
original_function=mock_original_function,
|
|
custom_llm_provider="openai",
|
|
vector_store_id="test-store",
|
|
)
|
|
|
|
# Verify custom_llm_provider was added to kwargs
|
|
mock_original_function.assert_called_once_with(
|
|
vector_store_id="test-store", custom_llm_provider="openai"
|
|
)
|
|
|
|
|
|
def test_apply_default_settings():
|
|
"""
|
|
Test the apply_default_settings method.
|
|
|
|
This test verifies that apply_default_settings correctly initializes
|
|
default pre-call checks and doesn't modify existing router state.
|
|
"""
|
|
# Test with fresh router
|
|
router = Router()
|
|
initial_optional_callbacks = router.optional_callbacks
|
|
|
|
# Test that the method runs without error
|
|
result = router.apply_default_settings()
|
|
|
|
# Verify method returns None as expected
|
|
assert result is None
|
|
|
|
# Verify that optional_callbacks remains None if it was initially None
|
|
# (since default_pre_call_checks is an empty list)
|
|
assert router.optional_callbacks == initial_optional_callbacks
|
|
|
|
# Test with router that already has some optional_callbacks
|
|
router_with_callbacks = Router()
|
|
mock_callback = MagicMock()
|
|
router_with_callbacks.optional_callbacks = [mock_callback]
|
|
|
|
# Apply default settings
|
|
result = router_with_callbacks.apply_default_settings()
|
|
|
|
# Verify method returns None
|
|
assert result is None
|
|
|
|
# Verify existing callbacks are preserved (since we're adding empty list)
|
|
assert mock_callback in router_with_callbacks.optional_callbacks
|
|
|
|
# Test that the method is called during router initialization
|
|
with patch.object(Router, "apply_default_settings") as mock_apply:
|
|
Router()
|
|
mock_apply.assert_called_once()
|
|
|
|
# Test with mocked add_optional_pre_call_checks to verify internal call
|
|
router_test = Router()
|
|
with patch.object(router_test, "add_optional_pre_call_checks") as mock_add_checks:
|
|
router_test.apply_default_settings()
|
|
|
|
# Verify add_optional_pre_call_checks was called with empty list
|
|
mock_add_checks.assert_called_once_with([])
|
|
|
|
|
|
def test_initialize_core_endpoints():
|
|
"""
|
|
Test that _initialize_core_endpoints correctly sets up all core router endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "anthropic/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_core_endpoints()
|
|
|
|
core_endpoints = [
|
|
"amoderation",
|
|
"aanthropic_messages",
|
|
"agenerate_content",
|
|
"aadapter_generate_content",
|
|
"aresponses",
|
|
"afile_delete",
|
|
"afile_content",
|
|
"responses",
|
|
"aget_responses",
|
|
"acancel_responses",
|
|
"adelete_responses",
|
|
"alist_input_items",
|
|
"_arealtime",
|
|
"acreate_fine_tuning_job",
|
|
"acancel_fine_tuning_job",
|
|
"alist_fine_tuning_jobs",
|
|
"aretrieve_fine_tuning_job",
|
|
"afile_list",
|
|
"aimage_edit",
|
|
"allm_passthrough_route",
|
|
]
|
|
|
|
for endpoint in core_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_specialized_endpoints():
|
|
"""
|
|
Test that _initialize_specialized_endpoints correctly sets up specialized endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_specialized_endpoints()
|
|
|
|
specialized_endpoints = [
|
|
"avector_store_search",
|
|
"avector_store_create",
|
|
"vector_store_search",
|
|
"vector_store_create",
|
|
"agenerate_content",
|
|
"generate_content",
|
|
"agenerate_content_stream",
|
|
"generate_content_stream",
|
|
"aocr",
|
|
"ocr",
|
|
"asearch",
|
|
"search",
|
|
"avideo_generation",
|
|
"video_generation",
|
|
"avideo_list",
|
|
"video_list",
|
|
"avideo_status",
|
|
"video_status",
|
|
"avideo_content",
|
|
"video_content",
|
|
"avideo_remix",
|
|
"video_remix",
|
|
"acreate_container",
|
|
"create_container",
|
|
"alist_containers",
|
|
"list_containers",
|
|
"aretrieve_container",
|
|
"retrieve_container",
|
|
"adelete_container",
|
|
"delete_container",
|
|
"acreate_skill",
|
|
"alist_skills",
|
|
"aget_skill",
|
|
"adelete_skill",
|
|
]
|
|
|
|
for endpoint in specialized_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_vector_store_endpoints():
|
|
"""
|
|
Test that _initialize_vector_store_endpoints correctly sets up vector store endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_vector_store_endpoints()
|
|
|
|
vector_store_endpoints = [
|
|
"avector_store_search",
|
|
"avector_store_create",
|
|
"vector_store_search",
|
|
"vector_store_create",
|
|
]
|
|
|
|
for endpoint in vector_store_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_vector_store_file_endpoints():
|
|
"""
|
|
Test that _initialize_vector_store_file_endpoints correctly sets up vector store file endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_vector_store_file_endpoints()
|
|
|
|
vector_store_file_endpoints = [
|
|
"avector_store_file_create",
|
|
"vector_store_file_create",
|
|
"avector_store_file_list",
|
|
"vector_store_file_list",
|
|
"avector_store_file_retrieve",
|
|
"vector_store_file_retrieve",
|
|
"avector_store_file_content",
|
|
"vector_store_file_content",
|
|
"avector_store_file_update",
|
|
"vector_store_file_update",
|
|
"avector_store_file_delete",
|
|
"vector_store_file_delete",
|
|
]
|
|
|
|
for endpoint in vector_store_file_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_google_genai_endpoints():
|
|
"""
|
|
Test that _initialize_google_genai_endpoints correctly sets up Google GenAI endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_google_genai_endpoints()
|
|
|
|
google_genai_endpoints = [
|
|
"agenerate_content",
|
|
"generate_content",
|
|
"agenerate_content_stream",
|
|
"generate_content_stream",
|
|
]
|
|
|
|
for endpoint in google_genai_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_ocr_search_endpoints():
|
|
"""
|
|
Test that _initialize_ocr_search_endpoints correctly sets up OCR and search endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_ocr_search_endpoints()
|
|
|
|
ocr_search_endpoints = [
|
|
"aocr",
|
|
"ocr",
|
|
"asearch",
|
|
"search",
|
|
]
|
|
|
|
for endpoint in ocr_search_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_video_endpoints():
|
|
"""
|
|
Test that _initialize_video_endpoints correctly sets up video endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_video_endpoints()
|
|
|
|
video_endpoints = [
|
|
"avideo_generation",
|
|
"video_generation",
|
|
"avideo_list",
|
|
"video_list",
|
|
"avideo_status",
|
|
"video_status",
|
|
"avideo_content",
|
|
"video_content",
|
|
"avideo_remix",
|
|
"video_remix",
|
|
]
|
|
|
|
for endpoint in video_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_container_endpoints():
|
|
"""
|
|
Test that _initialize_container_endpoints correctly sets up container endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_container_endpoints()
|
|
|
|
container_endpoints = [
|
|
"acreate_container",
|
|
"create_container",
|
|
"alist_containers",
|
|
"list_containers",
|
|
"aretrieve_container",
|
|
"retrieve_container",
|
|
"adelete_container",
|
|
"delete_container",
|
|
]
|
|
|
|
for endpoint in container_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
def test_initialize_skills_endpoints():
|
|
"""
|
|
Test that _initialize_skills_endpoints correctly sets up skills endpoints.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "anthropic/test-model",
|
|
"api_key": "fake-api-key",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
router._initialize_skills_endpoints()
|
|
|
|
skills_endpoints = [
|
|
"acreate_skill",
|
|
"alist_skills",
|
|
"aget_skill",
|
|
"adelete_skill",
|
|
]
|
|
|
|
for endpoint in skills_endpoints:
|
|
assert hasattr(router, endpoint)
|
|
assert callable(getattr(router, endpoint))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_containers_api_endpoints():
|
|
"""
|
|
Test that _init_containers_api_endpoints calls the original function
|
|
directly when there is no managed container ID (no embedded model_id).
|
|
"""
|
|
router = Router(model_list=[])
|
|
|
|
mock_response = {"id": "cntr_test", "name": "Test Container"}
|
|
mock_original_function = AsyncMock(return_value=mock_response)
|
|
|
|
result = await router._init_containers_api_endpoints(
|
|
original_function=mock_original_function,
|
|
custom_llm_provider="openai",
|
|
name="Test Container",
|
|
)
|
|
|
|
mock_original_function.assert_called_once_with(
|
|
custom_llm_provider="openai", name="Test Container"
|
|
)
|
|
assert result == mock_response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_containers_api_endpoints_managed_id_routes_via_generic_fallbacks():
|
|
"""
|
|
Managed ``cntr_`` IDs embed ``model_id``; router should decode and use
|
|
``_ageneric_api_call_with_fallbacks`` so deployment credentials apply.
|
|
"""
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "azure-router-model",
|
|
"litellm_params": {
|
|
"model": "azure/gpt-5.5",
|
|
"api_key": "fake-key",
|
|
"api_base": "https://westus.api.cognitive.microsoft.com",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
router._ageneric_api_call_with_fallbacks = AsyncMock()
|
|
|
|
managed_id = ResponsesAPIRequestUtils._build_container_id(
|
|
custom_llm_provider="azure",
|
|
model_id="azure-router-model",
|
|
container_id="cfile_upstream_abc",
|
|
)
|
|
|
|
await router._init_containers_api_endpoints(
|
|
original_function=AsyncMock(),
|
|
custom_llm_provider="openai",
|
|
container_id=managed_id,
|
|
file_id="cfile_xyz",
|
|
)
|
|
|
|
router._ageneric_api_call_with_fallbacks.assert_called_once()
|
|
call_kw = router._ageneric_api_call_with_fallbacks.call_args.kwargs
|
|
assert call_kw["model"] == "azure-router-model"
|
|
assert call_kw["container_id"] == "cfile_upstream_abc"
|
|
assert call_kw["file_id"] == "cfile_xyz"
|
|
assert call_kw["custom_llm_provider"] == "azure"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_containers_api_endpoints_managed_id_without_model_id_unwraps():
|
|
"""
|
|
Managed ``cntr_`` IDs may be encoded with an empty ``model_id`` (e.g. when a
|
|
streaming response had no router metadata). The router must still unwrap the
|
|
managed ID before calling the upstream provider — otherwise the raw
|
|
``cntr_...`` token leaks downstream and the provider rejects it.
|
|
"""
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
router = Router(model_list=[])
|
|
mock_original_function = AsyncMock(return_value={"ok": True})
|
|
|
|
managed_id = ResponsesAPIRequestUtils._build_container_id(
|
|
custom_llm_provider="openai",
|
|
model_id=None,
|
|
container_id="cfile_upstream_abc",
|
|
)
|
|
|
|
await router._init_containers_api_endpoints(
|
|
original_function=mock_original_function,
|
|
custom_llm_provider="openai",
|
|
container_id=managed_id,
|
|
file_id="cfile_xyz",
|
|
)
|
|
|
|
mock_original_function.assert_called_once()
|
|
call_kw = mock_original_function.call_args.kwargs
|
|
assert call_kw["container_id"] == "cfile_upstream_abc"
|
|
assert call_kw["file_id"] == "cfile_xyz"
|
|
assert call_kw["custom_llm_provider"] == "openai"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_containers_api_endpoints_managed_id_without_model_id_applies_decoded_provider():
|
|
"""
|
|
A managed ``cntr_`` ID can encode a non-OpenAI provider (e.g. ``azure``) with
|
|
an empty ``model_id`` (streaming events without router ``model_info.id``).
|
|
The router must still apply the decoded provider so the request routes to
|
|
the correct upstream — not stay on the default ``openai``.
|
|
"""
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
router = Router(model_list=[])
|
|
mock_original_function = AsyncMock(return_value={"ok": True})
|
|
|
|
managed_id = ResponsesAPIRequestUtils._build_container_id(
|
|
custom_llm_provider="azure",
|
|
model_id=None,
|
|
container_id="cfile_upstream_abc",
|
|
)
|
|
|
|
await router._init_containers_api_endpoints(
|
|
original_function=mock_original_function,
|
|
custom_llm_provider="openai",
|
|
container_id=managed_id,
|
|
file_id="cfile_xyz",
|
|
)
|
|
|
|
mock_original_function.assert_called_once()
|
|
call_kw = mock_original_function.call_args.kwargs
|
|
assert call_kw["container_id"] == "cfile_upstream_abc"
|
|
assert call_kw["file_id"] == "cfile_xyz"
|
|
assert call_kw["custom_llm_provider"] == "azure"
|
|
|
|
|
|
def test_router_model_group_encrypted_content_affinity_callback_registration():
|
|
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
|
|
DeploymentAffinityCheck,
|
|
)
|
|
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
|
|
EncryptedContentAffinityCheck,
|
|
)
|
|
|
|
model_group = "openai.gpt-5.1-codex"
|
|
model_group_affinity_config = {
|
|
model_group: ["encrypted_content_affinity"],
|
|
}
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": model_group,
|
|
"litellm_params": {
|
|
"model": "openai/gpt-5.1-codex",
|
|
"api_key": "mock-api-key",
|
|
},
|
|
}
|
|
],
|
|
model_group_affinity_config=model_group_affinity_config,
|
|
num_retries=0,
|
|
)
|
|
|
|
try:
|
|
callbacks = router.optional_callbacks or []
|
|
encrypted_content_callbacks = [
|
|
cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)
|
|
]
|
|
deployment_callback = next(
|
|
cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck)
|
|
)
|
|
assert len(encrypted_content_callbacks) == 1
|
|
assert encrypted_content_callbacks[0].enable_global_affinity is False
|
|
assert (
|
|
encrypted_content_callbacks[0].model_group_affinity_config
|
|
== model_group_affinity_config
|
|
)
|
|
assert callbacks.index(encrypted_content_callbacks[0]) < callbacks.index(
|
|
deployment_callback
|
|
)
|
|
|
|
router._add_encrypted_content_affinity_check(enable_global_affinity=True)
|
|
|
|
callbacks = router.optional_callbacks or []
|
|
encrypted_content_callbacks = [
|
|
cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)
|
|
]
|
|
assert len(encrypted_content_callbacks) == 1
|
|
assert encrypted_content_callbacks[0].enable_global_affinity is True
|
|
assert encrypted_content_callbacks[0].router is router
|
|
finally:
|
|
router.discard()
|