mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
chore: merge litellm_internal_staging into typing cleanup branch
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
901e9e0022
23 changed files with 865 additions and 616 deletions
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"reportAny": {
|
||||
"limit": 26575
|
||||
"limit": 26391
|
||||
},
|
||||
"reportArgumentType": {
|
||||
"limit": 2626
|
||||
"limit": 2614
|
||||
},
|
||||
"reportAssignmentType": {
|
||||
"limit": 329
|
||||
"limit": 327
|
||||
},
|
||||
"reportAttributeAccessIssue": {
|
||||
"limit": 514
|
||||
},
|
||||
"reportCallIssue": {
|
||||
"limit": 116
|
||||
"limit": 114
|
||||
},
|
||||
"reportConstantRedefinition": {
|
||||
"limit": 40
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"limit": 19
|
||||
},
|
||||
"reportExplicitAny": {
|
||||
"limit": 8704
|
||||
"limit": 8319
|
||||
},
|
||||
"reportFunctionMemberAccess": {
|
||||
"limit": 7
|
||||
|
|
@ -54,10 +54,10 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportMissingParameterType": {
|
||||
"limit": 5835
|
||||
"limit": 5825
|
||||
},
|
||||
"reportMissingTypeArgument": {
|
||||
"limit": 15746
|
||||
"limit": 15695
|
||||
},
|
||||
"reportMissingTypeStubs": {
|
||||
"limit": 40
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
"limit": 8
|
||||
},
|
||||
"reportReturnType": {
|
||||
"limit": 217
|
||||
"limit": 213
|
||||
},
|
||||
"reportTypedDictNotRequiredAccess": {
|
||||
"limit": 26
|
||||
|
|
@ -99,22 +99,22 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 45026
|
||||
"limit": 45004
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 113
|
||||
},
|
||||
"reportUnknownMemberType": {
|
||||
"limit": 39703
|
||||
"limit": 39649
|
||||
},
|
||||
"reportUnknownParameterType": {
|
||||
"limit": 20203
|
||||
"limit": 20132
|
||||
},
|
||||
"reportUnknownVariableType": {
|
||||
"limit": 31170
|
||||
"limit": 31156
|
||||
},
|
||||
"reportUnnecessaryCast": {
|
||||
"limit": 122
|
||||
"limit": 118
|
||||
},
|
||||
"reportUnnecessaryComparison": {
|
||||
"limit": 701
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
"limit": 5
|
||||
},
|
||||
"reportUnnecessaryIsInstance": {
|
||||
"limit": 862
|
||||
"limit": 857
|
||||
},
|
||||
"reportUntypedBaseClass": {
|
||||
"limit": 0
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import asyncio
|
|||
import contextvars
|
||||
from collections.abc import Coroutine
|
||||
from functools import partial
|
||||
from typing import Any, Final
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -21,8 +21,10 @@ from litellm.types.llms.openai_evals import (
|
|||
CancelRunResponse,
|
||||
CreateEvalRequest,
|
||||
CreateRunRequest,
|
||||
DataSourceConfig,
|
||||
DeleteEvalResponse,
|
||||
Eval,
|
||||
GraderConfig,
|
||||
ListEvalsParams,
|
||||
ListEvalsResponse,
|
||||
ListRunsParams,
|
||||
|
|
@ -41,13 +43,13 @@ DEFAULT_OPENAI_API_BASE: Final = "https://api.openai.com"
|
|||
|
||||
@client
|
||||
async def acreate_eval(
|
||||
data_source_config: dict[str, Any],
|
||||
testing_criteria: list[dict[str, Any]],
|
||||
data_source_config: DataSourceConfig,
|
||||
testing_criteria: list[GraderConfig],
|
||||
name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -110,17 +112,17 @@ async def acreate_eval(
|
|||
|
||||
@client
|
||||
def create_eval(
|
||||
data_source_config: dict[str, Any],
|
||||
testing_criteria: list[dict[str, Any]],
|
||||
data_source_config: DataSourceConfig,
|
||||
testing_criteria: list[GraderConfig],
|
||||
name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> Eval | Coroutine[Any, Any, Eval]:
|
||||
) -> Eval | Coroutine[object, object, Eval]:
|
||||
"""
|
||||
Create a new evaluation
|
||||
|
||||
|
|
@ -231,8 +233,8 @@ async def alist_evals(
|
|||
before: str | None = None,
|
||||
order: str | None = None,
|
||||
order_by: str | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -300,12 +302,12 @@ def list_evals(
|
|||
before: str | None = None,
|
||||
order: str | None = None,
|
||||
order_by: str | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> ListEvalsResponse | Coroutine[Any, Any, ListEvalsResponse]:
|
||||
) -> ListEvalsResponse | Coroutine[object, object, ListEvalsResponse]:
|
||||
"""
|
||||
List all evaluations
|
||||
|
||||
|
|
@ -413,8 +415,8 @@ def list_evals(
|
|||
@client
|
||||
async def aget_eval(
|
||||
eval_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -470,12 +472,12 @@ async def aget_eval(
|
|||
@client
|
||||
def get_eval(
|
||||
eval_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> Eval | Coroutine[Any, Any, Eval]:
|
||||
) -> Eval | Coroutine[object, object, Eval]:
|
||||
"""
|
||||
Get an evaluation by ID
|
||||
|
||||
|
|
@ -564,10 +566,10 @@ def get_eval(
|
|||
async def aupdate_eval(
|
||||
eval_id: str,
|
||||
name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -630,14 +632,14 @@ async def aupdate_eval(
|
|||
def update_eval(
|
||||
eval_id: str,
|
||||
name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> Eval | Coroutine[Any, Any, Eval]:
|
||||
) -> Eval | Coroutine[object, object, Eval]:
|
||||
"""
|
||||
Update an evaluation
|
||||
|
||||
|
|
@ -783,8 +785,8 @@ def update_eval(
|
|||
@client
|
||||
async def adelete_eval(
|
||||
eval_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -840,12 +842,12 @@ async def adelete_eval(
|
|||
@client
|
||||
def delete_eval(
|
||||
eval_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> DeleteEvalResponse | Coroutine[Any, Any, DeleteEvalResponse]:
|
||||
) -> DeleteEvalResponse | Coroutine[object, object, DeleteEvalResponse]:
|
||||
"""
|
||||
Delete an evaluation
|
||||
|
||||
|
|
@ -933,8 +935,8 @@ def delete_eval(
|
|||
@client
|
||||
async def acancel_eval(
|
||||
eval_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -990,12 +992,12 @@ async def acancel_eval(
|
|||
@client
|
||||
def cancel_eval(
|
||||
eval_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> CancelEvalResponse | Coroutine[Any, Any, CancelEvalResponse]:
|
||||
) -> CancelEvalResponse | Coroutine[object, object, CancelEvalResponse]:
|
||||
"""
|
||||
Cancel a running evaluation
|
||||
|
||||
|
|
@ -1092,12 +1094,12 @@ def cancel_eval(
|
|||
@client
|
||||
async def acreate_run(
|
||||
eval_id: str,
|
||||
data_source: dict[str, Any],
|
||||
data_source: dict[str, object],
|
||||
name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -1161,16 +1163,16 @@ async def acreate_run(
|
|||
@client
|
||||
def create_run(
|
||||
eval_id: str,
|
||||
data_source: dict[str, Any],
|
||||
data_source: dict[str, object],
|
||||
name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> Run | Coroutine[Any, Any, Run]:
|
||||
) -> Run | Coroutine[object, object, Run]:
|
||||
"""
|
||||
Create a new run for an evaluation
|
||||
|
||||
|
|
@ -1280,8 +1282,8 @@ async def alist_runs(
|
|||
after: str | None = None,
|
||||
before: str | None = None,
|
||||
order: str | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -1349,12 +1351,12 @@ def list_runs(
|
|||
after: str | None = None,
|
||||
before: str | None = None,
|
||||
order: str | None = None,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> ListRunsResponse | Coroutine[Any, Any, ListRunsResponse]:
|
||||
) -> ListRunsResponse | Coroutine[object, object, ListRunsResponse]:
|
||||
"""
|
||||
List all runs for an evaluation
|
||||
|
||||
|
|
@ -1462,8 +1464,8 @@ def list_runs(
|
|||
async def aget_run(
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -1522,12 +1524,12 @@ async def aget_run(
|
|||
def get_run(
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> Run | Coroutine[Any, Any, Run]:
|
||||
) -> Run | Coroutine[object, object, Run]:
|
||||
"""
|
||||
Get a specific run
|
||||
|
||||
|
|
@ -1618,8 +1620,8 @@ def get_run(
|
|||
async def acancel_run(
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -1678,12 +1680,12 @@ async def acancel_run(
|
|||
def cancel_run(
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> CancelRunResponse | Coroutine[Any, Any, CancelRunResponse]:
|
||||
) -> CancelRunResponse | Coroutine[object, object, CancelRunResponse]:
|
||||
"""
|
||||
Cancel a running run
|
||||
|
||||
|
|
@ -1783,8 +1785,8 @@ def cancel_run(
|
|||
async def adelete_run(
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
|
|
@ -1843,12 +1845,12 @@ async def adelete_run(
|
|||
def delete_run(
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
extra_headers: dict[str, Any] | None = None,
|
||||
extra_query: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
**kwargs,
|
||||
) -> RunDeleteResponse | Coroutine[Any, Any, RunDeleteResponse]:
|
||||
) -> RunDeleteResponse | Coroutine[object, object, RunDeleteResponse]:
|
||||
"""
|
||||
Delete a run
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ import asyncio
|
|||
import math
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from typing_extensions import TypeIs
|
||||
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -75,42 +72,17 @@ WEBSEARCH_EMIT_NATIVE_BLOCKS_KEY: Final = "_websearch_interception_emit_native_b
|
|||
# ``web_search_tool_result`` blocks to inject into the final response.
|
||||
WEBSEARCH_NATIVE_BLOCKS_METADATA_KEY: Final = "websearch_native_blocks"
|
||||
|
||||
_ResponseT = TypeVar("_ResponseT")
|
||||
|
||||
_CONTENT_ATTR: Final = "content"
|
||||
|
||||
_OBJECT_ITEMS_ADAPTER: Final[TypeAdapter[tuple[object, ...]]] = TypeAdapter(tuple[object, ...])
|
||||
|
||||
_NATIVE_BLOCKS_ADAPTER: Final[TypeAdapter[tuple[Mapping[str, object], ...]]] = TypeAdapter(
|
||||
tuple[Mapping[str, object], ...]
|
||||
)
|
||||
|
||||
_WEBSEARCH_CONFIG_ADAPTER: Final[TypeAdapter[WebSearchInterceptionConfig]] = TypeAdapter(WebSearchInterceptionConfig)
|
||||
class _PlanMetadataView(TypedDict):
|
||||
websearch_native_blocks: Sequence[Mapping[str, object]] | None
|
||||
|
||||
|
||||
def _is_json_object(value: object) -> TypeIs[dict[str, object]]: # guard-ok: trivial isinstance; JSON keys are str
|
||||
return isinstance(value, dict)
|
||||
class _AgenticLoopParamsView(TypedDict):
|
||||
agentic_loop_params: AgenticLoopParams
|
||||
|
||||
|
||||
def _parse_object_items(value: object) -> tuple[object, ...]:
|
||||
try:
|
||||
return _OBJECT_ITEMS_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return ()
|
||||
|
||||
|
||||
def _parse_native_blocks(value: object) -> tuple[Mapping[str, object], ...]:
|
||||
try:
|
||||
return _NATIVE_BLOCKS_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return ()
|
||||
|
||||
|
||||
def _parse_websearch_config(value: object) -> WebSearchInterceptionConfig:
|
||||
try:
|
||||
return _WEBSEARCH_CONFIG_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return {}
|
||||
class _WebSearchSettingsView(TypedDict):
|
||||
websearch_interception_params: WebSearchInterceptionConfig
|
||||
|
||||
|
||||
class WebSearchInterceptionLogger(CustomLogger):
|
||||
|
|
@ -434,7 +406,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
return tool.get("name")
|
||||
|
||||
@classmethod
|
||||
def _sync_forced_tool_choice(cls, tool_choice: Any, converted_tools: list[dict[str, object]]) -> object:
|
||||
def _sync_forced_tool_choice(cls, tool_choice: object, converted_tools: Sequence[Mapping[str, object]]) -> object:
|
||||
"""Repoint a forced ``tool_choice`` at ``litellm_web_search`` when it
|
||||
names a web-search tool that was just converted away.
|
||||
|
||||
|
|
@ -502,7 +474,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
kwargs[WEBSEARCH_EMIT_NATIVE_BLOCKS_KEY] = True
|
||||
|
||||
# Convert native web search tools to LiteLLM standard
|
||||
converted_tools: Final = []
|
||||
converted_tools: Final[list[dict[str, object]]] = []
|
||||
for tool in tools:
|
||||
if is_web_search_tool(tool):
|
||||
standard_tool = get_litellm_web_search_tool()
|
||||
|
|
@ -873,7 +845,10 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
Anthropic-native clients (Claude Desktop, the Anthropic SDK) can
|
||||
render citations / sources alongside the model's textual reply.
|
||||
"""
|
||||
native_blocks: Final = _parse_native_blocks(plan.metadata.get(WEBSEARCH_NATIVE_BLOCKS_METADATA_KEY))
|
||||
metadata_view: Final[_PlanMetadataView] = {
|
||||
"websearch_native_blocks": plan.metadata.get(WEBSEARCH_NATIVE_BLOCKS_METADATA_KEY)
|
||||
}
|
||||
native_blocks: Final = metadata_view["websearch_native_blocks"]
|
||||
if not native_blocks:
|
||||
return response
|
||||
return self._inject_native_blocks(response, native_blocks)
|
||||
|
|
@ -923,20 +898,17 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _inject_native_blocks(
|
||||
response: _ResponseT,
|
||||
native_blocks: Sequence[Mapping[str, object]],
|
||||
) -> _ResponseT:
|
||||
def _inject_native_blocks(response: Any, native_blocks: Sequence[Mapping[str, object]]) -> Any:
|
||||
"""Prepend native blocks to response content, dict or object form."""
|
||||
if not native_blocks:
|
||||
return response
|
||||
if _is_json_object(response):
|
||||
existing_items: Final = _parse_object_items(response.get(_CONTENT_ATTR))
|
||||
response[_CONTENT_ATTR] = [*native_blocks, *existing_items]
|
||||
if isinstance(response, dict):
|
||||
existing = response.get("content") or []
|
||||
response["content"] = list(native_blocks) + list(existing)
|
||||
return response
|
||||
existing_attr_items: Final = _parse_object_items(getattr(response, _CONTENT_ATTR, None))
|
||||
existing = getattr(response, "content", None) or []
|
||||
try:
|
||||
setattr(response, _CONTENT_ATTR, [*native_blocks, *existing_attr_items])
|
||||
response.content = list(native_blocks) + list(existing)
|
||||
except (AttributeError, TypeError):
|
||||
# Object refused write — fall through and leave the response
|
||||
# untouched rather than crash the request.
|
||||
|
|
@ -1321,8 +1293,10 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
kwargs_for_followup: Final = self._prepare_followup_kwargs(kwargs)
|
||||
|
||||
if logging_obj is not None:
|
||||
agentic_params: Final[AgenticLoopParams] = logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
full_model_name = agentic_params.get("model", model)
|
||||
agentic_view: Final[_AgenticLoopParamsView] = {
|
||||
"agentic_loop_params": logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
}
|
||||
full_model_name = agentic_view["agentic_loop_params"].get("model", model)
|
||||
verbose_logger.debug(
|
||||
"WebSearchInterception: Built anthropic request patch [call_id=%s model=%s messages=%d searches=%d]",
|
||||
_call_id,
|
||||
|
|
@ -1718,7 +1692,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
|
||||
@staticmethod
|
||||
def initialize_from_proxy_config(
|
||||
litellm_settings: Mapping[str, object],
|
||||
litellm_settings: dict[str, Any],
|
||||
callback_specific_params: Mapping[str, object],
|
||||
) -> "WebSearchInterceptionLogger":
|
||||
"""
|
||||
|
|
@ -1741,11 +1715,19 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
)
|
||||
"""
|
||||
# Get websearch_interception_params from litellm_settings or callback_specific_params
|
||||
raw_params: Final = (
|
||||
litellm_settings["websearch_interception_params"]
|
||||
if "websearch_interception_params" in litellm_settings
|
||||
else callback_specific_params.get("websearch_interception")
|
||||
)
|
||||
websearch_params: WebSearchInterceptionConfig = {}
|
||||
if "websearch_interception_params" in litellm_settings:
|
||||
settings_view: Final[_WebSearchSettingsView] = {
|
||||
"websearch_interception_params": litellm_settings["websearch_interception_params"]
|
||||
}
|
||||
websearch_params = settings_view["websearch_interception_params"]
|
||||
elif "websearch_interception" in callback_specific_params and isinstance(
|
||||
callback_specific_params["websearch_interception"], dict
|
||||
):
|
||||
websearch_params = cast(
|
||||
WebSearchInterceptionConfig,
|
||||
callback_specific_params["websearch_interception"],
|
||||
)
|
||||
|
||||
# Use classmethod to initialize from config
|
||||
return WebSearchInterceptionLogger.from_config_yaml(_parse_websearch_config(raw_params))
|
||||
return WebSearchInterceptionLogger.from_config_yaml(websearch_params)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ This module has no dependencies on proxy code and can be safely imported at the
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
|
|
@ -71,7 +72,7 @@ def get_litellm_gateway_api_key(
|
|||
return token_data["key"]
|
||||
|
||||
|
||||
def is_cli_token_fresh(token_data: dict, buffer_hours: float = 0.1) -> bool:
|
||||
def is_cli_token_fresh(token_data: Mapping[str, object], buffer_hours: float = 0.1) -> bool:
|
||||
"""Check whether a cached CLI token (as stored in token.json) is still
|
||||
within its expiration window. Used by `lite auth print-token` to fail
|
||||
fast, without a network round trip, once the cached token is past
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Final,
|
||||
TypeAlias,
|
||||
cast,
|
||||
)
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.asyncify import run_async_function
|
||||
|
|
@ -39,6 +40,11 @@ _AnthropicSystem: TypeAlias = "str | list[dict[str, object]] | None"
|
|||
_ContextManagementSpec: TypeAlias = "dict[str, object] | list[dict[str, object]] | None"
|
||||
|
||||
|
||||
class _CompletionKwargs(TypedDict, total=False, extra_items=object):
|
||||
model: str
|
||||
custom_llm_provider: str
|
||||
|
||||
|
||||
def _messages_have_compaction_block(messages: _AnthropicMessages) -> bool:
|
||||
"""Return True when any message carries a ``compaction`` content block."""
|
||||
for msg in messages:
|
||||
|
|
@ -312,7 +318,7 @@ ANTHROPIC_ADAPTER: Final = AnthropicAdapter()
|
|||
class LiteLLMMessagesToCompletionTransformationHandler:
|
||||
@staticmethod
|
||||
def _route_openai_thinking_to_responses_api_if_needed(
|
||||
completion_kwargs: dict[str, Any],
|
||||
completion_kwargs: _CompletionKwargs,
|
||||
*,
|
||||
thinking: Mapping[str, object] | None,
|
||||
) -> None:
|
||||
|
|
@ -377,7 +383,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
|
||||
@staticmethod
|
||||
def _normalize_reasoning_effort(
|
||||
completion_kwargs: dict[str, Any],
|
||||
completion_kwargs: _CompletionKwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Normalize reasoning_effort values based on target model capabilities.
|
||||
|
|
@ -393,7 +399,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
if reasoning_effort is None:
|
||||
return
|
||||
|
||||
model: Final = cast(str, completion_kwargs.get("model", ""))
|
||||
model: Final = completion_kwargs.get("model", "")
|
||||
custom_llm_provider: Final = completion_kwargs.get("custom_llm_provider")
|
||||
|
||||
if isinstance(reasoning_effort, str):
|
||||
|
|
@ -417,19 +423,19 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
max_tokens: int,
|
||||
messages: _AnthropicMessages,
|
||||
model: str,
|
||||
metadata: dict | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
stream: bool | None = False,
|
||||
system: _AnthropicSystem = None,
|
||||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
thinking: dict[str, object] | None = None,
|
||||
tool_choice: dict[str, object] | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
output_format: dict[str, object] | None = None,
|
||||
extra_kwargs: Mapping[str, object] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
) -> tuple[_CompletionKwargs, dict[str, str]]:
|
||||
"""Prepare kwargs for litellm.completion/acompletion.
|
||||
|
||||
Returns:
|
||||
|
|
@ -486,7 +492,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
if openai_request is None:
|
||||
raise ValueError("Failed to translate request to OpenAI format")
|
||||
|
||||
completion_kwargs: Final[dict[str, Any]] = dict(openai_request)
|
||||
completion_kwargs: Final[_CompletionKwargs] = {**openai_request}
|
||||
|
||||
if stream:
|
||||
completion_kwargs["stream"] = stream
|
||||
|
|
@ -538,17 +544,17 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
max_tokens: int,
|
||||
messages: _AnthropicMessages,
|
||||
model: str,
|
||||
metadata: dict | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
stream: bool | None = False,
|
||||
system: str | None = None,
|
||||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
thinking: dict[str, object] | None = None,
|
||||
tool_choice: dict[str, object] | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
output_format: dict[str, object] | None = None,
|
||||
**kwargs,
|
||||
) -> AnthropicMessagesResponse | AsyncIterator[bytes] | Iterator[bytes]:
|
||||
"""Handle non-Anthropic models asynchronously using the adapter"""
|
||||
|
|
@ -625,17 +631,17 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
max_tokens: int,
|
||||
messages: _AnthropicMessages,
|
||||
model: str,
|
||||
metadata: dict | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
stream: bool | None = False,
|
||||
system: str | None = None,
|
||||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
thinking: dict[str, object] | None = None,
|
||||
tool_choice: dict[str, object] | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
output_format: dict[str, object] | None = None,
|
||||
_is_async: bool = False,
|
||||
**kwargs,
|
||||
) -> (
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ tool through a ``tool_use`` content block, and results are fed back as
|
|||
``tool_result`` blocks in a user message.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, Final
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from typing import Any, Final, NamedTuple
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.responses.mcp.request_context import MCPRequestContext
|
||||
|
|
@ -24,14 +24,18 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
|
|||
MAX_MCP_TOOL_USE_ITERATIONS: Final = 10
|
||||
|
||||
|
||||
def _get_response_content(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, Any]]:
|
||||
class _AnthropicMessagesCall(NamedTuple):
|
||||
fn: Callable[..., Awaitable[AnthropicMessagesResponse | Iterator[bytes] | AsyncIterator[object]]]
|
||||
|
||||
|
||||
def _get_response_content(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, object]]:
|
||||
content: Final = response.get("content")
|
||||
if not isinstance(content, list):
|
||||
return ()
|
||||
return tuple(block for block in content if isinstance(block, dict))
|
||||
|
||||
|
||||
def _extract_tool_use_blocks(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, Any]]:
|
||||
def _extract_tool_use_blocks(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, object]]:
|
||||
"""Return the ``tool_use`` content blocks the model emitted."""
|
||||
return tuple(block for block in _get_response_content(response) if block.get("type") == "tool_use")
|
||||
|
||||
|
|
@ -41,7 +45,7 @@ def _get_stop_reason(response: AnthropicMessagesResponse) -> str | None:
|
|||
return stop_reason if isinstance(stop_reason, str) else None
|
||||
|
||||
|
||||
def _build_tool_result_message(tool_results: Sequence[Mapping[str, Any]]) -> AnthropicMessagesUserMessageParam:
|
||||
def _build_tool_result_message(tool_results: Sequence[Mapping[str, object]]) -> AnthropicMessagesUserMessageParam:
|
||||
"""Turn executed tool results into the user message Anthropic expects."""
|
||||
return AnthropicMessagesUserMessageParam(
|
||||
role="user",
|
||||
|
|
@ -58,11 +62,11 @@ def _build_tool_result_message(tool_results: Sequence[Mapping[str, Any]]) -> Ant
|
|||
|
||||
async def anthropic_messages_with_mcp(
|
||||
max_tokens: int,
|
||||
messages: Sequence[Mapping[str, Any]],
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
model: str,
|
||||
tools: Sequence[Mapping[str, Any]] | None = None,
|
||||
tools: Sequence[Mapping[str, object]] | None = None,
|
||||
**kwargs: Any, # kwargs-ok: forwarded verbatim to litellm.anthropic_messages, which owns the param contract
|
||||
) -> AnthropicMessagesResponse | AsyncIterator[Any]:
|
||||
) -> AnthropicMessagesResponse | Iterator[bytes] | AsyncIterator[object]:
|
||||
"""
|
||||
Expand litellm_proxy MCP references for `/v1/messages` and run the tool loop.
|
||||
|
||||
|
|
@ -81,7 +85,7 @@ async def anthropic_messages_with_mcp(
|
|||
mcp_references, other_tools = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools)
|
||||
|
||||
if not mcp_references:
|
||||
return await litellm.anthropic_messages(
|
||||
return await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
|
||||
max_tokens=max_tokens,
|
||||
messages=list(messages),
|
||||
model=model,
|
||||
|
|
@ -114,7 +118,7 @@ async def anthropic_messages_with_mcp(
|
|||
)
|
||||
stream: Final = bool(kwargs.pop("stream", False))
|
||||
|
||||
base_call_args: Final[Mapping[str, Any]] = {
|
||||
base_call_args: Final[Mapping[str, object]] = {
|
||||
"max_tokens": max_tokens,
|
||||
"model": model,
|
||||
"tools": all_tools or None,
|
||||
|
|
@ -123,10 +127,12 @@ async def anthropic_messages_with_mcp(
|
|||
}
|
||||
|
||||
if not should_auto_execute:
|
||||
return await litellm.anthropic_messages(messages=list(messages), stream=stream, **base_call_args)
|
||||
return await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
|
||||
messages=list(messages), stream=stream, **base_call_args
|
||||
)
|
||||
|
||||
working_messages: Sequence[Mapping[str, Any]] = tuple(messages)
|
||||
response: AnthropicMessagesResponse = await litellm.anthropic_messages(
|
||||
working_messages: Sequence[Mapping[str, object]] = tuple(messages)
|
||||
response: AnthropicMessagesResponse = await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
|
||||
messages=list(working_messages), stream=False, **base_call_args
|
||||
)
|
||||
|
||||
|
|
@ -161,7 +167,9 @@ async def anthropic_messages_with_mcp(
|
|||
{"role": "assistant", "content": list(_get_response_content(response))},
|
||||
_build_tool_result_message(tool_results),
|
||||
)
|
||||
response = await litellm.anthropic_messages(messages=list(working_messages), stream=False, **base_call_args)
|
||||
response = await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
|
||||
messages=list(working_messages), stream=False, **base_call_args
|
||||
)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
"MCP tool loop hit its %s iteration cap for model %s; returning the last response",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ from collections.abc import AsyncIterator, Coroutine
|
|||
from typing import Any, Final
|
||||
|
||||
import litellm
|
||||
from litellm.types.llms.anthropic import AnthropicMessagesRequest
|
||||
from litellm.types.llms.anthropic import (
|
||||
AllAnthropicToolsValues,
|
||||
AnthropicMessagesRequest,
|
||||
AnthropicOutputConfig,
|
||||
AnthropicOutputSchema,
|
||||
)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
)
|
||||
|
|
@ -27,24 +32,24 @@ def _build_responses_kwargs(
|
|||
model: str,
|
||||
context_management: dict | None = None,
|
||||
metadata: dict | None = None,
|
||||
output_config: dict | None = None,
|
||||
output_config: AnthropicOutputConfig | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
stream: bool | None = False,
|
||||
system: str | None = None,
|
||||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
tools: list[AllAnthropicToolsValues | dict] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
output_format: AnthropicOutputSchema | None = None,
|
||||
extra_kwargs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the kwargs dict to pass directly to litellm.responses() / litellm.aresponses().
|
||||
"""
|
||||
# Build a typed AnthropicMessagesRequest for the adapter
|
||||
request_data: Final[dict[str, Any]] = {
|
||||
request_data: Final[AnthropicMessagesRequest] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
|
|
@ -128,19 +133,19 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
model: str,
|
||||
context_management: dict | None = None,
|
||||
metadata: dict | None = None,
|
||||
output_config: dict | None = None,
|
||||
output_config: AnthropicOutputConfig | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
stream: bool | None = False,
|
||||
system: str | None = None,
|
||||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
tools: list[AllAnthropicToolsValues | dict] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
output_format: AnthropicOutputSchema | None = None,
|
||||
**kwargs,
|
||||
) -> AnthropicMessagesResponse | AsyncIterator:
|
||||
) -> AnthropicMessagesResponse | AsyncIterator[bytes]:
|
||||
responses_kwargs: Final = _build_responses_kwargs(
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
|
|
@ -179,23 +184,23 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
model: str,
|
||||
context_management: dict | None = None,
|
||||
metadata: dict | None = None,
|
||||
output_config: dict | None = None,
|
||||
output_config: AnthropicOutputConfig | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
stream: bool | None = False,
|
||||
system: str | None = None,
|
||||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
tools: list[AllAnthropicToolsValues | dict] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
output_format: AnthropicOutputSchema | None = None,
|
||||
_is_async: bool = False,
|
||||
**kwargs,
|
||||
) -> (
|
||||
AnthropicMessagesResponse
|
||||
| AsyncIterator[Any]
|
||||
| Coroutine[Any, Any, AnthropicMessagesResponse | AsyncIterator[Any]]
|
||||
| AsyncIterator[bytes]
|
||||
| Coroutine[None, None, AnthropicMessagesResponse | AsyncIterator[bytes]]
|
||||
):
|
||||
if _is_async:
|
||||
return LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from collections.abc import Mapping, Sequence
|
|||
from typing import Any, Final, NamedTuple, Optional, Protocol, Union, runtime_checkable
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.shared.context import RequestContext
|
||||
|
|
@ -28,8 +30,9 @@ if typing.TYPE_CHECKING:
|
|||
ToolUseContent,
|
||||
)
|
||||
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -1016,7 +1019,7 @@ async def _run_budget_checks(
|
|||
general_settings=general_settings or {},
|
||||
route="/chat/completions",
|
||||
llm_router=_llm_router,
|
||||
proxy_logging_obj=typing.cast("ProxyLogging", _proxy_logging_obj),
|
||||
proxy_logging_obj=_proxy_logging_obj,
|
||||
valid_token=user_api_key_auth,
|
||||
request=dummy_request,
|
||||
)
|
||||
|
|
@ -1176,15 +1179,19 @@ async def _build_completion_kwargs(
|
|||
)
|
||||
|
||||
|
||||
class _AcompletionCall(NamedTuple):
|
||||
fn: "Callable[..., Awaitable[ModelResponse | CustomStreamWrapper]]"
|
||||
|
||||
|
||||
async def _run_guardrails_and_call_llm(
|
||||
completion_kwargs: dict[str, Any],
|
||||
completion_kwargs: dict[str, object],
|
||||
user_api_key_auth: "UserAPIKeyAuth",
|
||||
) -> Any:
|
||||
try:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj as _plo
|
||||
|
||||
if _plo is not None:
|
||||
completion_kwargs = await typing.cast("ProxyLogging", _plo).pre_call_hook(
|
||||
completion_kwargs = await _plo.pre_call_hook(
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
data=completion_kwargs,
|
||||
call_type="acompletion",
|
||||
|
|
@ -1204,10 +1211,10 @@ async def _run_guardrails_and_call_llm(
|
|||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is not None:
|
||||
return await llm_router.acompletion(**completion_kwargs)
|
||||
return await litellm.acompletion(**completion_kwargs)
|
||||
return await _AcompletionCall(fn=llm_router.acompletion).fn(**completion_kwargs)
|
||||
return await _AcompletionCall(fn=litellm.acompletion).fn(**completion_kwargs)
|
||||
except ImportError:
|
||||
return await litellm.acompletion(**completion_kwargs)
|
||||
return await _AcompletionCall(fn=litellm.acompletion).fn(**completion_kwargs)
|
||||
|
||||
|
||||
async def handle_sampling_create_message(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ The A2A SDK can point to LiteLLM's URL and invoke agents registered with LiteLLM
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -36,7 +36,7 @@ from litellm.proxy.agent_endpoints.databricks_oauth import (
|
|||
)
|
||||
from litellm.proxy.agent_endpoints.utils import merge_agent_headers
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
from litellm.proxy.utils import ProxyLogging, get_custom_url
|
||||
from litellm.types.utils import all_litellm_params
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -46,7 +46,7 @@ if TYPE_CHECKING:
|
|||
|
||||
router: Final = APIRouter()
|
||||
|
||||
_PASCAL_TO_WIRE: Final[dict[str, str]] = {
|
||||
_PASCAL_TO_WIRE: Final[Mapping[str, str]] = {
|
||||
"SendMessage": "message/send",
|
||||
"SendStreamingMessage": "message/stream",
|
||||
"GetTask": "tasks/get",
|
||||
|
|
@ -118,9 +118,9 @@ def _caller_identity_headers(user_api_key_dict: UserAPIKeyAuth) -> dict[str, str
|
|||
|
||||
def _forwarding_headers(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_data: dict[str, Any],
|
||||
agent_extra_headers: dict[str, str] | None,
|
||||
) -> dict[str, str] | None:
|
||||
request_data: Mapping[str, object],
|
||||
agent_extra_headers: Mapping[str, str] | None,
|
||||
) -> Mapping[str, str] | None:
|
||||
sanitized: Final = (
|
||||
{k: v for k, v in agent_extra_headers.items() if not k.lower().startswith("x-litellm-")}
|
||||
if agent_extra_headers
|
||||
|
|
@ -136,7 +136,7 @@ def _forwarding_headers(
|
|||
|
||||
|
||||
def _jsonrpc_error(
|
||||
request_id: Any | None,
|
||||
request_id: object,
|
||||
code: int,
|
||||
message: str,
|
||||
status_code: int = 400,
|
||||
|
|
@ -162,7 +162,7 @@ def _get_agent(agent_id: str):
|
|||
return agent
|
||||
|
||||
|
||||
def _enforce_inbound_trace_id(agent: Any, request: Request) -> None:
|
||||
def _enforce_inbound_trace_id(agent: "AgentResponse", request: Request) -> None:
|
||||
"""Raise 400 if agent requires x-litellm-trace-id on inbound calls and it is missing."""
|
||||
agent_litellm_params: Final = agent.litellm_params or {}
|
||||
if not agent_litellm_params.get("require_trace_id_on_calls_to_agent"):
|
||||
|
|
@ -181,8 +181,8 @@ def _enforce_inbound_trace_id(agent: Any, request: Request) -> None:
|
|||
|
||||
async def _forward_jsonrpc(
|
||||
agent_url: str,
|
||||
body: dict[str, Any],
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
body: dict[str, object],
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
|
@ -205,11 +205,11 @@ async def _forward_jsonrpc(
|
|||
|
||||
async def _a2a_sse_event_source(
|
||||
agent_url: str,
|
||||
body: dict[str, Any],
|
||||
request_id: Any | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
body: Mapping[str, object],
|
||||
request_id: str | int | None = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
served_version: A2AVersion = "0.3",
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
) -> AsyncGenerator[Mapping[str, object], None]:
|
||||
"""Stream an upstream A2A SSE response as parsed JSON-RPC event dicts.
|
||||
|
||||
Upstream HTTP/JSON-RPC errors are surfaced as a single JSON-RPC error event
|
||||
|
|
@ -234,7 +234,7 @@ async def _a2a_sse_event_source(
|
|||
try:
|
||||
if not resp.is_success:
|
||||
error_body: Final = await resp.aread()
|
||||
error_event: dict[str, Any] | None = None
|
||||
error_event: Mapping[str, object] | None = None
|
||||
try:
|
||||
parsed: Final = json.loads(error_body)
|
||||
if isinstance(parsed, dict) and "error" in parsed:
|
||||
|
|
@ -267,12 +267,12 @@ async def _a2a_sse_event_source(
|
|||
|
||||
async def _forward_jsonrpc_sse(
|
||||
agent_url: str,
|
||||
body: dict[str, Any],
|
||||
request_id: Any | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
proxy_logging_obj: Any | None = None,
|
||||
user_api_key_dict: Any | None = None,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
body: Mapping[str, object],
|
||||
request_id: str | int | None = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
proxy_logging_obj: ProxyLogging | None = None,
|
||||
user_api_key_dict: UserAPIKeyAuth | None = None,
|
||||
request_data: dict[str, object] | None = None,
|
||||
served_version: A2AVersion = "0.3",
|
||||
) -> StreamingResponse:
|
||||
event_source: Final = _a2a_sse_event_source(
|
||||
|
|
@ -283,10 +283,10 @@ async def _forward_jsonrpc_sse(
|
|||
served_version=served_version,
|
||||
)
|
||||
|
||||
def _serialize_chunk(chunk: Any) -> str:
|
||||
def _serialize_chunk(chunk: object) -> str:
|
||||
return f"data: {json.dumps(chunk)}\n\n"
|
||||
|
||||
def _serialize_error(proxy_exc: Any) -> str:
|
||||
def _serialize_error(proxy_exc: object) -> str:
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
|
|
@ -331,17 +331,17 @@ async def _forward_jsonrpc_sse(
|
|||
|
||||
async def _handle_stream_message(
|
||||
api_base: str | None,
|
||||
request_id: Any,
|
||||
params: dict[str, Any],
|
||||
litellm_params: dict[str, Any] | None = None,
|
||||
request_id: str | int,
|
||||
params: dict[str, object],
|
||||
litellm_params: dict[str, object] | None = None,
|
||||
agent_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
proxy_server_request: dict[str, Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
proxy_server_request: dict[str, object] | None = None,
|
||||
*,
|
||||
agent_extra_headers: dict[str, str] | None = None,
|
||||
user_api_key_dict: UserAPIKeyAuth | None = None,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
proxy_logging_obj: Any | None = None,
|
||||
request_data: dict[str, object] | None = None,
|
||||
proxy_logging_obj: ProxyLogging | None = None,
|
||||
served_version: A2AVersion = "0.3",
|
||||
) -> StreamingResponse:
|
||||
"""Handle message/stream method via SDK functions.
|
||||
|
|
@ -430,7 +430,7 @@ async def _handle_stream_message(
|
|||
obj = normalize_stream_event(obj, served_version, request_id=request_id)
|
||||
return json.dumps(obj) + "\n"
|
||||
|
||||
def _ndjson_error(proxy_exc: Any) -> str:
|
||||
def _ndjson_error(proxy_exc: object) -> str:
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
|
|
@ -669,7 +669,7 @@ async def invoke_agent_a2a(
|
|||
agent_name: Final = agent_card_params.get("name", agent_id)
|
||||
|
||||
# Get litellm_params (may include custom_llm_provider for completion bridge)
|
||||
litellm_params = agent.litellm_params or {}
|
||||
litellm_params: dict[str, object] = agent.litellm_params or {}
|
||||
custom_llm_provider: Final = litellm_params.get("custom_llm_provider")
|
||||
|
||||
# Hand the authenticated key hash to the completion bridge so provider
|
||||
|
|
@ -725,7 +725,7 @@ async def invoke_agent_a2a(
|
|||
request_data = data
|
||||
|
||||
# Build merged headers for the backend agent
|
||||
static_headers: Final[dict[str, str]] = dict(agent.static_headers or {})
|
||||
static_headers: Final[Mapping[str, str]] = dict(agent.static_headers or {})
|
||||
|
||||
raw_headers: Final = dict(request.headers)
|
||||
normalized: Final = {k.lower(): v for k, v in raw_headers.items()}
|
||||
|
|
@ -893,7 +893,7 @@ async def invoke_agent_a2a(
|
|||
detail="Push notification URL must be a string",
|
||||
)
|
||||
_validate_push_notification_url(callback_url)
|
||||
forward_body = {
|
||||
forward_body: dict[str, object] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": method,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import click
|
|||
import requests
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
||||
from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
|
||||
|
|
@ -18,6 +19,57 @@ from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
|
|||
from .private_json import write_private_json
|
||||
|
||||
|
||||
class CliTokenData(TypedDict):
|
||||
base_url: str
|
||||
key: str
|
||||
user_id: str
|
||||
user_email: str
|
||||
user_role: str
|
||||
auth_header_name: str
|
||||
jwt_token: str
|
||||
timestamp: float
|
||||
|
||||
|
||||
class CliTeam(TypedDict, total=False):
|
||||
team_id: str | None
|
||||
team_alias: str | None
|
||||
models: list[str]
|
||||
max_budget: float | None
|
||||
|
||||
|
||||
class CliContextObj(TypedDict):
|
||||
base_url: str
|
||||
base_url_explicit: NotRequired[bool]
|
||||
|
||||
|
||||
class CliPollData(TypedDict, total=False):
|
||||
status: str
|
||||
key: str
|
||||
user_id: str
|
||||
teams: list[str]
|
||||
team_details: object
|
||||
requires_team_selection: bool
|
||||
team_id: str
|
||||
|
||||
|
||||
class CliPollRequestKwargs(TypedDict, total=False):
|
||||
timeout: int
|
||||
headers: dict[str, str]
|
||||
|
||||
|
||||
class CliSsoStartData(TypedDict):
|
||||
login_id: str
|
||||
poll_secret: str
|
||||
user_code: str
|
||||
|
||||
|
||||
class CliAuthResult(TypedDict):
|
||||
api_key: str
|
||||
user_id: str | None
|
||||
teams: list[str]
|
||||
team_id: str | None
|
||||
|
||||
|
||||
# Token storage utilities
|
||||
def get_token_file_path() -> str:
|
||||
"""Get the path to store the authentication token"""
|
||||
|
|
@ -27,12 +79,12 @@ def get_token_file_path() -> str:
|
|||
return str(config_dir / "token.json")
|
||||
|
||||
|
||||
def save_token(token_data: dict[str, Any]) -> None:
|
||||
def save_token(token_data: CliTokenData) -> None:
|
||||
"""Save token data to file"""
|
||||
write_private_json(get_token_file_path(), token_data)
|
||||
|
||||
|
||||
def load_token() -> dict[str, Any] | None:
|
||||
def load_token() -> CliTokenData | None:
|
||||
"""Load token data from file"""
|
||||
token_file: Final = get_token_file_path()
|
||||
if not os.path.exists(token_file):
|
||||
|
|
@ -65,7 +117,7 @@ def get_stored_api_key(expected_base_url: str | None = None) -> str | None:
|
|||
|
||||
|
||||
# Team selection utilities
|
||||
def display_teams_table(teams: list[dict[str, Any]]) -> None:
|
||||
def display_teams_table(teams: list[CliTeam]) -> None:
|
||||
"""Display teams in a formatted table"""
|
||||
console: Final = Console()
|
||||
|
||||
|
|
@ -165,7 +217,7 @@ def display_interactive_team_selection(teams: list[dict[str, Any]], selected_ind
|
|||
for i, team in enumerate(teams):
|
||||
team_alias = team.get("team_alias") or "N/A"
|
||||
team_id = team.get("team_id", "N/A")
|
||||
models = team.get("models", [])
|
||||
models: list[str] = team.get("models", [])
|
||||
max_budget = team.get("max_budget")
|
||||
|
||||
# Format models list
|
||||
|
|
@ -249,10 +301,11 @@ def prompt_team_selection_fallback(
|
|||
|
||||
while True:
|
||||
try:
|
||||
choice = click.prompt(
|
||||
prompt_response: str = click.prompt(
|
||||
"\nSelect a team by entering the index number (or 'skip' to continue without a team)",
|
||||
type=str,
|
||||
).strip()
|
||||
)
|
||||
choice = prompt_response.strip()
|
||||
|
||||
if choice.lower() == "skip":
|
||||
return None
|
||||
|
|
@ -275,7 +328,7 @@ def prompt_team_selection_fallback(
|
|||
|
||||
def _response_error_detail(response: requests.Response) -> str | None:
|
||||
try:
|
||||
body: Final = response.json()
|
||||
body: Final[dict[str, object] | list[object] | str | int | float | bool | None] = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail: Final = body.get("detail") if isinstance(body, dict) else None
|
||||
|
|
@ -309,15 +362,15 @@ def _poll_for_ready_data(
|
|||
other_status_log_every: int = 10,
|
||||
http_error_log_every: int = 10,
|
||||
connection_error_log_every: int = 10,
|
||||
) -> dict[str, Any] | None:
|
||||
) -> CliPollData | None:
|
||||
for attempt in range(total_timeout // poll_interval):
|
||||
try:
|
||||
request_kwargs: dict[str, Any] = {"timeout": request_timeout}
|
||||
request_kwargs: CliPollRequestKwargs = {"timeout": request_timeout}
|
||||
if headers is not None:
|
||||
request_kwargs["headers"] = headers
|
||||
response = requests.get(url, **request_kwargs)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
data: CliPollData = response.json()
|
||||
status = data.get("status")
|
||||
if status == "ready":
|
||||
return data
|
||||
|
|
@ -341,7 +394,7 @@ def _poll_for_ready_data(
|
|||
return None
|
||||
|
||||
|
||||
def _normalize_teams(teams, team_details):
|
||||
def _normalize_teams(teams: object, team_details: object) -> list[CliTeam]:
|
||||
"""If team_details are a
|
||||
|
||||
Args:
|
||||
|
|
@ -365,7 +418,7 @@ def _normalize_teams(teams, team_details):
|
|||
return []
|
||||
|
||||
|
||||
def _start_cli_sso_flow(base_url: str) -> dict[str, Any]:
|
||||
def _start_cli_sso_flow(base_url: str) -> CliSsoStartData:
|
||||
start_url: Final = f"{base_url}/sso/cli/start"
|
||||
try:
|
||||
response: Final = requests.post(start_url, timeout=10)
|
||||
|
|
@ -389,7 +442,7 @@ def _start_cli_sso_flow(base_url: str) -> dict[str, Any]:
|
|||
)
|
||||
|
||||
try:
|
||||
data: Final = response.json()
|
||||
data: Final[CliSsoStartData] = response.json()
|
||||
except ValueError:
|
||||
content_type: Final = response.headers.get("content-type", "unknown")
|
||||
raise ValueError(
|
||||
|
|
@ -398,7 +451,7 @@ def _start_cli_sso_flow(base_url: str) -> dict[str, Any]:
|
|||
f"Response starts with: {response.text[:200]!r}"
|
||||
)
|
||||
|
||||
required_fields: Final = ("login_id", "poll_secret", "user_code")
|
||||
required_fields: Final[tuple[str, ...]] = ("login_id", "poll_secret", "user_code")
|
||||
missing_fields: Final = tuple(field for field in required_fields if not isinstance(data.get(field), str))
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
|
|
@ -412,7 +465,7 @@ def _get_cli_sso_poll_headers(poll_secret: str) -> dict[str, str]:
|
|||
return {"x-litellm-cli-poll-secret": poll_secret}
|
||||
|
||||
|
||||
def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> dict | None:
|
||||
def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> CliAuthResult | None:
|
||||
"""
|
||||
Poll the server for authentication completion and handle team selection.
|
||||
|
||||
|
|
@ -431,7 +484,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> di
|
|||
teams = data.get("teams", [])
|
||||
team_details: Final = data.get("team_details")
|
||||
user_id = data.get("user_id")
|
||||
normalized_teams: Final[list[dict[str, Any]]] = _normalize_teams(teams, team_details)
|
||||
normalized_teams: Final[list[CliTeam]] = _normalize_teams(teams, team_details)
|
||||
if not normalized_teams:
|
||||
click.echo("Warning: No teams available for selection.")
|
||||
return None
|
||||
|
|
@ -478,7 +531,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> di
|
|||
|
||||
|
||||
def _handle_team_selection_during_polling(
|
||||
base_url: str, key_id: str, poll_secret: str, teams: list[dict[str, Any]]
|
||||
base_url: str, key_id: str, poll_secret: str, teams: list[CliTeam]
|
||||
) -> str | None:
|
||||
"""
|
||||
Handle team selection and re-poll with selected team_id.
|
||||
|
|
@ -522,7 +575,7 @@ def _handle_team_selection_during_polling(
|
|||
return None
|
||||
|
||||
|
||||
def _render_and_prompt_for_team_selection(teams: list[dict[str, Any]]) -> str | None:
|
||||
def _render_and_prompt_for_team_selection(teams: list[CliTeam]) -> str | None:
|
||||
"""Render teams table and prompt user for a team selection.
|
||||
|
||||
Returns the selected team_id as a string, or None if selection was
|
||||
|
|
@ -546,10 +599,11 @@ def _render_and_prompt_for_team_selection(teams: list[dict[str, Any]]) -> str |
|
|||
# Simple selection
|
||||
while True:
|
||||
try:
|
||||
choice = click.prompt(
|
||||
prompt_response: str = click.prompt(
|
||||
"\nSelect a team by entering the index number (or 'skip' to use first team)",
|
||||
type=str,
|
||||
).strip()
|
||||
)
|
||||
choice = prompt_response.strip()
|
||||
|
||||
if choice.lower() == "skip":
|
||||
# Default to the first team's ID if the user skips an
|
||||
|
|
@ -582,7 +636,8 @@ def login(ctx: click.Context):
|
|||
from litellm.constants import LITELLM_CLI_SOURCE_IDENTIFIER
|
||||
from litellm.proxy.client.cli.interface import show_commands
|
||||
|
||||
base_url: Final = ctx.obj["base_url"]
|
||||
ctx_obj: Final[CliContextObj] = ctx.obj
|
||||
base_url: Final = ctx_obj["base_url"]
|
||||
|
||||
try:
|
||||
cli_sso_flow: Final = _start_cli_sso_flow(base_url=base_url)
|
||||
|
|
@ -675,8 +730,9 @@ def print_token(ctx: click.Context):
|
|||
# explicitly pointed us at a server, trust whichever one `lite login`
|
||||
# actually issued this token for -- that's the whole point of not
|
||||
# needing a wrapper command.
|
||||
if ctx.obj.get("base_url_explicit"):
|
||||
base_url: Final = ctx.obj["base_url"]
|
||||
ctx_obj: Final[CliContextObj] = ctx.obj
|
||||
if ctx_obj.get("base_url_explicit"):
|
||||
base_url: Final = ctx_obj["base_url"]
|
||||
if token_data.get("base_url") != base_url.rstrip("/"):
|
||||
click.echo("Not authenticated for this server. Run 'lite login'.", err=True)
|
||||
sys.exit(1)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, TypeVar
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -311,14 +311,17 @@ def _coerce_timeout(value: Any, fallback: float) -> float:
|
|||
return fallback
|
||||
|
||||
|
||||
_ReadResultT: Final = TypeVar("_ReadResultT")
|
||||
|
||||
|
||||
async def call_with_db_reconnect_retry(
|
||||
prisma_client: Any,
|
||||
coro_factory: Callable[[], Awaitable[Any]],
|
||||
coro_factory: Callable[[], Awaitable[_ReadResultT]],
|
||||
*,
|
||||
reason: str,
|
||||
timeout_seconds: float | None = None,
|
||||
lock_timeout_seconds: float | None = None,
|
||||
) -> Any:
|
||||
) -> _ReadResultT:
|
||||
"""Run a Prisma read coroutine with one transport-reconnect-and-retry.
|
||||
|
||||
The canonical "self-heal a transient DB transport blip" wrapper used by
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Coroutine, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypedDict, cast
|
||||
|
||||
import yaml
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -28,6 +28,7 @@ from litellm.types.utils import (
|
|||
GenericGuardrailAPIInputs,
|
||||
GuardrailStatus,
|
||||
GuardrailTracingDetail,
|
||||
ModelResponse,
|
||||
ModelResponseStream,
|
||||
)
|
||||
|
||||
|
|
@ -83,6 +84,46 @@ WORD_NUMBER_SEQUENCE_PATTERN: Final = re.compile(
|
|||
WORD_NUMBER_TOKEN_FINDER: Final = re.compile(rf"(?:{WORD_NUMBER_TOKEN_REGEX})", re.IGNORECASE)
|
||||
|
||||
|
||||
class ConditionalCategoryConfig(TypedDict):
|
||||
identifier_words: Sequence[str]
|
||||
block_words: Sequence[str]
|
||||
action: ContentFilterAction
|
||||
severity: str
|
||||
|
||||
|
||||
class CompiledPatternEntry(TypedDict):
|
||||
regex: Pattern[str]
|
||||
pattern_name: str
|
||||
action: ContentFilterAction
|
||||
keyword_regex: Pattern[str] | None
|
||||
allow_word_numbers: bool
|
||||
|
||||
|
||||
class _PatternExtraLookup(TypedDict):
|
||||
keyword_pattern: str | None
|
||||
allow_word_numbers: bool
|
||||
|
||||
|
||||
class _CategoryConfigView(TypedDict):
|
||||
category: object
|
||||
enabled: object
|
||||
action: object
|
||||
category_file: str | None
|
||||
|
||||
|
||||
class CategoryFileData(TypedDict, total=False):
|
||||
category_name: str
|
||||
description: str
|
||||
default_action: str
|
||||
keywords: Sequence[Mapping[str, str]]
|
||||
exceptions: Sequence[str]
|
||||
identifier_words: Sequence[str]
|
||||
always_block_keywords: Sequence[Mapping[str, str]]
|
||||
inherit_from: str
|
||||
additional_block_words: Sequence[str]
|
||||
phrase_patterns: Sequence[str]
|
||||
|
||||
|
||||
# Helper data structure for category-based detection
|
||||
class CategoryConfig:
|
||||
"""Configuration for a content category."""
|
||||
|
|
@ -92,13 +133,13 @@ class CategoryConfig:
|
|||
category_name: str,
|
||||
description: str,
|
||||
default_action: ContentFilterAction,
|
||||
keywords: list[dict[str, str]],
|
||||
exceptions: list[str],
|
||||
identifier_words: list[str] | None = None,
|
||||
always_block_keywords: list[dict[str, str]] | None = None,
|
||||
keywords: Sequence[Mapping[str, str]],
|
||||
exceptions: Sequence[str],
|
||||
identifier_words: Sequence[str] | None = None,
|
||||
always_block_keywords: Sequence[Mapping[str, str]] | None = None,
|
||||
inherit_from: str | None = None,
|
||||
additional_block_words: list[str] | None = None,
|
||||
phrase_patterns: list[str] | None = None,
|
||||
additional_block_words: Sequence[str] | None = None,
|
||||
phrase_patterns: Sequence[str] | None = None,
|
||||
):
|
||||
self.category_name = category_name
|
||||
self.description = description
|
||||
|
|
@ -151,7 +192,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
severity_threshold: str = "medium",
|
||||
llm_router: Router | None = None,
|
||||
image_model: str | None = None,
|
||||
competitor_intent_config: dict[str, Any] | None = None,
|
||||
competitor_intent_config: dict[str, object] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
|
|
@ -194,9 +235,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
# Always-block keywords are checked after exceptions (exceptions take precedence)
|
||||
self.always_block_category_keywords: dict[str, tuple[str, str, ContentFilterAction]] = {}
|
||||
# Store conditional categories (identifier_words + block_words)
|
||||
self.conditional_categories: dict[
|
||||
str, dict[str, Any]
|
||||
] = {} # category_name -> {identifier_words, block_words, action, severity}
|
||||
self.conditional_categories: dict[str, ConditionalCategoryConfig] = {}
|
||||
|
||||
# Competitor intent checker (optional; airline uses major_airlines.json, generic requires competitors)
|
||||
self._competitor_intent_checker: BaseCompetitorIntentChecker | None = None
|
||||
|
|
@ -212,7 +251,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
normalized_blocked_words: Final = self._normalize_blocked_words(blocked_words)
|
||||
|
||||
# Compile regex patterns
|
||||
self.compiled_patterns: list[dict[str, Any]] = []
|
||||
self.compiled_patterns: list[CompiledPatternEntry] = []
|
||||
for pattern_config in normalized_patterns:
|
||||
self._add_pattern(pattern_config)
|
||||
|
||||
|
|
@ -250,7 +289,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
"Loaded %s categories with %s keywords", len(self.loaded_categories), len(self.category_keywords)
|
||||
)
|
||||
|
||||
def _init_competitor_intent_checker(self, competitor_intent_config: dict[str, Any]) -> None:
|
||||
def _init_competitor_intent_checker(self, competitor_intent_config: dict[str, object]) -> None:
|
||||
try:
|
||||
competitor_intent_type: Final = competitor_intent_config.get("competitor_intent_type", "airline")
|
||||
if competitor_intent_type == "generic":
|
||||
|
|
@ -293,6 +332,15 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
result.append(word)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _category_config_view(cat_config: ContentFilterCategoryConfig) -> _CategoryConfigView:
|
||||
return {
|
||||
"category": cat_config.get("category"),
|
||||
"enabled": cat_config.get("enabled", True),
|
||||
"action": cat_config.get("action"),
|
||||
"category_file": cat_config.get("category_file"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _assert_within_categories_dir(path: str, categories_dir: str) -> None:
|
||||
"""Raise ValueError if path escapes the categories directory."""
|
||||
|
|
@ -395,7 +443,8 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
categories_dir: Final = os.path.join(os.path.dirname(__file__), "categories")
|
||||
|
||||
for cat_config in categories:
|
||||
category_name = cat_config.get("category")
|
||||
view = self._category_config_view(cat_config)
|
||||
category_name = view["category"]
|
||||
if not category_name or not isinstance(category_name, str):
|
||||
verbose_proxy_logger.warning("Category name missing or invalid in config, skipping")
|
||||
continue
|
||||
|
|
@ -405,12 +454,12 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
verbose_proxy_logger.warning("Category name '%s' contains invalid characters, skipping", category_name)
|
||||
continue
|
||||
|
||||
enabled = cat_config.get("enabled", True)
|
||||
action = cat_config.get("action")
|
||||
enabled = view["enabled"]
|
||||
action = view["action"]
|
||||
severity_threshold = (
|
||||
cat_config.get("severity_threshold", self.severity_threshold) or self.severity_threshold
|
||||
)
|
||||
custom_file = cat_config.get("category_file")
|
||||
custom_file = view["category_file"]
|
||||
|
||||
if not enabled:
|
||||
verbose_proxy_logger.debug("Category %s is disabled, skipping", category_name)
|
||||
|
|
@ -514,7 +563,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
categories_dir: Directory containing category files
|
||||
"""
|
||||
try:
|
||||
block_words: Final = []
|
||||
block_words: Final[list[str]] = []
|
||||
inherit_from = category_config_obj.inherit_from
|
||||
|
||||
# Load inherited block words if specified
|
||||
|
|
@ -605,11 +654,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
"""
|
||||
if file_path.lower().endswith(".json"):
|
||||
return self._load_category_file_json(file_path)
|
||||
with open(file_path, "r") as f:
|
||||
data: Final = yaml.safe_load(f)
|
||||
|
||||
# Handle always_block_keywords if present
|
||||
always_block: Final = data.get("always_block_keywords", [])
|
||||
data: Final = self._read_category_yaml(file_path)
|
||||
|
||||
return CategoryConfig(
|
||||
category_name=data.get("category_name", "unknown"),
|
||||
|
|
@ -618,12 +663,17 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
keywords=data.get("keywords", []),
|
||||
exceptions=data.get("exceptions", []),
|
||||
identifier_words=data.get("identifier_words"),
|
||||
always_block_keywords=always_block,
|
||||
always_block_keywords=data.get("always_block_keywords", []),
|
||||
inherit_from=data.get("inherit_from"),
|
||||
additional_block_words=data.get("additional_block_words"),
|
||||
phrase_patterns=data.get("phrase_patterns"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_category_yaml(file_path: str) -> CategoryFileData:
|
||||
with open(file_path, "r") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def _load_category_file_json(self, file_path: str) -> CategoryConfig:
|
||||
"""
|
||||
Load a category from the harm_toxic_abuse-style JSON format.
|
||||
|
|
@ -682,13 +732,13 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
pattern_config: ContentFilterPattern configuration
|
||||
"""
|
||||
try:
|
||||
extra_config: dict[str, Any] = {}
|
||||
extra_config: _PatternExtraLookup = {"keyword_pattern": None, "allow_word_numbers": False}
|
||||
if pattern_config.pattern_type == "prebuilt":
|
||||
if not pattern_config.pattern_name:
|
||||
raise ValueError("pattern_name is required for prebuilt patterns")
|
||||
compiled = get_compiled_pattern(pattern_config.pattern_name)
|
||||
pattern_name = pattern_config.pattern_name
|
||||
extra_config = PATTERN_EXTRA_CONFIG.get(pattern_name, {}) or {}
|
||||
extra_config = self._lookup_pattern_extra(pattern_name)
|
||||
elif pattern_config.pattern_type == "regex":
|
||||
if not pattern_config.pattern:
|
||||
raise ValueError("pattern is required for regex patterns")
|
||||
|
|
@ -697,9 +747,8 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
else:
|
||||
raise ValueError(f"Unknown pattern_type: {pattern_config.pattern_type}")
|
||||
|
||||
keyword_regex: Pattern | None = None
|
||||
if extra_config.get("keyword_pattern"):
|
||||
keyword_regex = re.compile(extra_config["keyword_pattern"], re.IGNORECASE)
|
||||
keyword_pattern: Final = extra_config["keyword_pattern"]
|
||||
keyword_regex: Final = re.compile(keyword_pattern, re.IGNORECASE) if keyword_pattern else None
|
||||
|
||||
self.compiled_patterns.append(
|
||||
{
|
||||
|
|
@ -707,7 +756,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
"pattern_name": pattern_name,
|
||||
"action": pattern_config.action,
|
||||
"keyword_regex": keyword_regex,
|
||||
"allow_word_numbers": bool(extra_config.get("allow_word_numbers")),
|
||||
"allow_word_numbers": extra_config["allow_word_numbers"],
|
||||
}
|
||||
)
|
||||
verbose_proxy_logger.debug("Added pattern: %s with action %s", pattern_name, pattern_config.action)
|
||||
|
|
@ -715,6 +764,14 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
verbose_proxy_logger.error("Error adding pattern %s: %s", pattern_config, e)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _lookup_pattern_extra(pattern_name: str) -> _PatternExtraLookup:
|
||||
extra: Final = PATTERN_EXTRA_CONFIG.get(pattern_name)
|
||||
return {
|
||||
"keyword_pattern": extra.get("keyword_pattern") if extra is not None else None,
|
||||
"allow_word_numbers": bool(extra.get("allow_word_numbers")) if extra is not None else False,
|
||||
}
|
||||
|
||||
def _load_blocked_words_file(self, file_path: str) -> None:
|
||||
"""
|
||||
Load blocked words from a YAML file.
|
||||
|
|
@ -754,18 +811,16 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
except Exception as e:
|
||||
raise Exception(f"Error loading blocked words file {file_path}: {e}")
|
||||
|
||||
def _find_pattern_spans(self, text: str, pattern_entry: dict[str, Any]) -> list[tuple[int, int]]:
|
||||
def _find_pattern_spans(self, text: str, pattern_entry: CompiledPatternEntry) -> list[tuple[int, int]]:
|
||||
"""Return all match spans for a pattern, applying contextual rules if required."""
|
||||
|
||||
regex: Final[Pattern] = pattern_entry["regex"]
|
||||
keyword_regex: Final[Pattern | None] = pattern_entry.get("keyword_regex")
|
||||
regex: Final[Pattern[str]] = pattern_entry["regex"]
|
||||
keyword_regex: Final[Pattern[str] | None] = pattern_entry.get("keyword_regex")
|
||||
allow_word_numbers: Final[bool] = pattern_entry.get("allow_word_numbers", False)
|
||||
|
||||
keyword_matches: list[re.Match] | None = None
|
||||
if keyword_regex is not None:
|
||||
keyword_matches = list(keyword_regex.finditer(text))
|
||||
if not keyword_matches:
|
||||
return []
|
||||
keyword_matches: Final = list(keyword_regex.finditer(text)) if keyword_regex is not None else None
|
||||
if keyword_matches is not None and not keyword_matches:
|
||||
return []
|
||||
|
||||
match_spans: Final[list[tuple[int, int]]] = []
|
||||
|
||||
|
|
@ -795,7 +850,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
self,
|
||||
value_start: int,
|
||||
value_end: int,
|
||||
keyword_matches: list[re.Match],
|
||||
keyword_matches: Sequence[re.Match[str]],
|
||||
text: str,
|
||||
) -> bool:
|
||||
"""Check if a value is separated from a keyword by an allowed gap."""
|
||||
|
|
@ -861,7 +916,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
def _convert_word_number_sequence(self, sequence: str) -> str | None:
|
||||
"""Convert a spelled-out digit sequence (e.g., 'One-Two') into digits."""
|
||||
|
||||
tokens: Final = WORD_NUMBER_TOKEN_FINDER.findall(sequence)
|
||||
tokens: Final[list[str]] = WORD_NUMBER_TOKEN_FINDER.findall(sequence)
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
|
|
@ -1328,7 +1383,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
HTTPException: If sensitive content is detected and action is BLOCK
|
||||
"""
|
||||
# Collect all exceptions from loaded categories
|
||||
all_exceptions: Final = []
|
||||
all_exceptions: Final[list[str]] = []
|
||||
for category in self.loaded_categories.values():
|
||||
all_exceptions.extend(category.exceptions)
|
||||
|
||||
|
|
@ -1404,7 +1459,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
if not (images and self.image_model and self.llm_router):
|
||||
return
|
||||
|
||||
tasks: Final = []
|
||||
tasks: Final[list[Coroutine[object, object, ModelResponse]]] = []
|
||||
for image in images:
|
||||
task = self.llm_router.acompletion(
|
||||
model=self.image_model,
|
||||
|
|
@ -1425,12 +1480,10 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
tasks.append(task)
|
||||
|
||||
responses: Final = await asyncio.gather(*tasks)
|
||||
descriptions: Final = []
|
||||
descriptions: Final[list[str]] = []
|
||||
for response in responses:
|
||||
choice = response.choices[0]
|
||||
message = getattr(choice, "message", None)
|
||||
if message and getattr(message, "content", None):
|
||||
image_description = message.content
|
||||
image_description = self._describe_image_response_content(response)
|
||||
if image_description:
|
||||
verbose_proxy_logger.debug("Image description: %s", image_description)
|
||||
descriptions.append(image_description)
|
||||
else:
|
||||
|
|
@ -1447,7 +1500,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
except HTTPException as e:
|
||||
# e.detail can be a string or dict
|
||||
if isinstance(e.detail, dict) and "error" in e.detail:
|
||||
detail_dict = cast(dict[str, Any], e.detail)
|
||||
detail_dict = cast(dict[str, str], e.detail)
|
||||
detail_dict["error"] = detail_dict["error"] + " (Image description): " + description
|
||||
elif isinstance(e.detail, str):
|
||||
e.detail = e.detail + " (Image description): " + description
|
||||
|
|
@ -1455,6 +1508,14 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
e.detail = "Content blocked: Image description detected" + description
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def _describe_image_response_content(response: ModelResponse) -> str | None:
|
||||
choice = response.choices[0]
|
||||
message = getattr(choice, "message", None)
|
||||
if message and getattr(message, "content", None):
|
||||
return message.content
|
||||
return None
|
||||
|
||||
def _count_masked_entities(
|
||||
self,
|
||||
detections: list[ContentFilterDetection],
|
||||
|
|
@ -1484,12 +1545,12 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
category = category_detection["category"]
|
||||
masked_entity_count[category] = masked_entity_count.get(category, 0) + 1
|
||||
|
||||
def _build_match_details(self, detections: list[ContentFilterDetection]) -> list[dict]:
|
||||
def _build_match_details(self, detections: list[ContentFilterDetection]) -> list[dict[str, object]]:
|
||||
"""Build match_details list from content filter detections."""
|
||||
match_details: Final[list[dict]] = []
|
||||
match_details: Final[list[dict[str, object]]] = []
|
||||
for detection in detections:
|
||||
action_taken = detection.get("action", detection.get("action_hint", ""))
|
||||
detail: dict = {"type": detection["type"], "action_taken": action_taken}
|
||||
detail: dict[str, object] = {"type": detection["type"], "action_taken": action_taken}
|
||||
if detection["type"] == "pattern":
|
||||
detail["detection_method"] = "regex"
|
||||
detail["snippet"] = cast(PatternDetection, detection).get("pattern_name", "")
|
||||
|
|
@ -1510,7 +1571,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
|
||||
def _get_detection_methods(self, detections: list[ContentFilterDetection]) -> str:
|
||||
"""Get comma-separated detection methods used."""
|
||||
methods: Final[set] = set()
|
||||
methods: Final[set[str]] = set()
|
||||
for detection in detections:
|
||||
if detection["type"] == "pattern":
|
||||
methods.add("regex")
|
||||
|
|
@ -1659,7 +1720,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
guardrail_json_response = exception_str if exception_str else [dict(detection) for detection in detections]
|
||||
|
||||
# Competitor intent: add confidence and classification to tracing if present
|
||||
tracing_kw: Final[dict[str, Any]] = {
|
||||
tracing_kw: Final[GuardrailTracingDetail] = {
|
||||
"guardrail_id": self.config_guardrail_id or self.guardrail_name,
|
||||
"policy_template": self.config_policy_template or self._get_policy_templates(),
|
||||
"detection_method": (self._get_detection_methods(detections) if detections else None),
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ for pattern_data in _PATTERNS_DATA["patterns"]:
|
|||
PATTERN_EXTRA_CONFIG[pattern_data["name"]] = extra_config
|
||||
|
||||
|
||||
def get_compiled_pattern(pattern_name: str) -> Pattern:
|
||||
def get_compiled_pattern(pattern_name: str) -> Pattern[str]:
|
||||
"""
|
||||
Get a compiled regex pattern by name.
|
||||
|
||||
|
|
|
|||
|
|
@ -73,12 +73,14 @@ import hashlib
|
|||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Final, Optional
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Optional
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching import DualCache
|
||||
|
|
@ -90,13 +92,28 @@ from litellm.proxy._types import UserAPIKeyAuth
|
|||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from jwt.types import Options
|
||||
|
||||
|
||||
class _OIDCDiscoveryDocument(TypedDict, total=False):
|
||||
jwks_uri: str
|
||||
|
||||
|
||||
class _JWTDecodeKwargs(TypedDict):
|
||||
algorithms: Sequence[str]
|
||||
options: "Options"
|
||||
audience: NotRequired[str]
|
||||
issuer: NotRequired[str]
|
||||
|
||||
|
||||
# Module-level singleton for the JWKS discovery endpoint to access.
|
||||
_mcp_jwt_signer_instance: Optional["MCPJWTSigner"] = None
|
||||
|
||||
_MCP_JWT_CALL_TYPES: Final = frozenset({"call_mcp_tool", "list_mcp_tools"})
|
||||
|
||||
# Simple in-memory JWKS cache: keyed by JWKS URI → (keys_list, fetched_at).
|
||||
_jwks_cache: Final[dict[str, tuple]] = {}
|
||||
_jwks_cache: Final[dict[str, tuple[Sequence[Mapping[str, object]], float]]] = {}
|
||||
_JWKS_CACHE_TTL: Final = 3600 # 1 hour
|
||||
|
||||
|
||||
|
|
@ -133,7 +150,7 @@ def _int_to_base64url(n: int) -> str:
|
|||
return base64.urlsafe_b64encode(n.to_bytes(byte_length, byteorder="big")).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _compute_kid(public_key: Any) -> str:
|
||||
def _compute_kid(public_key: RSAPublicKey) -> str:
|
||||
"""Derive a key ID from the public key's DER encoding (SHA-256, first 16 hex chars)."""
|
||||
der_bytes: Final = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
|
|
@ -142,7 +159,7 @@ def _compute_kid(public_key: Any) -> str:
|
|||
return hashlib.sha256(der_bytes).hexdigest()[:16]
|
||||
|
||||
|
||||
async def _fetch_jwks(jwks_uri: str) -> list[dict[str, Any]]:
|
||||
async def _fetch_jwks(jwks_uri: str) -> Sequence[Mapping[str, object]]:
|
||||
"""
|
||||
Fetch and cache a JWKS from the given URI.
|
||||
|
||||
|
|
@ -163,12 +180,13 @@ async def _fetch_jwks(jwks_uri: str) -> list[dict[str, Any]]:
|
|||
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
resp: Final = await client.get(jwks_uri, headers={"Accept": "application/json"})
|
||||
resp.raise_for_status()
|
||||
keys = resp.json().get("keys", [])
|
||||
_jwks_cache[jwks_uri] = (keys, now)
|
||||
return keys
|
||||
jwks_body: Final[Mapping[str, Sequence[Mapping[str, object]]]] = resp.json()
|
||||
fetched_keys: Final = jwks_body.get("keys", [])
|
||||
_jwks_cache[jwks_uri] = (fetched_keys, now)
|
||||
return fetched_keys
|
||||
|
||||
|
||||
async def _fetch_oidc_discovery(discovery_uri: str) -> dict[str, Any]:
|
||||
async def _fetch_oidc_discovery(discovery_uri: str) -> _OIDCDiscoveryDocument:
|
||||
"""Fetch an OIDC discovery document and return its parsed JSON."""
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
|
|
@ -178,7 +196,8 @@ async def _fetch_oidc_discovery(discovery_uri: str) -> dict[str, Any]:
|
|||
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
resp: Final = await client.get(discovery_uri, headers={"Accept": "application/json"})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
document: Final[_OIDCDiscoveryDocument] = resp.json()
|
||||
return document
|
||||
|
||||
|
||||
class MCPJWTSigner(CustomGuardrail):
|
||||
|
|
@ -230,8 +249,8 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
# FR-12: End-user identity mapping
|
||||
end_user_claim_sources: list[str] | None = None,
|
||||
# FR-13: Claim operations
|
||||
add_claims: dict[str, Any] | None = None,
|
||||
set_claims: dict[str, Any] | None = None,
|
||||
add_claims: Mapping[str, object] | None = None,
|
||||
set_claims: Mapping[str, object] | None = None,
|
||||
remove_claims: list[str] | None = None,
|
||||
# FR-14: Two-token model
|
||||
channel_token_audience: str | None = None,
|
||||
|
|
@ -283,7 +302,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
self.verify_issuer: str | None = verify_issuer
|
||||
self.verify_audience: str | None = verify_audience
|
||||
# Cached OIDC discovery document (fetched lazily, TTL = 24 h)
|
||||
self._oidc_discovery_doc: dict[str, Any] | None = None
|
||||
self._oidc_discovery_doc: _OIDCDiscoveryDocument | None = None
|
||||
self._oidc_discovery_fetched_at: float = 0.0
|
||||
|
||||
# --- FR-12: End-user identity mapping ---
|
||||
|
|
@ -294,8 +313,8 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
]
|
||||
|
||||
# --- FR-13: Claim operations ---
|
||||
self.add_claims: dict[str, Any] = add_claims or {}
|
||||
self.set_claims: dict[str, Any] = set_claims or {}
|
||||
self.add_claims: Mapping[str, object] = add_claims or {}
|
||||
self.set_claims: Mapping[str, object] = set_claims or {}
|
||||
self.remove_claims: list[str] = remove_claims or []
|
||||
|
||||
# --- FR-14: Two-token model ---
|
||||
|
|
@ -347,7 +366,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
"""
|
||||
return 3600 if self._persistent_key else 300
|
||||
|
||||
def get_jwks(self) -> dict[str, Any]:
|
||||
def get_jwks(self) -> Mapping[str, Sequence[Mapping[str, str]]]:
|
||||
"""
|
||||
Return the JWKS for the RSA public key.
|
||||
Used by GET /.well-known/jwks.json so MCP servers can verify tokens.
|
||||
|
|
@ -374,7 +393,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
# the IdP, short enough to pick up jwks_uri changes after key rotation.
|
||||
_OIDC_DISCOVERY_TTL = 86400
|
||||
|
||||
async def _get_oidc_discovery(self) -> dict[str, Any]:
|
||||
async def _get_oidc_discovery(self) -> _OIDCDiscoveryDocument:
|
||||
"""Fetch and cache the OIDC discovery document with a 24-hour TTL.
|
||||
|
||||
Only caches when the doc contains a 'jwks_uri' so that a transient or
|
||||
|
|
@ -391,7 +410,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
return doc
|
||||
return self._oidc_discovery_doc or {}
|
||||
|
||||
async def _verify_incoming_jwt(self, raw_token: str) -> dict[str, Any]:
|
||||
async def _verify_incoming_jwt(self, raw_token: str) -> dict[str, object]:
|
||||
"""
|
||||
Verify an incoming Bearer JWT against the configured IdP's JWKS.
|
||||
|
||||
|
|
@ -438,8 +457,8 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
# it infers from the key type (RSAPublicKey → RS256).
|
||||
alg: Final = getattr(signing_jwk, "algorithm_name", None) or "RS256"
|
||||
|
||||
decode_options: Final[dict[str, Any]] = {"verify_exp": True}
|
||||
decode_kwargs: Final[dict[str, Any]] = {
|
||||
decode_options: Final[Options] = {"verify_exp": True}
|
||||
decode_kwargs: Final[_JWTDecodeKwargs] = {
|
||||
"algorithms": [alg],
|
||||
"options": decode_options,
|
||||
}
|
||||
|
|
@ -451,10 +470,10 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
if self.verify_issuer:
|
||||
decode_kwargs["issuer"] = self.verify_issuer
|
||||
|
||||
payload: Final[dict[str, Any]] = jwt.decode(raw_token, signing_jwk.key, **decode_kwargs)
|
||||
payload: Final[dict[str, object]] = jwt.decode(raw_token, signing_jwk.key, **decode_kwargs)
|
||||
return payload
|
||||
|
||||
async def _introspect_opaque_token(self, token: str) -> dict[str, Any]:
|
||||
async def _introspect_opaque_token(self, token: str) -> dict[str, object]:
|
||||
"""
|
||||
Perform RFC 7662 token introspection for opaque (non-JWT) tokens.
|
||||
|
||||
|
|
@ -479,7 +498,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result: Final[dict[str, Any]] = resp.json()
|
||||
result: Final[dict[str, object]] = resp.json()
|
||||
if not result.get("active", False):
|
||||
raise jwt.exceptions.ExpiredSignatureError(
|
||||
"MCPJWTSigner: incoming token is inactive (introspection returned active=false)"
|
||||
|
|
@ -492,7 +511,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
|
||||
def _validate_required_claims(
|
||||
self,
|
||||
jwt_claims: dict[str, Any] | None,
|
||||
jwt_claims: Mapping[str, object] | None,
|
||||
) -> None:
|
||||
"""
|
||||
Raise HTTP 403 if any required_claims are absent from the verified
|
||||
|
|
@ -522,7 +541,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
def _resolve_end_user_identity(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
jwt_claims: dict[str, Any] | None,
|
||||
jwt_claims: Mapping[str, object] | None,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the outbound JWT 'sub' using the ordered end_user_claim_sources list.
|
||||
|
|
@ -545,19 +564,19 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
value = str(raw) if raw else None
|
||||
|
||||
elif source == "litellm:user_id":
|
||||
uid = getattr(user_api_key_dict, "user_id", None)
|
||||
uid = user_api_key_dict.user_id
|
||||
value = str(uid) if uid else None
|
||||
|
||||
elif source == "litellm:email":
|
||||
email = getattr(user_api_key_dict, "user_email", None)
|
||||
email = user_api_key_dict.user_email
|
||||
value = str(email) if email else None
|
||||
|
||||
elif source == "litellm:end_user_id":
|
||||
eid = getattr(user_api_key_dict, "end_user_id", None)
|
||||
eid = user_api_key_dict.end_user_id
|
||||
value = str(eid) if eid else None
|
||||
|
||||
elif source == "litellm:team_id":
|
||||
tid = getattr(user_api_key_dict, "team_id", None)
|
||||
tid = user_api_key_dict.team_id
|
||||
value = str(tid) if tid else None
|
||||
|
||||
else:
|
||||
|
|
@ -568,7 +587,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
return value
|
||||
|
||||
# Final fallback for service accounts with no user identity
|
||||
token: Final = getattr(user_api_key_dict, "token", None) or getattr(user_api_key_dict, "api_key", None)
|
||||
token: Final = user_api_key_dict.token or user_api_key_dict.api_key
|
||||
if token:
|
||||
return "apikey:" + hashlib.sha256(str(token).encode()).hexdigest()[:16]
|
||||
return "litellm-proxy"
|
||||
|
|
@ -615,7 +634,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
# FR-13: Claim operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_claim_operations(self, claims: dict[str, Any]) -> dict[str, Any]:
|
||||
def _apply_claim_operations(self, claims: dict[str, object]) -> dict[str, object]:
|
||||
"""Apply add_claims, set_claims, and remove_claims to the claim dict."""
|
||||
# add_claims: insert only when key is absent
|
||||
for k, v in self.add_claims.items():
|
||||
|
|
@ -637,9 +656,9 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
|
||||
def _passthrough_optional_claims(
|
||||
self,
|
||||
claims: dict[str, Any],
|
||||
jwt_claims: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
claims: dict[str, object],
|
||||
jwt_claims: Mapping[str, object] | None,
|
||||
) -> dict[str, object]:
|
||||
"""Forward optional_claims from verified incoming token into the outbound JWT."""
|
||||
if not self.optional_claims or not jwt_claims:
|
||||
return claims
|
||||
|
|
@ -656,7 +675,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: dict,
|
||||
jwt_claims: dict[str, Any] | None = None,
|
||||
jwt_claims: Mapping[str, object] | None = None,
|
||||
call_type: CallTypesLiteral | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -669,7 +688,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
jwt_claims if available. None for pure API-key requests.
|
||||
"""
|
||||
now: Final = int(time.time())
|
||||
claims: dict[str, Any] = {
|
||||
claims: dict[str, object] = {
|
||||
"iss": self.issuer,
|
||||
"aud": self.audience,
|
||||
"iat": now,
|
||||
|
|
@ -681,18 +700,18 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
claims["sub"] = self._resolve_end_user_identity(user_api_key_dict, jwt_claims)
|
||||
|
||||
# email passthrough when available from LiteLLM context
|
||||
user_email: Final = getattr(user_api_key_dict, "user_email", None)
|
||||
user_email: Final = user_api_key_dict.user_email
|
||||
if user_email:
|
||||
claims["email"] = user_email
|
||||
|
||||
# act — RFC 8693 delegation claim (team/org context)
|
||||
team_id: Final = getattr(user_api_key_dict, "team_id", None)
|
||||
org_id: Final = getattr(user_api_key_dict, "org_id", None)
|
||||
team_id: Final = user_api_key_dict.team_id
|
||||
org_id: Final = user_api_key_dict.org_id
|
||||
act_sub: Final = team_id or org_id or "litellm-proxy"
|
||||
claims["act"] = {"sub": act_sub}
|
||||
|
||||
# end_user_id when set separately from user_id
|
||||
end_user_id: Final = getattr(user_api_key_dict, "end_user_id", None)
|
||||
end_user_id: Final = user_api_key_dict.end_user_id
|
||||
if end_user_id:
|
||||
claims["end_user_id"] = end_user_id
|
||||
|
||||
|
|
@ -710,8 +729,8 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
|
||||
def _build_channel_token_claims(
|
||||
self,
|
||||
base_claims: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
base_claims: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Build claims for the channel token (FR-14 two-token model).
|
||||
|
||||
|
|
@ -776,7 +795,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
# ------------------------------------------------------------------
|
||||
# FR-5: Verify incoming token before re-signing
|
||||
# ------------------------------------------------------------------
|
||||
jwt_claims: dict[str, Any] | None = None
|
||||
jwt_claims: dict[str, object] | None = None
|
||||
raw_token: Final[str | None] = hook_data.get("incoming_bearer_token")
|
||||
|
||||
if self.access_token_discovery_uri and raw_token:
|
||||
|
|
@ -810,7 +829,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
|
||||
# Fall back to LiteLLM-decoded JWT claims (available when proxy uses JWT auth).
|
||||
if jwt_claims is None:
|
||||
jwt_claims = getattr(user_api_key_dict, "jwt_claims", None)
|
||||
jwt_claims = user_api_key_dict.jwt_claims
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-15: Validate required claims
|
||||
|
|
@ -896,7 +915,7 @@ async def inject_mcp_jwt_headers_for_upstream(
|
|||
if auth_hdr.lower().startswith("bearer "):
|
||||
incoming_bearer_token = auth_hdr[len("bearer ") :]
|
||||
|
||||
hook_data: Final[dict[str, Any]] = {
|
||||
hook_data: Final = {
|
||||
"mcp_tool_name": "" if for_list_tools else mcp_tool_name,
|
||||
"incoming_bearer_token": incoming_bearer_token,
|
||||
"extra_headers": merged,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Provides real-time threat detection, DLP, URL filtering, content masking, and po
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncIterable, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -166,7 +167,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
GuardrailEventHooks.during_mcp_call: GuardrailEventHooks.during_call,
|
||||
}
|
||||
|
||||
def should_run_guardrail(self, data: Any, event_type: GuardrailEventHooks) -> bool:
|
||||
def should_run_guardrail(self, data: Mapping[str, object], event_type: GuardrailEventHooks) -> bool:
|
||||
if super().should_run_guardrail(data, event_type):
|
||||
return True
|
||||
compat: Final = self._MCP_COMPAT_MAP.get(event_type)
|
||||
|
|
@ -175,7 +176,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
return True
|
||||
return False
|
||||
|
||||
def _extract_text_from_messages(self, messages: list[dict[str, Any]]) -> str:
|
||||
def _extract_text_from_messages(self, messages: Sequence[Mapping[str, object]]) -> str:
|
||||
"""Extract text content from messages array."""
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return ""
|
||||
|
|
@ -242,10 +243,10 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
self,
|
||||
content: str = "",
|
||||
is_response: bool = False,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
call_id: str | None = None,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
call_id: object = None,
|
||||
tool_event: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""Call PANW Prisma AIRS API to scan content or a tool_event."""
|
||||
|
||||
if tool_event is None and not content.strip():
|
||||
|
|
@ -275,7 +276,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
else:
|
||||
app_name_value = self.app_name # Defaults to "LiteLLM"
|
||||
|
||||
panw_metadata: Final = {
|
||||
panw_metadata: Final[dict[str, object]] = {
|
||||
"app_user": (
|
||||
(metadata.get("app_user") or metadata.get("user") or "litellm_user") if metadata else "litellm_user"
|
||||
),
|
||||
|
|
@ -295,13 +296,13 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
panw_metadata["litellm_trace_id"] = metadata["litellm_trace_id"]
|
||||
|
||||
# Build contents: tool_event takes priority, else prompt/response text
|
||||
contents: list[dict[str, Any]]
|
||||
contents: Sequence[Mapping[str, object]]
|
||||
if tool_event is not None:
|
||||
contents = [{"tool_event": tool_event}]
|
||||
else:
|
||||
contents = [{"response" if is_response else "prompt": content}]
|
||||
|
||||
payload: Final = {
|
||||
payload: Final[dict[str, object]] = {
|
||||
"metadata": panw_metadata,
|
||||
"contents": contents,
|
||||
}
|
||||
|
|
@ -325,7 +326,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
# If neither profile_name nor profile_id is provided, PANW API will use the
|
||||
# profile linked to the API key (if configured in Strata Cloud Manager)
|
||||
if profile_name or profile_id:
|
||||
ai_profile: Final = {}
|
||||
ai_profile: Final[dict[str, object]] = {}
|
||||
if profile_id:
|
||||
ai_profile["profile_id"] = profile_id
|
||||
if profile_name:
|
||||
|
|
@ -333,7 +334,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
payload["ai_profile"] = ai_profile
|
||||
|
||||
if is_response and tool_event is None:
|
||||
payload["metadata"]["is_response"] = True
|
||||
panw_metadata["is_response"] = True
|
||||
|
||||
headers: Final = {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -355,7 +356,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result: Final = response.json()
|
||||
result: Final[dict[str, object]] = response.json()
|
||||
|
||||
# Validate response format
|
||||
if "action" not in result:
|
||||
|
|
@ -489,7 +490,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
)
|
||||
return "unknown"
|
||||
|
||||
def _get_masked_text(self, scan_result: dict[str, Any], is_response: bool = False) -> str | None:
|
||||
def _get_masked_text(self, scan_result: Mapping[str, object], is_response: bool = False) -> str | None:
|
||||
"""Extract masked text from PANW scan result."""
|
||||
masked_key: Final = "response_masked_data" if is_response else "prompt_masked_data"
|
||||
masked_data: Final = scan_result.get(masked_key)
|
||||
|
|
@ -511,7 +512,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
@staticmethod
|
||||
def _apply_mcp_masking(
|
||||
request_data: dict,
|
||||
original_args: Any,
|
||||
original_args: object,
|
||||
masked_text: str,
|
||||
*,
|
||||
is_blocked: bool = True,
|
||||
|
|
@ -544,7 +545,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
# If the original args were structured, preserve the type.
|
||||
if isinstance(original_args, (dict, list)):
|
||||
try:
|
||||
parsed: Final = json.loads(masked_text)
|
||||
parsed: Final[object] = json.loads(masked_text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -556,7 +557,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
}
|
||||
},
|
||||
)
|
||||
masked_value: Any = parsed
|
||||
masked_value: object = parsed
|
||||
else:
|
||||
masked_value = masked_text
|
||||
|
||||
|
|
@ -572,7 +573,9 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
else:
|
||||
verbose_proxy_logger.info("PANW Prisma AIRS: MCP request allowed with PII masking applied")
|
||||
|
||||
def _apply_masking_to_messages(self, messages: list[dict[str, Any]], masked_text: str) -> list[dict[str, Any]]:
|
||||
def _apply_masking_to_messages(
|
||||
self, messages: list[dict[str, object]], masked_text: str
|
||||
) -> Sequence[Mapping[str, object]]:
|
||||
"""Apply masked text to the last user message."""
|
||||
if not messages:
|
||||
return messages
|
||||
|
|
@ -622,7 +625,9 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
if hasattr(choice.message.function_call, "arguments"):
|
||||
choice.message.function_call.arguments = masked_text
|
||||
|
||||
def _build_error_detail(self, scan_result: dict[str, Any], is_response: bool = False) -> dict[str, Any]:
|
||||
def _build_error_detail(
|
||||
self, scan_result: Mapping[str, object], is_response: bool = False
|
||||
) -> Mapping[str, Mapping[str, object]]:
|
||||
"""Build enhanced error detail with scan information."""
|
||||
action_type: Final = "Response" if is_response else "Prompt"
|
||||
code_suffix: Final = "_response_blocked" if is_response else "_blocked"
|
||||
|
|
@ -642,7 +647,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
},
|
||||
)
|
||||
|
||||
error_detail: Final = {
|
||||
error_detail: Final[dict[str, dict[str, object]]] = {
|
||||
"error": {
|
||||
"message": error_msg,
|
||||
"type": "guardrail_violation",
|
||||
|
|
@ -672,12 +677,12 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
|
||||
def _handle_api_error_with_logging(
|
||||
self,
|
||||
scan_result: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
scan_result: dict[str, object],
|
||||
data: dict[str, object],
|
||||
start_time: datetime,
|
||||
event_type: GuardrailEventHooks,
|
||||
is_response: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
) -> None:
|
||||
"""Handle API errors with fail-open/fail-closed logic."""
|
||||
end_time: Final = datetime.now()
|
||||
duration: Final = (end_time - start_time).total_seconds()
|
||||
|
|
@ -722,7 +727,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
add_guardrail_to_applied_guardrails_header(
|
||||
request_data=data, guardrail_name=f"{self.guardrail_name}:unscanned"
|
||||
)
|
||||
return None
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
|
@ -783,7 +788,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_from_sse_bytes(chunks: list[bytes]) -> str:
|
||||
def _extract_text_from_sse_bytes(chunks: Sequence[bytes]) -> str:
|
||||
"""Extract text from Anthropic SSE byte chunks (content_block_delta → text_delta)."""
|
||||
texts: Final[list[str]] = []
|
||||
raw: Final = b"".join(chunks).decode("utf-8", errors="replace")
|
||||
|
|
@ -804,7 +809,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
return "".join(texts)
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_from_streaming_events(chunks: list) -> str:
|
||||
def _extract_text_from_streaming_events(chunks: Sequence[object]) -> str:
|
||||
"""Extract text from /v1/responses streaming events (object or dict)."""
|
||||
|
||||
def _attr(c, key):
|
||||
|
|
@ -960,7 +965,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
cache: DualCache,
|
||||
data: dict[str, Any],
|
||||
call_type: CallTypesLiteral,
|
||||
) -> dict[str, Any] | None:
|
||||
) -> dict[str, object] | None:
|
||||
"""
|
||||
Pre-call hook to scan user prompts before sending to LLM.
|
||||
|
||||
|
|
@ -1075,10 +1080,10 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
@log_guardrail_information
|
||||
async def async_post_call_success_hook(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
data: dict[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: Any,
|
||||
) -> Any:
|
||||
response: object,
|
||||
) -> object:
|
||||
"""
|
||||
Post-call hook to scan LLM responses before returning to user.
|
||||
|
||||
|
|
@ -1193,7 +1198,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
assembled_model_response: ModelResponse,
|
||||
request_data: dict,
|
||||
start_time: datetime,
|
||||
) -> tuple[bool, ModelResponse, dict[str, Any]]:
|
||||
) -> tuple[bool, ModelResponse, dict[str, object]]:
|
||||
"""
|
||||
Scan assembled streaming response and apply masking if needed.
|
||||
Returns (content_was_modified, response, scan_result).
|
||||
|
|
@ -1255,8 +1260,8 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
async def async_post_call_streaming_iterator_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: Any,
|
||||
request_data: dict,
|
||||
response: AsyncIterable[object],
|
||||
request_data: dict[str, object],
|
||||
):
|
||||
"""
|
||||
Process streaming response chunks and scan the assembled response.
|
||||
|
|
@ -1367,7 +1372,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
# returns a proper JSON error response with the correct status code.
|
||||
# (Raising from a generator hits create_response's generic except → 500.)
|
||||
detail: Final = e.detail if isinstance(e.detail, dict) else {"message": str(e.detail)}
|
||||
error_obj: Final[dict[str, Any]] = dict(detail.get("error", detail))
|
||||
error_obj: Final[dict[str, object]] = dict(detail.get("error", detail))
|
||||
error_obj["code"] = e.status_code
|
||||
yield f"data: {json.dumps({'error': error_obj})}\n\n"
|
||||
except Exception as e:
|
||||
|
|
@ -1378,8 +1383,8 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
self,
|
||||
tool_calls: list,
|
||||
is_response: bool,
|
||||
metadata: dict[str, Any],
|
||||
call_id: str,
|
||||
metadata: Mapping[str, object],
|
||||
call_id: object,
|
||||
request_data: dict,
|
||||
start_time: datetime,
|
||||
) -> None:
|
||||
|
|
@ -1416,7 +1421,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
tool_name = func.get("name")
|
||||
|
||||
# --- build tool_event payload (canonical PANW schema) -----------
|
||||
tool_event: dict[str, Any] = {
|
||||
tool_event: dict[str, object] = {
|
||||
"metadata": {
|
||||
"ecosystem": "openai",
|
||||
"method": "tools/call",
|
||||
|
|
@ -1472,7 +1477,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
|
||||
@staticmethod
|
||||
def _is_anthropic_request(
|
||||
request_data: dict,
|
||||
request_data: Mapping[str, object],
|
||||
logging_obj: Optional["LiteLLMLoggingObj"] = None,
|
||||
) -> bool:
|
||||
"""Detect if the current request is an Anthropic /v1/messages call."""
|
||||
|
|
@ -1497,7 +1502,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
|
||||
def _use_latest_user_only(
|
||||
self,
|
||||
request_data: dict,
|
||||
request_data: Mapping[str, object],
|
||||
logging_obj: Optional["LiteLLMLoggingObj"] = None,
|
||||
) -> bool:
|
||||
"""Resolve whether to scan only the latest user message.
|
||||
|
|
@ -1515,8 +1520,8 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
|
||||
@staticmethod
|
||||
def _get_latest_user_text_indices(
|
||||
texts: list[str],
|
||||
messages: list,
|
||||
texts: Sequence[str],
|
||||
messages: Sequence[object],
|
||||
) -> set | None:
|
||||
"""Return text indices belonging to only the latest scannable human-authored (user or developer) message.
|
||||
|
||||
|
|
@ -1569,8 +1574,8 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
|
||||
@staticmethod
|
||||
def _get_scannable_text_indices(
|
||||
texts: list[str],
|
||||
structured_messages: list,
|
||||
texts: Sequence[str],
|
||||
structured_messages: Sequence[object],
|
||||
) -> set | None:
|
||||
"""Derive which ``texts`` indices originate from user/system messages.
|
||||
|
||||
|
|
@ -1627,7 +1632,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict,
|
||||
request_data: dict[str, object],
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: Optional["LiteLLMLoggingObj"] = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
|
|
@ -1798,7 +1803,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
|
|||
# "mcp_tool_name"/"mcp_arguments". Check canonical first, then fallback.
|
||||
mcp_tool_name: Final = request_data.get("mcp_tool_name") or self._mcp_name_fallback(request_data)
|
||||
if mcp_tool_name and input_type == "request":
|
||||
mcp_tool_event: Final[dict[str, Any]] = {
|
||||
mcp_tool_event: Final[dict[str, object]] = {
|
||||
"metadata": {
|
||||
"ecosystem": "mcp",
|
||||
"method": "tools/call",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Pre-call hook that filters MCP tools semantically before LLM inference.
|
|||
Reduces context window size and improves tool selection accuracy.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -164,7 +165,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
return [name for name in names if name]
|
||||
|
||||
@staticmethod
|
||||
def _narrow_mcp_references(tools: list[Any], selected_tool_names: list[str]) -> list[Any]:
|
||||
def _narrow_mcp_references(tools: Sequence[Mapping[str, object]], selected_tool_names: list[str]) -> list[object]:
|
||||
"""
|
||||
Restrict each litellm_proxy MCP reference to the semantically selected tools.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import asyncio
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Final
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -37,8 +37,26 @@ from litellm.types.management_endpoints import (
|
|||
CacheSettingsField,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
|
||||
class _CacheConfigRow(Protocol):
|
||||
cache_settings: str | Mapping[str, object] | None
|
||||
|
||||
|
||||
class _CacheConfigTable(Protocol):
|
||||
async def find_unique(self, where: Mapping[str, str]) -> _CacheConfigRow | None: ...
|
||||
|
||||
async def upsert(self, where: Mapping[str, str], data: Mapping[str, Mapping[str, str]]) -> _CacheConfigRow: ...
|
||||
|
||||
|
||||
def _cache_config_table(prisma_client: "PrismaClient") -> _CacheConfigTable:
|
||||
return CacheConfigRepository(prisma_client).table
|
||||
|
||||
|
||||
# Cache fields holding credentials. Masked on read so plaintext Redis /
|
||||
# Sentinel passwords never leave the server in a GET response. `url` is here
|
||||
# because a Redis/Valkey URL can embed a password inline
|
||||
|
|
@ -197,7 +215,7 @@ def _saved_secret_is_reusable(incoming: Mapping[str, object], saved: Mapping[str
|
|||
return True
|
||||
|
||||
|
||||
def _merge_over_saved(incoming: Mapping[str, object], saved: Mapping[str, object]) -> dict[str, Any]:
|
||||
def _merge_over_saved(incoming: Mapping[str, object], saved: Mapping[str, object]) -> Mapping[str, object]:
|
||||
"""Keep the stored secret behind any credential the caller echoed back redacted or omitted.
|
||||
|
||||
GET returns credentials as the marker and the form never re-prefills a
|
||||
|
|
@ -339,7 +357,7 @@ class CacheSettingsManager:
|
|||
return normalized1 == normalized2
|
||||
|
||||
@staticmethod
|
||||
async def init_cache_settings_in_db(prisma_client, proxy_config):
|
||||
async def init_cache_settings_in_db(prisma_client: "PrismaClient", proxy_config):
|
||||
"""
|
||||
Initialize cache settings from database into the router on startup.
|
||||
Only reinitializes if cache params have changed.
|
||||
|
|
@ -349,7 +367,7 @@ class CacheSettingsManager:
|
|||
try:
|
||||
cache_config: Final = await call_with_db_reconnect_retry(
|
||||
prisma_client,
|
||||
lambda: CacheConfigRepository(prisma_client).table.find_unique(where={"id": "cache_config"}),
|
||||
lambda: _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"}),
|
||||
reason="init_cache_settings_in_db_lookup_failure",
|
||||
)
|
||||
if cache_config is not None and cache_config.cache_settings:
|
||||
|
|
@ -444,7 +462,7 @@ async def get_cache_settings(
|
|||
# Read the stored settings (decrypted); an env-only cache has none.
|
||||
stored: dict[str, object] = {}
|
||||
if prisma_client is not None:
|
||||
cache_config = await CacheConfigRepository(prisma_client).table.find_unique(where={"id": "cache_config"})
|
||||
cache_config = await _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"})
|
||||
if cache_config is not None and cache_config.cache_settings:
|
||||
stored = proxy_config._decrypt_db_variables(
|
||||
variables_dict=_parse_stored_settings(cache_config.cache_settings)
|
||||
|
|
@ -511,9 +529,7 @@ async def test_cache_connection(
|
|||
saved_settings: dict[str, object] = {}
|
||||
if prisma_client is not None:
|
||||
try:
|
||||
existing_row: Final = await CacheConfigRepository(prisma_client).table.find_unique(
|
||||
where={"id": "cache_config"}
|
||||
)
|
||||
existing_row: Final = await _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"})
|
||||
if existing_row is not None and existing_row.cache_settings:
|
||||
saved_settings = proxy_config._decrypt_db_variables(
|
||||
variables_dict=_parse_stored_settings(existing_row.cache_settings)
|
||||
|
|
@ -590,7 +606,7 @@ async def update_cache_settings(
|
|||
try:
|
||||
# Read the stored row first: its decrypted values back any credential the
|
||||
# caller echoed back redacted, and its key set drives the audit diff.
|
||||
existing_row: Final = await CacheConfigRepository(prisma_client).table.find_unique(where={"id": "cache_config"})
|
||||
existing_row: Final = await _cache_config_table(prisma_client).find_unique(where={"id": "cache_config"})
|
||||
before_settings: dict[str, object] | None = None
|
||||
saved_settings: dict[str, object] = {}
|
||||
if existing_row is not None and existing_row.cache_settings:
|
||||
|
|
@ -606,7 +622,7 @@ async def update_cache_settings(
|
|||
encrypted_settings: Final = proxy_config._encrypt_env_variables(environment_variables=cache_settings)
|
||||
|
||||
# Save to database
|
||||
await CacheConfigRepository(prisma_client).table.upsert(
|
||||
await _cache_config_table(prisma_client).upsert(
|
||||
where={"id": "cache_config"},
|
||||
data={
|
||||
"create": {
|
||||
|
|
|
|||
|
|
@ -18,20 +18,21 @@ Scoping:
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import (
|
||||
CommonProxyErrors,
|
||||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
user_api_key_has_admin_view,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.repositories.prisma_protocols import MemoryRecord
|
||||
from litellm.repositories.table_repositories import MemoryRepository
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.types.memory_management import (
|
||||
|
|
@ -48,11 +49,54 @@ if TYPE_CHECKING:
|
|||
router: Final = APIRouter()
|
||||
|
||||
|
||||
class _MemoryRecord(Protocol):
|
||||
memory_id: str
|
||||
key: str
|
||||
value: str
|
||||
metadata: object
|
||||
user_id: str | None
|
||||
team_id: str | None
|
||||
created_at: datetime | None
|
||||
created_by: str | None
|
||||
updated_at: datetime | None
|
||||
updated_by: str | None
|
||||
|
||||
|
||||
class _MemoryTableActions(Protocol):
|
||||
async def create(self, data: Mapping[str, object]) -> _MemoryRecord: ...
|
||||
|
||||
async def find_many(
|
||||
self,
|
||||
where: Mapping[str, object] | None = ...,
|
||||
order: Mapping[str, str] | None = ...,
|
||||
skip: int = ...,
|
||||
take: int = ...,
|
||||
) -> Sequence[_MemoryRecord]: ...
|
||||
|
||||
async def count(self, where: Mapping[str, object] | None = ...) -> int: ...
|
||||
|
||||
async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _MemoryRecord: ...
|
||||
|
||||
async def delete(self, where: Mapping[str, object]) -> _MemoryRecord | None: ...
|
||||
|
||||
|
||||
def _memory_table(prisma_client: "PrismaClient") -> _MemoryTableActions:
|
||||
return MemoryRepository(prisma_client).table
|
||||
|
||||
|
||||
class _TeamTableActions(Protocol):
|
||||
async def find_unique(self, where: Mapping[str, str]) -> LiteLLM_TeamTable | None: ...
|
||||
|
||||
|
||||
def _team_table(prisma_client: "PrismaClient") -> _TeamTableActions:
|
||||
return TeamRepository(prisma_client).table
|
||||
|
||||
|
||||
def _serialize_metadata_for_prisma(metadata: object) -> str:
|
||||
"""
|
||||
Encode a `metadata` payload for the `Json?` column.
|
||||
|
||||
`metadata` is typed `object | None`, so callers may send dicts, lists,
|
||||
`metadata` is typed `Optional[Any]`, so callers may send dicts, lists,
|
||||
or JSON scalars (including plain Python strings like `"hello"`).
|
||||
prisma-client-python rejects raw Python values on `Json?` columns
|
||||
(`MissingRequiredValueError` / `DataError`), and Postgres `jsonb`
|
||||
|
|
@ -74,18 +118,18 @@ def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object
|
|||
"""
|
||||
if user_api_key_has_admin_view(user_api_key_dict):
|
||||
return None
|
||||
ors: Final[list[dict[str, object]]] = []
|
||||
if user_api_key_dict.user_id:
|
||||
ors.append({"user_id": user_api_key_dict.user_id})
|
||||
if user_api_key_dict.team_id:
|
||||
ors.append({"team_id": user_api_key_dict.team_id})
|
||||
ors: Final = [
|
||||
{field: value}
|
||||
for field, value in (("user_id", user_api_key_dict.user_id), ("team_id", user_api_key_dict.team_id))
|
||||
if value
|
||||
]
|
||||
if not ors:
|
||||
# Caller has neither user_id nor team_id — match nothing.
|
||||
return {"memory_id": "__no_match__"}
|
||||
return {"OR": ors}
|
||||
|
||||
|
||||
def _row_to_model(row: MemoryRecord) -> LiteLLM_MemoryRow:
|
||||
def _row_to_model(row: _MemoryRecord) -> LiteLLM_MemoryRow:
|
||||
return LiteLLM_MemoryRow(
|
||||
memory_id=row.memory_id,
|
||||
key=row.key,
|
||||
|
|
@ -119,7 +163,7 @@ def _internal_error(log_message: str, exc: Exception, default_detail: str) -> HT
|
|||
|
||||
|
||||
async def _assert_write_access(
|
||||
prisma_client: "PrismaClient", row: MemoryRecord, user_api_key_dict: UserAPIKeyAuth
|
||||
prisma_client: "PrismaClient", row: _MemoryRecord, user_api_key_dict: UserAPIKeyAuth
|
||||
) -> None:
|
||||
"""
|
||||
Enforce ownership for mutations (PUT/DELETE).
|
||||
|
|
@ -142,8 +186,8 @@ async def _assert_write_access(
|
|||
"""
|
||||
if _is_admin(user_api_key_dict):
|
||||
return
|
||||
row_user_id: Final = row.user_id
|
||||
row_team_id: Final = row.team_id
|
||||
row_user_id: Final = getattr(row, "user_id", None)
|
||||
row_team_id: Final = getattr(row, "team_id", None)
|
||||
|
||||
# Personal ownership.
|
||||
if row_user_id and row_user_id == user_api_key_dict.user_id:
|
||||
|
|
@ -175,7 +219,7 @@ async def _is_team_admin_for(prisma_client: "PrismaClient", user_api_key_dict: U
|
|||
)
|
||||
|
||||
try:
|
||||
team_obj: Final = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
|
||||
team_obj: Final = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error loading team for write-auth check (team_id=%s): %s", team_id, e)
|
||||
return False
|
||||
|
|
@ -288,7 +332,7 @@ async def create_memory(
|
|||
create_data["metadata"] = _serialize_metadata_for_prisma(body.metadata)
|
||||
|
||||
try:
|
||||
row: Final = await MemoryRepository(prisma_client).rows.create(data=create_data)
|
||||
row: Final = await _memory_table(prisma_client).create(data=create_data)
|
||||
except Exception as e:
|
||||
# Key is globally unique. Any duplicate → 409.
|
||||
if _is_unique_violation(e):
|
||||
|
|
@ -348,8 +392,8 @@ async def list_memory(
|
|||
where = {"AND": [key_filter, vis]}
|
||||
|
||||
try:
|
||||
total: Final = await MemoryRepository(prisma_client).rows.count(where=where)
|
||||
rows: Final = await MemoryRepository(prisma_client).rows.find_many(
|
||||
total: Final = await _memory_table(prisma_client).count(where=where)
|
||||
rows: Final = await _memory_table(prisma_client).find_many(
|
||||
where=where,
|
||||
order={"updated_at": "desc"},
|
||||
skip=(page - 1) * page_size,
|
||||
|
|
@ -363,14 +407,12 @@ async def list_memory(
|
|||
|
||||
async def _find_memory_for_caller(
|
||||
prisma_client: "PrismaClient", key: str, user_api_key_dict: UserAPIKeyAuth
|
||||
) -> MemoryRecord:
|
||||
) -> _MemoryRecord:
|
||||
"""Look up a memory row by key, scoped to the caller's visibility."""
|
||||
key_filter: Final[Mapping[str, object]] = {"key": key}
|
||||
vis: Final = _visibility_filter(user_api_key_dict)
|
||||
where: Final[Mapping[str, object]] = key_filter if vis is None else {"AND": [key_filter, vis]}
|
||||
rows: Final = await MemoryRepository(prisma_client).rows.find_many(
|
||||
where=where, take=1, order={"updated_at": "desc"}
|
||||
)
|
||||
rows = await _memory_table(prisma_client).find_many(where=where, take=1, order={"updated_at": "desc"})
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail=f"Memory with key '{key}' not found")
|
||||
return rows[0]
|
||||
|
|
@ -438,7 +480,7 @@ async def upsert_memory(
|
|||
)
|
||||
data["updated_by"] = user_api_key_dict.user_id
|
||||
|
||||
async def _find_existing() -> MemoryRecord | None:
|
||||
async def _find_existing() -> _MemoryRecord | None:
|
||||
"""Return the caller-visible row for `key`, or None."""
|
||||
try:
|
||||
return await _find_memory_for_caller(prisma_client, key, user_api_key_dict)
|
||||
|
|
@ -455,7 +497,7 @@ async def upsert_memory(
|
|||
# their team) — otherwise a teammate could overwrite a personal
|
||||
# entry through the OR-based visibility filter.
|
||||
await _assert_write_access(prisma_client, existing, user_api_key_dict)
|
||||
row = await MemoryRepository(prisma_client).rows.update(
|
||||
row = await _memory_table(prisma_client).update(
|
||||
where={"memory_id": existing.memory_id},
|
||||
data=data,
|
||||
)
|
||||
|
|
@ -481,7 +523,7 @@ async def upsert_memory(
|
|||
if body.metadata is not None:
|
||||
create_data["metadata"] = _serialize_metadata_for_prisma(body.metadata)
|
||||
try:
|
||||
row = await MemoryRepository(prisma_client).rows.create(data=create_data)
|
||||
row = await _memory_table(prisma_client).create(data=create_data)
|
||||
except Exception as e:
|
||||
# Race: a concurrent PUT/POST created the row after our check.
|
||||
# Re-read and fall back to an update so the PUT stays idempotent
|
||||
|
|
@ -498,7 +540,7 @@ async def upsert_memory(
|
|||
)
|
||||
# Same write-authorization check as the non-race path.
|
||||
await _assert_write_access(prisma_client, existing_after_race, user_api_key_dict)
|
||||
row = await MemoryRepository(prisma_client).rows.update(
|
||||
row = await _memory_table(prisma_client).update(
|
||||
where={"memory_id": existing_after_race.memory_id},
|
||||
data=data,
|
||||
)
|
||||
|
|
@ -526,7 +568,7 @@ async def delete_memory(
|
|||
# Visibility != write authority — see the upsert handler for the rationale.
|
||||
await _assert_write_access(prisma_client, row, user_api_key_dict)
|
||||
try:
|
||||
await MemoryRepository(prisma_client).rows.delete(where={"memory_id": row.memory_id})
|
||||
await _memory_table(prisma_client).delete(where={"memory_id": row.memory_id})
|
||||
except Exception as e:
|
||||
raise _internal_error("Error deleting memory: %s", e, "Internal error deleting memory entry.")
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -35,10 +36,32 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
from litellm import Router
|
||||
from litellm.types.rag import RAGIngestOptions
|
||||
|
||||
|
||||
class S3VectorDataPayload(TypedDict):
|
||||
float32: Sequence[float]
|
||||
|
||||
|
||||
class S3VectorEntry(TypedDict):
|
||||
key: str
|
||||
data: S3VectorDataPayload
|
||||
metadata: Mapping[str, str]
|
||||
|
||||
|
||||
class S3VectorsQueryMatch(TypedDict, total=False):
|
||||
key: str
|
||||
distance: float
|
||||
metadata: Mapping[str, str]
|
||||
|
||||
|
||||
class S3VectorsQueryResponse(TypedDict, total=False):
|
||||
vectors: Sequence[S3VectorsQueryMatch]
|
||||
|
||||
|
||||
class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
||||
"""
|
||||
S3 Vectors RAG ingestion using httpx + AWS SigV4 signing.
|
||||
|
|
@ -66,10 +89,10 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
BaseAWSLLM.__init__(self)
|
||||
|
||||
# Extract config
|
||||
self.vector_bucket_name = self.vector_store_config["vector_bucket_name"]
|
||||
self.index_name = self.vector_store_config.get("index_name")
|
||||
self.distance_metric = self.vector_store_config.get("distance_metric", S3_VECTORS_DEFAULT_DISTANCE_METRIC)
|
||||
self.non_filterable_metadata_keys = self.vector_store_config.get(
|
||||
self.vector_bucket_name: str = self.vector_store_config["vector_bucket_name"]
|
||||
self.index_name: str | None = self.vector_store_config.get("index_name")
|
||||
self.distance_metric: str = self.vector_store_config.get("distance_metric", S3_VECTORS_DEFAULT_DISTANCE_METRIC)
|
||||
self.non_filterable_metadata_keys: Sequence[str] = self.vector_store_config.get(
|
||||
"non_filterable_metadata_keys",
|
||||
S3_VECTORS_DEFAULT_NON_FILTERABLE_METADATA_KEYS,
|
||||
)
|
||||
|
|
@ -78,7 +101,7 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
self.dimension = self._get_dimension_from_config()
|
||||
|
||||
# Get AWS region using BaseAWSLLM method
|
||||
_aws_region: Final = self.vector_store_config.get("aws_region_name")
|
||||
_aws_region: Final[str | None] = self.vector_store_config.get("aws_region_name")
|
||||
self.aws_region_name = self.get_aws_region_name_for_non_llm_api_calls(
|
||||
aws_region_name=str(_aws_region) if _aws_region else None
|
||||
)
|
||||
|
|
@ -135,7 +158,8 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
Returns None if dimension should be auto-detected.
|
||||
"""
|
||||
if "dimension" in self.vector_store_config:
|
||||
return int(self.vector_store_config["dimension"])
|
||||
configured_dimension: Final[int] = self.vector_store_config["dimension"]
|
||||
return int(configured_dimension)
|
||||
return None
|
||||
|
||||
async def _ensure_config_initialized(self):
|
||||
|
|
@ -258,7 +282,7 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
get_body: Final = safe_dumps({"vectorBucketName": self.vector_bucket_name})
|
||||
|
||||
try:
|
||||
response = await self._sign_and_execute_request("POST", get_url, data=get_body)
|
||||
response: httpx.Response = await self._sign_and_execute_request("POST", get_url, data=get_body)
|
||||
if response.status_code == 200:
|
||||
verbose_logger.debug("Vector bucket %s exists", self.vector_bucket_name)
|
||||
return
|
||||
|
|
@ -294,7 +318,7 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
get_body: Final = safe_dumps({"vectorBucketName": self.vector_bucket_name, "indexName": self.index_name})
|
||||
|
||||
try:
|
||||
response = await self._sign_and_execute_request("POST", get_url, data=get_body)
|
||||
response: httpx.Response = await self._sign_and_execute_request("POST", get_url, data=get_body)
|
||||
if response.status_code == 200:
|
||||
verbose_logger.debug("Vector index %s exists", self.index_name)
|
||||
return
|
||||
|
|
@ -311,7 +335,7 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
)
|
||||
|
||||
# Prepare index configuration per AWS API docs
|
||||
index_config: Final = {
|
||||
index_config: Final[dict[str, object]] = {
|
||||
"vectorBucketName": self.vector_bucket_name,
|
||||
"indexName": self.index_name,
|
||||
"dataType": "float32",
|
||||
|
|
@ -336,7 +360,7 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
verbose_logger.exception("Error creating vector index: %s", e)
|
||||
raise
|
||||
|
||||
async def _put_vectors(self, vectors: list[dict[str, Any]]):
|
||||
async def _put_vectors(self, vectors: Sequence[S3VectorEntry]):
|
||||
"""
|
||||
Call PutVectors API to store vectors in S3 Vectors.
|
||||
|
||||
|
|
@ -355,7 +379,9 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
}
|
||||
|
||||
try:
|
||||
response: Final = await self._sign_and_execute_request("POST", url, data=safe_dumps(request_body))
|
||||
response: Final[httpx.Response] = await self._sign_and_execute_request(
|
||||
"POST", url, data=safe_dumps(request_body)
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
verbose_logger.info("Successfully stored %s vectors in index %s", len(vectors), self.index_name)
|
||||
|
|
@ -442,24 +468,18 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
raise ValueError(error_msg)
|
||||
|
||||
# Prepare vectors for PutVectors API
|
||||
vectors: Final = []
|
||||
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
# Build metadata dict
|
||||
metadata: dict[str, str] = {
|
||||
"source_text": chunk, # Non-filterable (for reference)
|
||||
"chunk_index": str(i), # Filterable
|
||||
}
|
||||
|
||||
if filename:
|
||||
metadata["filename"] = filename # Filterable
|
||||
|
||||
vector_obj = {
|
||||
"key": f"{filename}_{i}" if filename else f"chunk_{i}",
|
||||
"data": {"float32": embedding},
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
vectors.append(vector_obj)
|
||||
vectors: Final = [
|
||||
S3VectorEntry(
|
||||
key=f"{filename}_{i}" if filename else f"chunk_{i}",
|
||||
data=S3VectorDataPayload(float32=embedding),
|
||||
metadata=(
|
||||
{"source_text": chunk, "chunk_index": str(i), "filename": filename}
|
||||
if filename
|
||||
else {"source_text": chunk, "chunk_index": str(i)}
|
||||
),
|
||||
)
|
||||
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings))
|
||||
]
|
||||
|
||||
# Call PutVectors API
|
||||
await self._put_vectors(vectors)
|
||||
|
|
@ -468,7 +488,9 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
vector_store_id: Final = f"{self.vector_bucket_name}:{self.index_name}"
|
||||
return vector_store_id, filename
|
||||
|
||||
async def query_vector_store(self, vector_store_id: str, query: str, top_k: int = 5) -> dict[str, Any] | None:
|
||||
async def query_vector_store(
|
||||
self, vector_store_id: str, query: str, top_k: int = 5
|
||||
) -> S3VectorsQueryResponse | None:
|
||||
"""
|
||||
Query S3 Vectors using QueryVectors API.
|
||||
|
||||
|
|
@ -489,7 +511,7 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
embedding_model: Final = self.embedding_config.get("model", "text-embedding-3-small")
|
||||
|
||||
response = await litellm.aembedding(model=embedding_model, input=[query])
|
||||
query_embedding: Final = response.data[0]["embedding"]
|
||||
query_embedding: Final[Sequence[float]] = response.data[0]["embedding"]
|
||||
|
||||
# Call QueryVectors API
|
||||
url: Final = f"https://s3vectors.{self.aws_region_name}.api.aws/QueryVectors"
|
||||
|
|
@ -504,15 +526,18 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
}
|
||||
|
||||
try:
|
||||
response = await self._sign_and_execute_request("POST", url, data=safe_dumps(request_body))
|
||||
query_response: Final[httpx.Response] = await self._sign_and_execute_request(
|
||||
"POST", url, data=safe_dumps(request_body)
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results: Final = response.json()
|
||||
if query_response.status_code == 200:
|
||||
results: Final[S3VectorsQueryResponse] = query_response.json()
|
||||
matches: Final = results.get("vectors")
|
||||
verbose_logger.debug("Query returned %s results", len(results.get("vectors", [])))
|
||||
|
||||
# Check if query terms appear in results
|
||||
if results.get("vectors"):
|
||||
for result in results["vectors"]:
|
||||
if matches:
|
||||
for result in matches:
|
||||
metadata = result.get("metadata", {})
|
||||
source_text = metadata.get("source_text", "")
|
||||
if query.lower() in source_text.lower():
|
||||
|
|
@ -521,7 +546,9 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
|
|||
# Return results even if exact match not found
|
||||
return results
|
||||
else:
|
||||
verbose_logger.error("QueryVectors failed with status %s: %s", response.status_code, response.text)
|
||||
verbose_logger.error(
|
||||
"QueryVectors failed with status %s: %s", query_response.status_code, query_response.text
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
verbose_logger.exception("Error querying vectors: %s", e)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import re
|
|||
import traceback
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypedDict, overload
|
||||
|
||||
from openai.types.chat import ChatCompletionToolParam
|
||||
from openai.types.responses.function_tool_param import FunctionToolParam
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
|
||||
|
|
@ -18,6 +21,7 @@ from litellm.types.llms.openai import (
|
|||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.llms.openai import ToolParam as ResponsesToolParam
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
Choices,
|
||||
|
|
@ -36,10 +40,14 @@ else:
|
|||
MCPTool = Any
|
||||
|
||||
# NOTE: We intentionally keep ToolParam as a broad type here to avoid tight coupling
|
||||
# to optional OpenAI SDK typing symbols in environments that may not have them available.
|
||||
# `Any` is used to keep mypy compatible with the broader OpenAI tool union types
|
||||
# passed around in Responses API while still allowing dict-style access at runtime.
|
||||
ToolParam = Any
|
||||
ToolParam: TypeAlias = Mapping[str, object]
|
||||
|
||||
|
||||
class MCPToolResult(TypedDict):
|
||||
tool_call_id: str | None
|
||||
result: str
|
||||
name: str | None
|
||||
|
||||
|
||||
LITELLM_PROXY_MCP_SERVER_URL: Final = "litellm_proxy"
|
||||
LITELLM_PROXY_MCP_SERVER_URL_PREFIX: Final = f"{LITELLM_PROXY_MCP_SERVER_URL}/mcp/"
|
||||
|
|
@ -199,13 +207,12 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
_get_tools_from_mcp_servers,
|
||||
)
|
||||
|
||||
mcp_servers: Final[list[str]] = []
|
||||
if mcp_tools_with_litellm_proxy:
|
||||
for _tool in mcp_tools_with_litellm_proxy:
|
||||
# if user specifies servers as server_url: litellm_proxy/mcp/zapier,github then return zapier,github
|
||||
server_url = _tool.get("server_url", "") if isinstance(_tool, dict) else ""
|
||||
if isinstance(server_url, str) and server_url.startswith(LITELLM_PROXY_MCP_SERVER_URL_PREFIX):
|
||||
mcp_servers.append(server_url.split("/")[-1])
|
||||
mcp_servers: Final = [
|
||||
server_url.split("/")[-1]
|
||||
for _tool in (mcp_tools_with_litellm_proxy or ())
|
||||
for server_url in (_tool.get("server_url", "") if isinstance(_tool, dict) else "",)
|
||||
if isinstance(server_url, str) and server_url.startswith(LITELLM_PROXY_MCP_SERVER_URL_PREFIX)
|
||||
]
|
||||
|
||||
# Resolve toolset names: collect all toolset IDs first, then apply their
|
||||
# combined permissions in a single pass so multiple toolsets are unioned
|
||||
|
|
@ -279,15 +286,15 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
allowed_mcp_servers=allowed_mcp_servers,
|
||||
)
|
||||
|
||||
server_names: Final[list[str]] = []
|
||||
for server in allowed_mcp_servers:
|
||||
if server is None:
|
||||
continue
|
||||
server_name = (
|
||||
getattr(server, "server_name", None) or getattr(server, "alias", None) or getattr(server, "name", None)
|
||||
server_names: Final = [
|
||||
server_name
|
||||
for server in allowed_mcp_servers
|
||||
if server is not None
|
||||
for server_name in (
|
||||
getattr(server, "server_name", None) or getattr(server, "alias", None) or getattr(server, "name", None),
|
||||
)
|
||||
if isinstance(server_name, str):
|
||||
server_names.append(server_name)
|
||||
if isinstance(server_name, str)
|
||||
]
|
||||
|
||||
return tools, server_names
|
||||
|
||||
|
|
@ -305,8 +312,8 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
List of deduplicated MCP tools
|
||||
The returned dictionary maps each tool_name to the server_name
|
||||
"""
|
||||
seen_names: Final = set()
|
||||
deduplicated_tools: Final = []
|
||||
seen_names: Final[set[str]] = set()
|
||||
deduplicated_tools: Final[list[MCPTool]] = []
|
||||
tool_server_map: Final[dict[str, str]] = {}
|
||||
|
||||
for tool in mcp_tools:
|
||||
|
|
@ -331,7 +338,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
) -> list[MCPTool]:
|
||||
"""Filter MCP tools based on allowed_tools parameter from the original tool configs."""
|
||||
# Collect all allowed tool names from all MCP tool configs
|
||||
allowed_tool_names: Final = set()
|
||||
allowed_tool_names: Final[set[str]] = set()
|
||||
for tool_config in mcp_tools_with_litellm_proxy:
|
||||
if isinstance(tool_config, dict) and "allowed_tools" in tool_config:
|
||||
allowed_tools = tool_config.get("allowed_tools", [])
|
||||
|
|
@ -343,23 +350,13 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
return mcp_tools
|
||||
|
||||
# Filter tools based on allowed names
|
||||
filtered_tools: Final = []
|
||||
for mcp_tool in mcp_tools:
|
||||
if isinstance(mcp_tool, dict):
|
||||
tool_name = mcp_tool.get("name")
|
||||
else:
|
||||
tool_name = getattr(mcp_tool, "name", None)
|
||||
|
||||
if not tool_name:
|
||||
continue
|
||||
|
||||
if tool_name in allowed_tool_names:
|
||||
filtered_tools.append(mcp_tool)
|
||||
continue
|
||||
|
||||
unprefixed_name, _ = split_server_prefix_from_name(tool_name)
|
||||
if unprefixed_name in allowed_tool_names:
|
||||
filtered_tools.append(mcp_tool)
|
||||
filtered_tools: Final = [
|
||||
mcp_tool
|
||||
for mcp_tool in mcp_tools
|
||||
for tool_name in (mcp_tool.get("name") if isinstance(mcp_tool, dict) else getattr(mcp_tool, "name", None),)
|
||||
if tool_name
|
||||
and (tool_name in allowed_tool_names or split_server_prefix_from_name(tool_name)[0] in allowed_tool_names)
|
||||
]
|
||||
|
||||
return filtered_tools
|
||||
|
||||
|
|
@ -448,24 +445,37 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
|
||||
return deduplicated_mcp_tools, tool_server_map
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def _transform_mcp_tools_to_openai(
|
||||
mcp_tools: Sequence[MCPTool],
|
||||
target_format: Literal["responses"] = ...,
|
||||
) -> list[FunctionToolParam]: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def _transform_mcp_tools_to_openai(
|
||||
mcp_tools: Sequence[MCPTool],
|
||||
target_format: Literal["chat"],
|
||||
) -> list[ChatCompletionToolParam]: ...
|
||||
|
||||
@staticmethod
|
||||
def _transform_mcp_tools_to_openai(
|
||||
mcp_tools: Sequence[MCPTool],
|
||||
target_format: Literal["responses", "chat"] = "responses",
|
||||
) -> list[Any]:
|
||||
) -> Sequence[FunctionToolParam | ChatCompletionToolParam]:
|
||||
"""Transform MCP tools to OpenAI-compatible format."""
|
||||
from litellm.experimental_mcp_client.tools import (
|
||||
transform_mcp_tool_to_openai_responses_api_tool,
|
||||
transform_mcp_tool_to_openai_tool,
|
||||
)
|
||||
|
||||
openai_tools: Final[list[Any]] = []
|
||||
for mcp_tool in mcp_tools:
|
||||
if target_format == "chat":
|
||||
openai_tool = transform_mcp_tool_to_openai_tool(mcp_tool)
|
||||
else:
|
||||
openai_tool = transform_mcp_tool_to_openai_responses_api_tool(mcp_tool)
|
||||
openai_tools.append(openai_tool)
|
||||
openai_tools: Final = [
|
||||
transform_mcp_tool_to_openai_tool(mcp_tool)
|
||||
if target_format == "chat"
|
||||
else transform_mcp_tool_to_openai_responses_api_tool(mcp_tool)
|
||||
for mcp_tool in mcp_tools
|
||||
]
|
||||
|
||||
return openai_tools
|
||||
|
||||
|
|
@ -496,9 +506,9 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
return True
|
||||
|
||||
@staticmethod
|
||||
def _extract_tool_calls_from_response(response: ResponsesAPIResponse) -> list[Any]:
|
||||
def _extract_tool_calls_from_response(response: ResponsesAPIResponse) -> list[object]:
|
||||
"""Extract tool calls from the response output."""
|
||||
tool_calls: Final[list[Any]] = []
|
||||
tool_calls: Final[list[object]] = []
|
||||
for output_item in response.output:
|
||||
# Check if this is a function call output item
|
||||
if isinstance(output_item, dict) and output_item.get("type") == "function_call":
|
||||
|
|
@ -533,7 +543,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
|
||||
@staticmethod
|
||||
def _extract_tool_call_details(
|
||||
tool_call,
|
||||
tool_call: object,
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Extract tool name, arguments, and call_id from a tool call."""
|
||||
if isinstance(tool_call, dict):
|
||||
|
|
@ -566,7 +576,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
return tool_name, tool_arguments, tool_call_id
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_arguments(tool_arguments: Any) -> dict[str, Any]:
|
||||
def _parse_tool_arguments(tool_arguments: str | None) -> dict[str, object]:
|
||||
"""Parse tool arguments, handling both string and dict formats."""
|
||||
import json
|
||||
|
||||
|
|
@ -591,23 +601,18 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
# Fallback to generic handling if MCP types not available
|
||||
return "Tool executed successfully"
|
||||
|
||||
text_parts: Final = []
|
||||
other_content_types: Final = []
|
||||
|
||||
for content_item in result.content:
|
||||
if isinstance(content_item, TextContent):
|
||||
# Text content - extract the text
|
||||
text_parts.append(str(content_item.text))
|
||||
elif isinstance(content_item, ImageContent):
|
||||
# Image content
|
||||
other_content_types.append("Image")
|
||||
elif isinstance(content_item, EmbeddedResource):
|
||||
# Embedded resource
|
||||
other_content_types.append("EmbeddedResource")
|
||||
else:
|
||||
# Other unknown content types
|
||||
content_type = type(content_item).__name__
|
||||
other_content_types.append(content_type)
|
||||
text_parts: Final = [
|
||||
str(content_item.text) for content_item in result.content if isinstance(content_item, TextContent)
|
||||
]
|
||||
other_content_types: Final = [
|
||||
"Image"
|
||||
if isinstance(content_item, ImageContent)
|
||||
else "EmbeddedResource"
|
||||
if isinstance(content_item, EmbeddedResource)
|
||||
else type(content_item).__name__
|
||||
for content_item in result.content
|
||||
if not isinstance(content_item, TextContent)
|
||||
]
|
||||
|
||||
# Combine text parts if any
|
||||
result_text = " ".join(text_parts) if text_parts else ""
|
||||
|
|
@ -631,7 +636,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
litellm_call_id: str | None = None,
|
||||
litellm_trace_id: str | None = None,
|
||||
request_tags: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> list[MCPToolResult]:
|
||||
"""Execute tool calls and return results."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -645,11 +650,11 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
)
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
tool_results: Final = []
|
||||
tool_results: Final[list[MCPToolResult]] = []
|
||||
tool_call_id: str | None = None
|
||||
rules_obj: Final = Rules()
|
||||
for tool_call in tool_calls:
|
||||
logging_request_data: dict[str, Any] = {}
|
||||
logging_request_data: dict[str, object] = {}
|
||||
tool_name: str | None = None
|
||||
try:
|
||||
(
|
||||
|
|
@ -678,7 +683,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
sanitized_tool_name = strip_known_server_prefix(resolved_tool_name, mcp_server)
|
||||
|
||||
start_time = datetime.now()
|
||||
logging_input = [
|
||||
logging_input: Sequence[Mapping[str, object]] = [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": {
|
||||
|
|
@ -688,13 +693,14 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
}
|
||||
]
|
||||
tool_logging_call_id = litellm_call_id or str(uuid.uuid4())
|
||||
logging_metadata: dict[str, object] = {
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_name": sanitized_tool_name,
|
||||
"server_name": server_name,
|
||||
}
|
||||
logging_request_data = {
|
||||
"model": f"MCP: {tool_name}",
|
||||
"metadata": {
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_name": sanitized_tool_name,
|
||||
"server_name": server_name,
|
||||
},
|
||||
"metadata": logging_metadata,
|
||||
"input": logging_input,
|
||||
"call_type": CallTypes.call_mcp_tool.value,
|
||||
"litellm_call_id": tool_logging_call_id,
|
||||
|
|
@ -712,7 +718,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
if litellm_trace_id:
|
||||
logging_request_data["litellm_trace_id"] = litellm_trace_id
|
||||
if request_tags:
|
||||
logging_request_data["metadata"]["tags"] = request_tags
|
||||
logging_metadata["tags"] = request_tags
|
||||
if user_api_key_auth is not None:
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
LiteLLMProxyRequestSetup,
|
||||
|
|
@ -902,16 +908,16 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
|
||||
@staticmethod
|
||||
def _create_follow_up_messages_for_chat(
|
||||
original_messages: list[Any],
|
||||
original_messages: list[object],
|
||||
response: ModelResponse,
|
||||
tool_results: Sequence[Mapping[str, object]],
|
||||
) -> list[Any]:
|
||||
) -> Sequence[Mapping[str, object]]:
|
||||
"""Create follow-up chat messages that include tool execution results."""
|
||||
from copy import deepcopy
|
||||
|
||||
from litellm.utils import convert_list_message_to_dict
|
||||
|
||||
follow_up_messages: list[Any] = convert_list_message_to_dict(deepcopy(original_messages))
|
||||
follow_up_messages: list[dict[str, object]] = convert_list_message_to_dict(deepcopy(original_messages))
|
||||
|
||||
if not follow_up_messages:
|
||||
follow_up_messages = []
|
||||
|
|
@ -950,9 +956,9 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
response: ResponsesAPIResponse,
|
||||
tool_results: Sequence[Mapping[str, object]],
|
||||
original_input: str | ResponseInputParam | None = None,
|
||||
) -> list[Any]:
|
||||
) -> list[object]:
|
||||
"""Create follow-up input with tool results in proper format."""
|
||||
follow_up_input: Final[list[Any]] = []
|
||||
follow_up_input: Final[list[object]] = []
|
||||
|
||||
# Add original user input if available to maintain conversation context
|
||||
if original_input:
|
||||
|
|
@ -964,8 +970,8 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
follow_up_input.append(original_input)
|
||||
|
||||
# Add the assistant message with function calls
|
||||
assistant_message_content: Final[list[Any]] = []
|
||||
function_calls: Final[list[dict[str, Any]]] = []
|
||||
assistant_message_content: Final[list[object]] = []
|
||||
function_calls: Final[list[dict[str, object]]] = []
|
||||
|
||||
for output_item in response.output:
|
||||
if not isinstance(output_item, dict) and hasattr(output_item, "model_dump"):
|
||||
|
|
@ -1027,7 +1033,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
async def _make_follow_up_call(
|
||||
follow_up_input: list[Any],
|
||||
model: str,
|
||||
all_tools: list[Any] | None,
|
||||
all_tools: Sequence[ResponsesToolParam] | None,
|
||||
response_id: str,
|
||||
**call_params: Any,
|
||||
) -> ResponsesAPIResponse | BaseResponsesAPIStreamingIterator:
|
||||
|
|
@ -1044,7 +1050,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
async def _log_mcp_tool_failure(
|
||||
*,
|
||||
proxy_logging_obj: Optional["ProxyLogging"],
|
||||
user_api_key_auth: Any,
|
||||
user_api_key_auth: "UserAPIKeyAuth | None",
|
||||
request_data: dict[str, object],
|
||||
error: Exception,
|
||||
) -> None:
|
||||
|
|
@ -1072,7 +1078,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
all_tools: Sequence[object] | None,
|
||||
mcp_tools_with_litellm_proxy: list[Mapping[str, object]],
|
||||
mcp_discovery_events: list[ResponsesAPIStreamingResponse],
|
||||
call_params: dict[str, Any],
|
||||
call_params: Mapping[str, object],
|
||||
previous_response_id: str | None,
|
||||
tool_server_map: dict[str, str],
|
||||
**kwargs,
|
||||
|
|
@ -1115,10 +1121,10 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
input: str | ResponseInputParam,
|
||||
model: str,
|
||||
all_tools: Sequence[object] | None,
|
||||
call_params: dict[str, Any],
|
||||
call_params: Mapping[str, object],
|
||||
previous_response_id: str | None,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
**kwargs: object,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Build a clean request parameters dictionary for MCP streaming.
|
||||
|
||||
|
|
@ -1126,7 +1132,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
in a clean, maintainable way.
|
||||
"""
|
||||
# Start with the core required parameters
|
||||
request_params: Final = {
|
||||
request_params: Final[dict[str, object]] = {
|
||||
"input": input,
|
||||
"model": model,
|
||||
"tools": all_tools,
|
||||
|
|
@ -1146,7 +1152,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
|
||||
@staticmethod
|
||||
def _create_tool_execution_events(
|
||||
tool_calls: Sequence[object], tool_results: list[dict[str, Any]]
|
||||
tool_calls: Sequence[object], tool_results: Sequence[MCPToolResult]
|
||||
) -> list[ResponsesAPIStreamingResponse]:
|
||||
"""
|
||||
Create MCP tool execution events for streaming.
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ from litellm.types.llms.openai import (
|
|||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamEvents,
|
||||
ResponsesAPIStreamingResponse,
|
||||
ToolParam,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import MCPToolResult
|
||||
else:
|
||||
MCPTool = Any
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ MAX_MCP_TOOL_CALL_ROUNDS: Final = 5
|
|||
|
||||
|
||||
async def create_mcp_list_tools_events(
|
||||
mcp_tools_with_litellm_proxy: list[ToolParam],
|
||||
mcp_tools_with_litellm_proxy: Sequence[Mapping[str, object]],
|
||||
user_api_key_auth: "UserAPIKeyAuth | None",
|
||||
base_item_id: str,
|
||||
pre_processed_mcp_tools: list[MCPTool],
|
||||
|
|
@ -44,13 +44,14 @@ async def create_mcp_list_tools_events(
|
|||
|
||||
try:
|
||||
# Extract MCP server names
|
||||
mcp_servers: Final = []
|
||||
for tool in mcp_tools_with_litellm_proxy:
|
||||
if isinstance(tool, dict) and "server_url" in tool:
|
||||
server_url = tool.get("server_url")
|
||||
if isinstance(server_url, str) and server_url.startswith("litellm_proxy/mcp/"):
|
||||
server_name = server_url.split("/")[-1]
|
||||
mcp_servers.append(server_name)
|
||||
_mcp_servers: Final = [
|
||||
server_url.split("/")[-1]
|
||||
for tool in mcp_tools_with_litellm_proxy
|
||||
if isinstance(tool, dict)
|
||||
and "server_url" in tool
|
||||
and isinstance(server_url := tool.get("server_url"), str)
|
||||
and server_url.startswith("litellm_proxy/mcp/")
|
||||
]
|
||||
|
||||
# Emit list tools in progress event
|
||||
in_progress_event: Final = MCPListToolsInProgressEvent(
|
||||
|
|
@ -65,15 +66,14 @@ async def create_mcp_list_tools_events(
|
|||
filtered_mcp_tools: Final = pre_processed_mcp_tools
|
||||
|
||||
# Convert tools to dict format for the event
|
||||
mcp_tools_dict: Final = []
|
||||
for tool in filtered_mcp_tools:
|
||||
if hasattr(tool, "model_dump") and callable(getattr(tool, "model_dump")):
|
||||
# Type cast to help mypy understand this is safe after hasattr check
|
||||
mcp_tools_dict.append(cast(Any, tool).model_dump())
|
||||
elif hasattr(tool, "__dict__"):
|
||||
mcp_tools_dict.append(tool.__dict__)
|
||||
else:
|
||||
mcp_tools_dict.append({"name": getattr(tool, "name", str(tool))})
|
||||
_mcp_tools_dict: Final = [
|
||||
tool.model_dump()
|
||||
if hasattr(tool, "model_dump") and callable(getattr(tool, "model_dump"))
|
||||
else tool.__dict__
|
||||
if hasattr(tool, "__dict__")
|
||||
else {"name": getattr(tool, "name", str(tool))}
|
||||
for tool in filtered_mcp_tools
|
||||
]
|
||||
|
||||
# Emit list tools completed event
|
||||
completed_event: Final = MCPListToolsCompletedEvent(
|
||||
|
|
@ -96,21 +96,18 @@ async def create_mcp_list_tools_events(
|
|||
server_label = str(server_label_value) if server_label_value is not None else ""
|
||||
|
||||
# Format tools for OpenAI output_item.done format
|
||||
formatted_tools: Final = []
|
||||
for tool in filtered_mcp_tools:
|
||||
tool_dict = {
|
||||
formatted_tools: Final = [
|
||||
{
|
||||
"name": getattr(tool, "name", "unknown"),
|
||||
"description": getattr(tool, "description", ""),
|
||||
"annotations": {"read_only": False},
|
||||
**dict.fromkeys(
|
||||
("input_schema",) if hasattr(tool, "inputSchema") or hasattr(tool, "input_schema") else (),
|
||||
getattr(tool, "inputSchema", getattr(tool, "input_schema", None)),
|
||||
),
|
||||
}
|
||||
|
||||
# Add input_schema if available
|
||||
if hasattr(tool, "inputSchema"):
|
||||
tool_dict["input_schema"] = getattr(tool, "inputSchema")
|
||||
elif hasattr(tool, "input_schema"):
|
||||
tool_dict["input_schema"] = getattr(tool, "input_schema")
|
||||
|
||||
formatted_tools.append(tool_dict)
|
||||
for tool in filtered_mcp_tools
|
||||
]
|
||||
|
||||
# Create the output_item.done event with MCP tools list
|
||||
output_item_done_event = OutputItemDoneEvent(
|
||||
|
|
@ -166,7 +163,7 @@ async def create_mcp_list_tools_events(
|
|||
|
||||
def create_mcp_call_events(
|
||||
tool_name: str,
|
||||
tool_call_id: str,
|
||||
tool_call_id: str | None,
|
||||
arguments: str,
|
||||
result: str | None = None,
|
||||
base_item_id: str | None = None,
|
||||
|
|
@ -256,9 +253,12 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
4. Emits tool execution events in the stream
|
||||
"""
|
||||
|
||||
model: str
|
||||
tool_results: "Sequence[MCPToolResult]"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_iterator: Any, # Can be None - will be created internally
|
||||
base_iterator: "BaseResponsesAPIStreamingIterator | ResponsesAPIResponse | None", # created internally when None
|
||||
mcp_events: list[ResponsesAPIStreamingResponse],
|
||||
tool_server_map: dict[str, str],
|
||||
mcp_tools_with_litellm_proxy: Sequence[Mapping[str, object]] | None = None,
|
||||
|
|
@ -285,7 +285,9 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
|
|||
self.tool_server_map = tool_server_map
|
||||
|
||||
# Iterator references
|
||||
self.base_iterator: Any | ResponsesAPIResponse | None = base_iterator # Will be created when needed
|
||||
self.base_iterator: BaseResponsesAPIStreamingIterator | ResponsesAPIResponse | None = (
|
||||
base_iterator # Will be created when needed
|
||||
)
|
||||
|
||||
# Response collection for tool execution
|
||||
self.collected_response: ResponsesAPIResponse | None = None
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"ANN001": {
|
||||
"limit": 3114
|
||||
"limit": 3106
|
||||
},
|
||||
"ANN002": {
|
||||
"limit": 71
|
||||
},
|
||||
"ANN003": {
|
||||
"limit": 834
|
||||
"limit": 832
|
||||
},
|
||||
"ANN201": {
|
||||
"limit": 2031
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"limit": 133
|
||||
},
|
||||
"ANN401": {
|
||||
"limit": 1499
|
||||
"limit": 1495
|
||||
},
|
||||
"ASYNC230": {
|
||||
"limit": 11
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
"limit": 505
|
||||
},
|
||||
"B009": {
|
||||
"limit": 81
|
||||
"limit": 79
|
||||
},
|
||||
"B010": {
|
||||
"limit": 190
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
"limit": 3
|
||||
},
|
||||
"BLE001": {
|
||||
"limit": 2921
|
||||
"limit": 2924
|
||||
},
|
||||
"C401": {
|
||||
"limit": 8
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
"limit": 1
|
||||
},
|
||||
"C901": {
|
||||
"limit": 314
|
||||
"limit": 313
|
||||
},
|
||||
"D419": {
|
||||
"limit": 6
|
||||
|
|
@ -192,7 +192,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"S110": {
|
||||
"limit": 215
|
||||
"limit": 218
|
||||
},
|
||||
"S112": {
|
||||
"limit": 22
|
||||
|
|
@ -234,7 +234,7 @@
|
|||
"limit": 5
|
||||
},
|
||||
"TID251": {
|
||||
"limit": 1229
|
||||
"limit": 1226
|
||||
},
|
||||
"TRY002": {
|
||||
"limit": 528
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"LIT001": {
|
||||
"limit": 23121
|
||||
"limit": 23064
|
||||
},
|
||||
"LIT002": {
|
||||
"limit": 27090
|
||||
"limit": 27166
|
||||
},
|
||||
"LIT003": {
|
||||
"limit": 269
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"LIT006": {
|
||||
"limit": 1083
|
||||
"limit": 1078
|
||||
},
|
||||
"LIT007": {
|
||||
"limit": 0
|
||||
|
|
@ -27,9 +27,9 @@
|
|||
"limit": 0
|
||||
},
|
||||
"LIT010": {
|
||||
"limit": 16669
|
||||
"limit": 16753
|
||||
},
|
||||
"LIT011": {
|
||||
"limit": 5592
|
||||
"limit": 5598
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue