mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
fix(router): bind and dispatch every factory endpoint from one table through host.invoke on both cores
- one table (litellm/router_endpoint_table.py) names each factory-built endpoint, its host op, call type and dispatch; both cores bind and dispatch from it, and an unknown call type is refused when bound - the endpoints that resolved to None without a call now dispatch like their siblings; sync create/get/delete/cancel_interaction answer synchronously - sync search picks among the router's search tools like asearch - ~80 endpoints bound to litellm functions (and agenerate_content's overwritten binding, avector_store_create) now go through host.invoke - routing decisions key on the endpoint's call type, never the host function's __name__ - arerank no longer sends the builtin input; responses get/cancel/delete/list with a plain id and no model call the handler directly - prompt-management groups work without a litellm_call_id or logging object
This commit is contained in:
parent
4a57012560
commit
2e9fa3a043
20 changed files with 1351 additions and 1623 deletions
|
|
@ -19,6 +19,7 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_checks import can_key_call_resolved_model, can_token_call_search_tool
|
||||
from litellm.router_api import RegistryReader, RoutingContext
|
||||
from litellm.router_endpoint_table import EndpointHandler
|
||||
from litellm.search import asearch
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
|
|
@ -61,7 +62,9 @@ async def is_search_tool_authorized_for_token(*, search_tool_name: str, valid_to
|
|||
|
||||
|
||||
def _is_search_request(request_kwargs: Mapping[str, object]) -> bool:
|
||||
return request_kwargs.get("original_generic_function") is asearch
|
||||
handler: Final = request_kwargs.get("original_generic_function")
|
||||
# The router hands over the endpoint's table handler, which keys on the call type, not litellm.search.asearch.
|
||||
return handler is asearch or (isinstance(handler, EndpointHandler) and handler.call_type == "asearch")
|
||||
|
||||
|
||||
def _token_in_metadata(metadata: object) -> UserAPIKeyAuth | None:
|
||||
|
|
|
|||
345
litellm/router_endpoint_table.py
Normal file
345
litellm/router_endpoint_table.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
"""
|
||||
The router's factory-built endpoints, in one table both router cores bind from.
|
||||
|
||||
Each row names a public router member, the host op that serves it (a litellm function name, or a
|
||||
dotted path to one under ``litellm``: ``vector_stores.main.asearch``), the call type the routing
|
||||
decisions key on (stream-timeout split, video-id pin, route-capability pin, the responses socket,
|
||||
the /v1/messages refusal gate, context compaction, endpoint eligibility), and how the endpoint
|
||||
dispatches: sync or async, and which helper routes it. A core binds every row and dispatches on
|
||||
``dispatch`` alone, so an endpoint cannot be bound without a dispatch or dispatched without a row.
|
||||
|
||||
Neither core is imported here: ``PythonCore`` and ``RustCore`` both read this module.
|
||||
"""
|
||||
|
||||
import random
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, Protocol, get_args
|
||||
|
||||
Dispatch = Literal[
|
||||
# async
|
||||
"assistants",
|
||||
"moderation",
|
||||
"search",
|
||||
"responses",
|
||||
"anthropic_messages",
|
||||
"generic",
|
||||
"generic_client",
|
||||
"generic_provider_client",
|
||||
"passthrough",
|
||||
"response_id",
|
||||
"container",
|
||||
"vector_store",
|
||||
"interaction",
|
||||
"managed_agent",
|
||||
# sync
|
||||
"search_sync",
|
||||
"generic_sync",
|
||||
"vector_store_sync",
|
||||
"vector_store_file_sync",
|
||||
"interaction_sync",
|
||||
"managed_agent_sync",
|
||||
]
|
||||
DISPATCHES: Final[frozenset[str]] = frozenset(get_args(Dispatch))
|
||||
SYNC_DISPATCHES: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"search_sync",
|
||||
"generic_sync",
|
||||
"vector_store_sync",
|
||||
"vector_store_file_sync",
|
||||
"interaction_sync",
|
||||
"managed_agent_sync",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EndpointSpec:
|
||||
"""One factory-built endpoint: ``name`` on the router, served by the host op ``host_op``."""
|
||||
|
||||
__slots__ = ("call_type", "dispatch", "host_op", "name")
|
||||
|
||||
def __init__(self, name: str, host_op: str, call_type: str, dispatch: Dispatch) -> None:
|
||||
self.name: Final = name
|
||||
self.host_op: Final = host_op
|
||||
self.call_type: Final = call_type
|
||||
self.dispatch: Final[Dispatch] = dispatch
|
||||
|
||||
@property
|
||||
def sync(self) -> bool:
|
||||
return self.dispatch in SYNC_DISPATCHES
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"EndpointSpec({self.name!r}, {self.host_op!r}, {self.call_type!r}, {self.dispatch!r})"
|
||||
|
||||
|
||||
def _rows() -> Iterable[EndpointSpec]:
|
||||
def row(name: str, host_op: str, dispatch: Dispatch, call_type: str | None = None) -> EndpointSpec:
|
||||
return EndpointSpec(name, host_op, call_type or name, dispatch)
|
||||
|
||||
for name in (
|
||||
"acreate_assistants",
|
||||
"adelete_assistant",
|
||||
"aget_assistants",
|
||||
"acreate_thread",
|
||||
"aget_thread",
|
||||
"a_add_message",
|
||||
"aget_messages",
|
||||
"arun_thread",
|
||||
):
|
||||
yield row(name, name, "assistants", "assistants")
|
||||
yield row("amoderation", "amoderation", "moderation", "moderation")
|
||||
yield row("aanthropic_messages", "anthropic_messages", "anthropic_messages", "anthropic_messages")
|
||||
yield row("anthropic_messages", "anthropic_messages", "anthropic_messages")
|
||||
yield row("aresponses", "aresponses", "responses")
|
||||
yield row("responses", "responses", "generic_sync")
|
||||
for name in ("aget_responses", "acancel_responses", "acompact_responses", "adelete_responses", "alist_input_items"):
|
||||
yield row(name, name, "response_id")
|
||||
for name in ("afile_delete", "afile_content"):
|
||||
yield row(name, name, "generic_provider_client")
|
||||
yield row("afile_list", "afile_list", "generic", "alist_files")
|
||||
for name in ("acreate_realtime_client_secret", "arealtime_calls", "acreate_realtime_transcription_session"):
|
||||
yield row(name, name, "generic_client")
|
||||
for name in (
|
||||
"_arealtime",
|
||||
"_aresponses_websocket",
|
||||
"acreate_fine_tuning_job",
|
||||
"acancel_fine_tuning_job",
|
||||
"alist_fine_tuning_jobs",
|
||||
"aretrieve_fine_tuning_job",
|
||||
"aimage_edit",
|
||||
"aadapter_generate_content",
|
||||
"acreate_skill",
|
||||
"alist_skills",
|
||||
"aget_skill",
|
||||
"adelete_skill",
|
||||
):
|
||||
yield row(name, name, "generic")
|
||||
yield row("allm_passthrough_route", "allm_passthrough_route", "passthrough")
|
||||
# vector stores (`avector_store_create` is a method on both cores: it routes to one deployment)
|
||||
for name in ("search", "retrieve", "list", "update", "delete"):
|
||||
yield row(f"avector_store_{name}", f"vector_stores.main.a{name}", "vector_store")
|
||||
for name in ("search", "create", "retrieve", "list", "update", "delete"):
|
||||
yield row(f"vector_store_{name}", f"vector_stores.main.{name}", "vector_store_sync")
|
||||
for name, target in (
|
||||
("create", "create"),
|
||||
("list", "list"),
|
||||
("retrieve", "retrieve"),
|
||||
("content", "retrieve_content"),
|
||||
("update", "update"),
|
||||
("delete", "delete"),
|
||||
):
|
||||
yield row(f"avector_store_file_{name}", f"vector_store_files.main.a{target}", "vector_store")
|
||||
yield row(f"vector_store_file_{name}", f"vector_store_files.main.{target}", "vector_store_file_sync")
|
||||
# Google GenAI, OCR, search, decisions, systemone, extraction
|
||||
for name in ("agenerate_content", "agenerate_content_stream"):
|
||||
yield row(name, f"google_genai.{name}", "generic")
|
||||
for name in ("generate_content", "generate_content_stream"):
|
||||
yield row(name, f"google_genai.{name}", "generic_sync")
|
||||
for module, name in (("ocr", "ocr"), ("decisions", "decisions"), ("systemone", "systemone")):
|
||||
yield row(f"a{name}", f"{module}.a{name}", "generic")
|
||||
yield row(name, f"{module}.{name}", "generic_sync")
|
||||
yield row("aextraction", "extraction.aextraction", "generic")
|
||||
yield row("extraction", "extraction.extraction", "generic_sync")
|
||||
yield row("asearch", "search.asearch", "search")
|
||||
yield row("search", "search.search", "search_sync")
|
||||
# videos
|
||||
for name in (
|
||||
"generation",
|
||||
"list",
|
||||
"status",
|
||||
"content",
|
||||
"remix",
|
||||
"create_character",
|
||||
"get_character",
|
||||
"edit",
|
||||
"extension",
|
||||
):
|
||||
yield row(f"avideo_{name}", f"videos.avideo_{name}", "generic")
|
||||
yield row(f"video_{name}", f"videos.video_{name}", "generic_sync")
|
||||
# containers and their files
|
||||
for name in ("create_container", "list_containers", "retrieve_container", "delete_container"):
|
||||
yield row(f"a{name}", f"containers.a{name}", "container")
|
||||
yield row(name, f"containers.{name}", "generic_sync")
|
||||
for name in (
|
||||
"upload_container_file",
|
||||
"list_container_files",
|
||||
"retrieve_container_file",
|
||||
"delete_container_file",
|
||||
"retrieve_container_file_content",
|
||||
):
|
||||
yield row(f"a{name}", f"containers.endpoint_factory._generated_endpoints.a{name}", "container")
|
||||
yield row(name, f"containers.endpoint_factory._generated_endpoints.{name}", "generic_sync")
|
||||
# Google Interactions and Managed Agents
|
||||
for name in ("create", "get", "delete", "cancel"):
|
||||
yield row(f"a{name}_interaction", f"interactions.a{name}", "interaction")
|
||||
yield row(f"{name}_interaction", f"interactions.{name}", "interaction_sync")
|
||||
for name, target in (
|
||||
("create_agent", "create"),
|
||||
("list_agents", "list"),
|
||||
("get_agent", "get"),
|
||||
("delete_agent", "delete"),
|
||||
("list_agent_versions", "list_versions"),
|
||||
):
|
||||
yield row(f"a{name}", f"interactions.agents.a{target}", "managed_agent")
|
||||
yield row(name, f"interactions.agents.{target}", "managed_agent_sync")
|
||||
|
||||
|
||||
ENDPOINTS: Final[Mapping[str, EndpointSpec]] = MappingProxyType({spec.name: spec for spec in _rows()})
|
||||
|
||||
|
||||
def _dispatch_by_call_type() -> Mapping[str, Dispatch]:
|
||||
table: Final[dict[str, Dispatch]] = {"avector_store_create": "vector_store"} # mutable-ok: built once here
|
||||
for spec in ENDPOINTS.values():
|
||||
known = table.setdefault(spec.call_type, spec.dispatch)
|
||||
if known != spec.dispatch:
|
||||
raise RuntimeError(f"call type {spec.call_type!r} dispatches both as {known!r} and {spec.dispatch!r}")
|
||||
return MappingProxyType(table)
|
||||
|
||||
|
||||
# `factory_function(original_function, call_type=...)`: how each call type dispatches.
|
||||
DISPATCH_BY_CALL_TYPE: Final[Mapping[str, Dispatch]] = _dispatch_by_call_type()
|
||||
|
||||
|
||||
def dispatch_for(call_type: str) -> Dispatch:
|
||||
"""How an endpoint of ``call_type`` dispatches; an unknown call type is refused when bound."""
|
||||
dispatch: Final = DISPATCH_BY_CALL_TYPE.get(call_type)
|
||||
if dispatch is None:
|
||||
raise ValueError(f"no router endpoint dispatches the call type {call_type!r}")
|
||||
return dispatch
|
||||
|
||||
|
||||
class _Invokes(Protocol):
|
||||
def invoke(self, op: str) -> Callable[..., object]: ...
|
||||
|
||||
|
||||
class EndpointHandler:
|
||||
"""An endpoint's handler: the function ``resolve()`` returns, looked up at each call, tagged with
|
||||
the endpoint's ``call_type`` so the routing decisions key on the endpoint, never on the
|
||||
function's ``__name__`` (a wrapped or renamed host function keeps them)."""
|
||||
|
||||
__slots__ = ("call_type", "op", "resolve")
|
||||
|
||||
def __init__(self, call_type: str, resolve: Callable[[], Callable[..., object]], op: str | None = None) -> None:
|
||||
self.call_type: Final = call_type
|
||||
self.resolve: Final = resolve
|
||||
self.op: Final = op
|
||||
|
||||
def __call__(self, *args: object, **kwargs: object) -> object: # kwargs-ok: the endpoint's request
|
||||
return self.resolve()(*args, **kwargs)
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, object]) -> "EndpointHandler": # mutable-ok: copy.deepcopy's memo
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"EndpointHandler({self.call_type!r}, op={self.op!r})"
|
||||
|
||||
|
||||
def host_handler(host: _Invokes, op: str, call_type: str) -> EndpointHandler:
|
||||
"""The host op ``op`` as the handler of an endpoint of ``call_type``, resolved at each call."""
|
||||
return EndpointHandler(call_type, lambda: host.invoke(op), op)
|
||||
|
||||
|
||||
def as_handler(function: Callable[..., object], call_type: str) -> EndpointHandler:
|
||||
"""``function`` as the handler of an endpoint of ``call_type`` (kept as given when it already is one)."""
|
||||
if isinstance(function, EndpointHandler):
|
||||
return function
|
||||
return EndpointHandler(call_type, lambda: function)
|
||||
|
||||
|
||||
def handler_call_type(function: object) -> str | None:
|
||||
"""The call type the routing decisions key on: the endpoint's, else the function's ``__name__``
|
||||
(a function a caller hands the router's generic call directly)."""
|
||||
if isinstance(function, EndpointHandler):
|
||||
return function.call_type
|
||||
name: Final[object] = getattr(function, "__name__", None)
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
|
||||
def handler_identity(function: object) -> tuple[str | None, str | None]:
|
||||
"""``(call type, module)`` for ``endpoint_selection``: an endpoint's call type names it alone."""
|
||||
if isinstance(function, EndpointHandler):
|
||||
return function.call_type, None
|
||||
module: Final[object] = getattr(function, "__module__", None)
|
||||
return handler_call_type(function), module if isinstance(module, str) else None
|
||||
|
||||
|
||||
def with_provider(
|
||||
custom_llm_provider: str | None, kwargs: Mapping[str, object]
|
||||
) -> dict[str, object]: # mutable-ok: the call's kwargs
|
||||
"""The request with the wrapper's ``custom_llm_provider`` when the request names none."""
|
||||
if custom_llm_provider and "custom_llm_provider" not in kwargs:
|
||||
return {**kwargs, "custom_llm_provider": custom_llm_provider}
|
||||
return dict(kwargs)
|
||||
|
||||
|
||||
def managed_agent_request(
|
||||
custom_llm_provider: str | None, kwargs: Mapping[str, object]
|
||||
) -> dict[str, object]: # mutable-ok: the call's kwargs
|
||||
"""A Managed Agents request: the given provider, else ``gemini``."""
|
||||
request: Final = with_provider(custom_llm_provider, kwargs)
|
||||
request.setdefault("custom_llm_provider", "gemini")
|
||||
return request
|
||||
|
||||
|
||||
def interaction_request(
|
||||
custom_llm_provider: str | None, kwargs: Mapping[str, object]
|
||||
) -> dict[str, object]: # mutable-ok: the call's kwargs
|
||||
"""An Interactions request: the given provider, else ``gemini``; an agent name sent as the model
|
||||
is no model group, so the model is cleared and the call is not routed."""
|
||||
request: Final = managed_agent_request(custom_llm_provider, kwargs)
|
||||
if request.get("agent") and request.get("model") == request.get("agent"):
|
||||
request["model"] = None
|
||||
return request
|
||||
|
||||
|
||||
_SEARCH_PROTECTED_PARAMS: Final = frozenset({"search_provider", "api_key", "api_base"})
|
||||
|
||||
|
||||
def search_tool_call(
|
||||
search_tools: Iterable[Mapping[str, object]], search_tool_name: str, kwargs: Mapping[str, object]
|
||||
) -> Mapping[str, object]:
|
||||
"""``SearchAPIRouter.async_search_with_fallbacks_helper``'s call: one of the search tools named
|
||||
``search_tool_name`` at random, with its provider and credentials, its params under the request's."""
|
||||
tools: Final = [tool for tool in search_tools if tool.get("search_tool_name") == search_tool_name]
|
||||
if not tools:
|
||||
raise ValueError(f"Search tool '{search_tool_name}' not found in router.search_tools")
|
||||
selected: Final = random.choice(tools) # load balancing across the providers sharing the name
|
||||
raw_params: Final[object] = selected.get("litellm_params") or {}
|
||||
params: Final[Mapping[str, object]] = raw_params if isinstance(raw_params, Mapping) else {} # pyright: ignore[reportUnknownVariableType] # a configured tool's params
|
||||
search_provider: Final = params.get("search_provider")
|
||||
if not search_provider:
|
||||
raise ValueError(f"search_provider not found in litellm_params for search tool '{search_tool_name}'")
|
||||
search_params: Final = {
|
||||
key: value
|
||||
for source in (params, kwargs)
|
||||
for key, value in source.items()
|
||||
if key not in _SEARCH_PROTECTED_PARAMS and value is not None
|
||||
} # comprehension-ok: the tool's params, then the request's
|
||||
return MappingProxyType(
|
||||
{
|
||||
"search_provider": search_provider,
|
||||
"api_key": params.get("api_key"),
|
||||
"api_base": params.get("api_base"),
|
||||
**search_params,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DISPATCHES",
|
||||
"DISPATCH_BY_CALL_TYPE",
|
||||
"ENDPOINTS",
|
||||
"SYNC_DISPATCHES",
|
||||
"Dispatch",
|
||||
"EndpointHandler",
|
||||
"EndpointSpec",
|
||||
"as_handler",
|
||||
"dispatch_for",
|
||||
"handler_call_type",
|
||||
"handler_identity",
|
||||
"host_handler",
|
||||
"interaction_request",
|
||||
"managed_agent_request",
|
||||
"search_tool_call",
|
||||
"with_provider",
|
||||
]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1974,9 +1974,7 @@ class RustCore(
|
|||
return await self._drive(plan_for("aspeech"), request)
|
||||
|
||||
async def arerank(self, *args: object, **kwargs: object) -> object: # kwargs-ok: endpoint payload
|
||||
# `arerank` sets kwargs["input"] = input: the Python builtin, which reaches the deployment call.
|
||||
request: Final = {**request_from_call("arerank", args, kwargs), "input": input}
|
||||
return await self._drive(plan_for("arerank"), request)
|
||||
return await self._drive(plan_for("arerank"), request_from_call("arerank", args, kwargs))
|
||||
|
||||
async def amoderation(self, *args: object, **kwargs: object) -> object: # kwargs-ok: endpoint payload
|
||||
return await self._endpoints.amoderation(request_from_call("amoderation", args, kwargs))
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ from litellm._logging import verbose_router_logger
|
|||
from litellm.litellm_core_utils.batch_utils import get_router_metadata_variable_name
|
||||
from litellm.litellm_core_utils.routing_common_utils import without_router_only_kwargs
|
||||
from litellm.responses.retrieve_stream_query import without_retrieve_stream_defaults
|
||||
from litellm.router_endpoint_table import (
|
||||
ENDPOINTS,
|
||||
EndpointSpec,
|
||||
interaction_request,
|
||||
managed_agent_request,
|
||||
search_tool_call,
|
||||
with_provider,
|
||||
)
|
||||
from litellm.router_rust_shaping import (
|
||||
ROUTER_CLIENTS_TARGET,
|
||||
STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
|
||||
|
|
@ -110,6 +118,8 @@ class Plan:
|
|||
# The named parameter the endpoint takes beside **kwargs, sent between the deployment params and
|
||||
# the request (`{**data, name: value, **kwargs}`).
|
||||
named: tuple[str, ...] = ()
|
||||
# The endpoint's call type, which the routing decisions key on (never the host function's name).
|
||||
call_type: str | None = None
|
||||
|
||||
@property
|
||||
def sets_headers(self) -> bool:
|
||||
|
|
@ -122,44 +132,36 @@ class Plan:
|
|||
return get_router_metadata_variable_name(function_name=self.function_name)
|
||||
|
||||
|
||||
def _generic(endpoint: str, host_op: str | None = None, *, sync: bool = False) -> Plan:
|
||||
def _generic(endpoint: str, host_op: str | None = None, *, sync: bool = False, call_type: str | None = None) -> Plan:
|
||||
op: Final = host_op or endpoint
|
||||
routed_as: Final = call_type or op.rsplit(".", 1)[-1]
|
||||
if sync:
|
||||
return Plan(
|
||||
endpoint=endpoint,
|
||||
host_op=host_op or endpoint,
|
||||
host_op=op,
|
||||
kind="generic_sync",
|
||||
sync=True,
|
||||
prepare_field="litellm_metadata",
|
||||
function_name="generic_api_call",
|
||||
splits_stream_timeout=_handler_name(host_op or endpoint) in STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
|
||||
splits_stream_timeout=routed_as in STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
|
||||
stamp="none",
|
||||
fail_key="group",
|
||||
single_attempt=True,
|
||||
call_type=routed_as,
|
||||
)
|
||||
return Plan(
|
||||
endpoint=endpoint,
|
||||
host_op=host_op or endpoint,
|
||||
host_op=op,
|
||||
kind="generic",
|
||||
prepare_field="litellm_metadata",
|
||||
function_name="_ageneric_api_call_with_fallbacks",
|
||||
splits_stream_timeout=_handler_name(host_op or endpoint) in STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
|
||||
splits_stream_timeout=routed_as in STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
|
||||
stamp="deployment",
|
||||
fail_key="group",
|
||||
call_type=routed_as,
|
||||
)
|
||||
|
||||
|
||||
def _handler_name(host_op: str) -> str:
|
||||
return host_op.rsplit(".", 1)[-1]
|
||||
|
||||
|
||||
def invoked_handler_name(core: "RustCore", plan: Plan) -> str | None:
|
||||
"""The generic handler's ``__name__`` (`original_generic_function.__name__`): the function the
|
||||
host serves for the op, which decides the stream-timeout split and the responses socket."""
|
||||
function: Final[object] = core._host.invoke(plan.host_op) # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] # the core's host
|
||||
name: Final[object] = getattr(function, "__name__", None)
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
|
||||
PLANS: Final[Mapping[str, Plan]] = MappingProxyType(
|
||||
{
|
||||
"acompletion": Plan(
|
||||
|
|
@ -294,166 +296,21 @@ PLANS: Final[Mapping[str, Plan]] = MappingProxyType(
|
|||
|
||||
# --- the generic endpoints ------------------------------------------------------------------
|
||||
|
||||
# endpoint -> host op, for the async generic endpoints routed through
|
||||
# `_ageneric_api_call_with_fallbacks` when they carry a model.
|
||||
_HOST_GENERIC: Final[Mapping[str, str]] = MappingProxyType(
|
||||
# The table's dispatches the Rust core routes with a generic plan (`_ageneric_api_call_with_fallbacks`,
|
||||
# or the sync `_generic_api_call_with_fallbacks`) when the request carries a model.
|
||||
_GENERIC_DISPATCHES: Final = frozenset(
|
||||
{
|
||||
"afile_delete": "afile_delete",
|
||||
"afile_content": "afile_content",
|
||||
"afile_list": "afile_list",
|
||||
"aget_responses": "aget_responses",
|
||||
"acancel_responses": "acancel_responses",
|
||||
"acompact_responses": "acompact_responses",
|
||||
"adelete_responses": "adelete_responses",
|
||||
"alist_input_items": "alist_input_items",
|
||||
"_arealtime": "_arealtime",
|
||||
"acreate_realtime_client_secret": "acreate_realtime_client_secret",
|
||||
"arealtime_calls": "arealtime_calls",
|
||||
"acreate_realtime_transcription_session": "acreate_realtime_transcription_session",
|
||||
"_aresponses_websocket": "_aresponses_websocket",
|
||||
"acreate_fine_tuning_job": "acreate_fine_tuning_job",
|
||||
"acancel_fine_tuning_job": "acancel_fine_tuning_job",
|
||||
"alist_fine_tuning_jobs": "alist_fine_tuning_jobs",
|
||||
"aretrieve_fine_tuning_job": "aretrieve_fine_tuning_job",
|
||||
"aimage_edit": "aimage_edit",
|
||||
"allm_passthrough_route": "allm_passthrough_route",
|
||||
"acreate_skill": "acreate_skill",
|
||||
"alist_skills": "alist_skills",
|
||||
"aget_skill": "aget_skill",
|
||||
"adelete_skill": "adelete_skill",
|
||||
"agenerate_content": "google_genai.agenerate_content",
|
||||
"agenerate_content_stream": "google_genai.agenerate_content_stream",
|
||||
"aocr": "ocr.aocr",
|
||||
"adecisions": "decisions.adecisions",
|
||||
"asystemone": "systemone.asystemone",
|
||||
"aextraction": "extraction.aextraction",
|
||||
**{f"avideo_{name}": f"videos.avideo_{name}" for name in ("generation", "list", "status", "content", "remix")},
|
||||
**{
|
||||
name: f"containers.{name}"
|
||||
for name in ("acreate_container", "alist_containers", "aretrieve_container", "adelete_container")
|
||||
},
|
||||
**{
|
||||
name: f"containers.endpoint_factory._generated_endpoints.{name}"
|
||||
for name in (
|
||||
"aupload_container_file",
|
||||
"alist_container_files",
|
||||
"aretrieve_container_file",
|
||||
"adelete_container_file",
|
||||
"aretrieve_container_file_content",
|
||||
)
|
||||
},
|
||||
**{
|
||||
f"avector_store_{name}": f"vector_stores.main.a{name}"
|
||||
for name in ("search", "retrieve", "list", "update", "delete")
|
||||
},
|
||||
**{
|
||||
f"avector_store_file_{name}": f"vector_store_files.main.a{target}"
|
||||
for name, target in (
|
||||
("create", "create"),
|
||||
("list", "list"),
|
||||
("retrieve", "retrieve"),
|
||||
("content", "retrieve_content"),
|
||||
("update", "update"),
|
||||
("delete", "delete"),
|
||||
)
|
||||
},
|
||||
"acreate_interaction": "interactions.acreate",
|
||||
"aget_interaction": "interactions.aget",
|
||||
"adelete_interaction": "interactions.adelete",
|
||||
"acancel_interaction": "interactions.acancel",
|
||||
}
|
||||
)
|
||||
|
||||
_SYNC_GENERIC: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"generate_content": "google_genai.generate_content",
|
||||
"generate_content_stream": "google_genai.generate_content_stream",
|
||||
"ocr": "ocr.ocr",
|
||||
# The sync wrapper routes a search tool name as a model group, unlike `asearch`.
|
||||
"search": "search.search",
|
||||
"decisions": "decisions.decisions",
|
||||
"systemone": "systemone.systemone",
|
||||
"extraction": "extraction.extraction",
|
||||
**{f"video_{name}": f"videos.video_{name}" for name in ("generation", "list", "status", "content", "remix")},
|
||||
**{
|
||||
name: f"containers.{name}"
|
||||
for name in ("create_container", "list_containers", "retrieve_container", "delete_container")
|
||||
},
|
||||
**{
|
||||
f"vector_store_{name}": f"vector_stores.main.{name}"
|
||||
for name in ("search", "create", "retrieve", "list", "update", "delete")
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_CONTAINER_FILE_ROUTED: Final = frozenset(
|
||||
{
|
||||
"aupload_container_file",
|
||||
"alist_container_files",
|
||||
"aretrieve_container_file",
|
||||
"adelete_container_file",
|
||||
"aretrieve_container_file_content",
|
||||
}
|
||||
)
|
||||
_VIDEO_DIRECT: Final = frozenset({"avideo_create_character", "avideo_get_character", "avideo_edit", "avideo_extension"})
|
||||
_SYNC_VIDEO_UNBRANCHED: Final = frozenset(
|
||||
{"video_create_character", "video_get_character", "video_edit", "video_extension"}
|
||||
)
|
||||
_SYNC_CONTAINER_FILES_UNBRANCHED: Final = frozenset(
|
||||
{
|
||||
"upload_container_file",
|
||||
"list_container_files",
|
||||
"retrieve_container_file",
|
||||
"delete_container_file",
|
||||
"retrieve_container_file_content",
|
||||
}
|
||||
)
|
||||
_UNBRANCHED: Final = frozenset(
|
||||
{
|
||||
"aadapter_generate_content",
|
||||
*_SYNC_VIDEO_UNBRANCHED,
|
||||
*_SYNC_CONTAINER_FILES_UNBRANCHED,
|
||||
"get_interaction",
|
||||
"delete_interaction",
|
||||
"cancel_interaction",
|
||||
}
|
||||
)
|
||||
_ASSISTANTS: Final = frozenset(
|
||||
{
|
||||
"acreate_assistants",
|
||||
"adelete_assistant",
|
||||
"aget_assistants",
|
||||
"acreate_thread",
|
||||
"aget_thread",
|
||||
"a_add_message",
|
||||
"aget_messages",
|
||||
"arun_thread",
|
||||
}
|
||||
)
|
||||
_MANAGED_AGENTS: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{ # comprehension-ok: a flat walk of nested rows
|
||||
f"{prefix}{name}": f"interactions.agents.{prefix}{target}"
|
||||
for prefix in ("a", "")
|
||||
for name, target in (
|
||||
("create_agent", "create"),
|
||||
("list_agents", "list"),
|
||||
("get_agent", "get"),
|
||||
("delete_agent", "delete"),
|
||||
("list_agent_versions", "list_versions"),
|
||||
)
|
||||
}
|
||||
)
|
||||
_SYNC_VECTOR_STORE_FILES: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
f"vector_store_file_{name}": f"vector_store_files.main.{target}"
|
||||
for name, target in (
|
||||
("create", "create"),
|
||||
("list", "list"),
|
||||
("retrieve", "retrieve"),
|
||||
("content", "retrieve_content"),
|
||||
("update", "update"),
|
||||
("delete", "delete"),
|
||||
)
|
||||
"generic",
|
||||
"generic_client",
|
||||
"generic_provider_client",
|
||||
"passthrough",
|
||||
"response_id",
|
||||
"container",
|
||||
"vector_store",
|
||||
"interaction",
|
||||
"generic_sync",
|
||||
"vector_store_sync",
|
||||
"interaction_sync",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -468,13 +325,10 @@ def plan_for(endpoint: str) -> Plan:
|
|||
def generic_plan(endpoint: str) -> Plan | None:
|
||||
if endpoint in PLANS:
|
||||
return PLANS[endpoint]
|
||||
if endpoint in _HOST_GENERIC:
|
||||
return _generic(endpoint, _HOST_GENERIC[endpoint])
|
||||
if endpoint in _VIDEO_DIRECT:
|
||||
return _generic(endpoint, f"videos.{endpoint}")
|
||||
if endpoint in _SYNC_GENERIC:
|
||||
return _generic(endpoint, _SYNC_GENERIC[endpoint], sync=True)
|
||||
return None
|
||||
spec: Final = ENDPOINTS.get(endpoint)
|
||||
if spec is None or spec.dispatch not in _GENERIC_DISPATCHES:
|
||||
return None
|
||||
return _generic(endpoint, spec.host_op, sync=spec.sync, call_type=spec.call_type)
|
||||
|
||||
|
||||
# --- preparing the request -------------------------------------------------------------------
|
||||
|
|
@ -636,7 +490,7 @@ def attempt_call(
|
|||
kwargs,
|
||||
cache=cache,
|
||||
function_name="generic_api_call",
|
||||
splits_stream_timeout=invoked_handler_name(core, plan) in STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
|
||||
splits_stream_timeout=plan.splits_stream_timeout,
|
||||
)
|
||||
endpoint = kwargs.get("endpoint") # rebind-ok: rebound as the Python router rebinds it
|
||||
if endpoint and isinstance(endpoint, str):
|
||||
|
|
@ -675,15 +529,14 @@ def _generic_attempt(
|
|||
from litellm.llms.base_llm.passthrough.transformation import replace_path_segment
|
||||
from litellm.router_rust_websocket import turn_admission
|
||||
|
||||
handler: Final = invoked_handler_name(core, plan)
|
||||
socket: Final = handler == "_aresponses_websocket"
|
||||
socket: Final = plan.call_type == "_aresponses_websocket"
|
||||
update_kwargs_with_deployment(
|
||||
core._shaping, # pyright: ignore[reportPrivateUsage] # the core's own settings
|
||||
deployment,
|
||||
kwargs,
|
||||
cache=core._cache, # pyright: ignore[reportPrivateUsage] # the core's routing cache
|
||||
function_name="_ageneric_api_call_with_fallbacks",
|
||||
splits_stream_timeout=handler in STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
|
||||
splits_stream_timeout=plan.splits_stream_timeout,
|
||||
opens_responses_socket=socket,
|
||||
)
|
||||
model_name: Final = str(data.get("model"))
|
||||
|
|
@ -715,9 +568,6 @@ def _generic_attempt(
|
|||
return AttemptCall(plan.host_op, final, kwargs, model_name, group)
|
||||
|
||||
|
||||
_SEARCH_PROTECTED_PARAMS: Final = frozenset({"search_provider", "api_key", "api_base"})
|
||||
|
||||
|
||||
def _search_attempt(
|
||||
core: "RustCore",
|
||||
plan: Plan,
|
||||
|
|
@ -726,33 +576,8 @@ def _search_attempt(
|
|||
) -> AttemptCall:
|
||||
"""`SearchAPIRouter.async_search_with_fallbacks_helper`: one of the search tools named
|
||||
``group`` at random, called with its provider, credentials and params under the request's."""
|
||||
import random
|
||||
|
||||
tools: Final = [
|
||||
tool
|
||||
for tool in core._endpoints.search_tools # pyright: ignore[reportPrivateUsage] # the core's live search tools
|
||||
if as_dict(tool).get("search_tool_name") == group
|
||||
]
|
||||
if not tools:
|
||||
raise ValueError(f"Search tool '{group}' not found in router.search_tools")
|
||||
selected: Final = random.choice(tools) # load balancing, as the Python router picks
|
||||
params: Final = as_dict(as_dict(selected).get("litellm_params"))
|
||||
search_provider: Final = params.get("search_provider")
|
||||
if not search_provider:
|
||||
raise ValueError(f"search_provider not found in litellm_params for search tool '{group}'")
|
||||
search_params: Final = {
|
||||
key: value
|
||||
for source in (params, kwargs)
|
||||
for key, value in source.items()
|
||||
if key not in _SEARCH_PROTECTED_PARAMS and value is not None
|
||||
} # comprehension-ok: the tool's params, then the request's
|
||||
final: Final = {
|
||||
"search_provider": search_provider,
|
||||
"api_key": params.get("api_key"),
|
||||
"api_base": params.get("api_base"),
|
||||
**search_params,
|
||||
}
|
||||
return AttemptCall(plan.host_op, final, kwargs, group, group)
|
||||
tools: Final = [as_dict(tool) for tool in core._endpoints.search_tools] # pyright: ignore[reportPrivateUsage] # the core's live search tools
|
||||
return AttemptCall(plan.host_op, dict(search_tool_call(tools, group, kwargs)), kwargs, group, group)
|
||||
|
||||
|
||||
def unrouted_fields(plan: Plan) -> Mapping[str, object]:
|
||||
|
|
@ -980,25 +805,6 @@ def start_silent_experiments(
|
|||
|
||||
# --- the endpoints ----------------------------------------------------------------------------
|
||||
|
||||
_KEEPS_CLIENT: Final = frozenset(
|
||||
{"acreate_realtime_client_secret", "arealtime_calls", "acreate_realtime_transcription_session"}
|
||||
)
|
||||
_KEEPS_PROVIDER_AND_CLIENT: Final = frozenset({"afile_delete", "afile_content"})
|
||||
_RESPONSE_ID_ENDPOINTS: Final = frozenset(
|
||||
{"aget_responses", "acancel_responses", "acompact_responses", "adelete_responses", "alist_input_items"}
|
||||
)
|
||||
_CONTAINER_ENDPOINTS: Final = frozenset(
|
||||
{"acreate_container", "alist_containers", "aretrieve_container", "adelete_container", *_CONTAINER_FILE_ROUTED}
|
||||
)
|
||||
_VECTOR_STORE_ENDPOINTS: Final = frozenset(
|
||||
{
|
||||
*(f"avector_store_{name}" for name in ("search", "create", "retrieve", "list", "update", "delete")),
|
||||
*(f"avector_store_file_{name}" for name in ("create", "list", "retrieve", "content", "update", "delete")),
|
||||
}
|
||||
)
|
||||
_INTERACTION_ENDPOINTS: Final = frozenset(
|
||||
{"acreate_interaction", "aget_interaction", "adelete_interaction", "acancel_interaction"}
|
||||
)
|
||||
_MISSING: Final = object()
|
||||
|
||||
|
||||
|
|
@ -1165,16 +971,6 @@ class RustEndpoints:
|
|||
|
||||
def member(self, name: str) -> Callable[..., object] | None:
|
||||
"""The public endpoint ``name``, bound to this core, or None when the core has none."""
|
||||
if name in _UNBRANCHED:
|
||||
return _answers_none
|
||||
if name in _ASSISTANTS:
|
||||
return self._assistants(name)
|
||||
if name in _MANAGED_AGENTS:
|
||||
return self._managed_agents(name)
|
||||
if name in _SYNC_VECTOR_STORE_FILES:
|
||||
return self._sync_vector_store_files(name)
|
||||
if name == "create_interaction":
|
||||
return self._factory("create_interaction", "interactions.create")
|
||||
direct: Final = {
|
||||
"avector_store_create": self.avector_store_create,
|
||||
"acreate_file": self.acreate_file,
|
||||
|
|
@ -1189,66 +985,125 @@ class RustEndpoints:
|
|||
"schedule_acompletion": self.schedule_acompletion,
|
||||
"priority_header_update": self.priority_header_update,
|
||||
"aguardrail": self.aguardrail,
|
||||
"asearch": self.asearch,
|
||||
}.get(name)
|
||||
if direct is not None:
|
||||
return direct
|
||||
if name == "responses":
|
||||
return self._sync_factory(name, PLANS["responses"])
|
||||
plan: Final = generic_plan(name)
|
||||
if plan is None or name in PLANS:
|
||||
return None
|
||||
if plan.sync:
|
||||
return self._sync_factory(name, plan)
|
||||
return self._factory(name, plan.host_op)
|
||||
spec: Final = ENDPOINTS.get(name)
|
||||
return None if spec is None else self._bind(spec)
|
||||
|
||||
# -- the factory wrappers ------------------------------------------------------------------
|
||||
|
||||
def _factory(self, name: str, host_op: str) -> Callable[..., Awaitable[object]]:
|
||||
def _bind(self, spec: EndpointSpec) -> Callable[..., object]:
|
||||
"""The wrapper serving ``spec``, dispatched as ``router_endpoint_table`` says."""
|
||||
if spec.sync:
|
||||
|
||||
def sync_wrapper(
|
||||
custom_llm_provider: str | None = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the endpoint's request
|
||||
) -> object:
|
||||
return self._dispatch_sync(spec, custom_llm_provider, client, dict(kwargs))
|
||||
|
||||
sync_wrapper.__name__ = spec.name
|
||||
return sync_wrapper
|
||||
|
||||
async def async_wrapper(
|
||||
custom_llm_provider: str | None = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the endpoint's request
|
||||
) -> object:
|
||||
return await self._wrapped(name, host_op, custom_llm_provider, client, dict(kwargs))
|
||||
return await self._dispatch_async(spec, custom_llm_provider, client, dict(kwargs))
|
||||
|
||||
async_wrapper.__name__ = name
|
||||
async_wrapper.__name__ = spec.name
|
||||
return async_wrapper
|
||||
|
||||
async def _wrapped(
|
||||
async def _dispatch_async(
|
||||
self,
|
||||
name: str,
|
||||
host_op: str,
|
||||
spec: EndpointSpec,
|
||||
custom_llm_provider: str | None,
|
||||
client: object,
|
||||
kwargs: dict[str, object], # mutable-ok: the wrapper's own kwargs
|
||||
) -> object:
|
||||
plan: Final = generic_plan(name) or _generic(name, host_op)
|
||||
if name in _CONTAINER_ENDPOINTS:
|
||||
return await self._containers(plan, custom_llm_provider, kwargs)
|
||||
if name in _RESPONSE_ID_ENDPOINTS:
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
match spec.dispatch:
|
||||
case "assistants":
|
||||
return await self._assistants(spec.host_op, custom_llm_provider, client, kwargs)
|
||||
case "moderation":
|
||||
return await self.amoderation(kwargs)
|
||||
case "search":
|
||||
return await self.asearch(**kwargs)
|
||||
case "responses" | "anthropic_messages":
|
||||
return await self.generic(plan_for(spec.name), kwargs)
|
||||
case "managed_agent":
|
||||
return await self._direct(spec.host_op, managed_agent_request(custom_llm_provider, kwargs))
|
||||
case _:
|
||||
pass
|
||||
plan: Final = plan_for(spec.name)
|
||||
match spec.dispatch:
|
||||
case "generic_client":
|
||||
return await self._ageneric(plan, {**kwargs, "client": client})
|
||||
case "generic_provider_client":
|
||||
return await self._ageneric(
|
||||
plan, {**kwargs, "custom_llm_provider": custom_llm_provider, "client": client}
|
||||
)
|
||||
case "passthrough":
|
||||
return await self._ageneric(plan, {**kwargs, "client": client} if client else kwargs, passthrough=True)
|
||||
case "response_id":
|
||||
return await self._response_id(plan, custom_llm_provider, kwargs)
|
||||
case "container":
|
||||
return await self._containers(plan, custom_llm_provider, kwargs)
|
||||
case "vector_store":
|
||||
return await self._vector_stores(spec.name, plan, custom_llm_provider, kwargs)
|
||||
case "interaction":
|
||||
return await self._interactions(plan, custom_llm_provider, kwargs)
|
||||
case _:
|
||||
return await self._ageneric(plan, kwargs)
|
||||
|
||||
model_id: Final = ResponsesAPIRequestUtils.get_model_id_from_response_id(kwargs.get("response_id")) # pyright: ignore[reportArgumentType] # any id the caller sends
|
||||
if model_id is not None:
|
||||
kwargs[ # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
"model"
|
||||
] = model_id
|
||||
return await self._ageneric(plan, kwargs)
|
||||
if name in _VECTOR_STORE_ENDPOINTS:
|
||||
return await self._vector_stores(name, plan, custom_llm_provider, kwargs)
|
||||
if name in _INTERACTION_ENDPOINTS or name == "create_interaction":
|
||||
return await self._interactions(plan, custom_llm_provider, kwargs)
|
||||
if name in _KEEPS_CLIENT:
|
||||
return await self._ageneric(plan, {**kwargs, "client": client})
|
||||
if name in _KEEPS_PROVIDER_AND_CLIENT:
|
||||
return await self._ageneric(plan, {**kwargs, "custom_llm_provider": custom_llm_provider, "client": client})
|
||||
if name == "allm_passthrough_route":
|
||||
if client:
|
||||
kwargs[ # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
"client"
|
||||
] = client
|
||||
return await self._ageneric(plan, kwargs, passthrough=True)
|
||||
def _dispatch_sync(
|
||||
self,
|
||||
spec: EndpointSpec,
|
||||
custom_llm_provider: str | None,
|
||||
client: object,
|
||||
kwargs: dict[str, object], # mutable-ok: the wrapper's own kwargs
|
||||
) -> object:
|
||||
match spec.dispatch:
|
||||
case "search_sync":
|
||||
return self.search(**kwargs)
|
||||
case "vector_store_file_sync":
|
||||
return self._invoke(spec.host_op)(custom_llm_provider=custom_llm_provider, client=client, **kwargs)
|
||||
case "managed_agent_sync":
|
||||
return self._invoke(spec.host_op)(**managed_agent_request(custom_llm_provider, kwargs))
|
||||
case "vector_store_sync":
|
||||
return self._sync_vector_stores(spec, custom_llm_provider, kwargs)
|
||||
case "interaction_sync":
|
||||
request: Final = interaction_request(custom_llm_provider, kwargs)
|
||||
if request.get("model"):
|
||||
return self._core._call_sync(plan_for(spec.name), request, _SINGLE_ATTEMPT) # pyright: ignore[reportPrivateUsage] # the endpoints drive their own core
|
||||
return self._invoke(spec.host_op)(**request)
|
||||
case _:
|
||||
if "model" not in kwargs:
|
||||
raise TypeError(
|
||||
"_generic_api_call_with_fallbacks() missing 1 required positional argument: 'model'"
|
||||
)
|
||||
return self._core._call_sync(plan_for(spec.name), kwargs, _SINGLE_ATTEMPT) # pyright: ignore[reportPrivateUsage] # see above
|
||||
|
||||
async def _response_id(
|
||||
self,
|
||||
plan: Plan,
|
||||
custom_llm_provider: str | None,
|
||||
kwargs: dict[str, object], # mutable-ok: the request kwargs the Python router mutates in place
|
||||
) -> object:
|
||||
"""`_init_responses_api_endpoints`: a LiteLLM response id pins the deployment it encodes; a
|
||||
plain id with no model calls the handler directly, as litellm does without a router."""
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
|
||||
model_id: Final = ResponsesAPIRequestUtils.get_model_id_from_response_id(kwargs.get("response_id")) # pyright: ignore[reportArgumentType] # any id the caller sends
|
||||
if model_id is not None:
|
||||
kwargs[ # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
"model"
|
||||
] = model_id
|
||||
if not kwargs.get("model"):
|
||||
direct: Final = {key: value for key, value in kwargs.items() if key != "model"}
|
||||
return await self._direct(plan.host_op, with_provider(custom_llm_provider, direct))
|
||||
return await self._ageneric(plan, kwargs)
|
||||
|
||||
async def generic(self, plan: Plan, request: Mapping[str, object]) -> object:
|
||||
|
|
@ -1275,40 +1130,26 @@ class RustEndpoints:
|
|||
|
||||
return await self._core._drive(plan, kwargs, passthrough=passthrough_call if passthrough else None) # pyright: ignore[reportPrivateUsage] # see above
|
||||
|
||||
def _sync_factory(self, name: str, plan: Plan) -> Callable[..., object]:
|
||||
is_vector_store: Final = name.startswith("vector_store_")
|
||||
|
||||
def sync_wrapper(
|
||||
custom_llm_provider: str | None = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the endpoint's request
|
||||
) -> object:
|
||||
if not is_vector_store:
|
||||
if "model" not in kwargs:
|
||||
raise TypeError(
|
||||
"_generic_api_call_with_fallbacks() missing 1 required positional argument: 'model'"
|
||||
)
|
||||
return self._core._call_sync(plan, kwargs, _SINGLE_ATTEMPT) # pyright: ignore[reportPrivateUsage] # see above
|
||||
provider_kwargs: dict[ # mutable-ok: the request kwargs the Python router mutates in place # rebind-ok: rebound as the Python router rebinds it
|
||||
str, object
|
||||
] = (
|
||||
{**kwargs, "custom_llm_provider": custom_llm_provider}
|
||||
if custom_llm_provider and "custom_llm_provider" not in kwargs
|
||||
else dict(kwargs)
|
||||
)
|
||||
if name == "vector_store_search":
|
||||
provider_kwargs = { # rebind-ok: rebound as the Python router rebinds it
|
||||
**provider_kwargs,
|
||||
"_direct_vector_store_embedding_executor": self._embedding_executor(kwargs),
|
||||
}
|
||||
if provider_kwargs.get("model"):
|
||||
return self._core._call_sync(plan, provider_kwargs, _SINGLE_ATTEMPT) # pyright: ignore[reportPrivateUsage] # see above
|
||||
if name == "vector_store_search":
|
||||
return self._invoke(plan.host_op)(**{**provider_kwargs, "router": self._core})
|
||||
return self._invoke(plan.host_op)(**provider_kwargs)
|
||||
|
||||
sync_wrapper.__name__ = name
|
||||
return sync_wrapper
|
||||
def _sync_vector_stores(
|
||||
self,
|
||||
spec: EndpointSpec,
|
||||
custom_llm_provider: str | None,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> object:
|
||||
"""The sync vector store wrapper: routed with a model, else the handler directly."""
|
||||
provider_kwargs: dict[ # mutable-ok: the request kwargs the Python router mutates in place # rebind-ok: rebound as the Python router rebinds it
|
||||
str, object
|
||||
] = with_provider(custom_llm_provider, kwargs)
|
||||
if spec.name == "vector_store_search":
|
||||
provider_kwargs = { # rebind-ok: rebound as the Python router rebinds it
|
||||
**provider_kwargs,
|
||||
"_direct_vector_store_embedding_executor": self._embedding_executor(kwargs),
|
||||
}
|
||||
if provider_kwargs.get("model"):
|
||||
return self._core._call_sync(plan_for(spec.name), provider_kwargs, _SINGLE_ATTEMPT) # pyright: ignore[reportPrivateUsage] # the endpoints drive their own core
|
||||
if spec.name == "vector_store_search":
|
||||
return self._invoke(spec.host_op)(**{**provider_kwargs, "router": self._core})
|
||||
return self._invoke(spec.host_op)(**provider_kwargs)
|
||||
|
||||
def _embedding_executor(self, kwargs: Mapping[str, object]) -> object:
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
|
|
@ -1388,92 +1229,31 @@ class RustEndpoints:
|
|||
kwargs: dict[str, object], # mutable-ok: the request kwargs the Python router mutates in place
|
||||
) -> object:
|
||||
"""`_init_interactions_api_endpoints`."""
|
||||
if custom_llm_provider and "custom_llm_provider" not in kwargs:
|
||||
kwargs[ # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
request: Final = interaction_request(custom_llm_provider, kwargs)
|
||||
if request.get("model"):
|
||||
return await self._ageneric(plan, request)
|
||||
return await self._direct(plan.host_op, request)
|
||||
|
||||
async def _assistants(
|
||||
self,
|
||||
op: str,
|
||||
custom_llm_provider: str | None,
|
||||
client: object,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> object:
|
||||
"""`_pass_through_assistants_endpoint_factory`."""
|
||||
provider: object = custom_llm_provider # rebind-ok: the assistants config's when none is given
|
||||
sent: Final = dict(kwargs)
|
||||
if provider is None:
|
||||
if self._assistants_config is None:
|
||||
raise Exception( # noqa: TRY002 # the Python router raises a bare Exception
|
||||
"'custom_llm_provider' must be set. Either via:\n `Router(assistants_config={'custom_llm_provider': ..})` \nor\n `router.arun_thread(custom_llm_provider=..)`"
|
||||
)
|
||||
provider = self._assistants_config[ # rebind-ok: rebound as the Python router rebinds it
|
||||
"custom_llm_provider"
|
||||
] = custom_llm_provider
|
||||
if "custom_llm_provider" not in kwargs:
|
||||
kwargs[ # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
"custom_llm_provider"
|
||||
] = "gemini"
|
||||
if kwargs.get("agent") and kwargs.get("model") == kwargs.get("agent"):
|
||||
kwargs["model"] = ( # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
None
|
||||
)
|
||||
if kwargs.get("model"):
|
||||
return await self._ageneric(plan, kwargs)
|
||||
return await self._direct(plan.host_op, kwargs)
|
||||
|
||||
def _managed_agents(self, name: str) -> Callable[..., object]:
|
||||
op: Final = _MANAGED_AGENTS[name]
|
||||
|
||||
def prepared(
|
||||
custom_llm_provider: str | None,
|
||||
kwargs: dict[str, object], # mutable-ok: the request kwargs the Python router mutates in place
|
||||
) -> dict[str, object]: # mutable-ok: the call's kwargs
|
||||
if custom_llm_provider and "custom_llm_provider" not in kwargs:
|
||||
kwargs[ # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
"custom_llm_provider"
|
||||
] = custom_llm_provider
|
||||
if "custom_llm_provider" not in kwargs:
|
||||
kwargs[ # rebind-ok: the request kwargs are shaped in place, as the Python router shapes them
|
||||
"custom_llm_provider"
|
||||
] = "gemini"
|
||||
return kwargs
|
||||
|
||||
if name.startswith("a"):
|
||||
|
||||
async def managed_agents_wrapper(
|
||||
custom_llm_provider: str | None = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the request
|
||||
) -> object:
|
||||
return await self._direct(op, prepared(custom_llm_provider, dict(kwargs)))
|
||||
|
||||
return managed_agents_wrapper
|
||||
|
||||
def managed_agents_sync_wrapper(
|
||||
custom_llm_provider: str | None = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the request
|
||||
) -> object:
|
||||
return self._invoke(op)(**prepared(custom_llm_provider, dict(kwargs)))
|
||||
|
||||
return managed_agents_sync_wrapper
|
||||
|
||||
def _sync_vector_store_files(self, name: str) -> Callable[..., object]:
|
||||
op: Final = _SYNC_VECTOR_STORE_FILES[name]
|
||||
|
||||
def vector_store_file_sync_wrapper(
|
||||
custom_llm_provider: str | None = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the request
|
||||
) -> object:
|
||||
return self._invoke(op)(custom_llm_provider=custom_llm_provider, client=client, **kwargs)
|
||||
|
||||
return vector_store_file_sync_wrapper
|
||||
|
||||
def _assistants(self, name: str) -> Callable[..., Awaitable[object]]:
|
||||
async def assistants_wrapper(
|
||||
custom_llm_provider: str | None = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the request
|
||||
) -> object:
|
||||
"""`_pass_through_assistants_endpoint_factory`."""
|
||||
provider: object = custom_llm_provider # rebind-ok: the assistants config's when none is given
|
||||
sent: Final = dict(kwargs)
|
||||
if provider is None:
|
||||
if self._assistants_config is None:
|
||||
raise Exception( # noqa: TRY002 # the Python router raises a bare Exception
|
||||
"'custom_llm_provider' must be set. Either via:\n `Router(assistants_config={'custom_llm_provider': ..})` \nor\n `router.arun_thread(custom_llm_provider=..)`"
|
||||
)
|
||||
provider = self._assistants_config[ # rebind-ok: rebound as the Python router rebinds it
|
||||
"custom_llm_provider"
|
||||
]
|
||||
sent.update(as_dict(self._assistants_config.get("litellm_params")))
|
||||
return await self._direct(name, {"custom_llm_provider": provider, "client": client, **sent})
|
||||
|
||||
return assistants_wrapper
|
||||
]
|
||||
sent.update(as_dict(self._assistants_config.get("litellm_params")))
|
||||
return await self._direct(op, {"custom_llm_provider": provider, "client": client, **sent})
|
||||
|
||||
# -- completions, priority and prompt management ----------------------------------------
|
||||
|
||||
|
|
@ -1786,7 +1566,11 @@ class RustEndpoints:
|
|||
|
||||
async def avector_store_create(self, model: str | None, **kwargs: object) -> object: # kwargs-ok: the request
|
||||
if model is None:
|
||||
return await self._factory("avector_store_create", "vector_stores.main.acreate")(**kwargs)
|
||||
provider: Final = kwargs.pop("custom_llm_provider", None)
|
||||
plan: Final = _generic("avector_store_create", "vector_stores.main.acreate")
|
||||
return await self._vector_stores(
|
||||
"avector_store_create", plan, provider if isinstance(provider, str) else None, dict(kwargs)
|
||||
)
|
||||
return await self._core._drive(PLANS["avector_store_create"], {**kwargs, "model": model}, _SINGLE_ATTEMPT) # pyright: ignore[reportPrivateUsage] # see above
|
||||
|
||||
async def aretrieve_batch(self, model: str | None = None, **kwargs: object) -> object: # kwargs-ok: the request
|
||||
|
|
@ -1963,7 +1747,7 @@ class RustEndpoints:
|
|||
|
||||
async def asearch(
|
||||
self,
|
||||
custom_llm_provider: str | None = None,
|
||||
custom_llm_provider: object = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the request
|
||||
) -> object:
|
||||
|
|
@ -1976,6 +1760,27 @@ class RustEndpoints:
|
|||
request["model"] = search_tool_name
|
||||
return await self._core._drive(PLANS["asearch"], request) # pyright: ignore[reportPrivateUsage] # the endpoints drive their own core
|
||||
|
||||
def search(
|
||||
self,
|
||||
custom_llm_provider: object = None,
|
||||
client: object = None,
|
||||
**kwargs: object, # kwargs-ok: the request
|
||||
) -> object:
|
||||
"""The sync ``search``: one attempt on one of the router's search tools named
|
||||
``search_tool_name`` (else ``model``), as ``asearch`` picks among them."""
|
||||
search_tool_name: Final = kwargs.get("search_tool_name", kwargs.get("model"))
|
||||
if not search_tool_name:
|
||||
raise ValueError("search_tool_name or model parameter is required for search")
|
||||
request: Final = {key: value for key, value in kwargs.items() if key != "model"}
|
||||
prepared: Final = prepare_request(
|
||||
PLANS["asearch"],
|
||||
request,
|
||||
str(search_tool_name),
|
||||
self._core._resolved.model_group_alias, # pyright: ignore[reportPrivateUsage] # the endpoints serve their own core
|
||||
)
|
||||
tools: Final = [as_dict(tool) for tool in self.search_tools]
|
||||
return self._invoke(ENDPOINTS["search"].host_op)(**search_tool_call(tools, str(search_tool_name), prepared))
|
||||
|
||||
# -- guardrails ---------------------------------------------------------------------------
|
||||
|
||||
async def aguardrail(
|
||||
|
|
@ -2067,11 +1872,6 @@ class RustEndpoints:
|
|||
_SINGLE_ATTEMPT: Final[Mapping[str, object]] = MappingProxyType({"num_retries": 0, "disable_fallbacks": True})
|
||||
|
||||
|
||||
async def _answers_none(*_args: object, **_kwargs: object) -> None: # kwargs-ok: any request
|
||||
"""An endpoint the Python router's async wrapper has no branch for."""
|
||||
return
|
||||
|
||||
|
||||
def _as_items(value: object) -> list[object]: # mutable-ok: a fresh list
|
||||
return list(value) if isinstance(value, list) else [] # pyright: ignore[reportUnknownArgumentType] # a config list
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ def _logging_and_kwargs(
|
|||
kwargs: dict[str, object], # mutable-ok: the request kwargs the Python router mutates in place
|
||||
) -> tuple[object, dict[str, object]]: # mutable-ok: same
|
||||
"""The request's logging object, else the one `function_setup` builds (with the kwargs it returns)."""
|
||||
import uuid
|
||||
|
||||
from litellm.utils import Rules, function_setup, get_utc_datetime
|
||||
|
||||
logging_obj: Final = kwargs.get("litellm_logging_obj")
|
||||
|
|
@ -44,10 +46,11 @@ def _logging_and_kwargs(
|
|||
return logging_obj, kwargs
|
||||
return function_setup( # pyright: ignore[reportUnknownVariableType] # litellm's untyped helpers
|
||||
**{ # pyright: ignore[reportArgumentType] # the request's keyword payload
|
||||
**kwargs,
|
||||
"original_function": "acompletion",
|
||||
"rules_obj": Rules(),
|
||||
"start_time": get_utc_datetime(),
|
||||
**kwargs,
|
||||
"litellm_call_id": kwargs.get("litellm_call_id") or str(uuid.uuid4()),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -61,8 +64,10 @@ async def prompt_management_acompletion(
|
|||
"""`_prompt_management_factory`, on the request as `acompletion` prepared it."""
|
||||
from litellm.utils import get_non_default_completion_params
|
||||
|
||||
# `acompletion` hands the routing function along; function_setup consumes it.
|
||||
logging_obj, kwargs = _logging_and_kwargs({**request, "original_function": core.acompletion})
|
||||
# The routing function is no request field: the routed call below drives its own plan.
|
||||
logging_obj, kwargs = _logging_and_kwargs(
|
||||
{key: value for key, value in request.items() if key != "original_function"}
|
||||
)
|
||||
caller_timeout: Final = kwargs.get("timeout") or kwargs.get("request_timeout")
|
||||
caller_stream_timeout: Final = kwargs.get("stream_timeout")
|
||||
specific_deployment: Final = kwargs.pop("specific_deployment", None)
|
||||
|
|
@ -114,7 +119,6 @@ async def prompt_management_acompletion(
|
|||
final["prompt_label"] = prompt_label
|
||||
target_list: Final = core._hosted._core_get_model_list(model_name=str(rendered_model)) # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType] # the core's host registry
|
||||
if target_list is None or len(target_list) == 0: # a direct call to the rendered model
|
||||
_ = final.pop("original_function")
|
||||
return await core._host.invoke("acompletion")(**final) # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] # the core's host
|
||||
timeouts: Final = {
|
||||
key: value
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ CHAT_ENDPOINT_BY_CALL: Final[Mapping[str, str]] = MappingProxyType(
|
|||
"agenerate_content": "generateContent",
|
||||
"agenerate_content_stream": "streamGenerateContent",
|
||||
"acreate_interaction": "/v1beta/interactions",
|
||||
"create_interaction": "/v1beta/interactions",
|
||||
"litellm.interactions.main.acreate": "/v1beta/interactions",
|
||||
"litellm.interactions.main.create": "/v1beta/interactions",
|
||||
"_aresponses_websocket": "/v1/responses",
|
||||
|
|
|
|||
|
|
@ -5,16 +5,15 @@ Handles search tool selection, load balancing, and fallback logic for search req
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.router_api import ChangeSet
|
||||
from litellm.router_deprecated import renamed_router_param
|
||||
from litellm.router_endpoint_table import search_tool_call
|
||||
from litellm.types.router import SearchToolLiteLLMParams, SearchToolTypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -205,44 +204,13 @@ class SearchAPIRouter:
|
|||
search_tool_name: Final = model # model field contains the search_tool_name
|
||||
|
||||
try:
|
||||
# Find matching search tools
|
||||
matching_tools: Final = SearchAPIRouter.get_matching_search_tools(
|
||||
ctx=ctx,
|
||||
search_tool_name=search_tool_name,
|
||||
)
|
||||
|
||||
# Simple random selection for load balancing across multiple providers with same name
|
||||
# For search tools, we use simple random choice since they don't have TPM/RPM constraints
|
||||
selected_tool: Final = random.choice(matching_tools)
|
||||
|
||||
# Extract search provider and other params from litellm_params
|
||||
litellm_params: Final = selected_tool.get("litellm_params", {})
|
||||
search_provider: Final = litellm_params.get("search_provider")
|
||||
if not search_provider:
|
||||
raise ValueError(f"search_provider not found in litellm_params for search tool '{search_tool_name}'")
|
||||
|
||||
api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials(
|
||||
tool_litellm_params=litellm_params,
|
||||
)
|
||||
protected_params: Final = frozenset(("search_provider", "api_key", "api_base"))
|
||||
search_params: Final = MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for params in (litellm_params, kwargs)
|
||||
for key, value in params.items()
|
||||
if key not in protected_params and value is not None
|
||||
}
|
||||
)
|
||||
call: Final = search_tool_call(ctx.settings().search_tools, search_tool_name, kwargs)
|
||||
search_provider: Final = call["search_provider"]
|
||||
|
||||
verbose_router_logger.debug("Selected search tool with provider: %s", search_provider)
|
||||
|
||||
# Call the original search function with the provider config
|
||||
response: Final = await original_generic_function(
|
||||
search_provider=search_provider,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
**search_params,
|
||||
)
|
||||
response: Final = await original_generic_function(**call)
|
||||
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -20,18 +20,21 @@ Member uses are found the same way the boundary survey found them: attribute
|
|||
access on a receiver whose name looks like a router (llm_router,
|
||||
litellm_router_instance, router, ...) or on the Router class, plus
|
||||
getattr/setattr/hasattr with a constant name on such a receiver. Members
|
||||
include the container file endpoints Router.__init__ registers by name.
|
||||
include the container file endpoints Router.__init__ registers by name and the
|
||||
endpoints of litellm/router_endpoint_table.py, which the cores bind by name.
|
||||
|
||||
Run: python tests/code_coverage_tests/router_surface_check.py [--write]
|
||||
--write regenerates the allowlist from the current tree (for shrinking it).
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, List, Set
|
||||
from collections.abc import Mapping
|
||||
from typing import Dict, List, Set, cast
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
ALLOWLIST = os.path.join(os.path.dirname(__file__), "router_surface_allowlist.json")
|
||||
|
|
@ -72,6 +75,8 @@ RECEIVER = re.compile(
|
|||
)
|
||||
# Router.__init__ registers these with setattr(self, name, ...), so they never appear as self.<name>.
|
||||
CONTAINER_FILE_ENDPOINTS = "litellm/containers/endpoints.json"
|
||||
# Both cores bind the factory-built endpoints from this table with setattr, so they never appear as self.<name>.
|
||||
ENDPOINT_TABLE = "litellm/router_endpoint_table.py"
|
||||
DYNAMIC_FUNCS = {"getattr", "setattr", "hasattr"}
|
||||
|
||||
|
||||
|
|
@ -86,6 +91,17 @@ def _is_test(rel: str) -> bool:
|
|||
return "/tests/" in rel or name.startswith("test_") or name.endswith("_test.py")
|
||||
|
||||
|
||||
def _endpoint_table_names() -> set[str]:
|
||||
"""The factory-built endpoints both cores bind with setattr from litellm/router_endpoint_table.py
|
||||
(a stdlib-only module, loaded from its file without importing litellm)."""
|
||||
spec = importlib.util.spec_from_file_location("_router_endpoint_table", os.path.join(ROOT, ENDPOINT_TABLE))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
endpoints = cast("Mapping[str, object]", module.ENDPOINTS) # cast-ok: the table maps endpoint names to rows
|
||||
return set(endpoints)
|
||||
|
||||
|
||||
def router_members() -> Set[str]:
|
||||
members: Set[str] = set()
|
||||
for rel in ROUTER_CLASS_FILES:
|
||||
|
|
@ -112,6 +128,7 @@ def router_members() -> Set[str]:
|
|||
with open(os.path.join(ROOT, CONTAINER_FILE_ENDPOINTS), encoding="utf-8") as f:
|
||||
for endpoint in json.load(f)["endpoints"]:
|
||||
members.update((endpoint["name"], endpoint["async_name"]))
|
||||
members.update(_endpoint_table_names())
|
||||
return {m for m in members if not (m.startswith("__") and m.endswith("__"))}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1057,9 +1057,10 @@ def _router_with_advisor_deployment(
|
|||
):
|
||||
"""Build a Router whose only deployment is the advisor model on Foundry.
|
||||
|
||||
The recorder replaces ``litellm.anthropic_messages`` before construction
|
||||
because Router binds it at init time, so the returned Router exercises the
|
||||
real deployment-resolution path and records what it dispatched.
|
||||
The router looks ``litellm.anthropic_messages`` up through its host at call
|
||||
time, so each test patches it with the recorder around the call; the router
|
||||
then exercises the real deployment-resolution path and records what it
|
||||
dispatched.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.router import Router
|
||||
|
|
@ -1111,6 +1112,7 @@ async def test_advisor_sub_call_routes_through_proxy_router():
|
|||
side_effect=mock_call,
|
||||
),
|
||||
patch.object(proxy_server, "llm_router", router),
|
||||
patch("litellm.anthropic_messages", new=recorder),
|
||||
):
|
||||
h = AdvisorOrchestrationHandler()
|
||||
result = await h.handle(
|
||||
|
|
@ -1172,6 +1174,7 @@ async def test_advisor_sub_call_routes_through_router_for_alias_and_wildcard(rou
|
|||
side_effect=mock_call,
|
||||
),
|
||||
patch.object(proxy_server, "llm_router", router),
|
||||
patch("litellm.anthropic_messages", new=recorder),
|
||||
):
|
||||
h = AdvisorOrchestrationHandler()
|
||||
await h.handle(
|
||||
|
|
@ -1223,6 +1226,7 @@ async def test_advisor_sub_call_bypasses_router_for_unconfigured_model():
|
|||
side_effect=mock_call,
|
||||
),
|
||||
patch.object(proxy_server, "llm_router", router),
|
||||
patch("litellm.anthropic_messages", new=recorder),
|
||||
):
|
||||
h = AdvisorOrchestrationHandler()
|
||||
await h.handle(
|
||||
|
|
@ -1278,6 +1282,7 @@ async def test_advisor_sub_call_client_override_bypasses_router():
|
|||
side_effect=mock_call,
|
||||
),
|
||||
patch.object(proxy_server, "llm_router", router),
|
||||
patch("litellm.anthropic_messages", new=recorder),
|
||||
patch.dict(proxy_server.general_settings, {"allow_client_side_credentials": True}),
|
||||
patch.object(litellm, "user_url_validation", False),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ def test_router_vector_store_search_injects_executor_and_request_metadata():
|
|||
) as fallback:
|
||||
assert wrapped(model="vector-alias", vector_store_id="store", query="query") == "routed"
|
||||
assert fallback.call_args.kwargs["model"] == "vector-alias"
|
||||
assert fallback.call_args.kwargs["original_function"] is original
|
||||
handler = fallback.call_args.kwargs["original_function"]
|
||||
assert handler.call_type == "vector_store_search"
|
||||
assert handler.resolve() is original
|
||||
|
||||
create_original = MagicMock(return_value="created")
|
||||
wrapped_create = router.factory_function(create_original, call_type="vector_store_create")
|
||||
|
|
@ -98,7 +100,12 @@ def test_router_vector_store_search_injects_executor_and_request_metadata():
|
|||
router, "_generic_api_call_with_fallbacks", return_value="created-through-router"
|
||||
) as fallback:
|
||||
assert wrapped_create(model="vector-alias", name="store") == "created-through-router"
|
||||
fallback.assert_called_once_with(original_function=create_original, model="vector-alias", name="store")
|
||||
create_kwargs = fallback.call_args.kwargs
|
||||
assert create_kwargs["original_function"].resolve() is create_original
|
||||
assert {k: v for k, v in create_kwargs.items() if k != "original_function"} == {
|
||||
"model": "vector-alias",
|
||||
"name": "store",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -303,9 +310,7 @@ async def test_vector_store_file_list_resolves_credentials_from_model_query_para
|
|||
assert result["api_base"] == "https://api.openai.com/v1"
|
||||
assert result["model"] == "openai/gpt-4o-mini"
|
||||
assert "custom_llm_provider" not in result
|
||||
llm_router.get_deployment_credentials_with_provider.assert_called_once_with(
|
||||
model_id="team-openai"
|
||||
)
|
||||
llm_router.get_deployment_credentials_with_provider.assert_called_once_with(model_id="team-openai")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -337,9 +342,7 @@ async def test_vector_store_file_list_registry_routed_model_skips_key_model_gran
|
|||
|
||||
assert result["api_key"] == "sk-team-openai"
|
||||
assert result["model"] == "openai/gpt-4o-mini"
|
||||
llm_router.get_deployment_credentials_with_provider.assert_called_once_with(
|
||||
model_id="team-openai"
|
||||
)
|
||||
llm_router.get_deployment_credentials_with_provider.assert_called_once_with(model_id="team-openai")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -370,9 +373,7 @@ async def test_vector_store_file_list_resolves_single_openai_team_deployment():
|
|||
assert result["api_base"] == "https://api.openai.com/v1"
|
||||
assert result["model"] == "openai/gpt-4o-mini"
|
||||
assert "custom_llm_provider" not in result
|
||||
llm_router.get_deployment_credentials_with_provider.assert_called_once_with(
|
||||
model_id="team-openai", team_id=None
|
||||
)
|
||||
llm_router.get_deployment_credentials_with_provider.assert_called_once_with(model_id="team-openai", team_id=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -668,9 +669,7 @@ async def test_update_request_data_with_litellm_managed_vector_store_registry():
|
|||
}
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = (
|
||||
mock_vector_store
|
||||
)
|
||||
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = mock_vector_store
|
||||
|
||||
# Test with vector store registry
|
||||
with patch.object(litellm, "vector_store_registry", mock_registry):
|
||||
|
|
@ -729,16 +728,13 @@ async def test_managed_vector_store_keeps_embedding_reference_and_explicit_confi
|
|||
assert managed_vector_store["litellm_params"]["litellm_embedding_config"] == explicit_config
|
||||
|
||||
|
||||
|
||||
class TestCheckVectorStorePermission:
|
||||
"""Test suite for check_vector_store_permission function."""
|
||||
|
||||
def test_permission_allowed_in_key_metadata(self):
|
||||
"""Test that permission is allowed when found in key metadata."""
|
||||
key_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read", "write"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read", "write"]}]
|
||||
}
|
||||
|
||||
result = check_vector_store_permission(
|
||||
|
|
@ -752,11 +748,7 @@ class TestCheckVectorStorePermission:
|
|||
|
||||
def test_permission_allowed_in_team_metadata(self):
|
||||
"""Test that permission is allowed when found in team metadata."""
|
||||
team_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "team-index", "index_permissions": ["write"]}
|
||||
]
|
||||
}
|
||||
team_metadata = {"allowed_vector_store_indexes": [{"index_name": "team-index", "index_permissions": ["write"]}]}
|
||||
|
||||
result = check_vector_store_permission(
|
||||
index_name="team-index",
|
||||
|
|
@ -769,11 +761,7 @@ class TestCheckVectorStorePermission:
|
|||
|
||||
def test_permission_denied_wrong_permission(self):
|
||||
"""Test that permission is denied when index exists but wrong permission."""
|
||||
key_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read"]}
|
||||
]
|
||||
}
|
||||
key_metadata = {"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read"]}]}
|
||||
|
||||
result = check_vector_store_permission(
|
||||
index_name="my-index",
|
||||
|
|
@ -787,9 +775,7 @@ class TestCheckVectorStorePermission:
|
|||
def test_permission_denied_index_not_found(self):
|
||||
"""Test that permission is denied when index doesn't exist."""
|
||||
key_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "other-index", "index_permissions": ["read", "write"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "other-index", "index_permissions": ["read", "write"]}]
|
||||
}
|
||||
|
||||
result = check_vector_store_permission(
|
||||
|
|
@ -827,16 +813,8 @@ class TestCheckVectorStorePermission:
|
|||
|
||||
def test_key_metadata_takes_precedence(self):
|
||||
"""Test that key metadata is checked and returns permission successfully."""
|
||||
key_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read"]}
|
||||
]
|
||||
}
|
||||
team_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["write"]}
|
||||
]
|
||||
}
|
||||
key_metadata = {"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read"]}]}
|
||||
team_metadata = {"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["write"]}]}
|
||||
|
||||
# Should find permission in key_metadata (checked first)
|
||||
result = check_vector_store_permission(
|
||||
|
|
@ -850,16 +828,8 @@ class TestCheckVectorStorePermission:
|
|||
|
||||
def test_team_metadata_as_fallback(self):
|
||||
"""Test that team metadata is checked when key metadata doesn't have permission."""
|
||||
key_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "other-index", "index_permissions": ["read"]}
|
||||
]
|
||||
}
|
||||
team_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["write"]}
|
||||
]
|
||||
}
|
||||
key_metadata = {"allowed_vector_store_indexes": [{"index_name": "other-index", "index_permissions": ["read"]}]}
|
||||
team_metadata = {"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["write"]}]}
|
||||
|
||||
# Should find permission in team_metadata
|
||||
result = check_vector_store_permission(
|
||||
|
|
@ -882,28 +852,12 @@ class TestCheckVectorStorePermission:
|
|||
}
|
||||
|
||||
# Test each index
|
||||
assert (
|
||||
check_vector_store_permission("index-1", "read", key_metadata, None) is True
|
||||
)
|
||||
assert (
|
||||
check_vector_store_permission("index-1", "write", key_metadata, None)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
check_vector_store_permission("index-2", "write", key_metadata, None)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
check_vector_store_permission("index-2", "read", key_metadata, None)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
check_vector_store_permission("index-3", "read", key_metadata, None) is True
|
||||
)
|
||||
assert (
|
||||
check_vector_store_permission("index-3", "write", key_metadata, None)
|
||||
is True
|
||||
)
|
||||
assert check_vector_store_permission("index-1", "read", key_metadata, None) is True
|
||||
assert check_vector_store_permission("index-1", "write", key_metadata, None) is False
|
||||
assert check_vector_store_permission("index-2", "write", key_metadata, None) is True
|
||||
assert check_vector_store_permission("index-2", "read", key_metadata, None) is False
|
||||
assert check_vector_store_permission("index-3", "read", key_metadata, None) is True
|
||||
assert check_vector_store_permission("index-3", "write", key_metadata, None) is True
|
||||
|
||||
def test_invalid_metadata_structure(self):
|
||||
"""Test handling of invalid metadata structures."""
|
||||
|
|
@ -972,9 +926,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1009,9 +961,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["write"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["write"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1046,9 +996,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1107,9 +1055,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read", "write"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read", "write"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1143,9 +1089,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read", "write"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read", "write"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1207,9 +1151,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read", "write"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read", "write"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1278,9 +1220,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = None
|
||||
mock_user_api_key.team_metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "team-index", "index_permissions": ["read"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "team-index", "index_permissions": ["read"]}]
|
||||
}
|
||||
|
||||
# Mock provider config
|
||||
|
|
@ -1351,11 +1291,7 @@ class TestIsAllowedToCallVectorStoreEndpoint:
|
|||
mock_user_api_key = UserAPIKeyAuth(
|
||||
token="sk-test-mock-token-404",
|
||||
key_name="sk-...plNQ",
|
||||
metadata={
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "dall-e-4", "index_permissions": ["write"]}
|
||||
]
|
||||
},
|
||||
metadata={"allowed_vector_store_indexes": [{"index_name": "dall-e-4", "index_permissions": ["write"]}]},
|
||||
spend=0.015,
|
||||
)
|
||||
mock_provider_config = MagicMock()
|
||||
|
|
@ -1427,12 +1363,8 @@ class TestIndexCreate:
|
|||
}
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_managedvectorstoreindextable.find_unique = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
mock_prisma.db.litellm_managedvectorstoreindextable.create = AsyncMock(
|
||||
return_value=mock_row
|
||||
)
|
||||
mock_prisma.db.litellm_managedvectorstoreindextable.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma.db.litellm_managedvectorstoreindextable.create = AsyncMock(return_value=mock_row)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
|
|
@ -1561,9 +1493,7 @@ class TestIsAllowedToCallVectorStoreFilesEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read", "write"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read", "write"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1588,9 +1518,7 @@ class TestIsAllowedToCallVectorStoreFilesEndpoint:
|
|||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
mock_user_api_key.metadata = {
|
||||
"allowed_vector_store_indexes": [
|
||||
{"index_name": "my-index", "index_permissions": ["read"]}
|
||||
]
|
||||
"allowed_vector_store_indexes": [{"index_name": "my-index", "index_permissions": ["read"]}]
|
||||
}
|
||||
mock_user_api_key.team_metadata = None
|
||||
|
||||
|
|
@ -1651,9 +1579,7 @@ class TestVectorStoreManagementEndpointsExist:
|
|||
|
||||
# Force-register the lazy vector_store_management routes so the
|
||||
# assertions can find them.
|
||||
already_registered = any(
|
||||
getattr(r, "path", None) == "/vector_store/new" for r in app.routes
|
||||
)
|
||||
already_registered = any(getattr(r, "path", None) == "/vector_store/new" for r in app.routes)
|
||||
if not already_registered:
|
||||
for feat in LAZY_FEATURES:
|
||||
if feat.name == "vector_store_management":
|
||||
|
|
@ -1682,8 +1608,7 @@ class TestVectorStoreManagementEndpointsExist:
|
|||
# Verify each expected endpoint exists
|
||||
for method, path in expected_endpoints:
|
||||
assert (method, path) in app_routes, (
|
||||
f"Expected endpoint {method} {path} not found in registered routes. "
|
||||
f"Available routes: {app_routes}"
|
||||
f"Expected endpoint {method} {path} not found in registered routes. Available routes: {app_routes}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1764,27 +1689,15 @@ async def test_vector_store_synchronization_across_instances():
|
|||
async def mock_delete(where):
|
||||
"""Mock delete for removing vector store from DB"""
|
||||
vector_store_id = where.get("vector_store_id")
|
||||
mock_db_vector_stores[:] = [
|
||||
vs
|
||||
for vs in mock_db_vector_stores
|
||||
if vs.get("vector_store_id") != vector_store_id
|
||||
]
|
||||
mock_db_vector_stores[:] = [vs for vs in mock_db_vector_stores if vs.get("vector_store_id") != vector_store_id]
|
||||
return None
|
||||
|
||||
# Create mock prisma client
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_many = AsyncMock(
|
||||
side_effect=mock_find_many
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(
|
||||
side_effect=mock_create
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.delete = AsyncMock(
|
||||
side_effect=mock_delete
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(side_effect=mock_find_unique)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_many = AsyncMock(side_effect=mock_find_many)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(side_effect=mock_create)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.delete = AsyncMock(side_effect=mock_delete)
|
||||
|
||||
# Test vector store data
|
||||
test_vector_store_id = "test-sync-store-001"
|
||||
|
|
@ -1804,18 +1717,13 @@ async def test_vector_store_synchronization_across_instances():
|
|||
|
||||
# Step 1: Create vector store on Instance 1
|
||||
# (Simulate what happens in new_vector_store endpoint)
|
||||
await mock_prisma_client.db.litellm_managedvectorstorestable.create(
|
||||
data=test_vector_store
|
||||
)
|
||||
await mock_prisma_client.db.litellm_managedvectorstorestable.create(data=test_vector_store)
|
||||
instance_1_registry.add_vector_store_to_registry(vector_store=test_vector_store)
|
||||
|
||||
# Verify it's in Instance 1's memory
|
||||
assert (
|
||||
instance_1_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
is not None
|
||||
), "Vector store should be in Instance 1's memory"
|
||||
assert instance_1_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id) is not None, (
|
||||
"Vector store should be in Instance 1's memory"
|
||||
)
|
||||
|
||||
# Verify it's in the database
|
||||
db_store = await mock_prisma_client.db.litellm_managedvectorstorestable.find_unique(
|
||||
|
|
@ -1825,41 +1733,28 @@ async def test_vector_store_synchronization_across_instances():
|
|||
|
||||
# Step 2: Instance 2 should be able to find it via database fallback
|
||||
# (Simulate what happens in pop_vector_stores_to_run_with_db_fallback)
|
||||
found_store = (
|
||||
await instance_2_registry.get_litellm_managed_vector_store_from_registry_or_db(
|
||||
vector_store_id=test_vector_store_id, prisma_client=mock_prisma_client
|
||||
)
|
||||
found_store = await instance_2_registry.get_litellm_managed_vector_store_from_registry_or_db(
|
||||
vector_store_id=test_vector_store_id, prisma_client=mock_prisma_client
|
||||
)
|
||||
assert found_store is not None, "Instance 2 should find vector store from database"
|
||||
assert found_store.get("vector_store_id") == test_vector_store_id
|
||||
|
||||
# Verify it's now cached in Instance 2's memory
|
||||
assert (
|
||||
instance_2_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
is not None
|
||||
), "Vector store should now be cached in Instance 2's memory"
|
||||
assert instance_2_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id) is not None, (
|
||||
"Vector store should now be cached in Instance 2's memory"
|
||||
)
|
||||
|
||||
# Step 3: Test that Instance 2 can list vector stores from database
|
||||
# (Simulate what happens in list_vector_stores endpoint - using DB as source of truth)
|
||||
vector_stores_from_db = await VectorStoreRegistry.get_vector_stores_from_db(
|
||||
prisma_client=mock_prisma_client
|
||||
)
|
||||
vector_stores_from_db = await VectorStoreRegistry.get_vector_stores_from_db(prisma_client=mock_prisma_client)
|
||||
|
||||
# Verify vector store appears in the database list
|
||||
vector_store_ids = [vs.get("vector_store_id") for vs in vector_stores_from_db]
|
||||
assert (
|
||||
test_vector_store_id in vector_store_ids
|
||||
), "Instance 2 should see vector store from database"
|
||||
assert test_vector_store_id in vector_store_ids, "Instance 2 should see vector store from database"
|
||||
|
||||
# Verify the list endpoint logic: only show DB stores (filter out stale cache)
|
||||
# This simulates what list_vector_stores does
|
||||
db_vector_store_ids = {
|
||||
vs.get("vector_store_id")
|
||||
for vs in vector_stores_from_db
|
||||
if vs.get("vector_store_id")
|
||||
}
|
||||
db_vector_store_ids = {vs.get("vector_store_id") for vs in vector_stores_from_db if vs.get("vector_store_id")}
|
||||
|
||||
# Instance 2's in-memory cache should only contain stores that exist in DB
|
||||
# (This is what the list endpoint cleanup does)
|
||||
|
|
@ -1869,59 +1764,39 @@ async def test_vector_store_synchronization_across_instances():
|
|||
instance_2_registry.delete_vector_store_from_registry(vector_store_id=vs_id)
|
||||
|
||||
# After cleanup, instance 2 should still have the vector store (it's in DB)
|
||||
assert (
|
||||
instance_2_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
is not None
|
||||
), "Instance 2 should still have vector store (it exists in DB)"
|
||||
assert instance_2_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id) is not None, (
|
||||
"Instance 2 should still have vector store (it exists in DB)"
|
||||
)
|
||||
|
||||
# Step 4: Delete vector store on Instance 1
|
||||
# (Simulate what happens in delete_vector_store endpoint)
|
||||
await mock_prisma_client.db.litellm_managedvectorstorestable.delete(
|
||||
where={"vector_store_id": test_vector_store_id}
|
||||
)
|
||||
instance_1_registry.delete_vector_store_from_registry(
|
||||
vector_store_id=test_vector_store_id
|
||||
)
|
||||
await mock_prisma_client.db.litellm_managedvectorstorestable.delete(where={"vector_store_id": test_vector_store_id})
|
||||
instance_1_registry.delete_vector_store_from_registry(vector_store_id=test_vector_store_id)
|
||||
|
||||
# Verify it's removed from Instance 1's memory
|
||||
assert (
|
||||
instance_1_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
is None
|
||||
), "Vector store should be removed from Instance 1's memory"
|
||||
assert instance_1_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id) is None, (
|
||||
"Vector store should be removed from Instance 1's memory"
|
||||
)
|
||||
|
||||
# Verify it's removed from database
|
||||
db_store_after_delete = (
|
||||
await mock_prisma_client.db.litellm_managedvectorstorestable.find_unique(
|
||||
where={"vector_store_id": test_vector_store_id}
|
||||
)
|
||||
db_store_after_delete = await mock_prisma_client.db.litellm_managedvectorstorestable.find_unique(
|
||||
where={"vector_store_id": test_vector_store_id}
|
||||
)
|
||||
assert db_store_after_delete is None, "Vector store should be removed from database"
|
||||
|
||||
# Step 5: Instance 2 should NOT show it in the list (database is source of truth)
|
||||
# The list endpoint logic should clean up stale cache entries
|
||||
vector_stores_from_db_after_delete = (
|
||||
await VectorStoreRegistry.get_vector_stores_from_db(
|
||||
prisma_client=mock_prisma_client
|
||||
)
|
||||
vector_stores_from_db_after_delete = await VectorStoreRegistry.get_vector_stores_from_db(
|
||||
prisma_client=mock_prisma_client
|
||||
)
|
||||
|
||||
# Verify vector store does NOT appear in the database list
|
||||
vector_store_ids_after_delete = [
|
||||
vs.get("vector_store_id") for vs in vector_stores_from_db_after_delete
|
||||
]
|
||||
assert (
|
||||
test_vector_store_id not in vector_store_ids_after_delete
|
||||
), "Deleted vector store should not be in database"
|
||||
vector_store_ids_after_delete = [vs.get("vector_store_id") for vs in vector_stores_from_db_after_delete]
|
||||
assert test_vector_store_id not in vector_store_ids_after_delete, "Deleted vector store should not be in database"
|
||||
|
||||
# Simulate list endpoint cleanup logic
|
||||
db_vector_store_ids_after_delete = {
|
||||
vs.get("vector_store_id")
|
||||
for vs in vector_stores_from_db_after_delete
|
||||
if vs.get("vector_store_id")
|
||||
vs.get("vector_store_id") for vs in vector_stores_from_db_after_delete if vs.get("vector_store_id")
|
||||
}
|
||||
|
||||
# Remove any in-memory vector stores that no longer exist in database
|
||||
|
|
@ -1931,27 +1806,20 @@ async def test_vector_store_synchronization_across_instances():
|
|||
instance_2_registry.delete_vector_store_from_registry(vector_store_id=vs_id)
|
||||
|
||||
# Verify it was removed from Instance 2's cache
|
||||
assert (
|
||||
instance_2_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
is None
|
||||
), "Deleted vector store should be removed from Instance 2's cache"
|
||||
assert instance_2_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id) is None, (
|
||||
"Deleted vector store should be removed from Instance 2's cache"
|
||||
)
|
||||
|
||||
# Step 6: Test that using a deleted vector store fails gracefully
|
||||
# (Simulate what happens in pop_vector_stores_to_run_with_db_fallback)
|
||||
non_default_params = {"vector_store_ids": [test_vector_store_id]}
|
||||
vector_stores_to_run = (
|
||||
await instance_2_registry.pop_vector_stores_to_run_with_db_fallback(
|
||||
non_default_params=non_default_params,
|
||||
tools=None,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
vector_stores_to_run = await instance_2_registry.pop_vector_stores_to_run_with_db_fallback(
|
||||
non_default_params=non_default_params,
|
||||
tools=None,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
|
||||
assert (
|
||||
len(vector_stores_to_run) == 0
|
||||
), "Deleted vector store should not be returned when trying to use it"
|
||||
assert len(vector_stores_to_run) == 0, "Deleted vector store should not be returned when trying to use it"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2020,15 +1888,9 @@ async def test_vector_store_update_and_list_synchronization():
|
|||
|
||||
# Create mock prisma client
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_many = AsyncMock(
|
||||
side_effect=mock_find_many
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(
|
||||
side_effect=mock_create
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock(
|
||||
side_effect=mock_update
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_many = AsyncMock(side_effect=mock_find_many)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(side_effect=mock_create)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock(side_effect=mock_update)
|
||||
|
||||
# Test vector store data
|
||||
test_vector_store_id = "test-update-store-001"
|
||||
|
|
@ -2050,26 +1912,18 @@ async def test_vector_store_update_and_list_synchronization():
|
|||
}
|
||||
|
||||
# Step 1: Create vector store on Instance 1
|
||||
await mock_prisma_client.db.litellm_managedvectorstorestable.create(
|
||||
data=test_vector_store
|
||||
)
|
||||
await mock_prisma_client.db.litellm_managedvectorstorestable.create(data=test_vector_store)
|
||||
instance_1_registry.add_vector_store_to_registry(vector_store=test_vector_store)
|
||||
|
||||
# Step 2: Instance 2 fetches and caches the vector store
|
||||
vector_stores_from_db = await VectorStoreRegistry.get_vector_stores_from_db(
|
||||
prisma_client=mock_prisma_client
|
||||
)
|
||||
vector_stores_from_db = await VectorStoreRegistry.get_vector_stores_from_db(prisma_client=mock_prisma_client)
|
||||
for vs in vector_stores_from_db:
|
||||
if vs.get("vector_store_id") == test_vector_store_id:
|
||||
instance_2_registry.add_vector_store_to_registry(vector_store=vs)
|
||||
|
||||
# Verify both instances have the original data
|
||||
instance_1_vs = instance_1_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
instance_2_vs = instance_2_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
instance_1_vs = instance_1_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id)
|
||||
instance_2_vs = instance_2_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id)
|
||||
assert instance_1_vs.get("vector_store_name") == original_name
|
||||
assert instance_2_vs.get("vector_store_name") == original_name
|
||||
|
||||
|
|
@ -2088,29 +1942,21 @@ async def test_vector_store_update_and_list_synchronization():
|
|||
)
|
||||
|
||||
# Verify Instance 1 has the updated data
|
||||
instance_1_vs_after_update = (
|
||||
instance_1_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
instance_1_vs_after_update = instance_1_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
assert instance_1_vs_after_update.get("vector_store_name") == updated_name
|
||||
|
||||
# Verify Instance 2 still has stale data in cache
|
||||
instance_2_vs_before_list = (
|
||||
instance_2_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
instance_2_vs_before_list = instance_2_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id)
|
||||
assert instance_2_vs_before_list.get("vector_store_name") == original_name, (
|
||||
"Instance 2 should still have stale cached data before list operation"
|
||||
)
|
||||
assert (
|
||||
instance_2_vs_before_list.get("vector_store_name") == original_name
|
||||
), "Instance 2 should still have stale cached data before list operation"
|
||||
|
||||
# Step 4: Instance 2 calls list endpoint (which should sync with database)
|
||||
# This simulates what list_vector_stores endpoint does
|
||||
vector_stores_from_db_after_update = (
|
||||
await VectorStoreRegistry.get_vector_stores_from_db(
|
||||
prisma_client=mock_prisma_client
|
||||
)
|
||||
vector_stores_from_db_after_update = await VectorStoreRegistry.get_vector_stores_from_db(
|
||||
prisma_client=mock_prisma_client
|
||||
)
|
||||
|
||||
# Build map from database vector stores (database is source of truth)
|
||||
|
|
@ -2126,22 +1972,18 @@ async def test_vector_store_update_and_list_synchronization():
|
|||
)
|
||||
|
||||
# Step 5: Verify Instance 2 now has the updated data
|
||||
instance_2_vs_after_list = (
|
||||
instance_2_registry.get_litellm_managed_vector_store_from_registry(
|
||||
test_vector_store_id
|
||||
)
|
||||
instance_2_vs_after_list = instance_2_registry.get_litellm_managed_vector_store_from_registry(test_vector_store_id)
|
||||
assert instance_2_vs_after_list.get("vector_store_name") == updated_name, (
|
||||
"Instance 2 should have updated data after list operation syncs with database"
|
||||
)
|
||||
assert (
|
||||
instance_2_vs_after_list.get("vector_store_name") == updated_name
|
||||
), "Instance 2 should have updated data after list operation syncs with database"
|
||||
|
||||
# Verify the list returned the correct data
|
||||
combined_vector_stores = list(vector_store_map.values())
|
||||
assert len(combined_vector_stores) == 1
|
||||
assert combined_vector_stores[0].get("vector_store_id") == test_vector_store_id
|
||||
assert (
|
||||
combined_vector_stores[0].get("vector_store_name") == updated_name
|
||||
), "List should return updated data from database"
|
||||
assert combined_vector_stores[0].get("vector_store_name") == updated_name, (
|
||||
"List should return updated data from database"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2185,9 +2027,7 @@ async def test_new_vector_store_persists_embedding_reference_without_credentials
|
|||
}
|
||||
return mock_created_vector_store
|
||||
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(
|
||||
side_effect=mock_create
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(side_effect=mock_create)
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.add_vector_store_to_registry = MagicMock()
|
||||
|
|
@ -2277,9 +2117,7 @@ async def test_new_vector_store_auto_resolves_from_router():
|
|||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||||
patch.object(litellm, "vector_store_registry", mock_registry),
|
||||
):
|
||||
result = await new_vector_store(
|
||||
vector_store=vector_store_data, user_api_key_dict=mock_user_api_key
|
||||
)
|
||||
result = await new_vector_store(vector_store=vector_store_data, user_api_key_dict=mock_user_api_key)
|
||||
|
||||
assert result["status"] == "success"
|
||||
# Resolution against the router happens at request-handling time now,
|
||||
|
|
@ -2292,9 +2130,7 @@ async def test_new_vector_store_auto_resolves_from_router():
|
|||
assert litellm_params_dict["litellm_embedding_model"] == "config-embedding-model"
|
||||
|
||||
response_vs = result["vector_store"]
|
||||
assert "router-resolved-api-key" not in _serialize_litellm_params(
|
||||
response_vs.get("litellm_params")
|
||||
)
|
||||
assert "router-resolved-api-key" not in _serialize_litellm_params(response_vs.get("litellm_params"))
|
||||
|
||||
|
||||
def _stub_user_api_key(
|
||||
|
|
@ -2406,9 +2242,7 @@ async def test_create_vector_store_in_db():
|
|||
mock_created_vector_store = MagicMock()
|
||||
mock_created_vector_store.model_dump.return_value = created_vector_store_data
|
||||
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(
|
||||
return_value=mock_created_vector_store
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(return_value=mock_created_vector_store)
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.add_vector_store_to_registry = MagicMock()
|
||||
|
|
@ -2441,9 +2275,7 @@ async def test_create_vector_store_in_db():
|
|||
mock_registry.add_vector_store_to_registry.assert_called_once()
|
||||
|
||||
# Verify that create was called with correct data structure
|
||||
create_call_args = (
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create.call_args
|
||||
)
|
||||
create_call_args = mock_prisma_client.db.litellm_managedvectorstorestable.create.call_args
|
||||
create_data = create_call_args.kwargs.get("data", {})
|
||||
assert create_data["vector_store_id"] == vector_store_id
|
||||
assert create_data["custom_llm_provider"] == custom_llm_provider
|
||||
|
|
@ -2462,9 +2294,7 @@ async def test_create_vector_store_in_db_raises_when_exists():
|
|||
|
||||
# Mock that vector store already exists
|
||||
existing_vector_store = MagicMock()
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
|
||||
return_value=existing_vector_store
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=existing_vector_store)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_vector_store_in_db(
|
||||
|
|
@ -2513,16 +2343,12 @@ class TestRedactSensitiveLitellmParams:
|
|||
"api_key": "sk-real-openai-key-12345",
|
||||
"aws_access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"aws_secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"vertex_credentials": (
|
||||
'{"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----..."}'
|
||||
),
|
||||
"vertex_credentials": ('{"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----..."}'),
|
||||
"azure_authorization_token": "Bearer eyJhbGciOi...",
|
||||
}
|
||||
out = _redact_sensitive_litellm_params(params)
|
||||
for k in params:
|
||||
assert (
|
||||
out[k] == REDACTED_BY_LITELM_STRING
|
||||
), f"{k} should be redacted, got {out[k]!r}"
|
||||
assert out[k] == REDACTED_BY_LITELM_STRING, f"{k} should be redacted, got {out[k]!r}"
|
||||
|
||||
def test_preserves_non_sensitive_keys(self):
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import (
|
||||
|
|
@ -2663,9 +2489,7 @@ class TestRedactSensitiveLitellmParams:
|
|||
_redact_sensitive_litellm_params,
|
||||
)
|
||||
|
||||
out = _redact_sensitive_litellm_params(
|
||||
"this is not json but might contain a secret"
|
||||
)
|
||||
out = _redact_sensitive_litellm_params("this is not json but might contain a secret")
|
||||
assert out == REDACTED_BY_LITELM_STRING
|
||||
|
||||
|
||||
|
|
@ -2698,9 +2522,7 @@ class TestUpdateVectorStoreAccessControlAndRedaction:
|
|||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
|
||||
return_value=existing_row
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=existing_row)
|
||||
|
||||
with (
|
||||
patch(
|
||||
|
|
@ -2720,9 +2542,7 @@ class TestUpdateVectorStoreAccessControlAndRedaction:
|
|||
vector_store_id="vs_other_team",
|
||||
vector_store_description="hijacked",
|
||||
),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_id="attacker", team_id="team-B"
|
||||
),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="attacker", team_id="team-B"),
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
# The attacker must NOT see the existing credential in the
|
||||
|
|
@ -2767,12 +2587,8 @@ class TestUpdateVectorStoreAccessControlAndRedaction:
|
|||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
|
||||
return_value=existing_row
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock(
|
||||
return_value=updated_row
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=existing_row)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock(return_value=updated_row)
|
||||
|
||||
with (
|
||||
patch(
|
||||
|
|
@ -2813,17 +2629,11 @@ class TestUpdateVectorStoreAccessControlAndRedaction:
|
|||
from litellm.types.vector_stores import VectorStoreUpdateRequest
|
||||
|
||||
existing_row = MagicMock()
|
||||
existing_row.model_dump = MagicMock(
|
||||
return_value={"vector_store_id": "vs_owned", "team_id": "team-A"}
|
||||
)
|
||||
existing_row.model_dump = MagicMock(return_value={"vector_store_id": "vs_owned", "team_id": "team-A"})
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
|
||||
return_value=existing_row
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=existing_row)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: stubs the auth gate so the test exercises the not-found branch under test
|
||||
|
|
@ -2835,8 +2645,12 @@ class TestUpdateVectorStoreAccessControlAndRedaction:
|
|||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), # test-quality-ok: proxy_server module global is the endpoint's only injection point
|
||||
patch("litellm.vector_store_registry", None), # test-quality-ok: litellm module global is the only injection point for the registry
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
|
||||
), # test-quality-ok: proxy_server module global is the endpoint's only injection point
|
||||
patch(
|
||||
"litellm.vector_store_registry", None
|
||||
), # test-quality-ok: litellm module global is the only injection point for the registry
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_vector_store(
|
||||
|
|
@ -3099,7 +2913,9 @@ def test_vector_store_search_rejects_caller_opensearch_index_name_only_on_opense
|
|||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||||
try:
|
||||
with (
|
||||
patch("litellm.vector_stores.main.asearch", new=fake_search), # test-quality-ok: the search boundary under test
|
||||
patch(
|
||||
"litellm.vector_stores.main.asearch", new=fake_search
|
||||
), # test-quality-ok: the search boundary under test
|
||||
patch.object(litellm, "vector_store_registry", registry), # test-quality-ok: seeds the store under test
|
||||
patch( # test-quality-ok: store access is not under test, so the request reaches the search boundary
|
||||
"litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store",
|
||||
|
|
@ -3107,7 +2923,9 @@ def test_vector_store_search_rejects_caller_opensearch_index_name_only_on_opense
|
|||
),
|
||||
):
|
||||
router = litellm.Router(model_list=[])
|
||||
with patch("litellm.proxy.proxy_server.llm_router", router): # test-quality-ok: router built over the fake search
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.llm_router", router
|
||||
): # test-quality-ok: router built over the fake search
|
||||
response = TestClient(app).post(
|
||||
"/v1/vector_stores/kb/search",
|
||||
json={"query": "q", "opensearch_index_name": "another-teams-index"},
|
||||
|
|
@ -3162,7 +2980,9 @@ class TestConfigOwnedVectorStores:
|
|||
|
||||
registry = self._registry()
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_managedvectorstorestable.find_many = AsyncMock(return_value=[self._db_row(self.DB_ID, "db-store")])
|
||||
prisma.db.litellm_managedvectorstorestable.find_many = AsyncMock(
|
||||
return_value=[self._db_row(self.DB_ID, "db-store")]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: proxy_server global, no seam
|
||||
|
|
@ -3171,7 +2991,10 @@ class TestConfigOwnedVectorStores:
|
|||
first = await list_vector_stores(user_api_key_dict=self._admin())
|
||||
second = await list_vector_stores(user_api_key_dict=self._admin())
|
||||
|
||||
assert [(vs["vector_store_id"], vs["is_config"]) for vs in first["data"]] == [(self.DB_ID, False), (self.CONFIG_ID, True)]
|
||||
assert [(vs["vector_store_id"], vs["is_config"]) for vs in first["data"]] == [
|
||||
(self.DB_ID, False),
|
||||
(self.CONFIG_ID, True),
|
||||
]
|
||||
assert second["data"] == first["data"]
|
||||
assert [vs["vector_store_id"] for vs in registry.vector_stores] == [self.CONFIG_ID, self.DB_ID]
|
||||
|
||||
|
|
@ -3193,10 +3016,16 @@ class TestConfigOwnedVectorStores:
|
|||
|
||||
by_id = {vs["vector_store_id"]: vs for vs in response["data"]}
|
||||
assert set(by_id) == {self.CONFIG_ID, self.DB_ID}, response
|
||||
assert (by_id[self.CONFIG_ID]["vector_store_name"], by_id[self.CONFIG_ID]["is_config"]) == ("config-store", True)
|
||||
assert (by_id[self.CONFIG_ID]["vector_store_name"], by_id[self.CONFIG_ID]["is_config"]) == (
|
||||
"config-store",
|
||||
True,
|
||||
)
|
||||
assert (by_id[self.DB_ID]["vector_store_name"], by_id[self.DB_ID]["is_config"]) == ("db-store", False)
|
||||
assert [vs["vector_store_id"] for vs in registry.vector_stores] == [self.CONFIG_ID, self.DB_ID]
|
||||
assert registry.get_litellm_managed_vector_store_from_registry(self.CONFIG_ID)["vector_store_name"] == "config-store"
|
||||
assert (
|
||||
registry.get_litellm_managed_vector_store_from_registry(self.CONFIG_ID)["vector_store_name"]
|
||||
== "config-store"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_reports_config_ownership(self):
|
||||
|
|
@ -3204,7 +3033,9 @@ class TestConfigOwnedVectorStores:
|
|||
from litellm.types.vector_stores import VectorStoreInfoRequest
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: proxy_server global, no seam
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", MagicMock()
|
||||
), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", self._registry()),
|
||||
):
|
||||
config_info = await get_vector_store_info(
|
||||
|
|
@ -3261,7 +3092,10 @@ class TestConfigOwnedVectorStores:
|
|||
assert exc_info.value.status_code == 400, exc_info.value.detail
|
||||
assert exc_info.value.detail["vector_store_id"] == self.CONFIG_ID
|
||||
prisma.db.litellm_managedvectorstorestable.update.assert_not_called()
|
||||
assert registry.get_litellm_managed_vector_store_from_registry(self.CONFIG_ID)["vector_store_name"] == "config-store"
|
||||
assert (
|
||||
registry.get_litellm_managed_vector_store_from_registry(self.CONFIG_ID)["vector_store_name"]
|
||||
== "config-store"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_of_config_store_is_rejected_and_store_stays_registered(self):
|
||||
|
|
@ -3308,7 +3142,9 @@ class TestConfigOwnedVectorStores:
|
|||
)
|
||||
|
||||
assert response["status"] == "success", response
|
||||
prisma.db.litellm_managedvectorstorestable.delete.assert_awaited_once_with(where={"vector_store_id": self.DB_ID})
|
||||
prisma.db.litellm_managedvectorstorestable.delete.assert_awaited_once_with(
|
||||
where={"vector_store_id": self.DB_ID}
|
||||
)
|
||||
assert registry.get_litellm_managed_vector_store_from_registry(self.DB_ID) is None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ Helpers for the endpoint characterization tests (``test_endpoints_characterizati
|
|||
litellm (assistants, skills, realtime, fine-tuning, batches, ...), so every ``host.invoke`` the core
|
||||
makes is answered and recorded.
|
||||
|
||||
Some endpoints do not go through ``host.invoke``: the Python core binds litellm's own functions for
|
||||
them when it is built (vector stores, Google GenAI, OCR, search, videos, containers, interactions,
|
||||
managed agents). ``patch_direct_endpoints`` replaces those litellm functions, before the core is
|
||||
built, with recording stand-ins that answer from the same host script, under the same ``__name__``
|
||||
(the core routes on the handler's name). A call to one of them is recorded with the function's name
|
||||
as its ``op``.
|
||||
Some endpoints are served by a host op named by its dotted path under ``litellm`` (vector stores,
|
||||
Google GenAI, OCR, search, videos, containers, interactions, managed agents), which ``host.invoke``
|
||||
resolves from litellm's modules at call time. ``patch_direct_endpoints`` replaces those litellm
|
||||
functions with recording stand-ins that answer from the same host script. A call to one of them is
|
||||
recorded with the function's name as its ``op``.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
|
@ -180,8 +179,8 @@ RECORDED_NAMES: Final[Mapping[tuple[str, str], str]] = {
|
|||
def patch_direct_endpoints(monkeypatch: pytest.MonkeyPatch, host: ScriptedHost) -> None:
|
||||
"""Replace litellm's directly bound endpoint functions with recording stand-ins on ``host``.
|
||||
|
||||
The stand-in keeps the function's ``__name__`` (the core routes on it); the call is recorded
|
||||
under ``RECORDED_NAMES`` when the bare name is ambiguous, else under the function's name."""
|
||||
The call is recorded under ``RECORDED_NAMES`` when the bare name is ambiguous, else under the
|
||||
function's name."""
|
||||
for module_name, attributes in DIRECT_ENDPOINTS.items():
|
||||
module: Final = importlib.import_module(module_name)
|
||||
for attribute, function_name in attributes.items():
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from collections.abc import Callable, Mapping
|
|||
from typing import Final
|
||||
|
||||
from litellm.router_core import RouterCore
|
||||
from litellm.router_python_host import Endpoint
|
||||
from tests.unit.test_router.core.scripted_host import Outcome, ScriptedHost, deployment
|
||||
|
||||
MakeCore = Callable[..., RouterCore]
|
||||
|
|
@ -64,20 +63,6 @@ def fails_once_then_answers(error: BaseException) -> Callable[..., Outcome]:
|
|||
return outcome
|
||||
|
||||
|
||||
class NamedEndpointHost(ScriptedHost):
|
||||
"""A ``ScriptedHost`` whose ``anthropic_messages`` endpoint carries that name, as litellm's
|
||||
own function does. The core recognises a /v1/messages call (and its safeguard-refusal gate)
|
||||
by the endpoint's ``__name__``; ``ScriptedHost``'s endpoints are all named ``endpoint``."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def invoke(self, op: str) -> Endpoint:
|
||||
endpoint: Final = super().invoke(op)
|
||||
if op == "anthropic_messages":
|
||||
endpoint.__name__ = op # pyright: ignore[reportFunctionMemberAccess] # a plain function
|
||||
return endpoint
|
||||
|
||||
|
||||
def anthropic_refusal(*, with_stop_details: bool = True) -> dict[str, object]: # mutable-ok: a provider response
|
||||
response: Final[dict[str, object]] = {
|
||||
"id": "msg_1",
|
||||
|
|
|
|||
|
|
@ -464,22 +464,33 @@ async def test_prompt_management_group_with_non_dict_prompt_variables_raises_val
|
|||
await core.acompletion(model="pm", messages=MESSAGES, litellm_call_id="c1")
|
||||
|
||||
|
||||
async def test_prompt_management_group_without_a_call_id_or_logging_object_raises_key_error(
|
||||
async def test_prompt_management_group_without_a_call_id_or_logging_object_builds_its_own(
|
||||
make_core: MakeCore, host: EndpointHost
|
||||
):
|
||||
"""With neither, the core builds the logging object under a fresh call id and goes on to
|
||||
validate the prompt, as it does when the caller sends a call id."""
|
||||
core: Final = make_core(model_list=[deployment("pm", "p1", model="langfuse/gpt-4o")])
|
||||
with pytest.raises(KeyError, match="litellm_call_id"):
|
||||
with pytest.raises(ValueError, match=re.escape("Prompt ID is not set or not a string. Got=None")):
|
||||
await core.acompletion(model="pm", messages=MESSAGES)
|
||||
|
||||
|
||||
async def test_litellm_agent_group_without_a_logging_object_raises_key_error(make_core: MakeCore, host: EndpointHost):
|
||||
"""function_setup consumes original_function, so the direct-call path cannot pop it."""
|
||||
core: Final = make_core(model_list=[deployment("pm", "p1", model="litellm_agent/openai/gpt-4o")])
|
||||
with pytest.raises(KeyError, match="original_function"):
|
||||
await core.acompletion(model="pm", messages=MESSAGES, litellm_call_id="c1")
|
||||
assert host.calls == []
|
||||
|
||||
|
||||
async def test_litellm_agent_group_without_a_call_id_or_logging_object_calls_the_stripped_model_directly(
|
||||
make_core: MakeCore, host: EndpointHost
|
||||
):
|
||||
core: Final = make_core(model_list=[deployment("pm", "p1", model="litellm_agent/openai/gpt-4o")])
|
||||
await core.acompletion(model="pm", messages=MESSAGES)
|
||||
call: Final = host.calls[0]
|
||||
assert (call.op, call.model, call.deployment_id, "original_function" in call.kwargs) == (
|
||||
"acompletion",
|
||||
"openai/gpt-4o",
|
||||
"p1",
|
||||
False,
|
||||
)
|
||||
sent_call_id: Final = call.kwargs["litellm_call_id"]
|
||||
assert isinstance(sent_call_id, str) and sent_call_id
|
||||
|
||||
|
||||
async def test_litellm_agent_group_calls_the_stripped_model_directly_with_the_deployment_params(
|
||||
make_core: MakeCore, host: EndpointHost
|
||||
):
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ agents, realtime, fine-tuning, files, image edit, LLM pass-through, vector store
|
|||
guardrails): which host op each reaches, the kwargs the core builds, and how an id in the request
|
||||
pins the deployment.
|
||||
|
||||
The Python core binds litellm's own functions for some of these endpoints instead of going through
|
||||
``host.invoke``; the ``host`` fixture replaces those functions with recording stand-ins (see
|
||||
Some of these endpoints are served by host ops named by a dotted path under ``litellm``; the
|
||||
``host`` fixture replaces those litellm functions with recording stand-ins (see
|
||||
``endpoints_helpers.py``), so every call shows up in ``host.calls``.
|
||||
"""
|
||||
|
||||
|
|
@ -208,39 +208,58 @@ async def test_async_generic_endpoint_is_retried_with_the_router_num_retries(mak
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint",
|
||||
("endpoint", "op"),
|
||||
[
|
||||
"aadapter_generate_content",
|
||||
"video_create_character",
|
||||
"video_get_character",
|
||||
"video_edit",
|
||||
"video_extension",
|
||||
"upload_container_file",
|
||||
"list_container_files",
|
||||
"retrieve_container_file",
|
||||
"delete_container_file",
|
||||
"retrieve_container_file_content",
|
||||
"get_interaction",
|
||||
"delete_interaction",
|
||||
"cancel_interaction",
|
||||
("aadapter_generate_content", "aadapter_generate_content"),
|
||||
("video_create_character", "video_create_character"),
|
||||
("video_get_character", "video_get_character"),
|
||||
("video_edit", "video_edit"),
|
||||
("video_extension", "video_extension"),
|
||||
("upload_container_file", "upload_container_file"),
|
||||
("list_container_files", "list_container_files"),
|
||||
("retrieve_container_file", "retrieve_container_file"),
|
||||
("delete_container_file", "delete_container_file"),
|
||||
("retrieve_container_file_content", "retrieve_container_file_content"),
|
||||
("get_interaction", "interactions.get"),
|
||||
("delete_interaction", "interactions.delete"),
|
||||
("cancel_interaction", "interactions.cancel"),
|
||||
],
|
||||
)
|
||||
async def test_endpoints_without_a_dispatch_branch_resolve_to_none_without_calling_anything(
|
||||
make_core: MakeCore, host: EndpointHost, endpoint: str
|
||||
async def test_endpoints_that_had_no_dispatch_branch_route_like_their_siblings(
|
||||
make_core: MakeCore, host: EndpointHost, endpoint: str, op: str
|
||||
):
|
||||
"""These are wrapped by factory_function's async wrapper, which has no branch for them."""
|
||||
"""The async one routes as the other async generic endpoints do; the sync ones answer
|
||||
synchronously, routed as the other sync generic endpoints are."""
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")])
|
||||
result: Final = getattr(core, endpoint)(model="g", container_id="c", video_id="v", interaction_id="i")
|
||||
assert inspect.isawaitable(result)
|
||||
assert await result is None
|
||||
assert host.calls == []
|
||||
assert inspect.isawaitable(result) is endpoint.startswith("a")
|
||||
response: Final = await result if inspect.isawaitable(result) else result
|
||||
assert response is not None
|
||||
assert [(call.op, call.deployment_id) for call in host.calls] == [(op, "d1")]
|
||||
|
||||
|
||||
async def test_sync_create_interaction_is_awaited_and_fails_with_type_error(make_core: MakeCore, host: EndpointHost):
|
||||
async def test_sync_create_interaction_answers_synchronously_with_the_routed_response(
|
||||
make_core: MakeCore, host: EndpointHost
|
||||
):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")], num_retries=0)
|
||||
with pytest.raises(TypeError, match="can't be used in 'await' expression"):
|
||||
await core.create_interaction(model="g", input="x")
|
||||
assert [call.op for call in host.calls] == ["interactions.create"]
|
||||
response: Final[object] = getattr(core, "create_interaction")(model="g", input="x")
|
||||
assert not inspect.isawaitable(response)
|
||||
assert response is not None
|
||||
assert [(call.op, call.deployment_id) for call in host.calls] == [("interactions.create", "d1")]
|
||||
|
||||
|
||||
async def test_sync_create_interaction_with_an_agent_calls_the_handler_directly_with_gemini(
|
||||
make_core: MakeCore, host: EndpointHost
|
||||
):
|
||||
core: Final = make_core(model_list=[deployment("ag", "d1")])
|
||||
getattr(core, "create_interaction")(agent="ag", model="ag", input="x")
|
||||
call: Final = host.calls[0]
|
||||
assert (call.op, call.deployment_id, call.kwargs["model"], call.kwargs["custom_llm_provider"]) == (
|
||||
"interactions.create",
|
||||
None,
|
||||
None,
|
||||
"gemini",
|
||||
)
|
||||
|
||||
|
||||
# --- request params, provider and timeouts ------------------------------------------------------
|
||||
|
|
@ -288,15 +307,25 @@ async def test_generic_timeout_takes_the_router_timeout(make_core: MakeCore, hos
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("endpoint", "request_body"), [("aresponses", {"input": "hi"}), ("agenerate_content", {"contents": []})]
|
||||
("endpoint", "request_body", "expected"),
|
||||
[
|
||||
# A stream-timeout-aware handler (by its endpoint, whatever the host function is named)
|
||||
# gets the request timeout and the stream timeout apart.
|
||||
("aresponses", {"input": "hi"}, (11.0, 3.0)),
|
||||
("agenerate_content", {"contents": []}, (3.0, 3.0)),
|
||||
],
|
||||
)
|
||||
async def test_streaming_generic_call_sends_the_deployment_stream_timeout_as_timeout(
|
||||
make_core: MakeCore, host: EndpointHost, endpoint: str, request_body: dict[str, object]
|
||||
async def test_streaming_generic_call_sends_the_deployment_stream_timeout(
|
||||
make_core: MakeCore,
|
||||
host: EndpointHost,
|
||||
endpoint: str,
|
||||
request_body: dict[str, object],
|
||||
expected: tuple[float, float],
|
||||
):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1", timeout=11, stream_timeout=3)])
|
||||
await _call(core, endpoint, {"model": "g", "stream": True, **request_body})
|
||||
sent: Final = host.calls[0].kwargs
|
||||
assert (sent["timeout"], sent["stream_timeout"]) == (3.0, 3.0)
|
||||
assert (sent["timeout"], sent["stream_timeout"]) == expected
|
||||
|
||||
|
||||
async def test_non_streaming_aresponses_sends_the_deployment_timeout(make_core: MakeCore, host: EndpointHost):
|
||||
|
|
@ -354,11 +383,19 @@ async def test_the_response_id_reaches_the_host_still_encoded(make_core: MakeCor
|
|||
assert host.calls[0].kwargs["response_id"] == ENCODED_RESPONSE_ID
|
||||
|
||||
|
||||
async def test_a_plain_response_id_without_a_model_fails_with_type_error(make_core: MakeCore, host: EndpointHost):
|
||||
async def test_a_plain_response_id_without_a_model_calls_the_handler_directly(make_core: MakeCore, host: EndpointHost):
|
||||
"""As litellm's own function does without a router: no deployment, the given provider."""
|
||||
core: Final = make_core(model_list=TWO_DEPLOYMENTS)
|
||||
with pytest.raises(TypeError, match="missing 1 required positional argument: 'model'"):
|
||||
await core.aget_responses(response_id="resp_plain")
|
||||
assert host.calls == []
|
||||
await _call(core, "aget_responses", {"response_id": "resp_plain", "custom_llm_provider": "azure"})
|
||||
call: Final = host.calls[0]
|
||||
assert (call.op, call.deployment_id, call.kwargs["response_id"], call.kwargs["custom_llm_provider"]) == (
|
||||
"aget_responses",
|
||||
None,
|
||||
"resp_plain",
|
||||
"azure",
|
||||
)
|
||||
assert "model" not in call.kwargs
|
||||
assert "litellm_metadata" not in call.kwargs
|
||||
|
||||
|
||||
# --- container ids ------------------------------------------------------------------------
|
||||
|
|
@ -730,6 +767,28 @@ async def test_asearch_calls_the_named_search_tool_with_its_litellm_params(make_
|
|||
assert response._hidden_params["additional_headers"]["x-litellm-model-group"] == "s" # pyright: ignore[reportAttributeAccessIssue] # the scripted response
|
||||
|
||||
|
||||
async def test_sync_search_calls_the_named_search_tool_like_asearch(make_core: MakeCore, host: EndpointHost):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")], search_tools=SEARCH_TOOLS)
|
||||
response: Final[object] = getattr(core, "search")(model="s", query="q")
|
||||
assert not inspect.isawaitable(response)
|
||||
call: Final = host.calls[0]
|
||||
assert (call.op, call.kwargs["search_provider"], call.kwargs["api_key"], call.kwargs["query"]) == (
|
||||
"search",
|
||||
"tavily",
|
||||
"tk",
|
||||
"q",
|
||||
)
|
||||
assert _bucket(call.kwargs, "litellm_metadata")["model_group"] == "s"
|
||||
assert "model" not in call.kwargs
|
||||
|
||||
|
||||
async def test_sync_search_with_an_unknown_tool_raises_value_error(make_core: MakeCore, host: EndpointHost):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")], search_tools=SEARCH_TOOLS)
|
||||
with pytest.raises(ValueError, match=re.escape("Search tool 'g' not found in router.search_tools")):
|
||||
getattr(core, "search")(search_tool_name="g", query="q")
|
||||
assert host.calls == []
|
||||
|
||||
|
||||
async def test_asearch_with_an_unknown_tool_raises_value_error(make_core: MakeCore, host: EndpointHost):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")], search_tools=SEARCH_TOOLS, num_retries=0)
|
||||
with pytest.raises(ValueError, match=re.escape("Search tool 'g' not found in router.search_tools")):
|
||||
|
|
|
|||
|
|
@ -106,12 +106,11 @@ async def test_amoderation_is_not_retried(make_core: MakeCore, host: EndpointHos
|
|||
assert host.sleeps == []
|
||||
|
||||
|
||||
async def test_arerank_sends_the_builtin_input_function_as_input(make_core: MakeCore, host: EndpointHost):
|
||||
"""arerank sets kwargs["input"] = input, the Python builtin, and it reaches the host."""
|
||||
async def test_arerank_sends_only_the_request_fields_and_no_input(make_core: MakeCore, host: EndpointHost):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")])
|
||||
await core.arerank(model="g", query="q", documents=["a"])
|
||||
sent: Final = _sent(host)
|
||||
assert (sent["input"], sent["query"], sent["documents"]) == (input, "q", ["a"])
|
||||
assert (sent["query"], sent["documents"], "input" in sent) == ("q", ["a"], False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("request_voice", "expected"), [(None, "deployment-voice"), ("alloy", "alloy")])
|
||||
|
|
|
|||
117
tests/unit/test_router/core/test_endpoints_dispatch_table.py
Normal file
117
tests/unit/test_router/core/test_endpoints_dispatch_table.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""
|
||||
The factory-built endpoints of ``litellm/router_endpoint_table.py`` on each core: every row is
|
||||
served, answers sync or async as its dispatch says, and reaches the host through ``host.invoke``
|
||||
with the row's host op. The host here names every function it serves ``renamed``, so the routing
|
||||
decisions that depend on the endpoint (stream-timeout split, video-id pin, the responses socket's
|
||||
turn admission) are shown to key on the endpoint, never on the host function's ``__name__``.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.router_core import RouterCore
|
||||
from litellm.router_endpoint_table import DISPATCHES, ENDPOINTS, EndpointSpec, dispatch_for
|
||||
from litellm.types.videos.utils import encode_video_id_with_provider
|
||||
from tests.unit.test_router.core.endpoints_helpers import recording_endpoint
|
||||
from tests.unit.test_router.core.scripted_host import ScriptedHost, deployment
|
||||
|
||||
MakeCore = Callable[..., RouterCore]
|
||||
HOST_OPS: Final = frozenset(spec.host_op for spec in ENDPOINTS.values())
|
||||
SYNC_HOST_OPS: Final = frozenset(spec.host_op for spec in ENDPOINTS.values() if spec.sync)
|
||||
SEARCH_TOOLS: Final = [{"search_tool_name": "s", "litellm_params": {"search_provider": "tavily", "api_key": "tk"}}]
|
||||
|
||||
|
||||
class RenamingHost(ScriptedHost):
|
||||
"""Serves every host op of the endpoint table with a recording function named ``renamed``,
|
||||
recorded under the op it was invoked as."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def invoke(self, op: str) -> Callable[..., object]:
|
||||
if op not in HOST_OPS:
|
||||
return super().invoke(op)
|
||||
endpoint: Final = recording_endpoint(self, op, is_async=op not in SYNC_HOST_OPS)
|
||||
endpoint.__name__ = "renamed"
|
||||
endpoint.__qualname__ = "renamed"
|
||||
return endpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host() -> RenamingHost:
|
||||
return RenamingHost()
|
||||
|
||||
|
||||
async def _settled(result: object) -> object:
|
||||
return await result if inspect.isawaitable(result) else result
|
||||
|
||||
|
||||
def _request(spec: EndpointSpec) -> Mapping[str, object]:
|
||||
match spec.dispatch:
|
||||
case "assistants":
|
||||
return {"custom_llm_provider": "openai"}
|
||||
case "search" | "search_sync":
|
||||
return {"model": "s", "query": "q"}
|
||||
case "managed_agent" | "managed_agent_sync":
|
||||
return {"name": "a"}
|
||||
case "vector_store_file_sync":
|
||||
return {"vector_store_id": "v"}
|
||||
case _:
|
||||
return {
|
||||
"model": "g",
|
||||
"input": "x",
|
||||
"video_id": "v",
|
||||
"container_id": "cntr_native",
|
||||
"response_id": "resp_plain",
|
||||
"interaction_id": "i",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(ENDPOINTS))
|
||||
async def test_every_table_endpoint_reaches_the_host_with_its_op(make_core: MakeCore, host: RenamingHost, name: str):
|
||||
spec: Final = ENDPOINTS[name]
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")], search_tools=SEARCH_TOOLS)
|
||||
result: Final[object] = getattr(core, name)(**_request(spec))
|
||||
assert inspect.isawaitable(result) is not spec.sync
|
||||
assert await _settled(result) is not None
|
||||
assert [call.op for call in host.calls] == [spec.host_op]
|
||||
|
||||
|
||||
def test_every_table_row_has_a_known_dispatch_and_every_call_type_one_dispatch():
|
||||
assert {spec.dispatch for spec in ENDPOINTS.values()} <= DISPATCHES
|
||||
assert all(dispatch_for(spec.call_type) == spec.dispatch for spec in ENDPOINTS.values())
|
||||
with pytest.raises(ValueError, match="no router endpoint dispatches the call type 'nope'"):
|
||||
dispatch_for("nope")
|
||||
|
||||
|
||||
def test_every_table_endpoint_is_declared_on_the_python_core_for_type_checkers():
|
||||
from litellm.router_python_core import PythonCore, TableEndpoint
|
||||
|
||||
declared: Final = {name for name, kind in PythonCore.__annotations__.items() if kind == TableEndpoint}
|
||||
assert declared == set(ENDPOINTS)
|
||||
|
||||
|
||||
async def test_a_renamed_host_function_keeps_the_stream_timeout_split(make_core: MakeCore, host: RenamingHost):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1", timeout=11, stream_timeout=3)])
|
||||
await getattr(core, "aresponses")(model="g", input="hi", stream=True)
|
||||
sent: Final = host.calls[0].kwargs
|
||||
assert (sent["timeout"], sent["stream_timeout"]) == (11.0, 3.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["avideo_status", "video_status"])
|
||||
async def test_a_renamed_host_function_keeps_the_video_id_pin(make_core: MakeCore, host: RenamingHost, endpoint: str):
|
||||
video_id: Final = encode_video_id_with_provider("video_up", "openai", model_id="d2", deployment_id="d2")
|
||||
core: Final = make_core(model_list=[deployment("g", "d1"), deployment("g", "d2")])
|
||||
for _ in range(6):
|
||||
await _settled(getattr(core, endpoint)(model="g", video_id=video_id))
|
||||
assert host.deployments_called() == ["d2"] * 6
|
||||
|
||||
|
||||
async def test_a_renamed_host_function_keeps_the_responses_socket_turn_admission(
|
||||
make_core: MakeCore, host: RenamingHost
|
||||
):
|
||||
core: Final = make_core(model_list=[deployment("g", "d1")])
|
||||
await getattr(core, "_aresponses_websocket")(model="g")
|
||||
assert callable(host.calls[0].kwargs["turn_admission"])
|
||||
|
|
@ -3,8 +3,8 @@ Characterization of a router core's Anthropic safeguard-refusal fallbacks on /v1
|
|||
(non-streaming): a ``stop_reason: "refusal"`` response carrying ``stop_details`` re-enters the
|
||||
fallback chain only when a fallback would resolve for it.
|
||||
|
||||
The core recognises a /v1/messages call by its endpoint's name, so this module runs on
|
||||
``NamedEndpointHost`` (a ``ScriptedHost`` whose ``anthropic_messages`` endpoint is named so).
|
||||
The core recognises a /v1/messages call by the endpoint it serves, never by the host function's
|
||||
``__name__``: ``ScriptedHost``'s endpoints are all named ``endpoint``, and the refusal gate holds.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
|
@ -13,16 +13,16 @@ from litellm.router_core import RouterCore
|
|||
from tests.unit.test_router.core.fallbacks_helpers import (
|
||||
MESSAGES,
|
||||
MakeCore,
|
||||
NamedEndpointHost,
|
||||
anthropic_message,
|
||||
anthropic_refusal,
|
||||
build,
|
||||
)
|
||||
from tests.unit.test_router.core.scripted_host import ScriptedHost
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host() -> NamedEndpointHost:
|
||||
return NamedEndpointHost()
|
||||
def host() -> ScriptedHost:
|
||||
return ScriptedHost()
|
||||
|
||||
|
||||
async def messages(core: RouterCore, **request: object) -> object: # kwargs-ok: request kwargs
|
||||
|
|
@ -40,7 +40,7 @@ async def messages(core: RouterCore, **request: object) -> object: # kwargs-ok:
|
|||
ids=["content-policy-chain", "general-chain", "default-fallbacks"],
|
||||
)
|
||||
async def test_a_safeguard_refusal_falls_back_when_a_chain_resolves(
|
||||
make_core: MakeCore, host: NamedEndpointHost, arguments: dict[str, object], expected: list[str]
|
||||
make_core: MakeCore, host: ScriptedHost, arguments: dict[str, object], expected: list[str]
|
||||
) -> None:
|
||||
core = build(make_core, **arguments)
|
||||
host.answer("openai/a", anthropic_refusal())
|
||||
|
|
@ -71,7 +71,7 @@ async def test_a_safeguard_refusal_falls_back_when_a_chain_resolves(
|
|||
)
|
||||
async def test_a_refusal_is_returned_to_the_caller_when_no_fallback_would_resolve(
|
||||
make_core: MakeCore,
|
||||
host: NamedEndpointHost,
|
||||
host: ScriptedHost,
|
||||
arguments: dict[str, object],
|
||||
request_kwargs: dict[str, object],
|
||||
refusal: dict[str, object],
|
||||
|
|
@ -89,7 +89,7 @@ async def test_a_refusal_is_returned_to_the_caller_when_no_fallback_would_resolv
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_last_refusal_is_returned_when_every_fallback_refuses(
|
||||
make_core: MakeCore, host: NamedEndpointHost
|
||||
make_core: MakeCore, host: ScriptedHost
|
||||
) -> None:
|
||||
core = build(make_core, fallbacks=[{"a": ["b"]}])
|
||||
host.answer("*", anthropic_refusal())
|
||||
|
|
|
|||
|
|
@ -26481,7 +26481,8 @@ class TestRouterEmbeddingIntegration:
|
|||
assert wrapped(model="vector-alias", vector_store_id="store", query="query") == "routed"
|
||||
|
||||
assert fallback.call_args.kwargs["model"] == "vector-alias"
|
||||
assert fallback.call_args.kwargs["original_function"] is original
|
||||
handler = fallback.call_args.kwargs["original_function"]
|
||||
assert (handler.resolve(), handler.call_type) == (original, "vector_store_search")
|
||||
assert isinstance(
|
||||
fallback.call_args.kwargs["_direct_vector_store_embedding_executor"],
|
||||
RouterVectorStoreEmbeddingExecutor,
|
||||
|
|
|
|||
|
|
@ -510,15 +510,15 @@ async def test_init_responses_api_endpoints():
|
|||
return_value=None
|
||||
)
|
||||
|
||||
# Call without a response_id (no model extraction should happen)
|
||||
# Call without a response_id or a model: nothing names a deployment, so the handler is called
|
||||
# directly (as litellm's own function is without a router), never routed
|
||||
direct_handler = AsyncMock()
|
||||
await router._init_responses_api_endpoints(
|
||||
original_function=AsyncMock(), thread_id="thread_xyz"
|
||||
original_function=direct_handler, 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"
|
||||
direct_handler.assert_awaited_once_with(thread_id="thread_xyz")
|
||||
router._ageneric_api_call_with_fallbacks.assert_not_called()
|
||||
|
||||
# Reset the mock
|
||||
router._ageneric_api_call_with_fallbacks.reset_mock()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue