mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
Merge remote-tracking branch 'origin/main' into litellm_mcp_options_without_auth
This commit is contained in:
commit
719b8849d7
146 changed files with 10173 additions and 899 deletions
8
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
8
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
|
|
@ -9,10 +9,16 @@
|
|||
<!-- The PR author rewrites this in their own words before asking for review. Being able to sum up the PR in a few
|
||||
plain sentences proves the author understands what it does, and human-written technical English is far easier
|
||||
for a reviewer to parse than AI-written English
|
||||
An AI agent may draft a starting point here, but the author replaces it with their own handwritten version
|
||||
An AI agent may draft a starting point here, but always wraps its draft in the make-me-human-readable tags below,
|
||||
backticks included so the tags stay visible on GitHub, so nobody mistakes it for the author's words. The author rewrites it in their own words and deletes the tags.
|
||||
Tags still present at review time mean the summary is AI-written and has not been rewritten yet
|
||||
Say what the PR does for users and why, then anything that keeps it working over time (a sync job, a config someone owns)
|
||||
No bullets, tables, or code names here. Everything else goes inside the Details block below -->
|
||||
|
||||
`<make-me-human-readable>`
|
||||
<blah>
|
||||
`</make-me-human-readable>`
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ Same applies for filing bug reports and feature requests, with .github/ISSUE_TEM
|
|||
|
||||
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just drop the section
|
||||
|
||||
Run QA proxies with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --num_workers 2 --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`, and swap its `--config` for a copy of `litellm/proxy/dev_config.yaml` that points both `router_settings` (`redis_host`, `redis_port`) and `general_settings.coordination_redis` (`host`, `port`) at one dedicated Redis instance, with credentials when required, so the workers share router state and spend, budget, and rate-limit counters. Check that two worker PIDs are serving, and use the same worker count and Redis setup for the before and after proof. When the PR is about `--run_gunicorn`, `--run_hypercorn`, or `--run_granian`, which `--reload` does not support, drop `--reload`. When it is about behavior without Redis, drop Redis. When the proxy prints `"workers" flag is ignored when reloading is enabled`, that checkout or its uvicorn reloads only one worker, so drop the two-worker check for it. Say in the proof which part you dropped and why
|
||||
|
||||
Simple PRs may skip before/after e2e when the full diff is mechanical and focused checks completely prove it, such as #49030 restoring an existing helper name. Trace affected callers and run relevant tests/static checks at the tip. For a bug fix, establish that the regression catches the original defect. Size alone never qualifies. New behavior, API/provider semantics, auth, billing, persistence, concurrency or uncertain integration risk need live e2e. In Screenshots / Proof of Fix, record `E2E exception at <tip SHA>`, scope, rationale, commands and results. Tests count as proof only for this exception. Reassess changed diffs, retain CI/bot gates, and honor explicit e2e requests (2026-10-28, unnecessary live QA for #49030)
|
||||
|
||||
For other PRs, proof must show the end user's real flow before and after, with commands and output against a live proxy and real provider calls where applicable. Pytest alone is insufficient. For UI or interactive coding-tool flows, drive that surface and embed before/after screenshots, with URLs, clicks and input fields a reviewer can reproduce
|
||||
|
|
|
|||
|
|
@ -696,7 +696,7 @@ For companies that need better security, user management and professional suppor
|
|||
[Talk to founders](https://enterprise.litellm.ai/demo)
|
||||
|
||||
This covers:
|
||||
- ✅ **Features under the [LiteLLM Commercial License](https://docs.litellm.ai/docs/proxy/enterprise):**
|
||||
- ✅ **Features under the [LiteLLM Commercial License](https://docs.litellm.ai/docs/enterprise):**
|
||||
- ✅ **Feature Prioritization**
|
||||
- ✅ **Custom Integrations**
|
||||
- ✅ **Professional Support - Dedicated discord + slack**
|
||||
|
|
|
|||
|
|
@ -1647,6 +1647,7 @@ def set_global_gitlab_config(config: Mapping[str, object]) -> None:
|
|||
# Lazy loading system for heavy modules to reduce initial import time and memory usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import guardrails as guardrails
|
||||
from litellm.types.utils import ModelInfo as _ModelInfoType
|
||||
from litellm.types.utils import PriorityReservationSettings
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
|
@ -2499,10 +2500,10 @@ def __getattr__(name: str) -> Any:
|
|||
handler_func: Final = registry[name]
|
||||
return handler_func(name)
|
||||
|
||||
if name == "proxy":
|
||||
if name in ("proxy", "guardrails"):
|
||||
import importlib
|
||||
|
||||
return importlib.import_module("litellm.proxy")
|
||||
return importlib.import_module(f"litellm.{name}")
|
||||
|
||||
if name == "harness" or name in _AGENT_EXPORTS:
|
||||
import importlib
|
||||
|
|
|
|||
66
litellm/guardrails.py
Normal file
66
litellm/guardrails.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Invoke configured gateway guardrails without generating a completion."""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from enum import Enum
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared factory has untyped optional parameters
|
||||
)
|
||||
from litellm.types.guardrails import NativeGuardrailResponse
|
||||
|
||||
_NATIVE_RESPONSE: Final[TypeAdapter[NativeGuardrailResponse]] = TypeAdapter(NativeGuardrailResponse)
|
||||
|
||||
|
||||
class _InputType(Enum):
|
||||
REQUEST = "request"
|
||||
RESPONSE = "response"
|
||||
|
||||
|
||||
async def apply(
|
||||
guardrail_name: str,
|
||||
*,
|
||||
input: str | None = None,
|
||||
response: str | None = None,
|
||||
messages: Sequence[Mapping[str, object]] | None = None,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
api_base: str | None = None,
|
||||
api_key: str | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
) -> NativeGuardrailResponse:
|
||||
"""Return a configured guardrail's native structured findings.
|
||||
|
||||
Supply exactly one of ``input`` or ``response``. Grounding guardrails can
|
||||
use ``messages`` as evidence. Connection settings default to
|
||||
``litellm.api_base`` and ``litellm.api_key``.
|
||||
"""
|
||||
if (input is None) == (response is None):
|
||||
raise ValueError("Supply exactly one of input or response.")
|
||||
resolved_base: Final = api_base if api_base is not None else litellm.api_base
|
||||
if not resolved_base:
|
||||
raise ValueError("Set api_base or litellm.api_base to your LiteLLM gateway URL.")
|
||||
resolved_key: Final = api_key if api_key is not None else litellm.api_key
|
||||
input_type: Final = _InputType.RESPONSE if response is not None else _InputType.REQUEST
|
||||
payload: Final = dict[str, object](
|
||||
guardrail_name=guardrail_name,
|
||||
text=response if response is not None else input,
|
||||
input_type=input_type.value,
|
||||
response_format="native",
|
||||
)
|
||||
if messages is not None:
|
||||
payload["messages"] = [dict(message) for message in messages]
|
||||
if metadata is not None:
|
||||
payload["metadata"] = dict(metadata)
|
||||
gateway_root: Final = resolved_base.rstrip("/").removesuffix("/v1")
|
||||
client: Final = get_async_httpx_client(llm_provider="litellm_guardrails")
|
||||
result: Final = await client.post( # pyright: ignore[reportUnknownMemberType] # shared handler has untyped optional parameters
|
||||
url=f"{gateway_root}/guardrails/apply_guardrail",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {resolved_key}"} if resolved_key else {},
|
||||
timeout=timeout,
|
||||
)
|
||||
return _NATIVE_RESPONSE.validate_json(result.content)
|
||||
|
|
@ -32,6 +32,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
|
|||
tool_call_ids_answered_by_client,
|
||||
tool_call_ids_answered_in,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import string_field
|
||||
from litellm.llms.anthropic.common_utils import (
|
||||
is_claude_code_one_shot_subagent_request,
|
||||
supports_anthropic_cache_control,
|
||||
|
|
@ -128,7 +129,7 @@ def _message_mark_reaches_provider(fields: Mapping[str, object]) -> bool:
|
|||
return False
|
||||
return (
|
||||
not isinstance(fields.get("content"), list)
|
||||
or fields.get("role") in _ROLES_SENDING_MESSAGE_MARK_WITH_LIST_CONTENT
|
||||
or string_field(fields, "role") in _ROLES_SENDING_MESSAGE_MARK_WITH_LIST_CONTENT
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -315,6 +315,8 @@ _FINISH_REASON_MAP: Final[dict[str, OpenAIChatCompletionFinishReason]] = {
|
|||
"model_context_window_exceeded": "length",
|
||||
# Cohere
|
||||
"COMPLETE": "stop",
|
||||
"STOP_SEQUENCE": "stop",
|
||||
"TOOL_CALL": "tool_calls",
|
||||
"ERROR_TOXIC": "content_filter",
|
||||
"ERROR": "stop",
|
||||
# HuggingFace / Together AI
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import io
|
|||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from graphlib import CycleError, TopologicalSorter
|
||||
from itertools import chain, groupby, islice
|
||||
|
|
@ -3235,3 +3235,15 @@ def text_completion_prompt_to_messages(prompt: object) -> tuple[AllMessageValues
|
|||
if string_entries and len(string_entries) == len(entries):
|
||||
return tuple(ChatCompletionUserMessage(role="user", content=entry) for entry in string_entries)
|
||||
raise ValueError(f"`prompt` must be a non-empty string or a non-empty list of strings. Got: {prompt_type_name}.")
|
||||
|
||||
|
||||
def is_present_non_string(value: object) -> bool:
|
||||
return value is not None and not isinstance(value, str)
|
||||
|
||||
|
||||
def invalid_type_message(field_path: str, value: object) -> str:
|
||||
return f"Invalid type for '{field_path}': expected a string, got {type(value).__name__}"
|
||||
|
||||
|
||||
def is_unhashable_value_behind(error: TypeError, value: object) -> bool:
|
||||
return not isinstance(value, Hashable) and f"unhashable type: '{type(value).__name__}'" in str(error)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
|
|||
parts_of,
|
||||
place_mid_conversation_system,
|
||||
split_leading_system_run,
|
||||
string_field,
|
||||
)
|
||||
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client, header_value
|
||||
|
|
@ -2023,7 +2024,7 @@ def _server_tool_answers_replaced_after(messages: Sequence[object], index: int)
|
|||
following: Final = _messages_until_the_next_assistant(messages, index)
|
||||
a_user_turn_remains: Final = any(
|
||||
message_field(message, "role") in ("user", "tool", "function")
|
||||
and message_field(message, "tool_call_id") not in kept_server_result_ids
|
||||
and string_field(message, "tool_call_id") not in kept_server_result_ids
|
||||
for message in following
|
||||
)
|
||||
return _tool_call_ids_in_tool_messages(following) & kept_server_result_ids if a_user_turn_remains else frozenset()
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ def message_field(message: object, key: str) -> object:
|
|||
return mapping.get(key) if mapping is not None else getattr(message, key, None)
|
||||
|
||||
|
||||
def string_field(message: object, key: str) -> str | None:
|
||||
value: Final = message_field(message, key)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def is_system_message(message: object) -> bool:
|
||||
return message_field(message, "role") == "system"
|
||||
|
||||
|
|
|
|||
|
|
@ -905,13 +905,21 @@ class CustomStreamWrapper:
|
|||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def _served_openrouter_model(self) -> str | None:
|
||||
if self.custom_llm_provider == LlmProviders.OPENROUTER.value or (
|
||||
self.custom_llm_provider == "cached_response"
|
||||
and self.logging_obj.model_call_details.get("custom_llm_provider") == LlmProviders.OPENROUTER.value
|
||||
):
|
||||
return self._provider_response_model
|
||||
return None
|
||||
|
||||
def model_response_creator(
|
||||
self,
|
||||
chunk: dict | None = None,
|
||||
hidden_params: Mapping[str, object] | None = None,
|
||||
delta: Delta | None = None,
|
||||
) -> ModelResponseStream:
|
||||
_model: Final = self._cached_model_name
|
||||
_model: Final = self._served_openrouter_model() or self._cached_model_name
|
||||
_logging_obj_llm_provider: Final = self._cached_logging_llm_provider
|
||||
|
||||
if chunk is None:
|
||||
|
|
@ -1699,7 +1707,7 @@ class CustomStreamWrapper:
|
|||
return dispatch_result.value
|
||||
response_obj = dispatch_result.response_obj
|
||||
|
||||
model_response.model = self.model
|
||||
model_response.model = self._served_openrouter_model() or self.model
|
||||
## FUNCTION CALL PARSING
|
||||
original_chunk: Final[_UpstreamChunkLike | None] = (
|
||||
response_obj.get("original_chunk") if response_obj is not None else None
|
||||
|
|
@ -2561,7 +2569,7 @@ class CustomStreamWrapper:
|
|||
if partial is None or not isinstance(usage, Usage):
|
||||
return None
|
||||
if self.model:
|
||||
partial.model = self.model
|
||||
partial.model = self._served_openrouter_model() or self.model
|
||||
backfill_missing_cache_usage_fields(usage)
|
||||
return partial
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import copy
|
|||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, NoReturn, cast
|
||||
|
||||
|
|
@ -27,7 +29,10 @@ from litellm.constants import (
|
|||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, map_finish_reason
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
DEFS_MAX_INLINED_BYTES,
|
||||
invalid_type_message,
|
||||
is_object_schema,
|
||||
is_present_non_string,
|
||||
is_unhashable_value_behind,
|
||||
sanitize_input_schema_for_anthropic,
|
||||
schema_fragment_ref,
|
||||
schema_grammar_branch_count,
|
||||
|
|
@ -45,6 +50,8 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import (
|
|||
inline_remote_image_urls,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
|
||||
message_field,
|
||||
parts_of,
|
||||
place_mid_conversation_system,
|
||||
split_leading_system_run,
|
||||
)
|
||||
|
|
@ -179,6 +186,76 @@ NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS: Final = 24
|
|||
NATIVE_OUTPUT_MAX_GRAMMAR_BRANCHES: Final = 2048
|
||||
|
||||
|
||||
def _tool_call_ids_failing(
|
||||
index: int, tool_calls: object, fails: Callable[[object], bool]
|
||||
) -> Iterator[tuple[str, object]]:
|
||||
ids: Final = ((position, message_field(tool_call, "id")) for position, tool_call in enumerate(parts_of(tool_calls)))
|
||||
return (
|
||||
(f"messages[{index}].tool_calls[{position}].id", tool_call_id)
|
||||
for position, tool_call_id in ids
|
||||
if fails(tool_call_id)
|
||||
)
|
||||
|
||||
|
||||
def _roles_failing(messages: Sequence[object], fails: Callable[[object], bool]) -> Iterator[tuple[str, object]]:
|
||||
roles: Final = ((index, message_field(message, "role")) for index, message in enumerate(messages))
|
||||
return ((f"messages[{index}].role", role) for index, role in roles if fails(role))
|
||||
|
||||
|
||||
def _is_non_empty_non_string(value: object) -> bool:
|
||||
return bool(value) and is_present_non_string(value)
|
||||
|
||||
|
||||
def _assistant_tool_call_ids_failing(
|
||||
messages: Sequence[object], fails: Callable[[object], bool]
|
||||
) -> Iterator[tuple[str, object]]:
|
||||
assistant_turns: Final = (
|
||||
(index, message) for index, message in enumerate(messages) if message_field(message, "role") == "assistant"
|
||||
)
|
||||
return chain.from_iterable(
|
||||
_tool_call_ids_failing(index, message_field(message, "tool_calls"), fails) for index, message in assistant_turns
|
||||
)
|
||||
|
||||
|
||||
def _tool_answer_ids_failing(
|
||||
messages: Sequence[object], fails: Callable[[object], bool]
|
||||
) -> Iterator[tuple[str, object]]:
|
||||
answers: Final = (
|
||||
(index, message_field(message, "tool_call_id"))
|
||||
for index, message in enumerate(messages)
|
||||
if message_field(message, "role") in ("tool", "function")
|
||||
)
|
||||
return ((f"messages[{index}].tool_call_id", tool_call_id) for index, tool_call_id in answers if fails(tool_call_id))
|
||||
|
||||
|
||||
def _is_non_string_value_behind(error: AttributeError, value: object) -> bool:
|
||||
return is_present_non_string(value) and value is error.obj
|
||||
|
||||
|
||||
def _fields_behind(messages: Sequence[object], error: Exception) -> Iterable[tuple[str, object]]:
|
||||
match error:
|
||||
case TypeError():
|
||||
caused_error: Final = partial(is_unhashable_value_behind, error)
|
||||
return chain(
|
||||
_assistant_tool_call_ids_failing(messages, caused_error),
|
||||
_tool_answer_ids_failing(messages, caused_error),
|
||||
_roles_failing(messages, caused_error),
|
||||
)
|
||||
case AttributeError():
|
||||
return _assistant_tool_call_ids_failing(messages, partial(_is_non_string_value_behind, error))
|
||||
case _:
|
||||
return ()
|
||||
|
||||
|
||||
def non_string_message_field_error(messages: Sequence[object], error: Exception) -> AnthropicError | None:
|
||||
malformed_field: Final = next(
|
||||
chain(_roles_failing(messages, _is_non_empty_non_string), _fields_behind(messages, error)), None
|
||||
)
|
||||
if malformed_field is None:
|
||||
return None
|
||||
return AnthropicError(status_code=400, message=invalid_type_message(*malformed_field))
|
||||
|
||||
|
||||
def _tool_type(tool: object) -> str | None:
|
||||
match tool:
|
||||
case {"type": str() as tool_type}:
|
||||
|
|
@ -2370,12 +2447,18 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
# Handling anthropic API Prompt Caching
|
||||
if len(anthropic_system_message_list) > 0:
|
||||
optional_params["system"] = anthropic_system_message_list
|
||||
conversation: Final = place_mid_conversation_system(
|
||||
later_messages,
|
||||
supports_mid_conversation_system=supports_mid_conversation_system(
|
||||
model=model, custom_llm_provider=self.custom_llm_provider
|
||||
),
|
||||
)
|
||||
try:
|
||||
conversation: Final = place_mid_conversation_system(
|
||||
later_messages,
|
||||
supports_mid_conversation_system=supports_mid_conversation_system(
|
||||
model=model, custom_llm_provider=self.custom_llm_provider
|
||||
),
|
||||
)
|
||||
except TypeError as error:
|
||||
field_error: Final = non_string_message_field_error(messages, error)
|
||||
if field_error is None:
|
||||
raise
|
||||
raise field_error from error
|
||||
# Format rest of message according to anthropic guidelines
|
||||
try:
|
||||
anthropic_messages = anthropic_messages_pt(
|
||||
|
|
@ -2385,7 +2468,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
drop_params=drop_params,
|
||||
)
|
||||
except Exception as e:
|
||||
raise AnthropicError(
|
||||
conversion_field_error: Final = non_string_message_field_error(messages, e)
|
||||
raise conversion_field_error or AnthropicError(
|
||||
status_code=400,
|
||||
message=f"{e}\nReceived Messages={request_messages}",
|
||||
) # don't use verbose_logger.exception, if exception is raised
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class _ClearToolUsesFields(BaseModel):
|
|||
clear_tool_inputs: bool | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
_BLOCK: Final = TypeAdapter(Mapping[str, object])
|
||||
_BLOCKS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
# api.anthropic.com, measured 2026-10-19 and 2026-10-28: its wording for the shapes pydantic words differently
|
||||
_API_MESSAGES: Final = MappingProxyType(
|
||||
|
|
@ -317,12 +318,10 @@ def _clear_tool_results(
|
|||
new_blocks: list[object] = []
|
||||
mutated = False
|
||||
for block in content:
|
||||
if (
|
||||
isinstance(block, dict)
|
||||
and block.get("type") == "tool_result"
|
||||
and block.get("tool_use_id") in ids_to_clear
|
||||
):
|
||||
new_blocks.append(_cleared_result(block))
|
||||
if (parsed := _parse(_BLOCK, block)) is not None and _block_id(
|
||||
parsed, "tool_result", "tool_use_id"
|
||||
) in ids_to_clear:
|
||||
new_blocks.append(_cleared_result(parsed))
|
||||
mutated = True
|
||||
cleared += 1
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ def responses_batch_body_to_chat_body(
|
|||
openai_request_body: Mapping[str, object],
|
||||
custom_llm_provider: str | None = None,
|
||||
drop_params: bool | None = None,
|
||||
*,
|
||||
caller_input: str | ResponseInputParam | None = None,
|
||||
) -> dict[str, object]: # mutable-ok: provider transforms take the bridged chat body as a plain dict
|
||||
"""
|
||||
Rewrite the body of an OpenAI `/v1/responses` batch record as a Chat Completions body.
|
||||
|
|
@ -52,5 +54,6 @@ def responses_batch_body_to_chat_body(
|
|||
responses_api_request=responses_request,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
drop_params=drop_params,
|
||||
caller_input=caller_input,
|
||||
metadata=openai_request_body.get("metadata"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
extract_file_data,
|
||||
text_completion_prompt_to_messages,
|
||||
)
|
||||
from litellm.llms.anthropic.common_utils import AnthropicError
|
||||
from litellm.llms.base_llm.base_utils import map_developer_role_to_system_role
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.files.batch_records import responses_batch_body_to_chat_body
|
||||
|
|
@ -347,7 +348,14 @@ class _BatchInputLine:
|
|||
record: _OpenAIBatchRecord
|
||||
|
||||
|
||||
_BATCH_RECORD_CONVERSION_ERRORS: Final = (AttributeError, LookupError, TypeError, ValueError, BadRequestError)
|
||||
_BATCH_RECORD_CONVERSION_ERRORS: Final = (
|
||||
AttributeError,
|
||||
LookupError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
BadRequestError,
|
||||
AnthropicError,
|
||||
)
|
||||
|
||||
|
||||
def _batch_line_error(line_number: int, problem: str) -> BedrockError:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from typing import Final, Literal
|
|||
|
||||
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.types.llms.cohere import CohereV2StreamedToolCall
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionToolCallChunk,
|
||||
|
|
@ -222,6 +223,7 @@ class CohereV2ModelResponseIterator:
|
|||
self.content_blocks: list = []
|
||||
self.tool_index = -1
|
||||
self.json_mode = json_mode
|
||||
self.tool_call_indexes_with_arguments: frozenset[int] = frozenset()
|
||||
|
||||
def _parse_content_delta(self, chunk: dict) -> str:
|
||||
"""Parse content-delta chunks to extract text."""
|
||||
|
|
@ -234,20 +236,23 @@ class CohereV2ModelResponseIterator:
|
|||
return content
|
||||
return ""
|
||||
|
||||
def _parse_tool_call_delta(self, chunk: dict) -> ChatCompletionToolCallChunk | None:
|
||||
"""Parse tool-call-delta chunks to extract tool calls."""
|
||||
delta: Final = chunk.get("delta", {})
|
||||
tool_calls: Final = delta.get("tool_calls", [])
|
||||
if tool_calls:
|
||||
return {
|
||||
"id": tool_calls[0].get("id", ""),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_calls[0].get("name", ""),
|
||||
"arguments": tool_calls[0].get("arguments", ""),
|
||||
},
|
||||
}
|
||||
return None
|
||||
def _parse_tool_call_delta(self, chunk: dict, index: int) -> ChatCompletionToolCallChunk:
|
||||
"""Parse tool-call-start and tool-call-delta chunks to extract tool calls."""
|
||||
tool_call: Final = CohereV2StreamedToolCall.model_validate(chunk)
|
||||
if tool_call.arguments:
|
||||
self.tool_call_indexes_with_arguments = self.tool_call_indexes_with_arguments | {index}
|
||||
return {
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {"name": tool_call.name, "arguments": tool_call.arguments},
|
||||
"index": index,
|
||||
}
|
||||
|
||||
def _parse_tool_call_end(self, index: int) -> ChatCompletionToolCallChunk | None:
|
||||
"""Close a tool call that streamed no arguments with an empty JSON object, as OpenAI streams one."""
|
||||
if index in self.tool_call_indexes_with_arguments:
|
||||
return None
|
||||
return {"id": None, "type": "function", "function": {"name": None, "arguments": "{}"}, "index": index}
|
||||
|
||||
def _parse_tool_plan_delta(self, chunk: dict) -> dict | None:
|
||||
"""Parse tool-plan-delta events to extract tool plan."""
|
||||
|
|
@ -301,7 +306,8 @@ class CohereV2ModelResponseIterator:
|
|||
|
||||
v2 format:
|
||||
- Content: chunk.type == "content-delta" -> chunk.delta.message.content.text
|
||||
- Tool calls: chunk.type == "tool-call-delta" -> chunk.delta.tool_calls
|
||||
- Tool calls: chunk.type in ("tool-call-start", "tool-call-delta") -> chunk.delta.message.tool_calls
|
||||
- Tool call end: chunk.type == "tool-call-end" -> "{}" arguments when the call streamed none
|
||||
- Tool plan: chunk.event == "tool-plan-delta" -> chunk.data.delta.message.tool_plan
|
||||
- Citations: chunk.event == "citation-start" -> chunk.data.delta.message.citations
|
||||
- Finish: chunk.event == "message-end" -> chunk.data.delta.finish_reason
|
||||
|
|
@ -321,8 +327,12 @@ class CohereV2ModelResponseIterator:
|
|||
# Handle different chunk types
|
||||
if chunk_type == "content-delta":
|
||||
text = self._parse_content_delta(chunk)
|
||||
elif chunk_type == "tool-call-delta":
|
||||
tool_use = self._parse_tool_call_delta(chunk)
|
||||
elif chunk_type in ("tool-call-start", "tool-call-delta", "tool-call-end"):
|
||||
tool_use = (
|
||||
self._parse_tool_call_end(index)
|
||||
if chunk_type == "tool-call-end"
|
||||
else self._parse_tool_call_delta(chunk, index)
|
||||
)
|
||||
elif event_type == "tool-plan-delta":
|
||||
provider_specific_fields = self._parse_tool_plan_delta(chunk)
|
||||
elif event_type == "citation-start":
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from litellm.llms.base_llm.chat.transformation import BaseConfig
|
|||
from litellm.llms.base_llm.image_variations.transformation import (
|
||||
BaseImageVariationConfig,
|
||||
)
|
||||
from litellm.llms.custom_httpx.aiohttp_pool_diagnostics import build_pool_wait_trace_config
|
||||
from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
|
|
@ -90,7 +91,7 @@ class BaseLLMAIOHTTPHandler:
|
|||
if self.transport is None:
|
||||
connector: Final = self._get_connector()
|
||||
if connector:
|
||||
return aiohttp.ClientSession(connector=connector)
|
||||
return aiohttp.ClientSession(connector=connector, trace_configs=[build_pool_wait_trace_config()])
|
||||
|
||||
transport: Final = self.transport or self._get_or_create_transport()
|
||||
if transport is not None and hasattr(transport, "_get_valid_client_session"):
|
||||
|
|
@ -99,7 +100,7 @@ class BaseLLMAIOHTTPHandler:
|
|||
except RuntimeError:
|
||||
pass
|
||||
|
||||
return aiohttp.ClientSession()
|
||||
return aiohttp.ClientSession(trace_configs=[build_pool_wait_trace_config()])
|
||||
|
||||
def _get_async_client_session(self, dynamic_client_session: ClientSession | None = None) -> ClientSession:
|
||||
if dynamic_client_session:
|
||||
|
|
|
|||
130
litellm/llms/custom_httpx/aiohttp_pool_diagnostics.py
Normal file
130
litellm/llms/custom_httpx/aiohttp_pool_diagnostics.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import (
|
||||
ClientSession,
|
||||
TraceConfig,
|
||||
TraceConnectionQueuedEndParams,
|
||||
TraceConnectionQueuedStartParams,
|
||||
TraceRequestExceptionParams,
|
||||
TraceRequestRedirectParams,
|
||||
TraceRequestStartParams,
|
||||
)
|
||||
from yarl import URL
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
_POOL_WAIT_WARNING_SECONDS: Final = 1.0
|
||||
|
||||
|
||||
class _PoolWaitContext(SimpleNamespace):
|
||||
def __init__(self, trace_request_ctx: object = None) -> None:
|
||||
super().__init__()
|
||||
self.host: str | None = None
|
||||
self.port: int | None = None
|
||||
self.started_at: float | None = None
|
||||
self.waited: float = 0.0
|
||||
self.warning: asyncio.TimerHandle | None = None
|
||||
self.warned: bool = False
|
||||
|
||||
def set_destination(self, params: TraceRequestStartParams) -> None:
|
||||
self.host = params.url.host
|
||||
self.port = params.url.port
|
||||
|
||||
def redirect(self, params: TraceRequestRedirectParams) -> None:
|
||||
location: Final = params.response.headers.get("Location") or params.response.headers.get("URI")
|
||||
if location is None:
|
||||
return
|
||||
try:
|
||||
destination: Final = params.url.join(URL(location))
|
||||
host: Final = destination.host
|
||||
port: Final = destination.port
|
||||
except ValueError:
|
||||
return
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def start(self, session: ClientSession) -> None:
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
self.started_at = loop.time()
|
||||
if not self.warned:
|
||||
self.warning = loop.call_later(max(0.0, _POOL_WAIT_WARNING_SECONDS - self.waited), self.warn, session)
|
||||
|
||||
def elapsed(self) -> float:
|
||||
return self.waited + (
|
||||
asyncio.get_running_loop().time() - self.started_at if self.started_at is not None else 0.0
|
||||
)
|
||||
|
||||
def warn(self, session: ClientSession) -> None:
|
||||
if self.warned or self.started_at is None:
|
||||
return
|
||||
self.warned = True
|
||||
connector: Final = session.connector
|
||||
if connector is None:
|
||||
return
|
||||
verbose_logger.warning(
|
||||
"aiohttp connection pool saturated: host=%s port=%s waited=%.3fs for a connection slot "
|
||||
"(acquired_total=%s, limit=%s, limit_per_host=%s). "
|
||||
"Wait occurred before sending the request upstream.",
|
||||
self.host,
|
||||
self.port,
|
||||
self.elapsed(),
|
||||
len(connector._acquired), # pyright: ignore[reportPrivateUsage] # aiohttp exposes no public acquired count
|
||||
connector.limit,
|
||||
connector.limit_per_host,
|
||||
)
|
||||
|
||||
def finish(self, session: ClientSession, failed: bool = False) -> None:
|
||||
if self.warning is not None:
|
||||
self.warning.cancel()
|
||||
self.warning = None
|
||||
if self.started_at is None:
|
||||
return
|
||||
if failed or self.elapsed() >= _POOL_WAIT_WARNING_SECONDS:
|
||||
self.warn(session)
|
||||
self.waited = self.elapsed()
|
||||
self.started_at = None
|
||||
|
||||
|
||||
async def _on_request_start(session: ClientSession, context: SimpleNamespace, params: TraceRequestStartParams) -> None:
|
||||
if isinstance(context, _PoolWaitContext):
|
||||
context.set_destination(params)
|
||||
|
||||
|
||||
async def _on_queued_start(
|
||||
session: ClientSession, context: SimpleNamespace, params: TraceConnectionQueuedStartParams
|
||||
) -> None:
|
||||
if isinstance(context, _PoolWaitContext):
|
||||
context.start(session)
|
||||
|
||||
|
||||
async def _on_queued_end(
|
||||
session: ClientSession, context: SimpleNamespace, params: TraceConnectionQueuedEndParams
|
||||
) -> None:
|
||||
if isinstance(context, _PoolWaitContext):
|
||||
context.finish(session)
|
||||
|
||||
|
||||
async def _on_request_exception(
|
||||
session: ClientSession, context: SimpleNamespace, params: TraceRequestExceptionParams
|
||||
) -> None:
|
||||
if isinstance(context, _PoolWaitContext):
|
||||
context.finish(session, failed=True)
|
||||
|
||||
|
||||
async def _on_request_redirect(
|
||||
session: ClientSession, context: SimpleNamespace, params: TraceRequestRedirectParams
|
||||
) -> None:
|
||||
if isinstance(context, _PoolWaitContext):
|
||||
context.redirect(params)
|
||||
|
||||
|
||||
def build_pool_wait_trace_config() -> TraceConfig:
|
||||
trace: Final = TraceConfig(trace_config_ctx_factory=_PoolWaitContext)
|
||||
trace.on_request_start.append(_on_request_start)
|
||||
trace.on_connection_queued_start.append(_on_queued_start)
|
||||
trace.on_connection_queued_end.append(_on_queued_end)
|
||||
trace.on_request_exception.append(_on_request_exception)
|
||||
trace.on_request_redirect.append(_on_request_redirect)
|
||||
return trace
|
||||
|
|
@ -44,6 +44,7 @@ from litellm.litellm_core_utils.logging_utils import track_llm_api_timing
|
|||
from litellm.litellm_core_utils.request_timeout_resolver import (
|
||||
get_configured_request_timeout,
|
||||
)
|
||||
from litellm.llms.custom_httpx.aiohttp_pool_diagnostics import build_pool_wait_trace_config
|
||||
from litellm.llms.custom_httpx.http2_connection_spread import AsyncHTTP2ConnectionSpread
|
||||
from litellm.types.llms.custom_http import *
|
||||
|
||||
|
|
@ -1456,6 +1457,7 @@ class AsyncHTTPHandler:
|
|||
connector=TCPConnector(**transport_connector_kwargs),
|
||||
cookie_jar=DummyCookieJar(),
|
||||
trust_env=trust_env,
|
||||
trace_configs=[build_pool_wait_trace_config()],
|
||||
)
|
||||
|
||||
# Use shared session if provided and valid
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ async def search_skills(
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> SkillSearchOutcome:
|
||||
if embedding_model is None:
|
||||
if embedding_model is None or not embedding_model.strip():
|
||||
return SkillSearchNotConfigured(
|
||||
reason="skill search needs litellm_settings.skill_search_embedding_model set to an embedding model from model_list"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15555,18 +15555,20 @@
|
|||
"input_cost_per_token": 2.65e-06,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 8000,
|
||||
"max_output_tokens": 2048,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.5e-06,
|
||||
"source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-70b-instruct.md",
|
||||
"supports_pdf_input": true
|
||||
},
|
||||
"bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0": {
|
||||
"input_cost_per_token": 3e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 8000,
|
||||
"max_output_tokens": 2048,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-8b-instruct.md",
|
||||
"supports_pdf_input": true
|
||||
},
|
||||
"bedrock/us-west-1/meta.llama3-70b-instruct-v1:0": {
|
||||
|
|
@ -48955,6 +48957,7 @@
|
|||
},
|
||||
"together_ai/Qwen/Qwen3.8-2.4T-A95B": {
|
||||
"cache_read_input_token_cost": 2.5e-07,
|
||||
"deprecation_date": "2026-11-04",
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 1010000,
|
||||
|
|
|
|||
|
|
@ -105,10 +105,10 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
|
|||
LazyFeature(
|
||||
name="a2a",
|
||||
module_path="litellm.proxy.agent_endpoints.a2a_endpoints",
|
||||
# ``/v1/a2a/{agent_id}/message/send`` is caught via the suffix so the
|
||||
# ``/v1/a2a/{agent_id}/message/send`` and ``/message/stream`` are caught via suffixes so the
|
||||
# ``/v1/a2a`` prefix doesn't subsume the discover prefix below.
|
||||
path_prefixes=("/a2a",),
|
||||
path_suffixes=("/message/send",),
|
||||
path_suffixes=("/message/send", "/message/stream"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="a2a_registration",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,57 @@
|
|||
],
|
||||
"title": "ValidationError",
|
||||
"type": "object"
|
||||
},
|
||||
"_A2AJsonRpcResponse": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Error"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Id"
|
||||
},
|
||||
"jsonrpc": {
|
||||
"const": "2.0",
|
||||
"title": "Jsonrpc",
|
||||
"type": "string"
|
||||
},
|
||||
"result": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Result"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"jsonrpc",
|
||||
"id"
|
||||
],
|
||||
"title": "_A2AJsonRpcResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -242,6 +293,119 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/a2a/{agent_id}/message/stream": {
|
||||
"post": {
|
||||
"description": "Invoke an agent using the A2A protocol (JSON-RPC 2.0).\n\nSupported methods:\n- message/send: Send a message and get a response\n- message/stream: Send a message and stream the response",
|
||||
"operationId": "invoke_agent_a2a_a2a__agent_id__message_stream_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "agent_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Agent Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"id": "stream-1",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"messageId": "message-1",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Id"
|
||||
},
|
||||
"jsonrpc": {
|
||||
"const": "2.0",
|
||||
"title": "Jsonrpc",
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"title": "Method",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"additionalProperties": true,
|
||||
"title": "Params",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"jsonrpc",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "_A2AJsonRpcRequest",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/_A2AJsonRpcResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Invoke Agent A2A",
|
||||
"tags": [
|
||||
"a2a"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/a2a/{agent_id}/message/send": {
|
||||
"post": {
|
||||
"description": "Invoke an agent using the A2A protocol (JSON-RPC 2.0).\n\nSupported methods:\n- message/send: Send a message and get a response\n- message/stream: Send a message and stream the response",
|
||||
|
|
@ -287,6 +451,119 @@
|
|||
"a2a"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/a2a/{agent_id}/message/stream": {
|
||||
"post": {
|
||||
"description": "Invoke an agent using the A2A protocol (JSON-RPC 2.0).\n\nSupported methods:\n- message/send: Send a message and get a response\n- message/stream: Send a message and stream the response",
|
||||
"operationId": "invoke_agent_a2a_v1_a2a__agent_id__message_stream_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "agent_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Agent Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"id": "stream-1",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"messageId": "message-1",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Id"
|
||||
},
|
||||
"jsonrpc": {
|
||||
"const": "2.0",
|
||||
"title": "Jsonrpc",
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"title": "Method",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"additionalProperties": true,
|
||||
"title": "Params",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"jsonrpc",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "_A2AJsonRpcRequest",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/_A2AJsonRpcResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Invoke Agent A2A",
|
||||
"tags": [
|
||||
"a2a"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -15326,6 +15603,10 @@
|
|||
],
|
||||
"title": "Metadata"
|
||||
},
|
||||
"response_format": {
|
||||
"$ref": "#/components/schemas/GuardrailResponseFormat",
|
||||
"default": "standard"
|
||||
},
|
||||
"text": {
|
||||
"title": "Text",
|
||||
"type": "string"
|
||||
|
|
@ -16203,6 +16484,14 @@
|
|||
"title": "BedrockChecksSensitiveInformationModel",
|
||||
"type": "object"
|
||||
},
|
||||
"BedrockGuardrailOutputScope": {
|
||||
"enum": [
|
||||
"INTERVENTIONS",
|
||||
"FULL"
|
||||
],
|
||||
"title": "BedrockGuardrailOutputScope",
|
||||
"type": "string"
|
||||
},
|
||||
"BlockedWord": {
|
||||
"description": "Represents a blocked word with its action and optional description",
|
||||
"properties": {
|
||||
|
|
@ -16746,6 +17035,14 @@
|
|||
"title": "GuardrailInfoResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GuardrailResponseFormat": {
|
||||
"enum": [
|
||||
"standard",
|
||||
"native"
|
||||
],
|
||||
"title": "GuardrailResponseFormat",
|
||||
"type": "string"
|
||||
},
|
||||
"GuardrailSubmissionItem": {
|
||||
"properties": {
|
||||
"created_at": {
|
||||
|
|
@ -16998,6 +17295,7 @@
|
|||
"title": "HTTPValidationError",
|
||||
"type": "object"
|
||||
},
|
||||
"JsonValue": {},
|
||||
"LakeraCategoryThresholds": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
|
|
@ -17924,6 +18222,18 @@
|
|||
"description": "Whether to fail the request if the guardrail encounters an error. Implemented by guardrail='model_armor', 'generic_guardrail_api' and 'crowdstrike_aidr'. True (default) raises the error. False logs a critical error and lets the request proceed, so only a valid guardrail response can block or modify it.",
|
||||
"title": "Fail On Error"
|
||||
},
|
||||
"file_checkpoint_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "File-checkpoint ID for the Ovalix Tracker service (falls back to pre/post).",
|
||||
"title": "File Checkpoint Id"
|
||||
},
|
||||
"file_guardrail_timeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -18426,6 +18736,17 @@
|
|||
],
|
||||
"description": "Optional parameters for the guardrail"
|
||||
},
|
||||
"outputScope": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BedrockGuardrailOutputScope"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "ApplyGuardrail response scope. FULL includes detected and non-detected findings. Omit to retain Bedrock's default of returning detected findings only."
|
||||
},
|
||||
"output_parse_pii": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -20308,7 +20629,24 @@
|
|||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApplyGuardrailResponse"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApplyGuardrailResponse"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"title": "Response Apply Guardrail Apply Guardrail Post"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -20400,7 +20738,24 @@
|
|||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApplyGuardrailResponse"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApplyGuardrailResponse"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"title": "Response Apply Guardrail Guardrails Apply Guardrail Post"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/a2a/{agent_id}/message/send",
|
||||
"/v1/a2a/{agent_id}/message/send",
|
||||
"/a2a/{agent_id}/message/stream",
|
||||
"/v1/a2a/{agent_id}/message/stream",
|
||||
"/a2a/{agent_id}/.well-known/agent-card.json",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from collections.abc import Mapping
|
|||
from copy import deepcopy
|
||||
from typing import Final, Literal
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, iter_items
|
||||
|
||||
SupportedA2AVersion = Literal["0.3", "1.0"]
|
||||
|
||||
# Protocol versions LiteLLM can serve to A2A clients. The admin pins one per agent;
|
||||
|
|
@ -21,7 +23,6 @@ SUPPORTED_A2A_PROTOCOL_VERSIONS: Final[tuple[SupportedA2AVersion, ...]] = ("0.3"
|
|||
# Default served version when the agent card does not pin one.
|
||||
LITELLM_A2A_PROTOCOL_VERSION: Final = "1.0"
|
||||
|
||||
|
||||
_PROTOCOL_VERSION_PATTERN: Final = re.compile(
|
||||
r"^(\d+\.\d+)(?:\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?$"
|
||||
)
|
||||
|
|
@ -50,6 +51,24 @@ def resolve_served_protocol_version(card: Mapping[str, object] | None) -> str:
|
|||
return normalized if normalized is not None else LITELLM_A2A_PROTOCOL_VERSION
|
||||
|
||||
|
||||
def point_agent_card_at_proxy(
|
||||
agent_card: Mapping[str, object],
|
||||
proxy_url: str,
|
||||
) -> dict[str, object]: # mutable-ok: the public helper contract requires a dictionary
|
||||
interfaces: Final = agent_card.get("supportedInterfaces")
|
||||
interface_items: Final = (
|
||||
tuple(iter_items(agent_card.get("supportedInterfaces"))) if isinstance(interfaces, list) else ()
|
||||
)
|
||||
first: Final[object] = interface_items[0] if interface_items else None
|
||||
first_mapping: Final = as_str_mapping(first)
|
||||
pointed_interfaces: Final = (
|
||||
{"supportedInterfaces": [{**first_mapping, "url": proxy_url}, *interface_items[1:]]}
|
||||
if isinstance(first, dict) and first_mapping is not None
|
||||
else {}
|
||||
)
|
||||
return deepcopy({**agent_card, "url": proxy_url, **pointed_interfaces})
|
||||
|
||||
|
||||
# Security scheme exposed by the LiteLLM-fronted agent card. Always replaces
|
||||
# whatever upstream advertised — the client must authenticate to the proxy,
|
||||
# not the upstream agent.
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ The A2A SDK can point to LiteLLM's URL and invoke agents registered with LiteLLM
|
|||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping
|
||||
from copy import deepcopy
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
|
@ -29,6 +28,7 @@ from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
|
|||
from litellm.llms.a2a.common_utils import resolve_a2a_hop_auth_header
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.a2a.agent_card import point_agent_card_at_proxy
|
||||
from litellm.proxy.a2a.version_convert import (
|
||||
A2AVersion,
|
||||
normalize_agent_card,
|
||||
|
|
@ -43,6 +43,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.common_utils.custom_openapi_spec import inline_request_body
|
||||
from litellm.proxy.common_utils.openai_error_payload import status_typed_proxy_exception
|
||||
from litellm.proxy.common_utils.sse_keepalive import (
|
||||
SSE_COMMENT_PING,
|
||||
|
|
@ -59,6 +60,42 @@ if TYPE_CHECKING:
|
|||
|
||||
router: Final = APIRouter()
|
||||
|
||||
|
||||
class _A2AJsonRpcRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
jsonrpc: Literal["2.0"]
|
||||
id: str | int | None = None
|
||||
method: str
|
||||
params: Mapping[str, object]
|
||||
|
||||
|
||||
class _A2AJsonRpcResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
jsonrpc: Literal["2.0"]
|
||||
id: str | int | None
|
||||
result: object | None = None
|
||||
error: Mapping[str, object] | None = None
|
||||
|
||||
|
||||
_A2A_MESSAGE_STREAM_REQUEST_BODY: Final = inline_request_body(
|
||||
_A2AJsonRpcRequest,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "stream-1",
|
||||
"method": "message/stream",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "message-1",
|
||||
"role": "user",
|
||||
"parts": [{"kind": "text", "text": "Hello"}],
|
||||
"kind": "message",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Mirrors the native seam's own headers: a reverse proxy that batches the whole
|
||||
# stream would swallow the keepalives this route sends to defeat idle timeouts.
|
||||
_SSE_KEEPALIVE_HEADERS: Final[Mapping[str, str]] = MappingProxyType(
|
||||
|
|
@ -206,6 +243,35 @@ def _forwarding_headers(
|
|||
return merged or None
|
||||
|
||||
|
||||
async def _log_agent_rejection(
|
||||
*,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_id: object,
|
||||
method: str | None,
|
||||
rejection: HTTPException,
|
||||
target_agent_id: str,
|
||||
model: str,
|
||||
) -> None:
|
||||
request_data: Final[dict[str, object]] = { # mutable-ok: the failure hook stamps its logging state onto it
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": method,
|
||||
"model": model,
|
||||
"agent_id": target_agent_id,
|
||||
"metadata": {"agent_id": target_agent_id},
|
||||
}
|
||||
try:
|
||||
await proxy_logging_obj.post_call_failure_hook( # pyright: ignore[reportUnknownMemberType] # bare dict in sig
|
||||
request_data=request_data,
|
||||
original_exception=rejection,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
route="/a2a/{agent_id}",
|
||||
)
|
||||
except Exception: # noqa: BLE001 # a logging failure must not change the rejection the caller gets
|
||||
verbose_proxy_logger.exception("Failed to log rejected A2A request")
|
||||
|
||||
|
||||
def _jsonrpc_error(
|
||||
request_id: object,
|
||||
code: int,
|
||||
|
|
@ -684,16 +750,12 @@ async def get_agent_card(
|
|||
)
|
||||
|
||||
proxy_url: Final = get_custom_url(str(request.base_url), route=f"a2a/{agent_id}")
|
||||
agent_card = deepcopy(agent.agent_card_params)
|
||||
agent_card["url"] = proxy_url
|
||||
interfaces: Final = agent_card.get("supportedInterfaces")
|
||||
if isinstance(interfaces, list) and interfaces:
|
||||
interfaces[0]["url"] = proxy_url
|
||||
agent_card: Final = point_agent_card_at_proxy(agent.agent_card_params, proxy_url)
|
||||
served_version: Final = _served_version(agent, request)
|
||||
agent_card = normalize_agent_card(agent_card, served_version)
|
||||
normalized_agent_card: Final = normalize_agent_card(agent_card, served_version)
|
||||
|
||||
verbose_proxy_logger.debug("Returning agent card for '%s' with proxy URL: %s", agent_id, proxy_url)
|
||||
return JSONResponse(content=agent_card)
|
||||
return JSONResponse(content=normalized_agent_card)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -717,6 +779,20 @@ async def get_agent_card(
|
|||
tags=["[beta] A2A Agents"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
@router.post(
|
||||
"/a2a/{agent_id}/message/stream",
|
||||
tags=["[beta] A2A Agents"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
openapi_extra={"requestBody": _A2A_MESSAGE_STREAM_REQUEST_BODY},
|
||||
responses={200: {"model": _A2AJsonRpcResponse}},
|
||||
)
|
||||
@router.post(
|
||||
"/v1/a2a/{agent_id}/message/stream",
|
||||
tags=["[beta] A2A Agents"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
openapi_extra={"requestBody": _A2A_MESSAGE_STREAM_REQUEST_BODY},
|
||||
responses={200: {"model": _A2AJsonRpcResponse}},
|
||||
)
|
||||
async def invoke_agent_a2a(
|
||||
agent_id: str,
|
||||
request: Request,
|
||||
|
|
@ -778,6 +854,15 @@ async def invoke_agent_a2a(
|
|||
# Find the agent
|
||||
agent: Final = await _get_agent(agent_id)
|
||||
if agent is None:
|
||||
await _log_agent_rejection(
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_id=request_id,
|
||||
method=method,
|
||||
rejection=HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found"),
|
||||
target_agent_id=agent_id,
|
||||
model=f"a2a_agent/{agent_id}",
|
||||
)
|
||||
return _jsonrpc_error(request_id, -32000, f"Agent '{agent_id}' not found", 404)
|
||||
|
||||
served_version: Final = _served_version(agent, request, original_method)
|
||||
|
|
@ -787,10 +872,20 @@ async def invoke_agent_a2a(
|
|||
user_api_key_auth=user_api_key_dict,
|
||||
)
|
||||
if not is_allowed:
|
||||
raise HTTPException(
|
||||
denial: Final = HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Agent '{agent_id}' is not allowed for your key/team. Contact proxy admin for access.",
|
||||
)
|
||||
await _log_agent_rejection(
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_id=request_id,
|
||||
method=method,
|
||||
rejection=denial,
|
||||
target_agent_id=agent.agent_id,
|
||||
model=f"a2a_agent/{(as_str_mapping(agent.agent_card_params or {}) or {}).get('name', agent_id)}",
|
||||
)
|
||||
raise denial
|
||||
|
||||
user_api_key_dict.invoked_agent_id = agent.agent_id
|
||||
strict_policy: Final = agent.identity_managed or bool(user_api_key_dict.requires_fresh_policy)
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ async def search_agents(
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> AgentSearchOutcome:
|
||||
if embedding_model is None:
|
||||
if embedding_model is None or not embedding_model.strip():
|
||||
return AgentSearchNotConfigured(
|
||||
reason="agent search needs litellm_settings.agent_search_embedding_model set to an embedding model from model_list"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from typing_extensions import ReadOnly, Required, assert_never
|
|||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS, AGENT_HEALTH_CHECK_TIMEOUT_SECONDS
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, iter_items
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.proxy._types import (
|
||||
CommonProxyErrors,
|
||||
|
|
@ -33,6 +34,7 @@ from litellm.proxy.a2a.agent_card import (
|
|||
SUPPORTED_A2A_PROTOCOL_VERSIONS,
|
||||
merge_agent_card,
|
||||
normalize_protocol_version,
|
||||
point_agent_card_at_proxy,
|
||||
)
|
||||
from litellm.proxy.agent_endpoints.agent_registry import (
|
||||
AgentIdWhere,
|
||||
|
|
@ -180,10 +182,36 @@ def _redact_agent_litellm_params_dict(
|
|||
return dict(parse_agent_litellm_params(redact_sensitive_agent_litellm_params(litellm_params)))
|
||||
|
||||
|
||||
def _non_admin_agent_card(
|
||||
card: Mapping[str, object], proxy_url: str
|
||||
) -> dict[str, object]: # mutable-ok: AgentResponse.agent_card_params requires a mutable dictionary
|
||||
pointed_card: Final = point_agent_card_at_proxy(card, proxy_url)
|
||||
interfaces: Final = pointed_card.get("supportedInterfaces")
|
||||
interface_items: Final = (
|
||||
tuple(iter_items(pointed_card.get("supportedInterfaces"))) if isinstance(interfaces, list) else ()
|
||||
)
|
||||
first_interface: Final[object] = interface_items[0] if interface_items else None
|
||||
first_mapping: Final = as_str_mapping(first_interface)
|
||||
proxy_interface: Final = (
|
||||
{"supportedInterfaces": [{**first_mapping, "url": proxy_url}]}
|
||||
if isinstance(first_interface, dict) and first_mapping is not None and first_mapping.get("url") == proxy_url
|
||||
else {}
|
||||
)
|
||||
return {
|
||||
**{
|
||||
key: value
|
||||
for key, value in pointed_card.items()
|
||||
if key not in ("additionalInterfaces", "supportedInterfaces")
|
||||
},
|
||||
**proxy_interface,
|
||||
}
|
||||
|
||||
|
||||
def _redact_sensitive_agent_fields(
|
||||
agents: Sequence[AgentResponse],
|
||||
*,
|
||||
is_admin: bool,
|
||||
request_base_url: str,
|
||||
) -> list[AgentResponse]:
|
||||
"""
|
||||
Return copies of the given agents with credential-bearing litellm_params
|
||||
|
|
@ -207,6 +235,11 @@ def _redact_sensitive_agent_fields(
|
|||
copy.keys = None
|
||||
copy.kill_switch = None
|
||||
copy.identity = None
|
||||
if copy.agent_card_params:
|
||||
copy.agent_card_params = _non_admin_agent_card(
|
||||
copy.agent_card_params,
|
||||
get_custom_url(request_base_url, route=f"a2a/{copy.agent_id}"),
|
||||
)
|
||||
if copy.litellm_params:
|
||||
copy.litellm_params = _redact_agent_litellm_params_dict(copy.litellm_params)
|
||||
copy.kill_switch = redact_kill_switch(copy.kill_switch)
|
||||
|
|
@ -461,14 +494,6 @@ async def get_agents(
|
|||
global_agent_registry.ids_for_agent(agent.agent_id).isdisjoint(litellm.public_agent_groups)
|
||||
)
|
||||
|
||||
# litellm_params secrets are always redacted; keys/headers stay
|
||||
# admin-only.
|
||||
is_admin: Final = (
|
||||
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
|
||||
)
|
||||
returned_agents = _redact_sensitive_agent_fields(returned_agents, is_admin=is_admin)
|
||||
|
||||
if health_check:
|
||||
agents_with_url: Final = [agent for agent in returned_agents if (agent.agent_card_params or {}).get("url")]
|
||||
agents_without_url = [agent for agent in returned_agents if not (agent.agent_card_params or {}).get("url")]
|
||||
|
|
@ -493,6 +518,18 @@ async def get_agents(
|
|||
healthy_ids: Final = {result["agent_id"] for result in health_results if result["healthy"]}
|
||||
returned_agents = [agent for agent in agents_with_url if agent.agent_id in healthy_ids] + agents_without_url
|
||||
|
||||
# litellm_params secrets are always redacted; keys/headers stay
|
||||
# admin-only.
|
||||
is_admin: Final = (
|
||||
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
|
||||
)
|
||||
returned_agents = _redact_sensitive_agent_fields(
|
||||
returned_agents,
|
||||
is_admin=is_admin,
|
||||
request_base_url=str(request.base_url),
|
||||
)
|
||||
|
||||
if query is None:
|
||||
return returned_agents
|
||||
return await _rank_agents_by_query(query, returned_agents, top_k, user_api_key_dict)
|
||||
|
|
@ -691,7 +728,11 @@ async def create_agent(
|
|||
|
||||
# The caller is a proxy admin (enforced above); litellm_params
|
||||
# secrets are still never echoed back in the response.
|
||||
return _redact_sensitive_agent_fields((result,), is_admin=True)[0]
|
||||
return _redact_sensitive_agent_fields(
|
||||
(result,),
|
||||
is_admin=True,
|
||||
request_base_url=str(http_request.base_url),
|
||||
)[0]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -708,6 +749,7 @@ async def create_agent(
|
|||
)
|
||||
async def get_agent_by_id(
|
||||
agent_id: str,
|
||||
request: Request,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
|
|
@ -770,7 +812,11 @@ async def get_agent_by_id(
|
|||
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
|
||||
)
|
||||
agent = _redact_sensitive_agent_fields((agent,), is_admin=is_admin)[0]
|
||||
agent = _redact_sensitive_agent_fields(
|
||||
(agent,),
|
||||
is_admin=is_admin,
|
||||
request_base_url=str(request.base_url),
|
||||
)[0]
|
||||
|
||||
return agent
|
||||
except HTTPException:
|
||||
|
|
@ -879,7 +925,11 @@ async def update_agent(
|
|||
"Successfully updated agent '%s' (ID: %s) in memory", existing_agent.get("agent_name"), agent_id
|
||||
)
|
||||
|
||||
return _redact_sensitive_agent_fields((result,), is_admin=True)[0]
|
||||
return _redact_sensitive_agent_fields(
|
||||
(result,),
|
||||
is_admin=True,
|
||||
request_base_url=str(http_request.base_url),
|
||||
)[0]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -987,7 +1037,11 @@ async def patch_agent(
|
|||
"Successfully updated agent '%s' (ID: %s) in memory", existing_agent.get("agent_name"), agent_id
|
||||
)
|
||||
|
||||
return _redact_sensitive_agent_fields((result,), is_admin=True)[0]
|
||||
return _redact_sensitive_agent_fields(
|
||||
(result,),
|
||||
is_admin=True,
|
||||
request_base_url=str(http_request.base_url),
|
||||
)[0]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
172
litellm/proxy/dev_reload.py
Normal file
172
litellm/proxy/dev_reload.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import importlib
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from functools import partial
|
||||
from inspect import signature
|
||||
from multiprocessing.process import BaseProcess
|
||||
from socket import socket
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import ConfigDict, ImportString, JsonValue, TypeAdapter, with_config
|
||||
from typing_extensions import ReadOnly, Required, TypedDict
|
||||
from uvicorn import Config, Server
|
||||
from uvicorn import config as uvicorn_config
|
||||
from uvicorn.supervisors import ChangeReload, Multiprocess
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
class ServerOptions(TypedDict, total=False):
|
||||
host: ReadOnly[str]
|
||||
port: ReadOnly[int]
|
||||
server_header: ReadOnly[bool]
|
||||
timeout_graceful_shutdown: ReadOnly[float]
|
||||
log_config: ReadOnly[ # mutable-ok: uvicorn.Config requires a concrete logging dict
|
||||
str | dict[str, JsonValue] | None
|
||||
]
|
||||
log_level: ReadOnly[str | int]
|
||||
timeout_keep_alive: ReadOnly[int]
|
||||
timeout_worker_healthcheck: ReadOnly[int]
|
||||
limit_max_requests: ReadOnly[int]
|
||||
limit_max_requests_jitter: ReadOnly[int]
|
||||
limit_concurrency: ReadOnly[int]
|
||||
ssl_keyfile: ReadOnly[str]
|
||||
ssl_certfile: ReadOnly[str]
|
||||
loop: ReadOnly[Literal["none", "auto", "asyncio", "uvloop"]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
class ReloadOptions(ServerOptions):
|
||||
app: Required[ReadOnly[str]]
|
||||
reload: ReadOnly[bool]
|
||||
reload_dirs: ReadOnly[tuple[str, ...]]
|
||||
reload_includes: ReadOnly[tuple[str, ...]]
|
||||
|
||||
|
||||
RELOAD_OPTIONS: Final = TypeAdapter(ReloadOptions)
|
||||
SERVER_OPTIONS: Final = TypeAdapter(ServerOptions)
|
||||
ASGI_APP: Final = TypeAdapter(ImportString[Callable[..., object]])
|
||||
LOGGER: Final = logging.getLogger("uvicorn.error")
|
||||
RELOAD_WORKER_HEALTHCHECK_SECONDS: Final = 30
|
||||
RELOADER_ONLY_KEYS: Final = frozenset(ReloadOptions.__annotations__) - frozenset(ServerOptions.__annotations__)
|
||||
|
||||
|
||||
def multi_worker_reload_supported(platform: str = sys.platform) -> bool:
|
||||
return platform != "win32" and hasattr(uvicorn_config, "STARTUP_FAILURE")
|
||||
|
||||
|
||||
def server_options(options: ReloadOptions) -> ServerOptions:
|
||||
return SERVER_OPTIONS.validate_python(
|
||||
{key: value for key, value in options.items() if key not in RELOADER_ONLY_KEYS}
|
||||
)
|
||||
|
||||
|
||||
def load_app_or_exit(import_path: str, factory: bool = False) -> Callable[..., object]:
|
||||
module_name: Final = import_path.partition(":")[0]
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
app: Final = ASGI_APP.validate_python(import_path)
|
||||
return ASGI_APP.validate_python(app()) if factory else app
|
||||
except Exception:
|
||||
LOGGER.exception("Error loading ASGI app %s", import_path)
|
||||
sys.exit(uvicorn_config.STARTUP_FAILURE)
|
||||
|
||||
|
||||
def stop_worker_when_pool_exits(pool_parent: BaseProcess) -> None:
|
||||
pool_parent.join()
|
||||
LOGGER.info("Worker pool process [%s] exited, stopping worker [%s].", pool_parent.pid, os.getpid())
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
def load_worker_app(import_path: str, worker_app_factory: bool) -> Callable[..., object]:
|
||||
pool_parent: Final = multiprocessing.parent_process()
|
||||
if pool_parent is not None:
|
||||
threading.Thread(target=stop_worker_when_pool_exits, args=(pool_parent,), daemon=True).start()
|
||||
return load_app_or_exit(import_path, worker_app_factory)
|
||||
|
||||
|
||||
def pool_config(options: ReloadOptions, workers: int, worker_overrides: Mapping[str, object] | None = None) -> Config:
|
||||
overrides: Final = worker_overrides or {}
|
||||
worker_app: Final = TypeAdapter(str).validate_python(overrides.get("app", options["app"]))
|
||||
worker_app_factory: Final = TypeAdapter(bool).validate_python(overrides.get("factory", False))
|
||||
return Config(
|
||||
partial(load_worker_app, worker_app, worker_app_factory),
|
||||
factory=True,
|
||||
workers=workers,
|
||||
**SERVER_OPTIONS.validate_python(
|
||||
{
|
||||
"timeout_worker_healthcheck": RELOAD_WORKER_HEALTHCHECK_SECONDS,
|
||||
**server_options(options),
|
||||
**{key: value for key, value in overrides.items() if key not in ("app", "factory")},
|
||||
}
|
||||
), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int
|
||||
)
|
||||
|
||||
|
||||
def stop_pool_when_reloader_exits(reloader: BaseProcess, pool_should_exit: threading.Event) -> None:
|
||||
reloader.join()
|
||||
LOGGER.info("Reloader process [%s] exited, stopping the worker pool.", reloader.pid)
|
||||
pool_should_exit.set()
|
||||
|
||||
|
||||
class ReloadWorkerPool(Multiprocess):
|
||||
quit_received: bool = False
|
||||
|
||||
def handle_quit(self) -> None:
|
||||
LOGGER.info("Received SIGQUIT, exiting.")
|
||||
self.quit_received = True
|
||||
self.should_exit.set()
|
||||
|
||||
def terminate_all(self) -> None:
|
||||
quit_signal: Final = getattr(signal, "SIGQUIT", None)
|
||||
if quit_signal is not None and quit_signal in self.signal_queue:
|
||||
self.handle_quit()
|
||||
if not self.quit_received:
|
||||
super().terminate_all()
|
||||
return
|
||||
for process in self.processes:
|
||||
process.kill()
|
||||
|
||||
|
||||
def _run_worker_pool(config: Config, sockets: Sequence[socket] | None) -> None:
|
||||
shared_sockets: Final = list(sockets or ())
|
||||
pool: Final = (
|
||||
ReloadWorkerPool(
|
||||
config=config,
|
||||
target=Server(config=config).run, # pyright: ignore[reportCallIssue] # Older uvicorn versions require target
|
||||
sockets=shared_sockets,
|
||||
)
|
||||
if "target" in signature(Multiprocess).parameters
|
||||
else ReloadWorkerPool(config=config, sockets=shared_sockets)
|
||||
)
|
||||
reloader: Final = multiprocessing.parent_process()
|
||||
if reloader is not None:
|
||||
threading.Thread(target=stop_pool_when_reloader_exits, args=(reloader, pool.should_exit), daemon=True).start()
|
||||
pool.run()
|
||||
|
||||
|
||||
def run_multi_worker_reload(
|
||||
options: ReloadOptions, workers: int, worker_overrides: Mapping[str, object] | None = None
|
||||
) -> None:
|
||||
worker_config: Final = pool_config(options, workers, worker_overrides)
|
||||
reload_config: Final = Config(
|
||||
options["app"],
|
||||
workers=1,
|
||||
reload=options["reload"],
|
||||
reload_dirs=list(options["reload_dirs"]),
|
||||
reload_includes=list(options["reload_includes"]),
|
||||
**server_options(options), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int
|
||||
)
|
||||
shared_socket: Final = worker_config.bind_socket()
|
||||
try:
|
||||
ChangeReload(
|
||||
config=reload_config,
|
||||
target=partial(_run_worker_pool, worker_config),
|
||||
sockets=[shared_socket],
|
||||
).run()
|
||||
finally:
|
||||
shared_socket.close()
|
||||
|
|
@ -9,7 +9,7 @@ import os
|
|||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType, UnionType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, Union, cast, get_args, get_origin
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar, Union, cast, get_args, get_origin
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -43,6 +43,11 @@ from litellm.proxy.guardrails.guardrail_registry import (
|
|||
encrypt_guardrail_litellm_params,
|
||||
parse_tolerant_litellm_params,
|
||||
)
|
||||
from litellm.proxy.guardrails.response_handlers import (
|
||||
GUARDRAIL_RESPONSE_HANDLERS,
|
||||
GuardrailEndpointResponse,
|
||||
StandardGuardrailResponseHandler,
|
||||
)
|
||||
from litellm.proxy.guardrails.usage_endpoints import router as guardrails_usage_router
|
||||
from litellm.proxy.management_endpoints.common_utils import ( # noqa: F401 # legacy module exports
|
||||
_user_has_admin_view, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
|
|
@ -53,7 +58,6 @@ from litellm.repositories.table_repositories import GuardrailsRepository
|
|||
from litellm.types.guardrails import (
|
||||
PII_ENTITY_CATEGORIES_MAP,
|
||||
ApplyGuardrailRequest,
|
||||
ApplyGuardrailResponse,
|
||||
BaseLitellmParams,
|
||||
BedrockGuardrailConfigModel,
|
||||
BedrockGuardrailStreamingParams,
|
||||
|
|
@ -2350,13 +2354,7 @@ async def _run_test_guardrail(execute_guardrail: Callable[[], object], timeout:
|
|||
return await await_with_timeout(raw_result, remaining, label="test")
|
||||
|
||||
|
||||
def _resolve_guardrail_input_type(active_guardrail: CustomGuardrail, input_type: str) -> Literal["request", "response"]:
|
||||
"""Return the effective input_type, auto-upgrading to 'response' for post_call guardrails."""
|
||||
if input_type == "request":
|
||||
hook: Final = getattr(active_guardrail, "event_hook", None)
|
||||
if hook == GuardrailEventHooks.post_call or hook == "post_call":
|
||||
return "response"
|
||||
return "response" if input_type == "response" else "request"
|
||||
_RESPONSE: Final[TypeAdapter[GuardrailEndpointResponse]] = TypeAdapter(GuardrailEndpointResponse)
|
||||
|
||||
|
||||
class _GuardrailLoggingObj(Protocol):
|
||||
|
|
@ -2384,35 +2382,43 @@ def _patch_logging_obj_for_guardrail(litellm_logging_obj: _GuardrailLoggingObj,
|
|||
)
|
||||
|
||||
|
||||
async def _emit_guardrail_success_logs(
|
||||
async def _guardrail_response_after_success_hook(
|
||||
response_handler: StandardGuardrailResponseHandler,
|
||||
proxy_logging_obj: _GuardrailProxyLogging,
|
||||
litellm_logging_obj: _GuardrailLoggingObj | None,
|
||||
data: dict,
|
||||
data: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: ApplyGuardrailResponse,
|
||||
start_time: datetime,
|
||||
) -> ApplyGuardrailResponse:
|
||||
"""Fire proxy and LiteLLM success hooks after a successful guardrail run.
|
||||
|
||||
Each hook is wrapped defensively so a callback failure never prevents the
|
||||
caller from receiving the guardrail response. Returns the (possibly
|
||||
hook-modified) response.
|
||||
"""
|
||||
response: GuardrailEndpointResponse,
|
||||
) -> GuardrailEndpointResponse:
|
||||
try:
|
||||
modified: Final = await proxy_logging_obj.post_call_success_hook(
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=response,
|
||||
)
|
||||
if isinstance(modified, ApplyGuardrailResponse):
|
||||
response = modified
|
||||
return response_handler.accept_hook_result(response, modified)
|
||||
except Exception:
|
||||
verbose_proxy_logger.exception("apply_guardrail: post_call_success_hook failed")
|
||||
return response
|
||||
|
||||
|
||||
async def _emit_guardrail_success_logs(
|
||||
response_handler: StandardGuardrailResponseHandler,
|
||||
proxy_logging_obj: _GuardrailProxyLogging,
|
||||
litellm_logging_obj: _GuardrailLoggingObj | None,
|
||||
data: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: GuardrailEndpointResponse,
|
||||
start_time: datetime,
|
||||
) -> GuardrailEndpointResponse:
|
||||
"""Apply the success hook and log the response returned to the caller."""
|
||||
updated_response: Final = await _guardrail_response_after_success_hook(
|
||||
response_handler, proxy_logging_obj, data, user_api_key_dict, response
|
||||
)
|
||||
|
||||
# Build the logging payload after post_call_success_hook so that logged
|
||||
# data matches what the caller actually receives if the hook modified
|
||||
# the response.
|
||||
response_for_logging: Final = {"response": response.model_dump(exclude_none=True)}
|
||||
response_for_logging: Final = {"response": _RESPONSE.dump_python(updated_response, mode="json")}
|
||||
|
||||
if litellm_logging_obj is not None:
|
||||
end_time: Final = datetime.now(timezone.utc)
|
||||
|
|
@ -2426,11 +2432,11 @@ async def _emit_guardrail_success_logs(
|
|||
except Exception:
|
||||
verbose_proxy_logger.exception("apply_guardrail: async_success_handler failed")
|
||||
|
||||
return response
|
||||
return updated_response
|
||||
|
||||
|
||||
@router.post("/guardrails/apply_guardrail", response_model=ApplyGuardrailResponse)
|
||||
@router.post("/apply_guardrail", response_model=ApplyGuardrailResponse)
|
||||
@router.post("/guardrails/apply_guardrail", response_model=GuardrailEndpointResponse)
|
||||
@router.post("/apply_guardrail", response_model=GuardrailEndpointResponse)
|
||||
async def apply_guardrail(
|
||||
fastapi_request: Request,
|
||||
request: ApplyGuardrailRequest,
|
||||
|
|
@ -2455,7 +2461,7 @@ async def apply_guardrail(
|
|||
)
|
||||
from litellm.proxy.utils import handle_exception_on_proxy
|
||||
|
||||
data: dict = {
|
||||
data: dict[str, object] = {
|
||||
"guardrail_name": request.guardrail_name,
|
||||
"input": [request.text],
|
||||
"messages": request.messages or [],
|
||||
|
|
@ -2495,18 +2501,15 @@ async def apply_guardrail(
|
|||
if litellm_logging_obj is not None:
|
||||
_patch_logging_obj_for_guardrail(litellm_logging_obj, request)
|
||||
|
||||
request_data: Final[dict] = {
|
||||
**({"messages": request.messages} if request.messages is not None else {}),
|
||||
**({"metadata": request.metadata} if request.metadata is not None else {}),
|
||||
}
|
||||
_input_type: Final = _resolve_guardrail_input_type(active_guardrail, request.input_type)
|
||||
response_handler: Final = GUARDRAIL_RESPONSE_HANDLERS[request.response_format]
|
||||
request_data: Final = dict(response_handler.prepare_request_data(request))
|
||||
input_type: Final = response_handler.resolve_input_type(request, active_guardrail)
|
||||
guardrailed_inputs: Final = await active_guardrail.apply_guardrail(
|
||||
inputs={"texts": [request.text]},
|
||||
request_data=request_data,
|
||||
input_type=_input_type,
|
||||
input_type=input_type,
|
||||
)
|
||||
response_text: Final = guardrailed_inputs.get("texts", [])
|
||||
response = ApplyGuardrailResponse(response_text=response_text[0] if response_text else request.text)
|
||||
response: Final = response_handler.build_response(request, guardrailed_inputs, request_data)
|
||||
except Exception as e:
|
||||
if litellm_logging_obj is not None and not isinstance(e, HTTPException):
|
||||
try:
|
||||
|
|
@ -2537,7 +2540,8 @@ async def apply_guardrail(
|
|||
raise handle_exception_on_proxy(status_typed_proxy_exception(e) if isinstance(e, HTTPException) else e)
|
||||
|
||||
# Success logging outside except so a hook error never triggers failure handlers.
|
||||
response = await _emit_guardrail_success_logs(
|
||||
logged_response: Final = await _emit_guardrail_success_logs(
|
||||
response_handler=response_handler,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
data=data,
|
||||
|
|
@ -2545,7 +2549,7 @@ async def apply_guardrail(
|
|||
response=response,
|
||||
start_time=start_time,
|
||||
)
|
||||
return response
|
||||
return logged_response
|
||||
|
||||
|
||||
# Usage (dashboard) endpoints: overview, detail, logs
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ from litellm.proxy.guardrails.anthropic_sse import (
|
|||
)
|
||||
from litellm.types.guardrails import (
|
||||
BedrockChecksConfigModel,
|
||||
BedrockGuardrailOutputScope,
|
||||
BedrockGuardrailStreamingParams,
|
||||
GuardrailEventHooks,
|
||||
LitellmParams,
|
||||
|
|
@ -289,6 +290,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
streaming_buffer_release_on_scan: bool | None = None,
|
||||
streaming_scan_in_background: bool | None = None,
|
||||
async_handler: AsyncHTTPHandler | None = None,
|
||||
outputScope: BedrockGuardrailOutputScope | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.async_handler = async_handler or get_async_httpx_client(
|
||||
|
|
@ -309,6 +311,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
)
|
||||
self.guardrailIdentifier = guardrailIdentifier
|
||||
self.guardrailVersion = guardrailVersion
|
||||
self.outputScope = outputScope
|
||||
self.guardrail_provider = "bedrock"
|
||||
self.chunk_budget_chars = chunk_budget_chars
|
||||
self.contextual_grounding_from_messages = contextual_grounding_from_messages
|
||||
|
|
@ -1280,10 +1283,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
AWS billed them to ``completed_chunk_usages``, and the attempt log sums those
|
||||
with the blocking call's own usage.
|
||||
"""
|
||||
bedrock_request_data: Final = {
|
||||
**base_request_data,
|
||||
"content": content,
|
||||
}
|
||||
bedrock_request_data: Final = dict(base_request_data)
|
||||
bedrock_request_data["content"] = content
|
||||
if self.outputScope is not None and "outputScope" not in bedrock_request_data:
|
||||
bedrock_request_data["outputScope"] = self.outputScope.value
|
||||
prepared_request: Final = await run_aws_signing(
|
||||
self._prepare_request,
|
||||
credentials=credentials,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
|
|||
application_id: Final = getattr(litellm_params, "application_id", None)
|
||||
pre_checkpoint_id: Final = getattr(litellm_params, "pre_checkpoint_id", None)
|
||||
post_checkpoint_id: Final = getattr(litellm_params, "post_checkpoint_id", None)
|
||||
file_checkpoint_id: Final = getattr(litellm_params, "file_checkpoint_id", None)
|
||||
|
||||
_ovalix_callback: Final = OvalixGuardrail(
|
||||
guardrail_name=guardrail.get("guardrail_name", ""),
|
||||
|
|
@ -27,6 +28,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
|
|||
application_id=application_id,
|
||||
pre_checkpoint_id=pre_checkpoint_id,
|
||||
post_checkpoint_id=post_checkpoint_id,
|
||||
file_checkpoint_id=file_checkpoint_id,
|
||||
event_hook=litellm_params.mode,
|
||||
default_on=litellm_params.default_on,
|
||||
timeout=litellm_params.timeout,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,19 @@ Use Ovalix Guardrails for your LLM calls. Supports pre_call (user input) and
|
|||
post_call (model output) checkpoints with optional correction/blocking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import gzip
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
import httpx
|
||||
from typing_extensions import ReadOnly, TypedDict, Unpack
|
||||
from typing_extensions import TypedDict, Unpack
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.exceptions import GuardrailRaisedException
|
||||
|
|
@ -18,10 +24,23 @@ from litellm.integrations.custom_guardrail import (
|
|||
CustomGuardrail,
|
||||
log_guardrail_information,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.ovalix.ovalix_extraction import (
|
||||
FilePart,
|
||||
extract_file_parts_from_images,
|
||||
extract_file_parts_from_messages,
|
||||
extract_tool_calls_from_messages,
|
||||
extract_tool_results,
|
||||
make_tool_data,
|
||||
str_or_none,
|
||||
tool_call_to_tool_data,
|
||||
tool_data_key,
|
||||
tool_result_text_indices,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
|
|
@ -32,13 +51,34 @@ if TYPE_CHECKING:
|
|||
|
||||
BLOCKED_BY_OVALIX_FALLBACK_MESSAGE: Final = "This message was blocked by Ovalix"
|
||||
BLOCKED_ACTION_TYPE: Final = "block"
|
||||
_MODIFY_ACTION_TYPES: Final = ("anonymize", "sanitize")
|
||||
_DEFAULT_FILE_SIZE_LIMIT: Final = 64 * 1024 * 1024
|
||||
_NO_METADATA: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
_FILE_BLOCK_ESCALATION_REASON: Final = (
|
||||
"This message was blocked by Ovalix because file content anonymization isn't possible via LiteLLM"
|
||||
)
|
||||
_UNDECODABLE_FILE_BLOCK_REASON: Final = (
|
||||
"This message was blocked by Ovalix because an attached file couldn't be decoded for inspection"
|
||||
)
|
||||
_TOOL_BLOCK_ESCALATION_REASON: Final = (
|
||||
"This message was blocked by Ovalix because tool call anonymization isn't possible via LiteLLM"
|
||||
)
|
||||
_TOOL_RESULT_BLOCK_ESCALATION_REASON: Final = (
|
||||
"This message was blocked by Ovalix because tool result anonymization isn't possible via LiteLLM"
|
||||
)
|
||||
|
||||
|
||||
def _encode_file_wire_format(raw: bytes) -> str:
|
||||
return base64.b64encode(gzip.compress(raw)).decode()
|
||||
|
||||
|
||||
def _mapping_or_empty(value: object) -> Mapping[str, object]:
|
||||
return as_str_mapping(value) or _NO_METADATA
|
||||
|
||||
|
||||
class _CustomGuardrailOptions(TypedDict, total=False, extra_items=object):
|
||||
"""Base-class constructor options this guardrail forwards untouched to CustomGuardrail."""
|
||||
|
||||
supported_event_hooks: ReadOnly[list[GuardrailEventHooks]]
|
||||
|
||||
|
||||
class OvalixGuardrailMissingSecrets(Exception):
|
||||
"""Raised when required Ovalix config (API base, key, application/checkpoint IDs) is missing."""
|
||||
|
|
@ -87,6 +127,8 @@ class OvalixGuardrail(CustomGuardrail):
|
|||
application_id: str | None = None,
|
||||
pre_checkpoint_id: str | None = None,
|
||||
post_checkpoint_id: str | None = None,
|
||||
file_checkpoint_id: str | None = None,
|
||||
supported_event_hooks: list[GuardrailEventHooks] | None = None,
|
||||
**kwargs: Unpack[_CustomGuardrailOptions],
|
||||
):
|
||||
self._tracker_api_base = tracker_api_base or os.environ.get("OVALIX_TRACKER_API_BASE")
|
||||
|
|
@ -94,23 +136,26 @@ class OvalixGuardrail(CustomGuardrail):
|
|||
self._application_id = application_id or os.environ.get("OVALIX_APPLICATION_ID")
|
||||
self._pre_checkpoint_id = pre_checkpoint_id or os.environ.get("OVALIX_PRE_CHECKPOINT_ID")
|
||||
self._post_checkpoint_id = post_checkpoint_id or os.environ.get("OVALIX_POST_CHECKPOINT_ID")
|
||||
self._file_checkpoint_id = file_checkpoint_id or os.environ.get("OVALIX_FILE_CHECKPOINT_ID")
|
||||
|
||||
supported_event_hooks: Final = kwargs.get("supported_event_hooks", [])
|
||||
event_hooks: Final = self._validated_event_hooks(supported_event_hooks or ())
|
||||
|
||||
self._validate_config(supported_event_hooks)
|
||||
|
||||
self._tracker_headers = httpx.Headers(
|
||||
{
|
||||
"Authorization": f"Bearer {self._tracker_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
encoding="utf-8",
|
||||
self._tracker_headers = dict(
|
||||
httpx.Headers(
|
||||
MappingProxyType(
|
||||
{
|
||||
"Authorization": f"Bearer {self._tracker_api_key}",
|
||||
"x-api-key": self._tracker_api_key or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
)
|
||||
|
||||
self._async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
|
||||
|
||||
forwarded: Final[_CustomGuardrailOptions] = {**kwargs, "supported_event_hooks": supported_event_hooks}
|
||||
super().__init__(**forwarded)
|
||||
super().__init__(supported_event_hooks=event_hooks, **kwargs)
|
||||
verbose_proxy_logger.debug(
|
||||
"Ovalix Guardrail initialized: tracker=%s, application_id=%s, pre_checkpoint_id=%s, post_checkpoint_id=%s",
|
||||
self._tracker_api_base,
|
||||
|
|
@ -119,44 +164,53 @@ class OvalixGuardrail(CustomGuardrail):
|
|||
self._post_checkpoint_id,
|
||||
)
|
||||
|
||||
def _validate_config(self, supported_event_hooks: list[GuardrailEventHooks]) -> None:
|
||||
"""Ensure required secrets and checkpoint IDs are set; auto-add hooks when IDs are present."""
|
||||
errors: Final[list[str]] = []
|
||||
|
||||
if not self._tracker_api_base:
|
||||
errors.append("Tracker API base, set OVALIX_TRACKER_API_BASE or pass tracker_api_base")
|
||||
if not self._tracker_api_key:
|
||||
errors.append("Tracker API key, set OVALIX_TRACKER_API_KEY or pass tracker_api_key")
|
||||
if not self._application_id:
|
||||
errors.append("Application ID, set OVALIX_APPLICATION_ID or pass application_id")
|
||||
if not self._pre_checkpoint_id and GuardrailEventHooks.pre_call in supported_event_hooks:
|
||||
errors.append("Pre-checkpoint ID, set OVALIX_PRE_CHECKPOINT_ID or pass pre_checkpoint_id")
|
||||
if not self._post_checkpoint_id and GuardrailEventHooks.post_call in supported_event_hooks:
|
||||
errors.append("Post-checkpoint ID, set OVALIX_POST_CHECKPOINT_ID or pass post_checkpoint_id")
|
||||
if not self._pre_checkpoint_id and not self._post_checkpoint_id:
|
||||
errors.append(
|
||||
"Pre-checkpoint ID or Post-checkpoint ID, set OVALIX_PRE_CHECKPOINT_ID or OVALIX_POST_CHECKPOINT_ID or pass pre_checkpoint_id or post_checkpoint_id"
|
||||
def _validated_event_hooks(self, requested: Sequence[GuardrailEventHooks]) -> list[GuardrailEventHooks]:
|
||||
"""Ensure required secrets and checkpoint IDs are set; return the requested hooks plus one per configured checkpoint."""
|
||||
errors: Final = tuple(
|
||||
message
|
||||
for missing, message in (
|
||||
(not self._tracker_api_base, "Tracker API base, set OVALIX_TRACKER_API_BASE or pass tracker_api_base"),
|
||||
(not self._tracker_api_key, "Tracker API key, set OVALIX_TRACKER_API_KEY or pass tracker_api_key"),
|
||||
(not self._application_id, "Application ID, set OVALIX_APPLICATION_ID or pass application_id"),
|
||||
(
|
||||
not self._pre_checkpoint_id and GuardrailEventHooks.pre_call in requested,
|
||||
"Pre-checkpoint ID, set OVALIX_PRE_CHECKPOINT_ID or pass pre_checkpoint_id",
|
||||
),
|
||||
(
|
||||
not self._post_checkpoint_id and GuardrailEventHooks.post_call in requested,
|
||||
"Post-checkpoint ID, set OVALIX_POST_CHECKPOINT_ID or pass post_checkpoint_id",
|
||||
),
|
||||
(
|
||||
not self._pre_checkpoint_id and not self._post_checkpoint_id,
|
||||
"Pre-checkpoint ID or Post-checkpoint ID, set OVALIX_PRE_CHECKPOINT_ID or OVALIX_POST_CHECKPOINT_ID or pass pre_checkpoint_id or post_checkpoint_id",
|
||||
),
|
||||
)
|
||||
if missing
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise OvalixGuardrailMissingSecrets("Missing Ovalix guardrail configuration errors: " + ". ".join(errors))
|
||||
|
||||
# auto-add hooks when checkpoint IDs are present
|
||||
if self._pre_checkpoint_id and GuardrailEventHooks.pre_call not in supported_event_hooks:
|
||||
supported_event_hooks.append(GuardrailEventHooks.pre_call)
|
||||
if self._post_checkpoint_id and GuardrailEventHooks.post_call not in supported_event_hooks:
|
||||
supported_event_hooks.append(GuardrailEventHooks.post_call)
|
||||
auto_added: Final = tuple(
|
||||
hook
|
||||
for configured, hook in (
|
||||
(self._pre_checkpoint_id, GuardrailEventHooks.pre_call),
|
||||
(self._post_checkpoint_id, GuardrailEventHooks.post_call),
|
||||
)
|
||||
if configured and hook not in requested
|
||||
)
|
||||
return [*requested, *auto_added]
|
||||
|
||||
def _get_actor(self, data: dict) -> str:
|
||||
def _get_actor(self, data: Mapping[str, object]) -> str:
|
||||
"""Return a stable actor identifier from request metadata (e.g. user email or id)."""
|
||||
metadata: Final = data.get("metadata") or data.get("litellm_metadata") or {}
|
||||
if metadata.get("user_api_key_user_email"):
|
||||
return metadata["user_api_key_user_email"]
|
||||
if metadata.get("user_api_key_user_id"):
|
||||
return metadata["user_api_key_user_id"]
|
||||
return "unknown"
|
||||
metadata: Final = _mapping_or_empty(data.get("metadata") or data.get("litellm_metadata"))
|
||||
return (
|
||||
str_or_none(metadata.get("user_api_key_user_email"))
|
||||
or str_or_none(metadata.get("user_api_key_user_id"))
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
def _get_tracker_actor_id(self, data: dict) -> str:
|
||||
def _get_tracker_actor_id(self, data: Mapping[str, object]) -> str:
|
||||
"""Normalize the actor string into a short, stable id for Tracker API payloads."""
|
||||
# NOTE: this hash is purely for normalization — it collapses an arbitrary actor
|
||||
# string (email, user id, or "unknown") into a compact, fixed-length, consistent
|
||||
|
|
@ -166,7 +220,7 @@ class OvalixGuardrail(CustomGuardrail):
|
|||
normalized_actor_id: Final = hashlib.sha256(actor_id).hexdigest()[:8]
|
||||
return normalized_actor_id
|
||||
|
||||
def _get_session_id(self, data: dict) -> str:
|
||||
def _get_session_id(self, data: Mapping[str, object]) -> str:
|
||||
"""Return a unique identifier for the chat/session (actor + date + application_id)."""
|
||||
actor_hash: Final = self._get_tracker_actor_id(data)
|
||||
today: Final = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
|
||||
|
|
@ -174,106 +228,254 @@ class OvalixGuardrail(CustomGuardrail):
|
|||
|
||||
async def _call_checkpoint(
|
||||
self,
|
||||
content: str,
|
||||
data_type: str,
|
||||
data: Mapping[str, object],
|
||||
checkpoint_id: str,
|
||||
actor: str,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Call the Ovalix Tracker checkpoint API and return the JSON response."""
|
||||
oversize: bool = False,
|
||||
) -> Mapping[str, object]:
|
||||
"""Call the Ovalix Tracker checkpoint API and return the JSON response.
|
||||
|
||||
Both routes live on the tracker's /beta litellm router, which accepts the api key this
|
||||
guardrail already sends.
|
||||
"""
|
||||
application_id: Final = self._application_id
|
||||
if not application_id or not checkpoint_id:
|
||||
raise ValueError("Ovalix: application_id or checkpoint_id not resolved")
|
||||
|
||||
url: Final = f"{self._tracker_api_base}/tracking/custom_application/checkpoint"
|
||||
headers: Final = dict(self._tracker_headers)
|
||||
route: Final = "file_checkpoint" if data_type == "FILE" else "checkpoint"
|
||||
payload: Final = {
|
||||
"application_id": application_id,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"actor": actor,
|
||||
"session_id": session_id,
|
||||
"data_type": "TEXT",
|
||||
"data": {"content": content},
|
||||
"data_type": data_type,
|
||||
"data": data,
|
||||
"tool": "LiteLLM",
|
||||
**(MappingProxyType({"oversize": True}) if oversize else _NO_METADATA),
|
||||
}
|
||||
response: Final = await self._async_handler.post(url, headers=headers, json=payload, timeout=self.timeout)
|
||||
response: Final = await self._async_handler.post(
|
||||
f"{self._tracker_api_base}/tracking/beta/{route}",
|
||||
headers=self._tracker_headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _verdict(self, resp: Mapping[str, object]) -> tuple[str, str | None]:
|
||||
return (str_or_none(resp.get("action_type")) or "").lower(), self._get_trackers_corrected_message(resp)
|
||||
|
||||
async def _block_reason_for_item(
|
||||
self,
|
||||
data_type: str,
|
||||
data: Mapping[str, object],
|
||||
checkpoint_id: str,
|
||||
actor: str,
|
||||
session_id: str,
|
||||
escalation_reason: str,
|
||||
oversize: bool = False,
|
||||
) -> str | None:
|
||||
try:
|
||||
resp: Final = await self._call_checkpoint(
|
||||
data_type, data, checkpoint_id, actor, session_id, oversize=oversize
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Ovalix checkpoint call failed: %s", e)
|
||||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name,
|
||||
message=f"Ovalix guardrail error: {e}",
|
||||
should_wrap_with_default_message=False,
|
||||
) from e
|
||||
action, corrected = self._verdict(resp)
|
||||
if action == BLOCKED_ACTION_TYPE:
|
||||
return corrected or BLOCKED_BY_OVALIX_FALLBACK_MESSAGE
|
||||
if action in _MODIFY_ACTION_TYPES:
|
||||
return escalation_reason
|
||||
return None
|
||||
|
||||
async def _check_items_block_only(
|
||||
self,
|
||||
items: Sequence[tuple[str, Mapping[str, object]]],
|
||||
checkpoint_id: str,
|
||||
actor: str,
|
||||
session_id: str,
|
||||
escalation_reason: str,
|
||||
) -> str | None:
|
||||
for data_type, data in items:
|
||||
reason = await self._block_reason_for_item(
|
||||
data_type, data, checkpoint_id, actor, session_id, escalation_reason
|
||||
)
|
||||
if reason is not None:
|
||||
return reason
|
||||
return None
|
||||
|
||||
async def _check_files_for_block(
|
||||
self,
|
||||
file_parts: Sequence[FilePart],
|
||||
checkpoint_id: str,
|
||||
actor: str,
|
||||
session_id: str,
|
||||
) -> str | None:
|
||||
if any(part.undecodable for part in file_parts):
|
||||
return _UNDECODABLE_FILE_BLOCK_REASON
|
||||
for part in sorted(file_parts, key=lambda p: p.message_index, reverse=True):
|
||||
data = await self._file_part_to_data(part)
|
||||
reason = await self._block_reason_for_item(
|
||||
"FILE",
|
||||
data,
|
||||
checkpoint_id,
|
||||
actor,
|
||||
session_id,
|
||||
_FILE_BLOCK_ESCALATION_REASON,
|
||||
oversize=part.oversize,
|
||||
)
|
||||
if reason is not None:
|
||||
return reason
|
||||
return None
|
||||
|
||||
@log_guardrail_information
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict,
|
||||
request_data: Mapping[str, object],
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: "LiteLLMLoggingObj | None" = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
"""
|
||||
Apply Ovalix guardrail to the given inputs (request or response text).
|
||||
|
||||
Used by the unified guardrail flow and the /apply_guardrail API.
|
||||
For "request", uses the pre-checkpoint; for "response", uses the post-checkpoint.
|
||||
|
||||
Args:
|
||||
inputs: Guardrail API inputs (e.g. texts to check).
|
||||
request_data: Full request payload (messages, metadata, response).
|
||||
input_type: "request" (pre_call) or "response" (post_call).
|
||||
logging_obj: Optional logging context.
|
||||
|
||||
Returns:
|
||||
Updated inputs (e.g. with replaced/corrected texts, or unchanged).
|
||||
"""
|
||||
if not self._pre_checkpoint_id and not self._post_checkpoint_id:
|
||||
return inputs
|
||||
|
||||
tracker_actor_id: Final = self._get_tracker_actor_id(request_data)
|
||||
actor: Final = self._get_actor(request_data)
|
||||
session_id: Final = self._get_session_id(request_data)
|
||||
texts: Final = inputs.get("texts") or []
|
||||
if not texts or not isinstance(texts, list):
|
||||
is_response: Final = input_type == "response"
|
||||
|
||||
prompt_checkpoint: Final = self._post_checkpoint_id if is_response else self._pre_checkpoint_id
|
||||
file_checkpoint: Final = self._file_checkpoint_id or prompt_checkpoint
|
||||
if not file_checkpoint:
|
||||
verbose_proxy_logger.debug(
|
||||
"Ovalix guardrail: application %s has no %s checkpoint, leaving this direction uninspected",
|
||||
self._application_id,
|
||||
input_type,
|
||||
)
|
||||
return inputs
|
||||
|
||||
if input_type == "response":
|
||||
if not self._post_checkpoint_id:
|
||||
return inputs
|
||||
corrected_llm_responses: Final = await self._generate_post_guardrail_llm_texts(
|
||||
texts, tracker_actor_id, session_id, self._post_checkpoint_id
|
||||
structured_messages: Final = inputs.get("structured_messages") or ()
|
||||
file_parts: Final = (
|
||||
extract_file_parts_from_images(inputs.get("images"), size_limit=_DEFAULT_FILE_SIZE_LIMIT)
|
||||
if is_response
|
||||
else extract_file_parts_from_messages(structured_messages, size_limit=_DEFAULT_FILE_SIZE_LIMIT)
|
||||
)
|
||||
file_block: Final = await self._check_files_for_block(file_parts, file_checkpoint, actor, session_id)
|
||||
if file_block is not None:
|
||||
self._block_current_message(file_block)
|
||||
|
||||
if not prompt_checkpoint:
|
||||
verbose_proxy_logger.debug(
|
||||
"Ovalix guardrail: application %s has only a %s file checkpoint, skipping text and tool inspection",
|
||||
self._application_id,
|
||||
input_type,
|
||||
)
|
||||
return {**inputs, "texts": corrected_llm_responses}
|
||||
return inputs
|
||||
|
||||
if self._pre_checkpoint_id:
|
||||
post_guardrail_texts: Final = await self._generate_post_guardrail_llm_texts(
|
||||
texts, tracker_actor_id, session_id, self._pre_checkpoint_id
|
||||
)
|
||||
return {**inputs, "texts": post_guardrail_texts}
|
||||
return inputs
|
||||
tool_calls: Final = (
|
||||
*(inputs.get("tool_calls") or ()),
|
||||
*extract_tool_calls_from_messages(structured_messages),
|
||||
)
|
||||
unique_tool_data: Final = MappingProxyType(
|
||||
{tool_data_key(data): data for data in (tool_call_to_tool_data(tc) for tc in tool_calls) if data}
|
||||
)
|
||||
tool_call_items: Final = tuple(("TOOL", data) for data in unique_tool_data.values())
|
||||
tool_block: Final = await self._check_items_block_only(
|
||||
tool_call_items,
|
||||
prompt_checkpoint,
|
||||
actor,
|
||||
session_id,
|
||||
_TOOL_BLOCK_ESCALATION_REASON,
|
||||
)
|
||||
if tool_block is not None:
|
||||
self._block_current_message(tool_block)
|
||||
|
||||
async def _generate_post_guardrail_llm_texts(
|
||||
self, texts: list[str], actor: str, session_id: str, checkpoint_id: str
|
||||
) -> list[str]:
|
||||
"""Generate post-guardrail LLM responses for the given LLM responses."""
|
||||
post_guardrail_texts: Final[list[str]] = []
|
||||
tool_results: Final = extract_tool_results(structured_messages)
|
||||
tool_result_items: Final = tuple(("TOOL", make_tool_data(name, content)) for name, content, _ in tool_results)
|
||||
tool_result_block: Final = await self._check_items_block_only(
|
||||
tool_result_items,
|
||||
prompt_checkpoint,
|
||||
actor,
|
||||
session_id,
|
||||
_TOOL_RESULT_BLOCK_ESCALATION_REASON,
|
||||
)
|
||||
if tool_result_block is not None:
|
||||
self._block_current_message(tool_result_block)
|
||||
|
||||
is_first_response = True
|
||||
for llm_response in reversed(texts):
|
||||
try:
|
||||
resp = await self._call_checkpoint(llm_response, checkpoint_id, actor, session_id)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Ovalix apply_guardrail checkpoint call failed: %s", e)
|
||||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name,
|
||||
message=f"Ovalix guardrail error: {e}",
|
||||
should_wrap_with_default_message=False,
|
||||
) from e
|
||||
texts: Final = inputs.get("texts") or ()
|
||||
if not texts:
|
||||
return inputs
|
||||
output_texts: Final = await self._check_texts(
|
||||
texts,
|
||||
prompt_checkpoint,
|
||||
actor,
|
||||
session_id,
|
||||
tool_result_text_indices(structured_messages, texts),
|
||||
)
|
||||
if output_texts is None:
|
||||
return inputs
|
||||
return {**inputs, "texts": output_texts}
|
||||
|
||||
action_type = (resp.get("action_type") or "").lower()
|
||||
blocking_message = self._get_trackers_corrected_message(resp) or BLOCKED_BY_OVALIX_FALLBACK_MESSAGE
|
||||
if action_type == BLOCKED_ACTION_TYPE and is_first_response:
|
||||
self._block_current_message(blocking_message)
|
||||
elif action_type == BLOCKED_ACTION_TYPE:
|
||||
post_guardrail_texts.insert(0, blocking_message)
|
||||
else:
|
||||
corrected_text = self._get_trackers_corrected_message(resp) or llm_response
|
||||
post_guardrail_texts.insert(0, corrected_text)
|
||||
is_first_response = False
|
||||
return post_guardrail_texts
|
||||
async def _file_part_to_data(self, part: FilePart) -> Mapping[str, object]:
|
||||
extension: Final = mimetypes.guess_extension(part.mime_hint) if part.mime_hint else None
|
||||
name: Final = part.name or (f"file{extension}" if extension else "file")
|
||||
content: Final = (
|
||||
await asyncio.get_event_loop().run_in_executor(None, _encode_file_wire_format, part.data)
|
||||
if part.data
|
||||
else None
|
||||
)
|
||||
return {"name": name, "content": content}
|
||||
|
||||
async def _check_texts(
|
||||
self,
|
||||
texts: Sequence[str],
|
||||
checkpoint_id: str,
|
||||
actor: str,
|
||||
session_id: str,
|
||||
skip_indices: frozenset[int],
|
||||
) -> list[str] | None:
|
||||
newest_first: Final = tuple(index for index in reversed(range(len(texts))) if index not in skip_indices)
|
||||
checked: Final = MappingProxyType(
|
||||
{
|
||||
index: await self._check_text(
|
||||
texts[index], checkpoint_id, actor, session_id, is_newest=index == len(texts) - 1
|
||||
)
|
||||
for index in newest_first
|
||||
}
|
||||
)
|
||||
output: Final = [checked.get(index, text) for index, text in enumerate(texts)]
|
||||
return output if output != list(texts) else None
|
||||
|
||||
async def _check_text(
|
||||
self,
|
||||
content: str,
|
||||
checkpoint_id: str,
|
||||
actor: str,
|
||||
session_id: str,
|
||||
is_newest: bool,
|
||||
) -> str:
|
||||
"""Return the text that replaces `content` after Ovalix checks it; a blocked newest text raises."""
|
||||
try:
|
||||
resp: Final = await self._call_checkpoint("TEXT", {"content": content}, checkpoint_id, actor, session_id)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Ovalix checkpoint call failed: %s", e)
|
||||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name,
|
||||
message=f"Ovalix guardrail error: {e}",
|
||||
should_wrap_with_default_message=False,
|
||||
) from e
|
||||
action, corrected = self._verdict(resp)
|
||||
if action == BLOCKED_ACTION_TYPE:
|
||||
block_message: Final = corrected or BLOCKED_BY_OVALIX_FALLBACK_MESSAGE
|
||||
if is_newest:
|
||||
self._block_current_message(block_message)
|
||||
return block_message
|
||||
if action in _MODIFY_ACTION_TYPES and corrected is not None:
|
||||
return corrected
|
||||
return content
|
||||
|
||||
def _block_current_message(self, blocking_message: str) -> None:
|
||||
"""Raise OvalixGuardrailBlockedException with the given message (no default wrapper)."""
|
||||
|
|
@ -283,12 +485,13 @@ class OvalixGuardrail(CustomGuardrail):
|
|||
should_wrap_with_default_message=False,
|
||||
)
|
||||
|
||||
def _get_trackers_corrected_message(self, resp: dict) -> str | None:
|
||||
def _get_trackers_corrected_message(self, resp: Mapping[str, object]) -> str | None:
|
||||
"""Extract corrected/blocking message content from Tracker checkpoint response."""
|
||||
modified: Final = resp.get("modified_data")
|
||||
if isinstance(modified, dict) and "content" in modified:
|
||||
return modified["content"]
|
||||
return None
|
||||
modified: Final = as_str_mapping(resp.get("modified_data"))
|
||||
if modified is None:
|
||||
return None
|
||||
content: Final = modified.get("content")
|
||||
return content if isinstance(content, str) else None
|
||||
|
||||
@staticmethod
|
||||
def get_config_model() -> type["GuardrailConfigModel"] | None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,396 @@
|
|||
import base64
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
from bisect import bisect_left
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from itertools import chain, groupby
|
||||
from operator import attrgetter
|
||||
from types import MappingProxyType
|
||||
from typing import Final, NamedTuple
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
|
||||
_TOOL_NAME_MAX_LENGTH: Final = 100
|
||||
_DEFAULT_TOOL_RESULT_NAME: Final = "tool_result"
|
||||
_NO_TOOL_INPUT: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
_DATA_URL_RE: Final = re.compile(r"^data:(?P<mime>[^;,]+)?(?P<params>(?:;[^;,]+)*?)(?P<b64>;base64)?,", re.IGNORECASE)
|
||||
_URLSAFE_TO_STANDARD_B64: Final = str.maketrans("-_", "+/")
|
||||
_OBJECT_LIST: Final = TypeAdapter(list[object])
|
||||
|
||||
|
||||
def str_or_none(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _list_items(value: object) -> Sequence[object]:
|
||||
try:
|
||||
return _OBJECT_LIST.validate_python(value, strict=True)
|
||||
except ValidationError:
|
||||
return ()
|
||||
|
||||
|
||||
class FilePart(NamedTuple):
|
||||
name: str | None
|
||||
data: bytes | None
|
||||
mime_hint: str | None
|
||||
inline: bool
|
||||
oversize: bool
|
||||
message_index: int
|
||||
undecodable: bool = False
|
||||
|
||||
|
||||
def _split_data_url(value: str) -> tuple[str | None, str | None]:
|
||||
match: Final = _DATA_URL_RE.match(value)
|
||||
if not match:
|
||||
if value.lower().startswith("data:"):
|
||||
return None, None
|
||||
return None, value
|
||||
mime: Final = match.group("mime") or None
|
||||
if not match.group("b64"):
|
||||
return mime, None
|
||||
return mime, value[match.end() :]
|
||||
|
||||
|
||||
def _b64decode_or_none(payload: str) -> bytes | None:
|
||||
try:
|
||||
return base64.b64decode(payload, validate=True)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _decoded_base64(cleaned: str) -> bytes | None:
|
||||
strict: Final = _b64decode_or_none(cleaned)
|
||||
if strict is not None or ("-" not in cleaned and "_" not in cleaned):
|
||||
return strict
|
||||
return _b64decode_or_none(cleaned.translate(_URLSAFE_TO_STANDARD_B64))
|
||||
|
||||
|
||||
def _inline_part(name: str | None, value: str, size_limit: int | None, message_index: int) -> FilePart | None:
|
||||
mime_hint, payload = _split_data_url(value)
|
||||
if payload is None:
|
||||
return FilePart(name, None, mime_hint, True, False, message_index, undecodable=True)
|
||||
cleaned: Final = "".join(payload.split())
|
||||
if not cleaned:
|
||||
return None
|
||||
if size_limit is not None and (len(cleaned) * 3) // 4 - 2 > size_limit:
|
||||
return FilePart(name, None, mime_hint, True, True, message_index)
|
||||
data: Final = _decoded_base64(cleaned)
|
||||
if data is None:
|
||||
return FilePart(name, None, mime_hint, True, False, message_index, undecodable=True)
|
||||
if size_limit is not None and len(data) > size_limit:
|
||||
return FilePart(name, None, mime_hint, True, True, message_index)
|
||||
return FilePart(name, data, mime_hint, True, False, message_index)
|
||||
|
||||
|
||||
def _name_from_url(url: str) -> str | None:
|
||||
try:
|
||||
return unquote(posixpath.basename(urlparse(url).path)) or None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _part_from_file_block(block: Mapping[str, object], size_limit: int | None, message_index: int) -> FilePart | None:
|
||||
file_obj: Final = as_str_mapping(block.get("file"))
|
||||
if file_obj is None:
|
||||
return None
|
||||
name: Final = str_or_none(file_obj.get("filename")) or str_or_none(file_obj.get("file_id"))
|
||||
file_data: Final = str_or_none(file_obj.get("file_data"))
|
||||
inline: Final = _inline_part(name, file_data, size_limit, message_index) if file_data else None
|
||||
return inline or FilePart(name, None, None, False, False, message_index)
|
||||
|
||||
|
||||
def _part_from_image_url_block(
|
||||
block: Mapping[str, object], size_limit: int | None, message_index: int
|
||||
) -> FilePart | None:
|
||||
image_url: Final = block.get("image_url")
|
||||
image_url_fields: Final = as_str_mapping(image_url)
|
||||
url: Final = image_url_fields.get("url") if image_url_fields is not None else image_url
|
||||
if not isinstance(url, str) or not url:
|
||||
return None
|
||||
if url.startswith("data:"):
|
||||
return _inline_part(None, url, size_limit, message_index)
|
||||
return FilePart(_name_from_url(url), None, None, False, False, message_index)
|
||||
|
||||
|
||||
def _part_from_input_file_block(
|
||||
block: Mapping[str, object], size_limit: int | None, message_index: int
|
||||
) -> FilePart | None:
|
||||
declared_name: Final = str_or_none(block.get("filename")) or str_or_none(block.get("file_id"))
|
||||
file_data: Final = str_or_none(block.get("file_data"))
|
||||
inline: Final = _inline_part(declared_name, file_data, size_limit, message_index) if file_data else None
|
||||
if inline is not None:
|
||||
return inline
|
||||
file_url: Final = block.get("file_url")
|
||||
name: Final = (
|
||||
_name_from_url(file_url) if isinstance(file_url, str) and file_url and not declared_name else declared_name
|
||||
)
|
||||
return FilePart(name, None, None, False, False, message_index)
|
||||
|
||||
|
||||
def _part_from_input_audio_block(
|
||||
block: Mapping[str, object], size_limit: int | None, message_index: int
|
||||
) -> FilePart | None:
|
||||
audio: Final = as_str_mapping(block.get("input_audio"))
|
||||
if audio is None:
|
||||
return None
|
||||
data_b64: Final = audio.get("data")
|
||||
if not isinstance(data_b64, str) or not data_b64:
|
||||
return None
|
||||
name: Final = f"audio.{audio.get('format') or 'bin'}"
|
||||
return _inline_part(name, data_b64, size_limit, message_index) or FilePart(
|
||||
name, None, None, False, False, message_index
|
||||
)
|
||||
|
||||
|
||||
_BLOCK_PARSERS: Final[Mapping[str, Callable[[Mapping[str, object], int | None, int], FilePart | None]]] = (
|
||||
MappingProxyType(
|
||||
{
|
||||
"file": _part_from_file_block,
|
||||
"image_url": _part_from_image_url_block,
|
||||
"input_image": _part_from_image_url_block,
|
||||
"input_file": _part_from_input_file_block,
|
||||
"input_audio": _part_from_input_audio_block,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _file_parts_of_message(
|
||||
message: Mapping[str, object], size_limit: int | None, message_index: int
|
||||
) -> Iterator[FilePart]:
|
||||
for block in map(as_str_mapping, _list_items(message.get("content"))):
|
||||
if block is None:
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if not isinstance(block_type, str):
|
||||
continue
|
||||
parser = _BLOCK_PARSERS.get(block_type)
|
||||
if parser is None:
|
||||
continue
|
||||
try:
|
||||
part = parser(block, size_limit, message_index)
|
||||
except (TypeError, ValueError, AttributeError, KeyError):
|
||||
continue
|
||||
if part is not None and (part.inline or part.name):
|
||||
yield part
|
||||
|
||||
|
||||
def _file_parts_of_messages(structured_messages: Sequence[object], size_limit: int | None) -> Iterator[FilePart]:
|
||||
for message_index, message in enumerate(map(as_str_mapping, structured_messages)):
|
||||
if message is not None:
|
||||
yield from _file_parts_of_message(message, size_limit, message_index)
|
||||
|
||||
|
||||
def extract_file_parts_from_messages(
|
||||
structured_messages: Sequence[object] | None, size_limit: int | None = None
|
||||
) -> tuple[FilePart, ...]:
|
||||
return tuple(_file_parts_of_messages(structured_messages or (), size_limit))
|
||||
|
||||
|
||||
def _file_part_of_image(value: str, size_limit: int | None, index: int) -> FilePart | None:
|
||||
if value.startswith(("http://", "https://")):
|
||||
name: Final = _name_from_url(value)
|
||||
return FilePart(name, None, None, False, False, index) if name else None
|
||||
return _inline_part(None, value, size_limit, index)
|
||||
|
||||
|
||||
def extract_file_parts_from_images(
|
||||
images: Sequence[object] | None, size_limit: int | None = None
|
||||
) -> tuple[FilePart, ...]:
|
||||
candidates: Final = (
|
||||
_file_part_of_image(value, size_limit, index)
|
||||
for index, value in enumerate(images or ())
|
||||
if isinstance(value, str) and value
|
||||
)
|
||||
return tuple(part for part in candidates if part is not None)
|
||||
|
||||
|
||||
def make_tool_data(
|
||||
name: str, content: str | None, tool_input: Mapping[str, object] | None = None
|
||||
) -> Mapping[str, object]:
|
||||
action_name: Final = str(name) if str(name).strip() else _DEFAULT_TOOL_RESULT_NAME
|
||||
truncated: Final = action_name[:_TOOL_NAME_MAX_LENGTH]
|
||||
tool_name: Final = truncated if truncated.strip() else _DEFAULT_TOOL_RESULT_NAME
|
||||
return {
|
||||
"content": content,
|
||||
"tool_name": tool_name,
|
||||
"action_name": action_name,
|
||||
"tool_input": dict(tool_input or ()),
|
||||
}
|
||||
|
||||
|
||||
def _tool_call_field(tool_call: object, key: str) -> object:
|
||||
fields: Final = as_str_mapping(tool_call)
|
||||
return fields.get(key) if fields is not None else getattr(tool_call, key, None)
|
||||
|
||||
|
||||
def _json_or_str(value: object) -> str:
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _parsed_tool_input(raw_arguments: str) -> Mapping[str, object]:
|
||||
if not raw_arguments:
|
||||
return _NO_TOOL_INPUT
|
||||
try:
|
||||
parsed: Final = json.loads(raw_arguments)
|
||||
except (ValueError, TypeError):
|
||||
return _NO_TOOL_INPUT
|
||||
return as_str_mapping(parsed) or _NO_TOOL_INPUT
|
||||
|
||||
|
||||
def _tool_content_and_input(raw_arguments: object) -> tuple[str, Mapping[str, object]]:
|
||||
if isinstance(raw_arguments, str):
|
||||
return raw_arguments, _parsed_tool_input(raw_arguments)
|
||||
if raw_arguments is None:
|
||||
return "", _NO_TOOL_INPUT
|
||||
arguments: Final = as_str_mapping(raw_arguments)
|
||||
return _json_or_str(raw_arguments), arguments if arguments is not None else _NO_TOOL_INPUT
|
||||
|
||||
|
||||
def tool_call_to_tool_data(tool_call: object) -> Mapping[str, object] | None:
|
||||
function: Final = _tool_call_field(tool_call, "function")
|
||||
name: Final = _tool_call_field(function, "name")
|
||||
if not name or not str(name).strip():
|
||||
return None
|
||||
content, tool_input = _tool_content_and_input(_tool_call_field(function, "arguments"))
|
||||
return make_tool_data(str(name), content, tool_input)
|
||||
|
||||
|
||||
def _message_tool_calls(message: object) -> Sequence[object]:
|
||||
fields: Final = as_str_mapping(message)
|
||||
return _list_items(fields.get("tool_calls")) if fields is not None else ()
|
||||
|
||||
|
||||
def extract_tool_calls_from_messages(structured_messages: Sequence[object] | None) -> tuple[object, ...]:
|
||||
"""Tool calls declared on the messages themselves.
|
||||
|
||||
Surfaces such as the Anthropic request path populate ``structured_messages`` but leave the
|
||||
top-level ``tool_calls`` input empty, so calls made in prior assistant turns are only visible here.
|
||||
"""
|
||||
return tuple(chain.from_iterable(_message_tool_calls(message) for message in structured_messages or ()))
|
||||
|
||||
|
||||
def tool_data_key(tool_data: Mapping[str, object]) -> str:
|
||||
"""Stable identity for a tool payload, so a call reached from two sources is only scanned once."""
|
||||
return json.dumps(tool_data, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _tool_content_block_text(block: object) -> str | None:
|
||||
if isinstance(block, str):
|
||||
return block
|
||||
fields: Final = as_str_mapping(block)
|
||||
return str_or_none(fields.get("text")) if fields is not None else None
|
||||
|
||||
|
||||
def _tool_content_text(content: object) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
fields: Final = as_str_mapping(content)
|
||||
if fields is not None:
|
||||
return _json_or_str(fields)
|
||||
texts: Final = (_tool_content_block_text(block) for block in _list_items(content))
|
||||
return "\n".join(text for text in texts if text is not None)
|
||||
|
||||
|
||||
def _extract_tool_content(content: object) -> str | None:
|
||||
text: Final = _tool_content_text(content)
|
||||
return text if text.strip() else None
|
||||
|
||||
|
||||
class _Declaration(NamedTuple):
|
||||
call_id: str
|
||||
message_index: int
|
||||
name: str
|
||||
|
||||
|
||||
def _declarations_in(message_index: int, message: object) -> Iterator[_Declaration]:
|
||||
fields: Final = as_str_mapping(message)
|
||||
if fields is None or fields.get("role") != "assistant":
|
||||
return
|
||||
for tool_call in map(as_str_mapping, _list_items(fields.get("tool_calls"))):
|
||||
if tool_call is None:
|
||||
continue
|
||||
call_id = tool_call.get("id")
|
||||
function = as_str_mapping(tool_call.get("function"))
|
||||
name = str_or_none(function.get("name")) if function is not None else None
|
||||
if isinstance(call_id, str) and call_id and name and name.strip():
|
||||
yield _Declaration(call_id, message_index, name)
|
||||
|
||||
|
||||
def _declarations_by_call_id(messages: Sequence[object]) -> Mapping[str, tuple[_Declaration, ...]]:
|
||||
"""Every named declaration of each tool call id, in message order, built in one pass."""
|
||||
declarations: Final = chain.from_iterable(
|
||||
_declarations_in(index, message) for index, message in enumerate(messages)
|
||||
)
|
||||
ordered: Final = sorted(declarations, key=attrgetter("call_id", "message_index"))
|
||||
return MappingProxyType({call_id: tuple(group) for call_id, group in groupby(ordered, key=attrgetter("call_id"))})
|
||||
|
||||
|
||||
def _resolve_tool_name(
|
||||
declarations: Mapping[str, tuple[_Declaration, ...]], tool_index: int, tool_call_id: object
|
||||
) -> str:
|
||||
"""Name from the nearest declaration of ``tool_call_id`` before ``tool_index``."""
|
||||
if not isinstance(tool_call_id, str) or not tool_call_id:
|
||||
return _DEFAULT_TOOL_RESULT_NAME
|
||||
declared: Final = declarations.get(tool_call_id, ())
|
||||
earlier: Final = bisect_left(declared, tool_index, key=attrgetter("message_index"))
|
||||
return declared[earlier - 1].name if earlier else _DEFAULT_TOOL_RESULT_NAME
|
||||
|
||||
|
||||
def extract_tool_results(
|
||||
structured_messages: Sequence[object] | None,
|
||||
) -> tuple[tuple[str, str, object], ...]:
|
||||
messages: Final = tuple(structured_messages or ())
|
||||
declarations: Final = _declarations_by_call_id(messages)
|
||||
|
||||
def _results() -> Iterator[tuple[str, str, object]]:
|
||||
for index, message in enumerate(map(as_str_mapping, messages)):
|
||||
if message is None or message.get("role") != "tool":
|
||||
continue
|
||||
content = _extract_tool_content(message.get("content"))
|
||||
if content is None:
|
||||
continue
|
||||
tool_call_id = message.get("tool_call_id")
|
||||
yield _resolve_tool_name(declarations, index, tool_call_id), content, tool_call_id
|
||||
|
||||
return tuple(_results())
|
||||
|
||||
|
||||
def _block_texts(content: object) -> Iterator[object]:
|
||||
blocks: Final = (as_str_mapping(block) for block in _list_items(content))
|
||||
return (block.get("text") for block in blocks if block is not None and block.get("text") is not None)
|
||||
|
||||
|
||||
def _message_text_origins(structured_messages: Sequence[object] | None) -> Iterator[tuple[object, bool]]:
|
||||
for message in map(as_str_mapping, structured_messages or ()):
|
||||
if message is None:
|
||||
continue
|
||||
content = message.get("content")
|
||||
from_tool_result = message.get("role") == "tool" and _extract_tool_content(content) is not None
|
||||
if isinstance(content, str):
|
||||
yield content, from_tool_result
|
||||
else:
|
||||
yield from ((text, from_tool_result) for text in _block_texts(content))
|
||||
|
||||
|
||||
def tool_result_text_indices(structured_messages: Sequence[object] | None, texts: Sequence[str]) -> frozenset[int]:
|
||||
"""Positions in ``texts`` that hold content already submitted under the TOOL policy.
|
||||
|
||||
The chat-completions guardrail flow builds ``texts`` and ``structured_messages`` from the
|
||||
same message list, so tool-role content lands in both and would otherwise be checked twice.
|
||||
Other surfaces (e.g. Anthropic messages) build ``texts`` from a differently shaped payload,
|
||||
so the mapping is only trusted when replaying it reproduces ``texts`` exactly; anything else
|
||||
falls back to checking every text.
|
||||
"""
|
||||
origins: Final = tuple(_message_text_origins(structured_messages))
|
||||
if tuple(text for text, _ in origins) != tuple(texts):
|
||||
return frozenset()
|
||||
return frozenset(index for index, (_, from_tool_result) in enumerate(origins) if from_tool_result)
|
||||
|
|
@ -18,6 +18,7 @@ def initialize_bedrock(litellm_params: LitellmParams, guardrail: Guardrail):
|
|||
event_hook=litellm_params.mode,
|
||||
guardrailIdentifier=litellm_params.guardrailIdentifier,
|
||||
guardrailVersion=litellm_params.guardrailVersion,
|
||||
outputScope=litellm_params.outputScope,
|
||||
checks=litellm_params.checks if isinstance(litellm_params.checks, BedrockChecksConfigModel) else None,
|
||||
content_filter_threshold=litellm_params.content_filter_threshold,
|
||||
prompt_attack_threshold=litellm_params.prompt_attack_threshold,
|
||||
|
|
|
|||
117
litellm/proxy/guardrails/response_handlers.py
Normal file
117
litellm/proxy/guardrails/response_handlers.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Response policies for the standalone guardrail endpoint."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.types.guardrails import (
|
||||
ApplyGuardrailRequest,
|
||||
ApplyGuardrailResponse,
|
||||
GuardrailEventHooks,
|
||||
GuardrailResponseFormat,
|
||||
NativeGuardrailResponse,
|
||||
)
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
GuardrailEndpointResponse: TypeAlias = ApplyGuardrailResponse | NativeGuardrailResponse
|
||||
_NATIVE_RESPONSE: Final[TypeAdapter[NativeGuardrailResponse]] = TypeAdapter(NativeGuardrailResponse)
|
||||
|
||||
|
||||
class StandardGuardrailResponseHandler:
|
||||
"""Preserve the endpoint's existing text response and hook behavior."""
|
||||
|
||||
def prepare_request_data(self, request: ApplyGuardrailRequest) -> Mapping[str, object]:
|
||||
return request.model_dump(include={"messages", "metadata"}, exclude_none=True)
|
||||
|
||||
def resolve_input_type(
|
||||
self, request: ApplyGuardrailRequest, guardrail: CustomGuardrail
|
||||
) -> Literal["request", "response"]:
|
||||
if request.input_type == "request" and guardrail.event_hook in (GuardrailEventHooks.post_call, "post_call"):
|
||||
return "response"
|
||||
return "response" if request.input_type == "response" else "request"
|
||||
|
||||
def build_response(
|
||||
self,
|
||||
request: ApplyGuardrailRequest,
|
||||
processed_inputs: GenericGuardrailAPIInputs,
|
||||
request_data: Mapping[str, object],
|
||||
) -> GuardrailEndpointResponse:
|
||||
texts: Final = processed_inputs.get("texts", [])
|
||||
return ApplyGuardrailResponse(response_text=texts[0] if texts else request.text)
|
||||
|
||||
def accept_hook_result(self, original: GuardrailEndpointResponse, modified: object) -> GuardrailEndpointResponse:
|
||||
return modified if isinstance(modified, ApplyGuardrailResponse) else original
|
||||
|
||||
|
||||
class _NativeGuardrailRecord(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
guardrail_name: str | None = None
|
||||
guardrail_response: JsonValue = None
|
||||
|
||||
|
||||
class _NativeGuardrailMetadata(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
standard_logging_guardrail_information: tuple[_NativeGuardrailRecord, ...] = ()
|
||||
|
||||
|
||||
class NativeGuardrailResponseHandler(StandardGuardrailResponseHandler):
|
||||
"""Return structured findings recorded by this guardrail invocation."""
|
||||
|
||||
def prepare_request_data(self, request: ApplyGuardrailRequest) -> Mapping[str, object]:
|
||||
return request.model_dump(
|
||||
include={"messages", "metadata"},
|
||||
exclude={"metadata": {"standard_logging_guardrail_information"}},
|
||||
exclude_none=True,
|
||||
)
|
||||
|
||||
def resolve_input_type(
|
||||
self, request: ApplyGuardrailRequest, guardrail: CustomGuardrail
|
||||
) -> Literal["request", "response"]:
|
||||
if "input_type" in request.model_fields_set:
|
||||
return "response" if request.input_type == "response" else "request"
|
||||
return super().resolve_input_type(request, guardrail)
|
||||
|
||||
def build_response(
|
||||
self,
|
||||
request: ApplyGuardrailRequest,
|
||||
processed_inputs: GenericGuardrailAPIInputs,
|
||||
request_data: Mapping[str, object],
|
||||
) -> GuardrailEndpointResponse:
|
||||
metadata: Final = request_data.get(get_metadata_variable_name_from_kwargs(request_data))
|
||||
recorded: Final = _NativeGuardrailMetadata.model_validate_json(
|
||||
safe_dumps(metadata if metadata is not None else {})
|
||||
)
|
||||
findings: Final = tuple(
|
||||
record.guardrail_response
|
||||
for record in recorded.standard_logging_guardrail_information
|
||||
if record.guardrail_name == request.guardrail_name and isinstance(record.guardrail_response, (dict, list))
|
||||
)
|
||||
if not findings:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Guardrail '{request.guardrail_name}' did not report structured native findings.",
|
||||
)
|
||||
return findings[0] if len(findings) == 1 else findings
|
||||
|
||||
def accept_hook_result(self, original: GuardrailEndpointResponse, modified: object) -> GuardrailEndpointResponse:
|
||||
if modified is None:
|
||||
return original
|
||||
return _NATIVE_RESPONSE.validate_python(modified)
|
||||
|
||||
|
||||
GUARDRAIL_RESPONSE_HANDLERS: Final[Mapping[GuardrailResponseFormat, StandardGuardrailResponseHandler]] = (
|
||||
MappingProxyType(
|
||||
{
|
||||
GuardrailResponseFormat.STANDARD: StandardGuardrailResponseHandler(),
|
||||
GuardrailResponseFormat.NATIVE: NativeGuardrailResponseHandler(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -118,7 +118,7 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
|
|||
elif priority is not None and litellm.priority_reservation is not None:
|
||||
if os.getenv("LITELLM_LICENSE", None) is None:
|
||||
verbose_proxy_logger.error(
|
||||
"PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/proxy/enterprise."
|
||||
"PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/enterprise."
|
||||
)
|
||||
else:
|
||||
value: Final = litellm.priority_reservation[priority]
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
elif priority is not None and litellm.priority_reservation is not None:
|
||||
if os.getenv("LITELLM_LICENSE", None) is None:
|
||||
verbose_proxy_logger.error(
|
||||
"PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/proxy/enterprise."
|
||||
"PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/enterprise."
|
||||
)
|
||||
else:
|
||||
value: Final = litellm.priority_reservation[priority]
|
||||
|
|
|
|||
|
|
@ -2405,7 +2405,7 @@ async def generate_key_fn(
|
|||
- soft_budget: Optional[float] - Specify soft budget for a given key. Will trigger a slack alert when this soft budget is reached.
|
||||
- tags: Optional[List[str]] - Tags for [tracking spend](https://litellm.vercel.app/docs/proxy/enterprise#tracking-spend-for-custom-tags) and/or doing [tag-based routing](https://litellm.vercel.app/docs/proxy/tag_routing).
|
||||
- prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
|
||||
- enforced_params: Optional[List[str]] - List of enforced params for the key (Enterprise only). [Docs](https://docs.litellm.ai/docs/proxy/enterprise#enforce-required-params-for-llm-requests)
|
||||
- enforced_params: Optional[List[str]] - List of enforced params for the key (Enterprise only). [Docs](https://docs.litellm.ai/docs/enterprise#governance-and-cost)
|
||||
- prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
|
||||
- allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
- allowed_passthrough_routes: Optional[list] - List of allowed pass through endpoints for the key. Store the actual endpoint or store a wildcard pattern for a set of endpoints. Example - ["/my-custom-endpoint"]. Use this instead of allowed_routes, if you just want to specify which pass through endpoints the key can access, without specifying the routes. If allowed_routes is specified, allowed_pass_through_endpoints is ignored.
|
||||
|
|
@ -2613,7 +2613,7 @@ async def generate_service_account_key_fn(
|
|||
- tpd_limit: Optional[int] - Specify tpd limit for a given key (Tokens per day). Charged by batch submissions instead of tpm_limit/rpm_limit.
|
||||
- soft_budget: Optional[float] - Specify soft budget for a given key. Will trigger a slack alert when this soft budget is reached.
|
||||
- tags: Optional[List[str]] - Tags for [tracking spend](https://litellm.vercel.app/docs/proxy/enterprise#tracking-spend-for-custom-tags) and/or doing [tag-based routing](https://litellm.vercel.app/docs/proxy/tag_routing).
|
||||
- enforced_params: Optional[List[str]] - List of enforced params for the key (Enterprise only). [Docs](https://docs.litellm.ai/docs/proxy/enterprise#enforce-required-params-for-llm-requests)
|
||||
- enforced_params: Optional[List[str]] - List of enforced params for the key (Enterprise only). [Docs](https://docs.litellm.ai/docs/enterprise#governance-and-cost)
|
||||
- allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
Examples:
|
||||
|
|
@ -4045,7 +4045,7 @@ async def update_key_fn(
|
|||
- models: Optional[list] - Model_name's a user is allowed to call
|
||||
- tags: Optional[List[str]] - Tags for organizing keys (Enterprise only)
|
||||
- prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
|
||||
- enforced_params: Optional[List[str]] - List of enforced params for the key (Enterprise only). [Docs](https://docs.litellm.ai/docs/proxy/enterprise#enforce-required-params-for-llm-requests)
|
||||
- enforced_params: Optional[List[str]] - List of enforced params for the key (Enterprise only). [Docs](https://docs.litellm.ai/docs/enterprise#governance-and-cost)
|
||||
- spend: Optional[float] - Amount spent by key
|
||||
- max_budget: Optional[float] - Max budget for key
|
||||
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets keyed by model name, `*` pattern like "claude-opus*", or model access group, e.g. {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}
|
||||
|
|
|
|||
|
|
@ -3614,7 +3614,9 @@ async def deepgram_listen_websocket_route(
|
|||
if not deepgram_listen_is_priced(target):
|
||||
await websocket.close(
|
||||
code=1008,
|
||||
reason=_DEEPGRAM_WS_UNPRICED_REASON.format(registry_key=deepgram_listen_registry_key(target)),
|
||||
reason=truncated_close_reason(
|
||||
_DEEPGRAM_WS_UNPRICED_REASON.format(registry_key=deepgram_listen_registry_key(target))
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -1193,7 +1193,7 @@ class ProxyInitializationHelpers:
|
|||
"--reload",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable uvicorn hot reload (dev only). Also reloads when the --config YAML file changes. Incompatible with --num_workers>1, --run_gunicorn, and --run_hypercorn.",
|
||||
help="Enable uvicorn hot reload (dev only), including every --num_workers worker on uvicorn>=0.50.0 outside Windows. Also reloads when the --config YAML or .env changes. Incompatible with --run_gunicorn, --run_hypercorn, and --run_granian.",
|
||||
)
|
||||
@click.option(
|
||||
"--prometheus_metrics_port",
|
||||
|
|
@ -1785,9 +1785,28 @@ def run_server(
|
|||
if num_workers > 1:
|
||||
start_query_engine_reaper()
|
||||
if num_workers > 1 or reload:
|
||||
uvicorn_args.update(
|
||||
ProxyInitializationHelpers.child_process_uvicorn_overrides(uvicorn_args.get("log_config"))
|
||||
worker_overrides: Final = ProxyInitializationHelpers.child_process_uvicorn_overrides(
|
||||
uvicorn_args.get("log_config")
|
||||
)
|
||||
if reload and num_workers > 1:
|
||||
from litellm.proxy.dev_reload import (
|
||||
RELOAD_OPTIONS,
|
||||
multi_worker_reload_supported,
|
||||
run_multi_worker_reload,
|
||||
)
|
||||
|
||||
if multi_worker_reload_supported():
|
||||
run_multi_worker_reload(
|
||||
RELOAD_OPTIONS.validate_python(uvicorn_args),
|
||||
workers=TypeAdapter(int).validate_python(num_workers),
|
||||
worker_overrides=worker_overrides,
|
||||
)
|
||||
return
|
||||
print(
|
||||
"\033[1;33mLiteLLM Proxy: --reload with --num_workers>1 requires uvicorn>=0.50.0 "
|
||||
"and an OS other than Windows, so only a single worker reloads.\033[0m"
|
||||
)
|
||||
uvicorn_args.update(worker_overrides)
|
||||
uvicorn.run(
|
||||
**uvicorn_args,
|
||||
workers=num_workers,
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ from litellm.llms.anthropic.messages.convertible_blocks import (
|
|||
drop_non_string_tool_result_texts,
|
||||
malformed_tool_result_count_error,
|
||||
)
|
||||
from litellm.llms.custom_httpx.aiohttp_pool_diagnostics import build_pool_wait_trace_config
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.openai.live import parse_live_session_start
|
||||
from litellm.llms.openai_like.model_info import MODEL_INFO_REFRESH_SECONDS
|
||||
|
|
@ -1209,7 +1210,7 @@ ui_message += "\n\n💸 [```LiteLLM Model Cost Map```](https://models.litellm.ai
|
|||
ui_message += f"\n\n🔎 [```LiteLLM Model Hub```]({model_hub_link}). See available models on the proxy. [**Docs**](https://docs.litellm.ai/docs/proxy/ai_hub)"
|
||||
|
||||
custom_swagger_message: Final = (
|
||||
"[**Customize Swagger Docs**](https://docs.litellm.ai/docs/proxy/enterprise#swagger-docs---custom-routes--branding)"
|
||||
"[**Customize Swagger Docs**](https://docs.litellm.ai/docs/enterprise#operations--branding)"
|
||||
)
|
||||
|
||||
### CUSTOM BRANDING [ENTERPRISE FEATURE] ###
|
||||
|
|
@ -1460,7 +1461,11 @@ async def initialize_shared_aiohttp_session() -> "ClientSession | None":
|
|||
connector_kwargs["socket_factory"] = socket_factory
|
||||
|
||||
connector: Final = TCPConnector(**connector_kwargs)
|
||||
session: Final = ClientSession(connector=connector, cookie_jar=DummyCookieJar())
|
||||
session: Final = ClientSession(
|
||||
connector=connector,
|
||||
cookie_jar=DummyCookieJar(),
|
||||
trace_configs=[build_pool_wait_trace_config()],
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
"SESSION REUSE: Created shared aiohttp session for connection pooling (ID: %s, limit=%s, limit_per_host=%s)",
|
||||
|
|
@ -3070,6 +3075,29 @@ redis_usage_cache: RedisCache | None = None # redis cache used for tracking spe
|
|||
polling_via_cache_enabled: Literal["all"] | list[str] | bool = False
|
||||
native_background_mode: list[str] = [] # Models that should use native provider background mode instead of polling
|
||||
polling_cache_ttl: int = 3600 # Default 1 hour TTL for polling cache
|
||||
|
||||
|
||||
class _ResponseBackgroundSettings(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
polling_via_cache: Literal["all"] | tuple[str, ...] | bool = False
|
||||
native_background_mode: tuple[str, ...] = ()
|
||||
ttl: int = 3600
|
||||
|
||||
|
||||
def _apply_background_response_settings(value: SettingsJsonValue) -> None:
|
||||
global polling_via_cache_enabled, native_background_mode, polling_cache_ttl
|
||||
|
||||
responses: Final = _as_settings_mapping(value)
|
||||
settings: Final = _ResponseBackgroundSettings.model_validate(responses.get("background_mode") or {})
|
||||
polling: Final = settings.polling_via_cache
|
||||
polling_via_cache_enabled, native_background_mode, polling_cache_ttl = ( # rebind-ok: Refresh runtime config.
|
||||
list(polling) if isinstance(polling, tuple) else polling,
|
||||
list(settings.native_background_mode),
|
||||
settings.ttl,
|
||||
)
|
||||
|
||||
|
||||
user_custom_auth = None
|
||||
user_custom_key_generate = None
|
||||
# Sentinel: prevents PKCE-no-Redis advisory from re-logging on config hot-reload.
|
||||
|
|
@ -6932,20 +6960,7 @@ class ProxyConfig:
|
|||
# see usage here: https://docs.litellm.ai/docs/proxy/caching
|
||||
pass
|
||||
elif key == "responses":
|
||||
# Initialize global polling via cache settings
|
||||
global polling_via_cache_enabled, native_background_mode, polling_cache_ttl
|
||||
background_mode = value.get("background_mode", {})
|
||||
polling_via_cache_enabled = background_mode.get("polling_via_cache", False)
|
||||
native_background_mode = background_mode.get("native_background_mode", [])
|
||||
polling_cache_ttl = background_mode.get("ttl", 3600)
|
||||
verbose_proxy_logger.debug(
|
||||
"%s Initialized polling via cache: enabled=%s, native_background_mode=%s, ttl=%s%s",
|
||||
blue_color_code,
|
||||
polling_via_cache_enabled,
|
||||
native_background_mode,
|
||||
polling_cache_ttl,
|
||||
reset_color_code,
|
||||
)
|
||||
_apply_background_response_settings(value)
|
||||
elif key == "max_ui_session_budget":
|
||||
litellm.max_ui_session_budget = float(value) if value is not None else None
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
@ -8653,6 +8668,7 @@ class ProxyConfig:
|
|||
|
||||
def _apply_litellm_settings_db_values(self, db_values: Mapping[str, SettingsJsonValue]) -> None:
|
||||
self.litellm_settings.apply_db_row("litellm_settings", db_values)
|
||||
_apply_background_response_settings(self.litellm_settings.get("responses", {}))
|
||||
for key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES:
|
||||
if key in db_values and (value := self.litellm_settings.get(key)) is not None:
|
||||
setattr(litellm, key, value)
|
||||
|
|
@ -18110,6 +18126,8 @@ async def model_deprecations(
|
|||
def get_model_group_info(
|
||||
llm_router: Router, all_models_str: Sequence[str], model_group: str | None, team_id: str | None = None
|
||||
) -> list[ModelGroupInfoProxy]:
|
||||
from litellm.proxy.response_polling.polling_handler import should_use_polling_for_request
|
||||
|
||||
model_groups: Final[list[ModelGroupInfoProxy]] = []
|
||||
|
||||
unique_models: Final = tuple(dict.fromkeys(all_models_str))
|
||||
|
|
@ -18119,6 +18137,14 @@ def get_model_group_info(
|
|||
if model_group is not None and model_group != model:
|
||||
continue
|
||||
|
||||
supports_background_chat = should_use_polling_for_request(
|
||||
background_mode=True,
|
||||
polling_via_cache_enabled=polling_via_cache_enabled,
|
||||
redis_cache=redis_usage_cache,
|
||||
model=model,
|
||||
llm_router=llm_router,
|
||||
native_background_mode=native_background_mode,
|
||||
)
|
||||
_model_group_info = registry.group(model, team_id=team_id)
|
||||
|
||||
if _model_group_info is not None:
|
||||
|
|
@ -18126,6 +18152,7 @@ def get_model_group_info(
|
|||
ModelGroupInfoProxy.model_validate(
|
||||
{
|
||||
**_model_group_info.model_dump(),
|
||||
"supports_background_chat": supports_background_chat,
|
||||
**model_group_custom_fields(
|
||||
registry.deployments(
|
||||
resolve_model_group_alias(registry.aliases(), model) or model,
|
||||
|
|
@ -18140,6 +18167,7 @@ def get_model_group_info(
|
|||
model_group_info = ModelGroupInfoProxy(
|
||||
model_group=model,
|
||||
providers=[],
|
||||
supports_background_chat=supports_background_chat,
|
||||
)
|
||||
model_groups.append(model_group_info)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ https://platform.openai.com/docs/api-reference/responses-streaming
|
|||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Callable, Mapping, Sequence
|
||||
from time import perf_counter
|
||||
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias, runtime_checkable
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
|
@ -128,6 +129,16 @@ async def background_streaming_task(
|
|||
https://platform.openai.com/docs/api-reference/responses/object
|
||||
"""
|
||||
|
||||
started_at: Final = perf_counter()
|
||||
first_token_at: float | None = None # rebind-ok: first content event closes the TTFT interval
|
||||
finished_at: float | None = None # rebind-ok: terminal event closes the execution interval
|
||||
|
||||
def timing_snapshot() -> Mapping[str, float]:
|
||||
return {
|
||||
**({"time_to_first_token_ms": (first_token_at - started_at) * 1000} if first_token_at is not None else {}),
|
||||
**({"total_latency_ms": (finished_at - started_at) * 1000} if finished_at is not None else {}),
|
||||
}
|
||||
|
||||
try:
|
||||
verbose_proxy_logger.info("Starting background streaming for %s", polling_id)
|
||||
|
||||
|
|
@ -191,7 +202,7 @@ async def background_streaming_task(
|
|||
incomplete_details_data = None
|
||||
|
||||
state_dirty = False # Track if state needs to be synced
|
||||
last_update_time = asyncio.get_event_loop().time()
|
||||
last_update_time = perf_counter()
|
||||
UPDATE_INTERVAL: Final = 0.150 # 150ms batching interval
|
||||
|
||||
# Track the terminal event from the stream (may not be "completed")
|
||||
|
|
@ -210,13 +221,14 @@ async def background_streaming_task(
|
|||
"""Flush accumulated state to Redis if interval elapsed or forced"""
|
||||
nonlocal state_dirty, last_update_time
|
||||
|
||||
current_time: Final = asyncio.get_event_loop().time()
|
||||
current_time: Final = perf_counter()
|
||||
if state_dirty and (force or (current_time - last_update_time) >= UPDATE_INTERVAL):
|
||||
# Convert output_items dict to list for update
|
||||
output_list: Final = list(output_items.values())
|
||||
await polling_handler.update_state(
|
||||
polling_id=polling_id,
|
||||
output=output_list,
|
||||
timing=timing_snapshot(),
|
||||
)
|
||||
state_dirty = False
|
||||
last_update_time = current_time
|
||||
|
|
@ -242,6 +254,20 @@ async def background_streaming_task(
|
|||
event: _StreamEvent = _StreamEventParser.parse(chunk_data)
|
||||
event_type = event.get("type", "")
|
||||
|
||||
if (
|
||||
first_token_at is None
|
||||
and event_type
|
||||
in (
|
||||
"response.output_text.delta",
|
||||
"response.reasoning_text.delta",
|
||||
"response.reasoning_summary_text.delta",
|
||||
"response.reasoning.delta",
|
||||
)
|
||||
and event.get("delta")
|
||||
):
|
||||
first_token_at = perf_counter()
|
||||
state_dirty = True
|
||||
|
||||
# Process different event types based on OpenAI streaming spec
|
||||
if event_type == "response.output_item.added":
|
||||
# New output item added
|
||||
|
|
@ -334,6 +360,7 @@ async def background_streaming_task(
|
|||
):
|
||||
# Terminal event - extract all ResponsesAPIResponse fields
|
||||
# https://platform.openai.com/docs/api-reference/responses-streaming
|
||||
finished_at = perf_counter()
|
||||
response_data = event.get("response", {})
|
||||
terminal_status = response_data.get(
|
||||
"status",
|
||||
|
|
@ -384,10 +411,13 @@ async def background_streaming_task(
|
|||
|
||||
# Use the terminal status from the stream, default to "completed"
|
||||
final_status: Final = terminal_status or "completed"
|
||||
if finished_at is None:
|
||||
finished_at = perf_counter() # rebind-ok: close the interval when the stream has no terminal event
|
||||
|
||||
await polling_handler.update_state(
|
||||
polling_id=polling_id,
|
||||
status=final_status,
|
||||
timing=timing_snapshot(),
|
||||
usage=usage_data,
|
||||
error=terminal_error,
|
||||
reasoning=reasoning_data,
|
||||
|
|
@ -417,6 +447,7 @@ async def background_streaming_task(
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
finished_at = perf_counter() # rebind-ok: capture execution time at failure
|
||||
verbose_proxy_logger.error("Error in background streaming task for %s: %s", polling_id, e)
|
||||
import traceback
|
||||
|
||||
|
|
@ -425,6 +456,7 @@ async def background_streaming_task(
|
|||
await polling_handler.update_state(
|
||||
polling_id=polling_id,
|
||||
status="failed",
|
||||
timing=timing_snapshot(),
|
||||
error={
|
||||
"type": "internal_error",
|
||||
"message": str(e),
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ Response Polling Handler for Background Responses with Cache
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
|
|
@ -15,6 +15,9 @@ from litellm._uuid import uuid4
|
|||
from litellm.caching.redis_cache import RedisCache
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse, ResponsesAPIStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
_RESPONSE_POLLING_TARGET: Final = "response_polling"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
_JSON_LIST: Final = TypeAdapter(list[object])
|
||||
|
|
@ -116,6 +119,7 @@ class ResponsePollingHandler:
|
|||
parallel_tool_calls: bool | None = None,
|
||||
user: str | None = None,
|
||||
store: bool | None = None,
|
||||
timing: Mapping[str, float] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update the polling state in Redis
|
||||
|
|
@ -144,6 +148,7 @@ class ResponsePollingHandler:
|
|||
parallel_tool_calls: Whether parallel tool calls are enabled
|
||||
user: User identifier
|
||||
store: Whether to store the response
|
||||
timing: Server execution timings in milliseconds
|
||||
"""
|
||||
if not self.redis_cache:
|
||||
return
|
||||
|
|
@ -167,6 +172,9 @@ class ResponsePollingHandler:
|
|||
if output is not None:
|
||||
state["output"] = output
|
||||
|
||||
if timing is not None:
|
||||
state["litellm_response_timing"] = timing
|
||||
|
||||
# Update usage
|
||||
if usage:
|
||||
state["usage"] = usage
|
||||
|
|
@ -264,10 +272,10 @@ class ResponsePollingHandler:
|
|||
|
||||
def should_use_polling_for_request(
|
||||
background_mode: bool,
|
||||
polling_via_cache_enabled, # Can be False, "all", or List[str]
|
||||
redis_cache, # RedisCache or None
|
||||
polling_via_cache_enabled: bool | str | Sequence[str],
|
||||
redis_cache: RedisCache | None,
|
||||
model: str,
|
||||
llm_router, # Router instance or None
|
||||
llm_router: "Router | None",
|
||||
native_background_mode: list[str] | None = None, # List of models that should use native background mode
|
||||
) -> bool:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -216,6 +216,20 @@ class _SettingsWithSchema(LiteLLMBaseModel):
|
|||
field_schema: dict[str, object]
|
||||
|
||||
|
||||
class BackgroundChatSettings(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
enabled: bool = Field(description="Enable cache-backed background Responses for Chat and API requests.")
|
||||
|
||||
|
||||
class BackgroundChatSettingsResponse(BackgroundChatSettings):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
redis_configured: bool = Field(description="Whether this proxy has coordination Redis configured.")
|
||||
managed_by_config: bool = Field(description="Whether the responses configuration is owned by the config file.")
|
||||
can_save: bool = Field(description="Whether database-backed configuration updates are available.")
|
||||
|
||||
|
||||
class SSOSettingsResponse(SettingsResponse):
|
||||
"""Response model for SSO settings"""
|
||||
|
||||
|
|
@ -1815,6 +1829,87 @@ async def sync_ui_settings_to_general_settings(
|
|||
return apply_runtime_general_settings_flags(parsed)
|
||||
|
||||
|
||||
def _background_chat_settings_response(config: Mapping[str, object]) -> BackgroundChatSettingsResponse:
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
settings: Final = _as_settings_section(config.get("litellm_settings"))
|
||||
responses: Final = _as_settings_section(settings.get("responses"))
|
||||
background: Final = _as_settings_section(responses.get("background_mode"))
|
||||
polling: Final = background.get("polling_via_cache", False)
|
||||
return BackgroundChatSettingsResponse(
|
||||
enabled=bool(polling),
|
||||
redis_configured=proxy_server.redis_usage_cache is not None,
|
||||
managed_by_config=proxy_server.proxy_config.litellm_settings.owned_by_config("responses"),
|
||||
can_save=proxy_server.prisma_client is not None and proxy_server.store_model_in_db is True,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/background_chat_settings",
|
||||
tags=["Settings"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=BackgroundChatSettingsResponse,
|
||||
)
|
||||
async def get_background_chat_settings() -> BackgroundChatSettingsResponse:
|
||||
"""Get saved background-response settings and the prerequisites for changing them."""
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
config: Final = _as_settings_section(_DECODED_JSON.validate_python(await proxy_config.get_config()))
|
||||
return _background_chat_settings_response(config)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/update/background_chat_settings",
|
||||
tags=["Settings"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=BackgroundChatSettingsResponse,
|
||||
)
|
||||
async def update_background_chat_settings(
|
||||
settings: BackgroundChatSettings,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> BackgroundChatSettingsResponse:
|
||||
"""Save background-response polling settings for the proxy configuration refresh."""
|
||||
from litellm.proxy.proxy_server import (
|
||||
create_config_audit_log,
|
||||
prisma_client,
|
||||
proxy_config,
|
||||
redis_usage_cache,
|
||||
store_model_in_db,
|
||||
)
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only proxy admins can update background chat settings.")
|
||||
if prisma_client is None or store_model_in_db is not True:
|
||||
raise HTTPException(status_code=400, detail="Enable database-backed configuration to save this setting.")
|
||||
if settings.enabled and redis_usage_cache is None:
|
||||
raise HTTPException(status_code=400, detail="Configure coordination Redis and restart the proxy first.")
|
||||
|
||||
raw_config: Final = await proxy_config.get_config()
|
||||
config: Final = _as_settings_section(_DECODED_JSON.validate_python(raw_config))
|
||||
litellm_settings: Final = _as_settings_section(config.get("litellm_settings"))
|
||||
responses: Final = _UI_SETTINGS_OBJECT.validate_python(_as_settings_section(litellm_settings.get("responses")))
|
||||
background: Final = _UI_SETTINGS_OBJECT.validate_python(_as_settings_section(responses.get("background_mode")))
|
||||
updated_responses: Final[Mapping[str, JsonValue]] = {
|
||||
**responses,
|
||||
"background_mode": {**background, "polling_via_cache": "all" if settings.enabled else False},
|
||||
}
|
||||
proxy_config.reject_config_owned_writes(
|
||||
section_name="litellm_settings", changed_keys={"responses": dict(updated_responses)}
|
||||
)
|
||||
raw_config["litellm_settings"] = {**litellm_settings, "responses": dict(updated_responses)}
|
||||
await proxy_config.save_config(new_config=config)
|
||||
asyncio.create_task(
|
||||
create_config_audit_log(
|
||||
param_name="responses.background_mode.polling_via_cache",
|
||||
action="updated",
|
||||
before_value=background.get("polling_via_cache", False),
|
||||
after_value="all" if settings.enabled else False,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
)
|
||||
return _background_chat_settings_response(config)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/ui_settings",
|
||||
tags=["UI Settings"],
|
||||
|
|
|
|||
|
|
@ -128,6 +128,15 @@ def _with_hoisted_additional_tools(responses_request: Mapping[str, object], drop
|
|||
return {**responses_request, "input": hoisted.input, "tools": list(hoisted.tools)}
|
||||
|
||||
|
||||
def _caller_input(responses_request: Mapping[str, object]) -> ResponseInputParam | None:
|
||||
responses_input: Final = responses_request.get("input")
|
||||
return (
|
||||
cast(ResponseInputParam, responses_input) # cast-ok: forwarded as sent, as inference does
|
||||
if isinstance(responses_input, list)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _with_emulated_file_search(responses_request: Mapping[str, object]) -> Mapping[str, object]:
|
||||
tools: Final = responses_request.get("tools")
|
||||
if tools is None:
|
||||
|
|
@ -200,6 +209,7 @@ async def chat_count_request(
|
|||
),
|
||||
custom_llm_provider,
|
||||
policy.drop_params,
|
||||
caller_input=_caller_input(responses_request),
|
||||
),
|
||||
model,
|
||||
custom_llm_provider,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Handler for transforming responses api requests to litellm.completion requests
|
|||
|
||||
from collections.abc import Coroutine, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -32,6 +33,13 @@ from litellm.types.llms.openai import (
|
|||
from litellm.types.utils import ModelResponse
|
||||
|
||||
_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_BRIDGE_TRANSFORM_KEYWORDS: Final = frozenset({"caller_input", "names_non_string_fields"})
|
||||
|
||||
|
||||
def _without_bridge_transform_keywords(request_kwargs: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType(
|
||||
{key: value for key, value in request_kwargs.items() if key not in _BRIDGE_TRANSFORM_KEYWORDS}
|
||||
)
|
||||
|
||||
|
||||
def _chat_verbosity(responses_api_request: Mapping[str, object]) -> Mapping[str, object]:
|
||||
|
|
@ -95,6 +103,7 @@ class LiteLLMCompletionTransformationHandler:
|
|||
previous_response_id,
|
||||
hoisted,
|
||||
model=model,
|
||||
caller_input=input,
|
||||
responses_api_request=responses_api_request,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
stream=stream,
|
||||
|
|
@ -108,11 +117,13 @@ class LiteLLMCompletionTransformationHandler:
|
|||
**LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
|
||||
model=model,
|
||||
input=bridged_input,
|
||||
caller_input=input,
|
||||
names_non_string_fields=True,
|
||||
responses_api_request=bridged_request,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
stream=stream,
|
||||
extra_headers=extra_headers,
|
||||
**kwargs,
|
||||
**_without_bridge_transform_keywords(_REQUEST_KWARGS_ADAPTER.validate_python(kwargs)),
|
||||
),
|
||||
**_chat_verbosity(bridged_request),
|
||||
}
|
||||
|
|
@ -163,6 +174,7 @@ class LiteLLMCompletionTransformationHandler:
|
|||
/,
|
||||
*,
|
||||
model: str,
|
||||
caller_input: str | ResponseInputParam,
|
||||
responses_api_request: ResponsesAPIOptionalRequestParams,
|
||||
custom_llm_provider: str | None,
|
||||
stream: bool | None,
|
||||
|
|
@ -186,11 +198,13 @@ class LiteLLMCompletionTransformationHandler:
|
|||
**LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
|
||||
model=model,
|
||||
input=bridged.input,
|
||||
caller_input=caller_input,
|
||||
names_non_string_fields=True,
|
||||
responses_api_request=bridged.responses_api_request,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
stream=stream,
|
||||
extra_headers=extra_headers,
|
||||
**request_kwargs,
|
||||
**_without_bridge_transform_keywords(request_kwargs),
|
||||
),
|
||||
**_chat_verbosity(bridged.responses_api_request),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from litellm.constants import (
|
|||
REDACTED_BY_LITELLM,
|
||||
REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER,
|
||||
)
|
||||
from litellm.exceptions import BadRequestError
|
||||
from litellm.integrations.anthropic_cache_control_hook import (
|
||||
AnthropicCacheControlHook,
|
||||
block_with_cache_control,
|
||||
|
|
@ -65,6 +66,12 @@ from litellm.litellm_core_utils.hidden_params import (
|
|||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.model_param_helper import embeds_thought_signature_in_tool_call_id
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
invalid_type_message,
|
||||
is_present_non_string,
|
||||
is_unhashable_value_behind,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field, parts_of
|
||||
from litellm.llms.anthropic.common_utils import supports_anthropic_cache_control
|
||||
from litellm.llms.anthropic.messages.native.utils import anthropic_stop_reason, openai_chat_refusal_text
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
|
@ -268,6 +275,7 @@ _IMAGE_URL_ADAPTER: Final = TypeAdapter(ChatCompletionImageUrlObject)
|
|||
_FILE_PART_ADAPTER: Final = TypeAdapter(_ResponsesFilePart)
|
||||
_MessageT = TypeVar("_MessageT")
|
||||
_MediaT: Final = TypeVar("_MediaT")
|
||||
_InputItemT: Final = TypeVar("_InputItemT")
|
||||
_CACHED_CONTENT_ADAPTER: Final = TypeAdapter(ChatCompletionCachedContent)
|
||||
_DICT_ITEMS_LIST_ADAPTER: Final = TypeAdapter(list[dict[object, object]])
|
||||
_TEXT_ADAPTER: Final = TypeAdapter(str)
|
||||
|
|
@ -445,6 +453,86 @@ TOOL_CALL_OUTPUT_ITEM_TYPES: Final = frozenset(
|
|||
)
|
||||
|
||||
|
||||
def _read_positions(
|
||||
input_items: Sequence[_InputItemT], read_input: str | ResponseInputParam
|
||||
) -> Iterator[tuple[int, _InputItemT]]:
|
||||
read_ids: Final = frozenset(map(id, parts_of(read_input)))
|
||||
return ((index, item) for index, item in enumerate(input_items) if id(item) in read_ids)
|
||||
|
||||
|
||||
def _first_invalid_input_type(fields: Iterator[tuple[str, object]]) -> BadRequestError | None:
|
||||
first: Final = next(fields, None)
|
||||
return None if first is None else _invalid_input_type(*first)
|
||||
|
||||
|
||||
def _is_tool_output_item(item: object) -> bool:
|
||||
item_type: Final = message_field(item, "type")
|
||||
return isinstance(item_type, str) and item_type in TOOL_CALL_OUTPUT_ITEM_TYPES
|
||||
|
||||
|
||||
def _converts_before(
|
||||
convert_input: Callable[[str | ResponseInputParam], object], sent_input: str | ResponseInputParam, position: int
|
||||
) -> bool:
|
||||
try:
|
||||
convert_input(sent_input[:position])
|
||||
except TypeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _unhashable_tool_output_call_id_error(
|
||||
input: str | ResponseInputParam,
|
||||
read_input: str | ResponseInputParam,
|
||||
error: TypeError,
|
||||
converts_before: Callable[[int], bool],
|
||||
) -> BadRequestError | None:
|
||||
call_ids: Final = (
|
||||
(f"input[{index}].call_id", message_field(item, "call_id"), item)
|
||||
for index, item in _read_positions(parts_of(input), read_input)
|
||||
if _is_tool_output_item(item)
|
||||
)
|
||||
first: Final = next((candidate for candidate in call_ids if is_unhashable_value_behind(error, candidate[1])), None)
|
||||
if first is None:
|
||||
return None
|
||||
path, call_id, item = first
|
||||
read_position: Final = next(
|
||||
position for position, read_item in enumerate(parts_of(read_input)) if read_item is item
|
||||
)
|
||||
return _invalid_input_type(path, call_id) if converts_before(read_position) else None
|
||||
|
||||
|
||||
def _carries_non_string_field(message: object) -> bool:
|
||||
tool_call_ids: Final = (
|
||||
message_field(tool_call, "id") for tool_call in parts_of(message_field(message, "tool_calls"))
|
||||
)
|
||||
return is_present_non_string(message_field(message, "role")) or any(map(is_present_non_string, tool_call_ids))
|
||||
|
||||
|
||||
def _non_string_item_fields(
|
||||
item: Mapping[str, object], convert_item: Callable[[Mapping[str, object]], Sequence[object]]
|
||||
) -> Iterator[tuple[str, object]]:
|
||||
messages: Final = convert_item(item)
|
||||
tool_call_id_field: Final = "call_id" if item.get("call_id") else "id"
|
||||
roles: Final = (("role", message_field(message, "role")) for message in messages)
|
||||
tool_calls: Final = chain.from_iterable(parts_of(message_field(message, "tool_calls")) for message in messages)
|
||||
tool_call_ids: Final = ((tool_call_id_field, message_field(tool_call, "id")) for tool_call in tool_calls)
|
||||
return ((field, value) for field, value in chain(roles, tool_call_ids) if is_present_non_string(value))
|
||||
|
||||
|
||||
def _indexed_non_string_item_fields(
|
||||
input: ResponseInputParam,
|
||||
read_input: str | ResponseInputParam,
|
||||
convert_item: Callable[[Mapping[str, object]], Sequence[object]],
|
||||
) -> Iterator[tuple[str, object]]:
|
||||
for index, item in _read_positions(input, read_input):
|
||||
for field, value in _non_string_item_fields(item, convert_item):
|
||||
yield f"input[{index}].{field}", value
|
||||
|
||||
|
||||
def _invalid_input_type(field_path: str, value: object) -> BadRequestError:
|
||||
return BadRequestError(message=invalid_type_message(field_path, value), model="", llm_provider="")
|
||||
|
||||
|
||||
def _input_item_role(input_item: Mapping[str, object]) -> str:
|
||||
return cast(str, input_item.get("role") or "user") # cast-ok: client-supplied role forwarded verbatim, unvalidated
|
||||
|
||||
|
|
@ -1101,6 +1189,9 @@ class LiteLLMCompletionResponsesConfig:
|
|||
stream: bool | None = None,
|
||||
extra_headers: Mapping[str, object] | None = None,
|
||||
drop_params: object = None,
|
||||
*,
|
||||
caller_input: str | ResponseInputParam | None = None,
|
||||
names_non_string_fields: bool = False,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""
|
||||
|
|
@ -1149,6 +1240,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
native_compaction: Final = _replays_compaction_block(
|
||||
request_input, context_management
|
||||
) and provider_takes_compaction_blocks(model, custom_llm_provider)
|
||||
numbered_input: Final = request_input if caller_input is None else caller_input
|
||||
bridged_messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
||||
input=request_input,
|
||||
responses_api_request=responses_api_request,
|
||||
|
|
@ -1157,7 +1249,28 @@ class LiteLLMCompletionResponsesConfig:
|
|||
tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id(request_kwargs),
|
||||
native_compaction=native_compaction,
|
||||
only_since_compaction=not native_compaction,
|
||||
caller_input=numbered_input,
|
||||
)
|
||||
malformed_field_error: Final = (
|
||||
_first_invalid_input_type(
|
||||
_indexed_non_string_item_fields(
|
||||
numbered_input,
|
||||
_input_to_read(request_input, not native_compaction, native_compaction),
|
||||
partial(
|
||||
LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message,
|
||||
replay_reasoning=True,
|
||||
native_compaction=native_compaction,
|
||||
),
|
||||
)
|
||||
)
|
||||
if names_non_string_fields
|
||||
and isinstance(numbered_input, list)
|
||||
and any(map(_carries_non_string_field, bridged_messages))
|
||||
and provider_builds_anthropic_request(model, custom_llm_provider)
|
||||
else None
|
||||
)
|
||||
if malformed_field_error is not None:
|
||||
raise malformed_field_error
|
||||
|
||||
litellm_completion_request: dict = {
|
||||
"messages": (
|
||||
|
|
@ -1221,6 +1334,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
tool_call_provider_fields: bool = False,
|
||||
native_compaction: bool = False,
|
||||
only_since_compaction: bool = False,
|
||||
caller_input: str | ResponseInputParam | None = None,
|
||||
) -> list[
|
||||
AllMessageValues
|
||||
| GenericChatCompletionMessage
|
||||
|
|
@ -1274,13 +1388,27 @@ class LiteLLMCompletionResponsesConfig:
|
|||
|
||||
dumped_input: Final = with_compaction_items_as_params(input)
|
||||
read_input: Final = _input_to_read(dumped_input, only_since_compaction, native_compaction)
|
||||
messages.extend(
|
||||
LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
|
||||
input=_without_item_keys(read_input, _dropped_item_keys(item_cache_control, tool_call_provider_fields)),
|
||||
replay_reasoning=replay_reasoning,
|
||||
native_compaction=native_compaction,
|
||||
)
|
||||
sent_input: Final = _without_item_keys(
|
||||
read_input, _dropped_item_keys(item_cache_control, tool_call_provider_fields)
|
||||
)
|
||||
convert_input: Final = partial(
|
||||
LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message,
|
||||
replay_reasoning=replay_reasoning,
|
||||
native_compaction=native_compaction,
|
||||
)
|
||||
try:
|
||||
converted: Final = convert_input(sent_input)
|
||||
except TypeError as error:
|
||||
call_id_error: Final = _unhashable_tool_output_call_id_error(
|
||||
dumped_input if caller_input is None else caller_input,
|
||||
read_input,
|
||||
error,
|
||||
partial(_converts_before, convert_input, sent_input),
|
||||
)
|
||||
if call_id_error is None:
|
||||
raise
|
||||
raise call_id_error from error
|
||||
messages.extend(converted)
|
||||
|
||||
return messages
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ else:
|
|||
RoutingContext = Any
|
||||
|
||||
|
||||
def litellm_to_list(embeds: litellm.EmbeddingResponse) -> list[list[float]]:
|
||||
def litellm_to_list(embeds: object) -> list[list[float]]:
|
||||
"""Convert a LiteLLM embedding response to a list of embeddings.
|
||||
|
||||
:param embeds: The LiteLLM embedding response.
|
||||
:return: A list of embeddings.
|
||||
"""
|
||||
if not embeds or not embeds.data:
|
||||
if not embeds or not isinstance(embeds, litellm.EmbeddingResponse) or not embeds.data:
|
||||
raise ValueError("No embeddings found in LiteLLM embedding response.")
|
||||
return [x["embedding"] for x in embeds.data]
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ class JevBudgetedRequest:
|
|||
telemetry: StandardLoggingRoutingDecision
|
||||
|
||||
|
||||
@renamed_router_param("router", "context")
|
||||
def jev_input_targets(context: "RoutingContext", config: "OpenSourceClassifierConfig") -> tuple[JevInputTarget, ...]:
|
||||
from litellm.types.router import Deployment
|
||||
|
||||
|
|
|
|||
|
|
@ -392,9 +392,11 @@ def _resolve_constraint_only_pool(
|
|||
|
||||
def _all_deployments_or_fallback(
|
||||
ctx: "RoutingContext",
|
||||
model: str,
|
||||
model: str | None,
|
||||
fallback: _DeploymentPool,
|
||||
) -> Sequence[_DeploymentLike | DeploymentTypedDict] | Mapping[_DeploymentLike, object]:
|
||||
if model is None: # a group of no name has no deployments, it is not a failed lookup
|
||||
return ()
|
||||
try:
|
||||
return ctx.registry().deployments(model, source="all")
|
||||
except Exception: # noqa: BLE001 # fail safe toward today's healthy-only behavior on lookup errors
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ else:
|
|||
|
||||
@renamed_router_param("litellm_router_instance")
|
||||
async def send_llm_exception_alert(
|
||||
ctx: "RoutingContext",
|
||||
ctx: "RoutingContext | None",
|
||||
request_kwargs: dict,
|
||||
error_traceback_str: str,
|
||||
original_exception,
|
||||
|
|
@ -39,6 +39,9 @@ async def send_llm_exception_alert(
|
|||
Returns:
|
||||
None
|
||||
"""
|
||||
if ctx is None:
|
||||
return
|
||||
|
||||
slack_alerting_logger: Final = ctx.observers().slack_alerting_logger
|
||||
if slack_alerting_logger is None:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal, TypeAlias, cast
|
||||
|
||||
from pydantic import ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import ConfigDict, Field, JsonValue, field_validator, model_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from typing_extensions import ReadOnly, Required, TypedDict
|
||||
|
||||
|
|
@ -604,6 +604,11 @@ class BedrockChecksConfigModel(LiteLLMBaseModel):
|
|||
return self
|
||||
|
||||
|
||||
class BedrockGuardrailOutputScope(Enum):
|
||||
INTERVENTIONS = "INTERVENTIONS"
|
||||
FULL = "FULL"
|
||||
|
||||
|
||||
class BedrockGuardrailConfigModel(LiteLLMBaseModel):
|
||||
"""Configuration parameters for the AWS Bedrock guardrail"""
|
||||
|
||||
|
|
@ -612,6 +617,11 @@ class BedrockGuardrailConfigModel(LiteLLMBaseModel):
|
|||
default=None,
|
||||
description="The version of your Bedrock guardrail (e.g., DRAFT or version number)",
|
||||
)
|
||||
outputScope: BedrockGuardrailOutputScope | None = Field(
|
||||
default=None,
|
||||
description="ApplyGuardrail response scope. FULL includes detected and non-detected findings. "
|
||||
"Omit to retain Bedrock's default of returning detected findings only.",
|
||||
)
|
||||
disable_exception_on_block: bool | None = Field(
|
||||
default=False,
|
||||
description="If True, will not raise an exception when the guardrail is blocked. Useful for OpenWebUI where exceptions can end the chat flow.",
|
||||
|
|
@ -1540,6 +1550,14 @@ class PresidioPerRequestConfig(LiteLLMBaseModel):
|
|||
entities: list[PiiEntityType] | None = None
|
||||
|
||||
|
||||
NativeGuardrailResponse: TypeAlias = Mapping[str, JsonValue] | Sequence[JsonValue]
|
||||
|
||||
|
||||
class GuardrailResponseFormat(Enum):
|
||||
STANDARD = "standard"
|
||||
NATIVE = "native"
|
||||
|
||||
|
||||
class ApplyGuardrailRequest(LiteLLMBaseModel):
|
||||
guardrail_name: str
|
||||
text: str
|
||||
|
|
@ -1548,6 +1566,7 @@ class ApplyGuardrailRequest(LiteLLMBaseModel):
|
|||
input_type: str = "request"
|
||||
messages: list[dict[str, object]] | None = None
|
||||
metadata: dict[str, object] | None = None
|
||||
response_format: GuardrailResponseFormat = GuardrailResponseFormat.STANDARD
|
||||
|
||||
|
||||
class ApplyGuardrailResponse(LiteLLMBaseModel):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from typing import Literal
|
||||
|
||||
from pydantic import AliasPath, BaseModel, ConfigDict, Field
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
|
||||
|
|
@ -98,3 +99,15 @@ class CohereV2ChatResponse(TypedDict):
|
|||
message: CohereV2ChatResponseMessage
|
||||
usage: CohereV2ChatResponseUsage
|
||||
logprobs: CohereV2ChatResponseLogProbs
|
||||
|
||||
|
||||
class CohereV2StreamedToolCall(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str | None = Field(default=None, validation_alias=AliasPath("delta", "message", "tool_calls", "id"))
|
||||
name: str | None = Field(
|
||||
default=None, validation_alias=AliasPath("delta", "message", "tool_calls", "function", "name")
|
||||
)
|
||||
arguments: str = Field(
|
||||
default="", validation_alias=AliasPath("delta", "message", "tool_calls", "function", "arguments")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ class OvalixGuardrailConfigModel(GuardrailConfigModel):
|
|||
default=None,
|
||||
description="Post-checkpoint ID for the Ovalix Tracker service.",
|
||||
)
|
||||
file_checkpoint_id: str | None = Field(
|
||||
default=None,
|
||||
description="File-checkpoint ID for the Ovalix Tracker service (falls back to pre/post).",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ui_friendly_name() -> str:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ class PatchModelRequest(updateDeployment):
|
|||
|
||||
|
||||
class ModelGroupInfoProxy(ModelGroupInfo):
|
||||
supports_background_chat: bool = Field(
|
||||
default=False, description="Whether cache-backed background Responses runs are enabled for this model group"
|
||||
)
|
||||
is_public_model_group: bool = Field(default=False)
|
||||
health_status: str | None = Field(default=None)
|
||||
health_response_time: float | None = Field(default=None)
|
||||
|
|
|
|||
|
|
@ -1151,6 +1151,8 @@ API_ROUTE_TO_CALL_TYPES: Final[Mapping[str, Sequence[CallTypes]]] = {
|
|||
# A2A (Agent-to-Agent)
|
||||
"/a2a/{agent_id}": [CallTypes.asend_message, CallTypes.send_message],
|
||||
"/a2a/{agent_id}/message/send": [CallTypes.asend_message, CallTypes.send_message],
|
||||
"/a2a/{agent_id}/message/stream": [CallTypes.asend_message, CallTypes.send_message],
|
||||
"/v1/a2a/{agent_id}/message/stream": [CallTypes.asend_message, CallTypes.send_message],
|
||||
# Passthrough endpoints
|
||||
"/llm_passthrough": [
|
||||
CallTypes.allm_passthrough_route,
|
||||
|
|
|
|||
|
|
@ -15555,18 +15555,20 @@
|
|||
"input_cost_per_token": 2.65e-06,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 8000,
|
||||
"max_output_tokens": 2048,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.5e-06,
|
||||
"source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-70b-instruct.md",
|
||||
"supports_pdf_input": true
|
||||
},
|
||||
"bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0": {
|
||||
"input_cost_per_token": 3e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 8000,
|
||||
"max_output_tokens": 2048,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-8b-instruct.md",
|
||||
"supports_pdf_input": true
|
||||
},
|
||||
"bedrock/us-west-1/meta.llama3-70b-instruct-v1:0": {
|
||||
|
|
@ -48955,6 +48957,7 @@
|
|||
},
|
||||
"together_ai/Qwen/Qwen3.8-2.4T-A95B": {
|
||||
"cache_read_input_token_cost": 2.5e-07,
|
||||
"deprecation_date": "2026-11-04",
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 1010000,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ def enqueue(body: object, status: int = 200) -> None:
|
|||
|
||||
def enqueue_stream() -> None:
|
||||
chunk: Final = {**CHAT, "object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {"content": "pong"}}]}
|
||||
RESPONSES.put((200, f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode()))
|
||||
terminal: Final = {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
|
||||
stream: Final = f"data: {json.dumps(chunk)}\n\ndata: {json.dumps(terminal)}\n\ndata: [DONE]\n\n"
|
||||
RESPONSES.put((200, stream.encode()))
|
||||
|
||||
|
||||
def bedrock_event(event: str, payload: object) -> bytes:
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ The suites run against a live proxy, so bring one up first by running the litell
|
|||
|
||||
They also need a proxy whose bundled UI contains the change under test, so run the proxy from your branch (an editable install serves the UI your checkout builds)
|
||||
|
||||
Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and point `PHOENIX_COLLECTOR_HTTP_ENDPOINT` at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy. The presidio guardrail tests need a running Presidio analyzer and anonymizer the proxy can reach, addressed by `PRESIDIO_ANALYZER_API_BASE` / `PRESIDIO_ANONYMIZER_API_BASE`
|
||||
Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and start the proxy with `LITELLM_OTEL_V2=true`, the `otel` callback, and `OTEL_EXPORTER_OTLP_ENDPOINT` pointing at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy. The presidio guardrail tests need a running Presidio analyzer and anonymizer the proxy can reach, addressed by `PRESIDIO_ANALYZER_API_BASE` / `PRESIDIO_ANONYMIZER_API_BASE`
|
||||
|
||||
The Presidio spend-log audit test also requires prompt storage on the proxy, because its assertions inspect the detected entities and their scores. Add the following to the proxy config before starting it:
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ litellm_settings:
|
|||
- host: 127.0.0.1
|
||||
port: 6379
|
||||
ssl: true
|
||||
callbacks: ["arize_phoenix", "datadog", "smtp_email", "prometheus", "otel", "websearch_interception"]
|
||||
callbacks: ["datadog", "smtp_email", "prometheus", "otel", "websearch_interception"]
|
||||
websearch_interception_params:
|
||||
enabled_providers: ["bedrock"]
|
||||
search_tool_name: e2e-search
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ def _assert_otel_destination_configured(client: LoggingClient) -> None:
|
|||
details = _ReadinessDetails.model_validate_json(readiness_details_body(client))
|
||||
assert OTEL_V2_LOGGER_NAME in details.success_callbacks, (
|
||||
f"the proxy must report the {OTEL_V2_LOGGER_NAME} callback active "
|
||||
f"(LITELLM_OTEL_V2 + arize_phoenix preset in the compose config); got: {details.success_callbacks}"
|
||||
f"(LITELLM_OTEL_V2=true + the otel callback in the stack config); got: {details.success_callbacks}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
145
tests/integration/_support/a2a_denial_probe.py
Normal file
145
tests/integration/_support/a2a_denial_probe.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
|
||||
def _mapping(value: object) -> Mapping[str, object]:
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _logged(hook: str, kwargs: Mapping[str, object]) -> Mapping[str, object]:
|
||||
logged: Final = _mapping(kwargs.get("standard_logging_object"))
|
||||
metadata: Final = _mapping(logged.get("metadata"))
|
||||
return {
|
||||
"hook": hook,
|
||||
"call_type": kwargs.get("call_type"),
|
||||
"litellm_call_id": kwargs.get("litellm_call_id"),
|
||||
"model": logged.get("model"),
|
||||
"status": logged.get("status"),
|
||||
"key_alias": metadata.get("user_api_key_alias"),
|
||||
"key_hash": metadata.get("user_api_key_hash"),
|
||||
"user_id": metadata.get("user_api_key_user_id"),
|
||||
"team_id": metadata.get("user_api_key_team_id"),
|
||||
}
|
||||
|
||||
|
||||
def _status(exception: Exception) -> int | None:
|
||||
status: Final = getattr(exception, "status_code", None)
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
code: Final = getattr(exception, "code", None)
|
||||
return int(code) if isinstance(code, str) and code.isdigit() else None
|
||||
|
||||
|
||||
def _hooked(
|
||||
request_data: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth, original_exception: Exception
|
||||
) -> Mapping[str, object]:
|
||||
return {
|
||||
"hook": "post_call_failure",
|
||||
"call_type": request_data.get("call_type"),
|
||||
"litellm_call_id": request_data.get("litellm_call_id"),
|
||||
"key_alias": user_api_key_dict.key_alias,
|
||||
"key_hash": user_api_key_dict.api_key,
|
||||
"user_id": user_api_key_dict.user_id,
|
||||
"team_id": user_api_key_dict.team_id,
|
||||
"agent_id": request_data.get("agent_id"),
|
||||
"metadata_agent_id": _mapping(request_data.get("metadata")).get("agent_id"),
|
||||
"status_code": _status(original_exception),
|
||||
}
|
||||
|
||||
|
||||
def _send(record: Mapping[str, object]) -> None:
|
||||
with httpx.Client(trust_env=False) as client:
|
||||
client.post(os.environ["A2A_DENIAL_PROBE_URL"], content=json.dumps(record, default=str)).raise_for_status()
|
||||
|
||||
|
||||
async def _send_async(record: Mapping[str, object]) -> None:
|
||||
async with httpx.AsyncClient(trust_env=False) as client:
|
||||
response: Final = await client.post(os.environ["A2A_DENIAL_PROBE_URL"], content=json.dumps(record, default=str))
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
class A2ADenialProbe(CustomLogger):
|
||||
"""Reports the identity, target and outcome every logging and failure hook receives, one record per call."""
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
await _send_async(_logged("async_success", kwargs))
|
||||
|
||||
def log_failure_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
_send(_logged("sync_failure", kwargs))
|
||||
|
||||
async def async_log_failure_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
await _send_async(_logged("async_failure", kwargs))
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
self,
|
||||
request_data: dict[str, object],
|
||||
original_exception: Exception,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
traceback_str: str | None = None,
|
||||
) -> None:
|
||||
await _send_async(_hooked(request_data, user_api_key_dict, original_exception))
|
||||
|
||||
|
||||
class RaisingFailureLogger(CustomLogger):
|
||||
"""Fails inside both failure hooks."""
|
||||
|
||||
async def async_log_failure_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
raise RuntimeError("failure logger is broken")
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
self,
|
||||
request_data: dict[str, object],
|
||||
original_exception: Exception,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
traceback_str: str | None = None,
|
||||
) -> None:
|
||||
raise RuntimeError("failure hook is broken")
|
||||
|
||||
|
||||
class ReturningTeapotLogger(CustomLogger):
|
||||
"""Hands a replacement 418 back from the failure hook."""
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
self,
|
||||
request_data: dict[str, object],
|
||||
original_exception: Exception,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
traceback_str: str | None = None,
|
||||
) -> HTTPException:
|
||||
return HTTPException(status_code=418, detail="transformed")
|
||||
|
||||
|
||||
class RaisingTeapotLogger(CustomLogger):
|
||||
"""Raises a replacement 418 from the failure hook."""
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
self,
|
||||
request_data: dict[str, object],
|
||||
original_exception: Exception,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
traceback_str: str | None = None,
|
||||
) -> None:
|
||||
raise HTTPException(status_code=418, detail="transformed")
|
||||
|
||||
|
||||
probe: Final = A2ADenialProbe()
|
||||
raising_failure_logger: Final = RaisingFailureLogger()
|
||||
returning_teapot_logger: Final = ReturningTeapotLogger()
|
||||
raising_teapot_logger: Final = RaisingTeapotLogger()
|
||||
|
|
@ -10,6 +10,7 @@ from dataclasses import dataclass, field
|
|||
from pathlib import Path
|
||||
from typing import Final, Literal
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
from integration._support.asgi import asgi_server
|
||||
from integration._support.client import Gateway, Scenario
|
||||
|
|
@ -63,7 +64,9 @@ class Confirmation(BaseModel):
|
|||
confirmed: bool
|
||||
|
||||
|
||||
def math_service(name: str = "integration-math", *, rich: bool = False) -> MCPServer:
|
||||
def math_service(
|
||||
name: str = "integration-math", *, rich: bool = False, on_slow: Callable[[], None] | None = None
|
||||
) -> MCPServer:
|
||||
service: Final = MCPServer(name)
|
||||
service.add_tool(add)
|
||||
service.add_tool(multiply)
|
||||
|
|
@ -77,6 +80,8 @@ def math_service(name: str = "integration-math", *, rich: bool = False) -> MCPSe
|
|||
|
||||
@service.tool()
|
||||
async def slow(seconds: float) -> str:
|
||||
if on_slow is not None:
|
||||
on_slow()
|
||||
await asyncio.sleep(seconds)
|
||||
return "slept"
|
||||
|
||||
|
|
@ -114,6 +119,29 @@ def math_service(name: str = "integration-math", *, rich: bool = False) -> MCPSe
|
|||
return service
|
||||
|
||||
|
||||
def _cancel_on_disconnect(app: ASGIApp) -> ASGIApp:
|
||||
async def connected(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
sender, reader = anyio.create_memory_object_stream[Message]()
|
||||
async with sender, reader, anyio.create_task_group() as tasks:
|
||||
|
||||
async def relay() -> None:
|
||||
while True:
|
||||
message: Final = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
tasks.cancel_scope.cancel()
|
||||
return
|
||||
await sender.send(message)
|
||||
|
||||
tasks.start_soon(relay)
|
||||
await app(scope, reader.receive, send)
|
||||
tasks.cancel_scope.cancel()
|
||||
|
||||
return connected
|
||||
|
||||
|
||||
def _capturing(app: Callable[[Scope, Receive, Send], object], observed: queue.Queue[dict[str, object]]):
|
||||
async def capture(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
|
|
@ -247,8 +275,10 @@ def _draining_sse_watcher(app: Callable[[Scope, Receive, Send], object]):
|
|||
|
||||
|
||||
@contextmanager
|
||||
def mcp_peer(transport: Literal["http", "sse"] = "http", *, rich: bool = False) -> Iterator[McpPeer]:
|
||||
service: Final = math_service(rich=rich)
|
||||
def mcp_peer(
|
||||
transport: Literal["http", "sse"] = "http", *, rich: bool = False, on_slow: Callable[[], None] | None = None
|
||||
) -> Iterator[McpPeer]:
|
||||
service: Final = math_service(rich=rich, on_slow=on_slow)
|
||||
security: Final = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
app: Final = (
|
||||
_draining_sse_watcher(service.sse_app(transport_security=security))
|
||||
|
|
@ -256,7 +286,11 @@ def mcp_peer(transport: Literal["http", "sse"] = "http", *, rich: bool = False)
|
|||
else service.streamable_http_app(stateless_http=True, json_response=True, transport_security=security)
|
||||
)
|
||||
observed: Final[queue.Queue[dict[str, object]]] = queue.Queue()
|
||||
with asgi_server(_capturing(app, observed), before_stop=_drain_sse_streams if transport == "sse" else None) as url:
|
||||
captured: Final = _capturing(app, observed)
|
||||
with asgi_server(
|
||||
_cancel_on_disconnect(captured) if transport == "http" else captured,
|
||||
before_stop=_drain_sse_streams if transport == "sse" else None,
|
||||
) as url:
|
||||
yield McpPeer(url + ("/sse" if transport == "sse" else "/mcp"), observed, transport)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ def _peer_card(url: str, name: str) -> dict[str, JsonValue]:
|
|||
"description": "Synthetic peer",
|
||||
"version": "1.0.0",
|
||||
"url": url + "/",
|
||||
"supportedInterfaces": [
|
||||
{
|
||||
"url": url + "/",
|
||||
"protocolBinding": "JSONRPC",
|
||||
"protocolVersion": "0.3",
|
||||
}
|
||||
],
|
||||
"capabilities": {"streaming": True},
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
|
|
@ -73,10 +80,15 @@ def _peer_card(url: str, name: str) -> dict[str, JsonValue]:
|
|||
}
|
||||
|
||||
|
||||
def _register_agent(gateway: Gateway, scenario: Scenario, name: str, url: str) -> str:
|
||||
created: Final = gateway.request(
|
||||
"POST", "/v1/agents", {"agent_name": name, "agent_card_params": _peer_card(url, name)}
|
||||
)
|
||||
def _register_agent(
|
||||
gateway: Gateway,
|
||||
scenario: Scenario,
|
||||
name: str,
|
||||
url: str,
|
||||
agent_card: dict[str, JsonValue] | None = None,
|
||||
) -> str:
|
||||
card: Final = _peer_card(url, name) if agent_card is None else agent_card
|
||||
created: Final = gateway.request("POST", "/v1/agents", {"agent_name": name, "agent_card_params": card})
|
||||
assert created.status_code == 200, created.text
|
||||
identity: Final = created.json()["agent_id"]
|
||||
|
||||
|
|
@ -416,3 +428,68 @@ def test_legacy_agent_json_card_path_applies_agent_permissions_to_non_admin_keys
|
|||
)
|
||||
assert (denied_legacy.status_code, denied_legacy.text) == (403, refusal(denied)), denied_legacy.text
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
||||
def test_agent_list_and_detail_cards_use_proxy_urls_for_scoped_keys(gateway: Gateway) -> None:
|
||||
marker: Final = "a2acardurl" + uuid.uuid4().hex[:12]
|
||||
|
||||
def upstream(request: Request) -> Reply:
|
||||
assert request.method == "GET", request.method
|
||||
return Reply(body=json.dumps(_peer_card(wire.url, marker)).encode())
|
||||
|
||||
with wire_server(upstream) as wire, gateway.scenario() as scenario:
|
||||
backend_url: Final = wire.url + "/"
|
||||
peer_card: Final = _peer_card(wire.url, marker)
|
||||
peer_interfaces: Final = peer_card["supportedInterfaces"]
|
||||
assert isinstance(peer_interfaces, list)
|
||||
agent_card: Final = {
|
||||
**peer_card,
|
||||
"additionalInterfaces": [{"url": backend_url, "transport": "GRPC"}],
|
||||
"supportedInterfaces": [
|
||||
*peer_interfaces,
|
||||
{
|
||||
"url": backend_url,
|
||||
"protocolBinding": "GRPC",
|
||||
"protocolVersion": "0.3",
|
||||
},
|
||||
],
|
||||
}
|
||||
agent_id: Final = _register_agent(gateway, scenario, marker, wire.url, agent_card)
|
||||
user_id: Final = scenario.user(user_role="internal_user")
|
||||
scoped_key: Final = scenario.key(user_id=user_id, object_permission={"agents": [agent_id]})
|
||||
|
||||
public_card: Final = gateway.request(
|
||||
"GET",
|
||||
f"/a2a/{agent_id}/.well-known/agent-card.json",
|
||||
key=scoped_key,
|
||||
)
|
||||
listed: Final = gateway.request("GET", "/v1/agents", key=scoped_key)
|
||||
detailed: Final = gateway.request("GET", f"/v1/agents/{agent_id}", key=scoped_key)
|
||||
|
||||
assert public_card.status_code == 200, public_card.text
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert detailed.status_code == 200, detailed.text
|
||||
assert wire.url not in listed.text
|
||||
assert wire.url not in detailed.text
|
||||
assert "additionalInterfaces" not in listed.text
|
||||
assert "additionalInterfaces" not in detailed.text
|
||||
public_urls: Final = (
|
||||
public_card.json()["url"],
|
||||
public_card.json()["supportedInterfaces"][0]["url"],
|
||||
)
|
||||
listed_agent: Final = next(item for item in listed.json() if item["agent_id"] == agent_id)
|
||||
for body in (listed_agent, detailed.json()):
|
||||
card: Final = body["agent_card_params"]
|
||||
assert (card["url"], card["supportedInterfaces"][0]["url"]) == public_urls
|
||||
assert card["supportedInterfaces"] == [
|
||||
{
|
||||
"url": public_urls[1],
|
||||
"protocolBinding": "JSONRPC",
|
||||
"protocolVersion": "0.3",
|
||||
}
|
||||
]
|
||||
|
||||
admin_listed: Final = gateway.request("GET", "/v1/agents")
|
||||
assert admin_listed.status_code == 200, admin_listed.text
|
||||
admin_agent: Final = next(item for item in admin_listed.json() if item["agent_id"] == agent_id)
|
||||
assert admin_agent["agent_card_params"]["url"] == backend_url
|
||||
|
|
|
|||
809
tests/integration/authorization/test_agent_denial_logging.py
Normal file
809
tests/integration/authorization/test_agent_denial_logging.py
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import (
|
||||
JSON_OBJECT,
|
||||
Gateway,
|
||||
Scenario,
|
||||
eventually,
|
||||
gateway_from_environment,
|
||||
held,
|
||||
object_value,
|
||||
string_value,
|
||||
)
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue
|
||||
|
||||
pytestmark = pytest.mark.timeout(900)
|
||||
|
||||
_PROBE: Final = "integration._support.a2a_denial_probe."
|
||||
_SETTLE: Final = 3.0
|
||||
_CONVERGE: Final = 30.0
|
||||
_NO_SETTINGS: Final[Mapping[str, JsonValue]] = MappingProxyType({})
|
||||
_ROWS: Final = (
|
||||
'SELECT request_id, call_type, status, model, agent_id, spend, api_key, "user", team_id, metadata '
|
||||
'FROM "LiteLLM_SpendLogs" WHERE api_key = %s'
|
||||
)
|
||||
_ONE_FAILURE: Final = ("async_failure", "post_call_failure")
|
||||
_STREAM_METHODS: Final = frozenset({"message/stream", "SendStreamingMessage"})
|
||||
|
||||
|
||||
class _Event(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
hook: str
|
||||
call_type: str | None = None
|
||||
litellm_call_id: str | None = None
|
||||
model: str | None = None
|
||||
status: str | None = None
|
||||
key_alias: str | None = None
|
||||
key_hash: str | None = None
|
||||
user_id: str | None = None
|
||||
team_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
metadata_agent_id: str | None = None
|
||||
status_code: int | None = None
|
||||
|
||||
|
||||
class _Events:
|
||||
def __init__(self, sink: Wire) -> None:
|
||||
self.sink: Final = sink
|
||||
self.seen: tuple[_Event, ...] = ()
|
||||
|
||||
def of(self, alias: str) -> tuple[_Event, ...]:
|
||||
self.seen = (*self.seen, *(_Event.model_validate_json(request.body) for request in self.sink.drain()))
|
||||
return tuple(event for event in self.seen if event.key_alias == alias)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Agent:
|
||||
agent_id: str
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Rig:
|
||||
proxy: Gateway
|
||||
events: _Events
|
||||
upstream: Wire
|
||||
allowed: _Agent
|
||||
denied: _Agent
|
||||
denied_legacy: _Agent
|
||||
bridge: _Agent
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Caller:
|
||||
key: str
|
||||
alias: str
|
||||
user_id: str
|
||||
team_id: str | None
|
||||
|
||||
@property
|
||||
def hashed(self) -> str:
|
||||
return hashlib.sha256(self.key.encode()).hexdigest()
|
||||
|
||||
|
||||
def _compact(content: Mapping[str, JsonValue]) -> bytes:
|
||||
return json.dumps(content, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
def _refusal(agent: str, response: httpx.Response | None = None) -> bytes:
|
||||
message: Final = f"Agent '{agent}' is not allowed for your key/team. Contact proxy admin for access."
|
||||
if response is None:
|
||||
return _compact({"detail": message})
|
||||
return _compact(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": JSON_OBJECT.validate_json(response.request.content)["id"],
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": message,
|
||||
"data": {"type": "permission_error", "param": None, "code": "403"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _not_found(request_id: JsonValue, agent: str) -> bytes:
|
||||
return _compact(
|
||||
{"jsonrpc": "2.0", "id": request_id, "error": {"code": -32000, "message": f"Agent '{agent}' not found"}}
|
||||
)
|
||||
|
||||
|
||||
def _card(url: str, name: str, version: str) -> dict[str, JsonValue]:
|
||||
card: Final[dict[str, JsonValue]] = {
|
||||
"protocolVersion": version,
|
||||
"name": name,
|
||||
"description": "Synthetic peer",
|
||||
"version": "1.0.0",
|
||||
"url": url + "/",
|
||||
"capabilities": {"streaming": True},
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
"skills": [],
|
||||
}
|
||||
if version == "0.3":
|
||||
return card
|
||||
return {**card, "supportedInterfaces": [{"url": url + "/", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}]}
|
||||
|
||||
|
||||
def _peer(request: Request) -> Reply:
|
||||
if request.method == "GET":
|
||||
return Reply(body=_compact(_card("http://" + request.headers["host"], "peer", "0.3")))
|
||||
body: Final = JSON_OBJECT.validate_json(request.body)
|
||||
if body.get("method") == "message/stream":
|
||||
frame: Final = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": body["id"],
|
||||
"result": {
|
||||
"kind": "status-update",
|
||||
"taskId": "t1",
|
||||
"contextId": "c1",
|
||||
"status": {"state": "completed"},
|
||||
"final": True,
|
||||
},
|
||||
}
|
||||
return Reply(content_type="text/event-stream", chunks=(f"data: {json.dumps(frame)}\n\n".encode(),))
|
||||
return Reply(
|
||||
body=_compact(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": body.get("id"),
|
||||
"result": {
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"messageId": "peer-message",
|
||||
"parts": [{"kind": "text", "text": "granted"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _register(proxy: Gateway, scenario: Scenario, body: Mapping[str, JsonValue]) -> _Agent:
|
||||
created: Final = proxy.request("POST", "/v1/agents", body)
|
||||
assert created.status_code == 200, created.text
|
||||
agent: Final = _Agent(string_value(JSON_OBJECT.validate_json(created.content)["agent_id"]), str(body["agent_name"]))
|
||||
|
||||
def cleanup() -> None:
|
||||
deleted: Final = proxy.request("DELETE", f"/v1/agents/{agent.agent_id}")
|
||||
assert deleted.status_code == 200, deleted.text
|
||||
|
||||
scenario.cleanups.callback(cleanup)
|
||||
return agent
|
||||
|
||||
|
||||
def _config(directory: Path, callbacks: tuple[str, ...], general: Mapping[str, JsonValue]) -> Path:
|
||||
base: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
config: Final = {
|
||||
**base,
|
||||
"general_settings": {**object_value(base["general_settings"]), **general},
|
||||
"litellm_settings": {
|
||||
**object_value(base["litellm_settings"]),
|
||||
"callbacks": [_PROBE + "probe", *(_PROBE + name for name in callbacks)],
|
||||
},
|
||||
}
|
||||
path: Final = directory / "a2a-denials.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _owned_rig(
|
||||
directory: Path,
|
||||
callbacks: tuple[str, ...] = (),
|
||||
general: Mapping[str, JsonValue] = _NO_SETTINGS,
|
||||
environment: Mapping[str, str] = MappingProxyType({}),
|
||||
) -> Iterator[_Rig]:
|
||||
marker: Final = "a2adeny" + uuid.uuid4().hex[:10]
|
||||
with (
|
||||
wire_server(_peer) as upstream,
|
||||
wire_server(lambda request: Reply()) as sink,
|
||||
gateway_from_environment() as shared,
|
||||
owned_proxy(
|
||||
shared,
|
||||
directory,
|
||||
{"A2A_DENIAL_PROBE_URL": sink.url, **environment},
|
||||
config=_config(directory, callbacks, general),
|
||||
) as proxy,
|
||||
proxy.scenario() as scenario,
|
||||
):
|
||||
|
||||
def agent(suffix: str, version: str, **extra: JsonValue) -> _Agent:
|
||||
name: Final = f"{marker}-{suffix}"
|
||||
return _register(
|
||||
proxy, scenario, {"agent_name": name, "agent_card_params": _card(upstream.url, name, version), **extra}
|
||||
)
|
||||
|
||||
yield _Rig(
|
||||
proxy,
|
||||
_Events(sink),
|
||||
upstream,
|
||||
allowed=agent("allowed", "0.3"),
|
||||
denied=agent("denied", "1.0"),
|
||||
denied_legacy=agent("legacy", "0.3"),
|
||||
bridge=agent(
|
||||
"bridge",
|
||||
"0.3",
|
||||
litellm_params={
|
||||
"custom_llm_provider": "openai",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
"api_base": upstream.url + "/v1",
|
||||
"api_key": "synthetic-bridge-key",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
||||
with _owned_rig(tmp_path_factory.mktemp("a2a-denials")) as owned:
|
||||
yield owned
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=["raising_failure_logger,returning_teapot_logger", "raising_teapot_logger"])
|
||||
def hostile(request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
||||
callbacks: Final = tuple(str(request.param).split(","))
|
||||
with _owned_rig(tmp_path_factory.mktemp("a2a-denials-hostile"), callbacks) as owned:
|
||||
yield owned
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=["disable_pre_routing_error_logs", "disable_error_logs"])
|
||||
def flagged(request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
||||
with _owned_rig(tmp_path_factory.mktemp("a2a-denials-flagged"), general={str(request.param): True}) as owned:
|
||||
yield owned
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def alerting(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[_Rig, Wire]]:
|
||||
with wire_server(lambda request: Reply(body=b"ok", content_type="text/plain")) as alerts:
|
||||
with _owned_rig(
|
||||
tmp_path_factory.mktemp("a2a-denials-alerting"),
|
||||
general={"alerting": ["slack"], "alert_types": ["llm_exceptions"]},
|
||||
environment={"SLACK_WEBHOOK_URL": alerts.url, "DEFAULT_FLUSH_INTERVAL_SECONDS": "1"},
|
||||
) as owned:
|
||||
yield owned, alerts
|
||||
|
||||
|
||||
def _caller(rig: _Rig, scenario: Scenario, grant: str = "team") -> _Caller:
|
||||
alias: Final = "a2adeny-" + uuid.uuid4().hex[:12]
|
||||
user: Final = scenario.user()
|
||||
allowed: Final[list[JsonValue]] = [rig.allowed.agent_id]
|
||||
if grant == "personal":
|
||||
return _Caller(
|
||||
scenario.key(user_id=user, key_alias=alias, object_permission={"agents": allowed}), alias, user, None
|
||||
)
|
||||
if grant == "access group":
|
||||
created: Final = rig.proxy.request(
|
||||
"POST", "/v1/access_group", {"access_group_name": alias, "access_agent_ids": allowed}
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
group: Final = string_value(JSON_OBJECT.validate_json(created.content)["access_group_id"])
|
||||
scenario.cleanups.callback(rig.proxy.request, "DELETE", f"/v1/access_group/{group}")
|
||||
return _Caller(scenario.key(user_id=user, key_alias=alias, access_group_ids=[group]), alias, user, None)
|
||||
team: Final = scenario.team(object_permission={"agents": allowed})
|
||||
return _Caller(scenario.key(user_id=user, team_id=team, key_alias=alias), alias, user, team)
|
||||
|
||||
|
||||
def _params(method: str, message_id: str, version: str | None) -> dict[str, JsonValue]:
|
||||
if method.startswith("tasks/pushNotificationConfig"):
|
||||
return {"taskId": "t1", "pushNotificationConfig": {"url": "https://callback.example.com/hook"}}
|
||||
if method in {"tasks/get", "tasks/cancel", "GetTask", "CancelTask"}:
|
||||
return {"id": "t1"}
|
||||
if version == "1.0":
|
||||
return {"message": {"messageId": message_id, "role": "ROLE_USER", "parts": [{"text": "x"}]}}
|
||||
return {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"messageId": message_id,
|
||||
"role": "user",
|
||||
"parts": [{"kind": "text", "text": "x"}],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _send(
|
||||
rig: _Rig,
|
||||
key: str,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "SendMessage",
|
||||
version: str | None = "1.0",
|
||||
request_id: JsonValue = None,
|
||||
message_id: str | None = None,
|
||||
extra: Mapping[str, JsonValue] = _NO_SETTINGS,
|
||||
) -> httpx.Response:
|
||||
headers: Final = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
**({"A2A-Version": version} if version is not None else {}),
|
||||
**({"Accept": "text/event-stream"} if method in _STREAM_METHODS else {}),
|
||||
}
|
||||
body: Final = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "r-" + uuid.uuid4().hex if request_id is None else request_id,
|
||||
"method": method,
|
||||
"params": _params(method, message_id or uuid.uuid4().hex, version),
|
||||
**extra,
|
||||
}
|
||||
return rig.proxy.client.post(path, headers=headers, json=body)
|
||||
|
||||
|
||||
def _assert_never_dispatched(rig: _Rig, scenario: Scenario, denied_message: str) -> None:
|
||||
control_key: Final = scenario.key(object_permission={"agents": [rig.allowed.agent_id]})
|
||||
control_message: Final = "control-" + uuid.uuid4().hex
|
||||
granted: Final = _send(
|
||||
rig,
|
||||
control_key,
|
||||
f"/a2a/{rig.allowed.agent_id}",
|
||||
method="message/send",
|
||||
version=None,
|
||||
message_id=control_message,
|
||||
)
|
||||
assert granted.status_code == 200, granted.text
|
||||
received: Final = tuple(request.body.decode() for request in rig.upstream.drain())
|
||||
assert any(control_message in body for body in received), received
|
||||
assert not any(denied_message in body for body in received), received
|
||||
|
||||
|
||||
def _metadata(row: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
value: Final = row["metadata"]
|
||||
return JSON_OBJECT.validate_json(value) if isinstance(value, str) else object_value(value)
|
||||
|
||||
|
||||
def _rows(rig: _Rig, caller: _Caller) -> list[dict[str, JsonValue]]:
|
||||
return rig.proxy.rows(_ROWS, (caller.hashed,))
|
||||
|
||||
|
||||
def _hooks(events: tuple[_Event, ...]) -> tuple[str, ...]:
|
||||
return tuple(sorted(event.hook for event in events))
|
||||
|
||||
|
||||
def _spend(rig: _Rig, caller: _Caller) -> tuple[JsonValue, ...]:
|
||||
key: Final = object_value(rig.proxy.get("/key/info", {"key": caller.key})["info"])["spend"]
|
||||
user: Final = object_value(rig.proxy.get("/user/info", {"user_id": caller.user_id})["user_info"])["spend"]
|
||||
if caller.team_id is None:
|
||||
return key, user
|
||||
team: Final = object_value(rig.proxy.get("/team/info", {"team_id": caller.team_id})["team_info"])["spend"]
|
||||
return key, user, team
|
||||
|
||||
|
||||
def _assert_one_failure(rig: _Rig, caller: _Caller, *, status: int, agent_id: str, model: str) -> dict[str, JsonValue]:
|
||||
events: Final = held(
|
||||
lambda: rig.events.of(caller.alias),
|
||||
lambda found: _hooks(found) == _ONE_FAILURE,
|
||||
holding=_SETTLE,
|
||||
seconds=_CONVERGE,
|
||||
)
|
||||
logged: Final = next(event for event in events if event.hook == "async_failure")
|
||||
assert (logged.call_type, logged.status, logged.model) == ("asend_message", "failure", model), logged
|
||||
assert (logged.key_hash, logged.user_id, logged.team_id) == (caller.hashed, caller.user_id, caller.team_id), logged
|
||||
hooked: Final = next(event for event in events if event.hook == "post_call_failure")
|
||||
assert (hooked.status_code, hooked.agent_id, hooked.key_hash) == (status, agent_id, caller.hashed), hooked
|
||||
assert (hooked.user_id, hooked.team_id) == (caller.user_id, caller.team_id), hooked
|
||||
rows: Final = held(lambda: _rows(rig, caller), lambda found: len(found) == 1, holding=_SETTLE, seconds=_CONVERGE)
|
||||
row: Final = rows[0]
|
||||
observed: Final = (row["status"], row["call_type"], row["agent_id"], row["api_key"], row["user"], row["spend"])
|
||||
assert observed == ("failure", "asend_message", agent_id, caller.hashed, caller.user_id, 0.0), row
|
||||
assert (row["team_id"] or None) == caller.team_id, row
|
||||
assert object_value(_metadata(row)["error_information"])["error_code"] == str(status), row
|
||||
assert all(spend == 0.0 for spend in _spend(rig, caller)), _spend(rig, caller)
|
||||
return row
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route", "method", "version", "legacy"),
|
||||
[
|
||||
pytest.param("/a2a/{}", "SendMessage", "1.0", False, id="regression-send-1.0"),
|
||||
pytest.param("/a2a/{}/message/send", "SendMessage", "1.0", False, id="regression-send-alias"),
|
||||
pytest.param("/v1/a2a/{}/message/send", "SendMessage", "1.0", False, id="regression-send-v1-alias"),
|
||||
pytest.param("/a2a/{}", "SendStreamingMessage", "1.0", False, id="regression-stream-1.0"),
|
||||
pytest.param("/a2a/{}", "GetTask", "1.0", False, id="regression-get-task-1.0"),
|
||||
pytest.param("/a2a/{}", "CancelTask", "1.0", False, id="regression-cancel-task-1.0"),
|
||||
pytest.param("/a2a/{}", "message/send", None, True, id="regression-send-0.3"),
|
||||
pytest.param("/a2a/{}", "message/stream", None, True, id="regression-stream-0.3"),
|
||||
pytest.param("/a2a/{}", "tasks/get", None, True, id="regression-tasks-get-0.3"),
|
||||
pytest.param("/a2a/{}", "tasks/cancel", None, True, id="regression-tasks-cancel-0.3"),
|
||||
pytest.param("/a2a/{}", "tasks/pushNotificationConfig/set", None, True, id="regression-push-set-0.3"),
|
||||
],
|
||||
)
|
||||
def test_denied_agent_keeps_its_403_logs_one_zero_spend_failure_for_the_caller_and_never_dispatches(
|
||||
rig: _Rig, route: str, method: str, version: str | None, legacy: bool
|
||||
) -> None:
|
||||
target: Final = rig.denied_legacy if legacy else rig.denied
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
message: Final = "denied-" + uuid.uuid4().hex
|
||||
response: Final = _send(
|
||||
rig, caller.key, route.format(target.agent_id), method=method, version=version, message_id=message
|
||||
)
|
||||
assert (response.status_code, response.content) == (403, _refusal(target.agent_id, response))
|
||||
assert response.headers["content-type"] == "application/json"
|
||||
_assert_never_dispatched(rig, scenario, message)
|
||||
_assert_one_failure(rig, caller, status=403, agent_id=target.agent_id, model=f"a2a_agent/{target.name}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("grant", ["personal", "team", "access group"], ids=lambda grant: "regression-" + grant)
|
||||
def test_denied_agent_failure_is_attributed_to_the_authenticated_key_for_every_grant_kind(
|
||||
rig: _Rig, grant: str
|
||||
) -> None:
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario, grant)
|
||||
granted: Final = _send(rig, caller.key, f"/a2a/{rig.allowed.agent_id}", method="message/send", version=None)
|
||||
assert granted.status_code == 200, granted.text
|
||||
response: Final = _send(rig, caller.key, f"/a2a/{rig.denied.agent_id}")
|
||||
assert (response.status_code, response.content) == (403, _refusal(rig.denied.agent_id, response))
|
||||
events: Final = held(
|
||||
lambda: rig.events.of(caller.alias),
|
||||
lambda found: _hooks(found) == tuple(sorted((*_ONE_FAILURE, "async_success"))),
|
||||
holding=_SETTLE,
|
||||
seconds=_CONVERGE,
|
||||
)
|
||||
failure: Final = next(event for event in events if event.hook == "async_failure")
|
||||
assert (failure.key_hash, failure.user_id, failure.team_id) == (caller.hashed, caller.user_id, caller.team_id)
|
||||
rows: Final = held(
|
||||
lambda: _rows(rig, caller), lambda found: len(found) == 2, holding=_SETTLE, seconds=_CONVERGE
|
||||
)
|
||||
failed: Final = tuple(row for row in rows if row["status"] == "failure")
|
||||
assert [(row["agent_id"], row["user"], row["spend"]) for row in failed] == [
|
||||
(rig.denied.agent_id, caller.user_id, 0.0)
|
||||
], rows
|
||||
|
||||
|
||||
def test_denied_completion_bridge_agent_logs_one_failure_and_never_reaches_the_bridged_model(rig: _Rig) -> None:
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
message: Final = "bridge-" + uuid.uuid4().hex
|
||||
response: Final = _send(
|
||||
rig, caller.key, f"/a2a/{rig.bridge.agent_id}", method="message/send", version=None, message_id=message
|
||||
)
|
||||
assert (response.status_code, response.content) == (403, _refusal(rig.bridge.agent_id, response))
|
||||
_assert_never_dispatched(rig, scenario, message)
|
||||
_assert_one_failure(rig, caller, status=403, agent_id=rig.bridge.agent_id, model=f"a2a_agent/{rig.bridge.name}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route", "method", "version"),
|
||||
[
|
||||
pytest.param("/a2a/{}", "SendMessage", "1.0", id="regression-send"),
|
||||
pytest.param("/a2a/{}/message/send", "SendMessage", "1.0", id="regression-send-alias"),
|
||||
pytest.param("/v1/a2a/{}/message/send", "SendMessage", "1.0", id="regression-send-v1-alias"),
|
||||
pytest.param("/a2a/{}", "message/stream", None, id="regression-stream-0.3"),
|
||||
],
|
||||
)
|
||||
def test_unknown_agent_keeps_its_jsonrpc_404_and_logs_one_zero_spend_failure_for_the_caller(
|
||||
rig: _Rig, route: str, method: str, version: str | None
|
||||
) -> None:
|
||||
unknown: Final = "no-such-" + uuid.uuid4().hex
|
||||
request_id: Final = "u-" + uuid.uuid4().hex
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
message: Final = "unknown-" + uuid.uuid4().hex
|
||||
response: Final = _send(
|
||||
rig,
|
||||
caller.key,
|
||||
route.format(unknown),
|
||||
method=method,
|
||||
version=version,
|
||||
request_id=request_id,
|
||||
message_id=message,
|
||||
)
|
||||
assert (response.status_code, response.content) == (404, _not_found(request_id, unknown))
|
||||
assert response.headers["content-type"] == "application/json"
|
||||
_assert_never_dispatched(rig, scenario, message)
|
||||
_assert_one_failure(rig, caller, status=404, agent_id=unknown, model=f"a2a_agent/{unknown}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_id", [0, 7, "abc"], ids=lambda value: f"regression-id-{value!r}")
|
||||
def test_unknown_agent_echoes_the_jsonrpc_id_with_its_type_and_logs_one_failure(
|
||||
rig: _Rig, request_id: JsonValue
|
||||
) -> None:
|
||||
unknown: Final = "no-such-" + uuid.uuid4().hex
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
response: Final = _send(rig, caller.key, f"/a2a/{unknown}", request_id=request_id)
|
||||
assert (response.status_code, response.content) == (404, _not_found(request_id, unknown))
|
||||
assert type(JSON_OBJECT.validate_json(response.content)["id"]) is type(request_id)
|
||||
_assert_one_failure(rig, caller, status=404, agent_id=unknown, model=f"a2a_agent/{unknown}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["denied", "unknown"], ids=lambda outcome: "regression-" + outcome)
|
||||
def test_client_supplied_attribution_cannot_rewrite_a_denial_failure_row(rig: _Rig, outcome: str) -> None:
|
||||
unknown: Final = "no-such-" + uuid.uuid4().hex
|
||||
target: Final = rig.denied.agent_id if outcome == "denied" else unknown
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
other: Final = _caller(rig, scenario)
|
||||
success_id: Final = "s-" + uuid.uuid4().hex
|
||||
granted: Final = _send(
|
||||
rig, other.key, f"/a2a/{rig.allowed.agent_id}", method="message/send", version=None, request_id=success_id
|
||||
)
|
||||
assert granted.status_code == 200, granted.text
|
||||
success_rows: Final = eventually(lambda: _rows(rig, other), lambda found: len(found) == 1, seconds=_CONVERGE)
|
||||
success_request: Final = success_rows[0]["request_id"]
|
||||
request_id: Final = "spoof-" + uuid.uuid4().hex
|
||||
spoof: Final[dict[str, JsonValue]] = {
|
||||
"agent_id": "spoof",
|
||||
"metadata": {
|
||||
"agent_id": "spoof",
|
||||
"user_api_key_alias": "spoof",
|
||||
"user_api_key_team_id": "spoof",
|
||||
"tags": ["x"],
|
||||
},
|
||||
"litellm_call_id": success_request,
|
||||
}
|
||||
body: Final = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "SendMessage",
|
||||
"params": {**_params("SendMessage", uuid.uuid4().hex, "1.0"), "litellm_call_id": "spoof2"},
|
||||
**spoof,
|
||||
}
|
||||
response: Final = rig.proxy.client.post(
|
||||
f"/a2a/{target}", headers={"Authorization": f"Bearer {caller.key}", "A2A-Version": "1.0"}, json=body
|
||||
)
|
||||
expected: Final = _refusal(target, response) if outcome == "denied" else _not_found(request_id, unknown)
|
||||
assert (response.status_code, response.content) == (403 if outcome == "denied" else 404, expected)
|
||||
model: Final = f"a2a_agent/{rig.denied.name}" if outcome == "denied" else f"a2a_agent/{unknown}"
|
||||
row: Final = _assert_one_failure(rig, caller, status=response.status_code, agent_id=target, model=model)
|
||||
assert row["request_id"] not in {success_request, "spoof2", request_id}, row
|
||||
metadata: Final = _metadata(row)
|
||||
assert (metadata["user_api_key_alias"], metadata["user_api_key_team_id"]) == (caller.alias, caller.team_id)
|
||||
assert rig.events.of("spoof") == ()
|
||||
after: Final = _rows(rig, other)
|
||||
assert [(item["request_id"], item["status"]) for item in after] == [(success_request, "success")], after
|
||||
|
||||
|
||||
def test_two_keys_reusing_one_jsonrpc_id_get_one_denial_row_each(rig: _Rig) -> None:
|
||||
shared_id: Final = "same-" + uuid.uuid4().hex
|
||||
with rig.proxy.scenario() as scenario:
|
||||
callers: Final = (_caller(rig, scenario), _caller(rig, scenario))
|
||||
for caller in callers:
|
||||
response = _send(rig, caller.key, f"/a2a/{rig.denied.agent_id}", request_id=shared_id)
|
||||
assert (response.status_code, response.content) == (403, _refusal(rig.denied.agent_id, response))
|
||||
rows: Final = tuple(
|
||||
_assert_one_failure(
|
||||
rig, caller, status=403, agent_id=rig.denied.agent_id, model=f"a2a_agent/{rig.denied.name}"
|
||||
)
|
||||
for caller in callers
|
||||
)
|
||||
assert rows[0]["request_id"] != rows[1]["request_id"], rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["denied", "unknown"])
|
||||
def test_failing_or_transforming_failure_callbacks_never_change_the_denial_response(
|
||||
hostile: _Rig, outcome: str
|
||||
) -> None:
|
||||
unknown: Final = "no-such-" + uuid.uuid4().hex
|
||||
request_id: Final = "h-" + uuid.uuid4().hex
|
||||
target: Final = hostile.denied.agent_id if outcome == "denied" else unknown
|
||||
with hostile.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(hostile, scenario)
|
||||
response: Final = _send(hostile, caller.key, f"/a2a/{target}", request_id=request_id)
|
||||
expected: Final = (403, _refusal(target, response)) if outcome == "denied" else (404, _not_found(request_id, unknown))
|
||||
assert (response.status_code, response.content) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["denied", "unknown"], ids=lambda outcome: "regression-" + outcome)
|
||||
def test_failing_or_transforming_failure_callbacks_do_not_stop_the_denial_from_being_logged(
|
||||
hostile: _Rig, outcome: str
|
||||
) -> None:
|
||||
unknown: Final = "no-such-" + uuid.uuid4().hex
|
||||
target: Final = hostile.denied.agent_id if outcome == "denied" else unknown
|
||||
with hostile.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(hostile, scenario)
|
||||
response: Final = _send(hostile, caller.key, f"/a2a/{target}")
|
||||
assert response.status_code == (403 if outcome == "denied" else 404), response.text
|
||||
model: Final = f"a2a_agent/{hostile.denied.name}" if outcome == "denied" else f"a2a_agent/{unknown}"
|
||||
_assert_one_failure(hostile, caller, status=response.status_code, agent_id=target, model=model)
|
||||
|
||||
|
||||
def test_denials_follow_the_model_denied_chat_logging_under_error_log_flags(flagged: _Rig) -> None:
|
||||
unknown: Final = "no-such-" + uuid.uuid4().hex
|
||||
request_id: Final = "f-" + uuid.uuid4().hex
|
||||
with flagged.proxy.scenario() as scenario:
|
||||
chat_caller: Final = _caller(flagged, scenario)
|
||||
denied_caller: Final = _caller(flagged, scenario)
|
||||
unknown_caller: Final = _caller(flagged, scenario)
|
||||
chat_alias: Final = "a2adeny-chat-" + uuid.uuid4().hex[:8]
|
||||
chat_key: Final = scenario.key(
|
||||
user_id=chat_caller.user_id, team_id=chat_caller.team_id, key_alias=chat_alias, models=["openai/gpt-5-mini"]
|
||||
)
|
||||
chat: Final = flagged.proxy.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": "openai/gpt-5.4-mini", "messages": [{"role": "user", "content": "x"}]},
|
||||
key=chat_key,
|
||||
)
|
||||
assert chat.status_code in {401, 403}, chat.text
|
||||
denied: Final = _send(flagged, denied_caller.key, f"/a2a/{flagged.denied.agent_id}")
|
||||
assert (denied.status_code, denied.content) == (403, _refusal(flagged.denied.agent_id, denied))
|
||||
missing: Final = _send(flagged, unknown_caller.key, f"/a2a/{unknown}", request_id=request_id)
|
||||
assert (missing.status_code, missing.content) == (404, _not_found(request_id, unknown))
|
||||
_assert_never_dispatched(flagged, scenario, "never-sent")
|
||||
chat_hooks: Final = held(
|
||||
lambda: _hooks(flagged.events.of(chat_alias)),
|
||||
lambda found: len(found) > 0,
|
||||
holding=_SETTLE,
|
||||
seconds=_CONVERGE,
|
||||
)
|
||||
assert _hooks(flagged.events.of(denied_caller.alias)) == chat_hooks
|
||||
assert _hooks(flagged.events.of(unknown_caller.alias)) == chat_hooks
|
||||
control: Final = _caller(flagged, scenario)
|
||||
granted: Final = _send(
|
||||
flagged, control.key, f"/a2a/{flagged.allowed.agent_id}", method="message/send", version=None
|
||||
)
|
||||
assert granted.status_code == 200, granted.text
|
||||
eventually(lambda: _rows(flagged, control), lambda found: len(found) == 1, seconds=_CONVERGE)
|
||||
assert (_rows(flagged, denied_caller), _rows(flagged, unknown_caller)) == ([], [])
|
||||
|
||||
|
||||
def test_client_error_denials_never_raise_an_llm_exceptions_alert(alerting: tuple[_Rig, Wire]) -> None:
|
||||
rig, alerts = alerting
|
||||
unknown: Final = "no-such-" + uuid.uuid4().hex
|
||||
marker: Final = "boom-" + uuid.uuid4().hex
|
||||
|
||||
def failing(request: Request) -> Reply:
|
||||
return Reply(
|
||||
status=500, body=_compact({"error": {"message": f"upstream exploded {marker}", "type": "server_error"}})
|
||||
)
|
||||
|
||||
with rig.proxy.scenario() as scenario, wire_server(failing) as broken:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
assert _send(rig, caller.key, f"/a2a/{rig.denied.agent_id}").status_code == 403
|
||||
assert _send(rig, caller.key, f"/a2a/{unknown}").status_code == 404
|
||||
model: Final = scenario.model(api_base=broken.url + "/v1", num_retries=0)
|
||||
failed: Final = rig.proxy.request(
|
||||
"POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": "x"}]}
|
||||
)
|
||||
assert failed.status_code >= 500, failed.text
|
||||
texts: Final = eventually(
|
||||
lambda: tuple(JSON_OBJECT.validate_json(request.body).get("text") for request in alerts.drain()),
|
||||
lambda found: any(marker in str(text) for text in found),
|
||||
seconds=_CONVERGE,
|
||||
)
|
||||
assert not any(rig.denied.agent_id in str(text) or unknown in str(text) for text in texts), texts
|
||||
|
||||
|
||||
def test_guard_model_denied_chat_logs_one_acompletion_failure(rig: _Rig) -> None:
|
||||
with rig.proxy.scenario() as scenario:
|
||||
user: Final = scenario.user()
|
||||
alias: Final = "a2adeny-chat-" + uuid.uuid4().hex[:8]
|
||||
key: Final = scenario.key(user_id=user, key_alias=alias, models=["openai/gpt-5-mini"])
|
||||
caller: Final = _Caller(key, alias, user, None)
|
||||
response: Final = rig.proxy.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": "openai/gpt-5.4-mini", "messages": [{"role": "user", "content": "x"}]},
|
||||
key=key,
|
||||
)
|
||||
assert response.status_code == 403, response.text
|
||||
assert "key_model_access_denied" in response.text
|
||||
events: Final = held(
|
||||
lambda: rig.events.of(alias),
|
||||
lambda found: _hooks(found) == _ONE_FAILURE,
|
||||
holding=_SETTLE,
|
||||
seconds=_CONVERGE,
|
||||
)
|
||||
assert {event.status_code for event in events if event.hook == "post_call_failure"} == {403}
|
||||
rows: Final = held(
|
||||
lambda: _rows(rig, caller), lambda found: len(found) == 1, holding=_SETTLE, seconds=_CONVERGE
|
||||
)
|
||||
assert (rows[0]["status"], rows[0]["call_type"], rows[0]["spend"]) == ("failure", "acompletion", 0.0), rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "call_type"), [("message/send", "asend_message"), ("message/stream", "asend_message_streaming")]
|
||||
)
|
||||
def test_guard_granted_agent_succeeds_with_one_success_row_and_no_failure(
|
||||
rig: _Rig, method: str, call_type: str
|
||||
) -> None:
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
request_id: Final = "g-" + uuid.uuid4().hex
|
||||
response: Final = _send(
|
||||
rig, caller.key, f"/a2a/{rig.allowed.agent_id}", method=method, version=None, request_id=request_id
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
if method == "message/stream":
|
||||
assert response.text.startswith("data: ") and request_id in response.text, response.text
|
||||
else:
|
||||
assert object_value(JSON_OBJECT.validate_json(response.content)["result"])["parts"] == [
|
||||
{"kind": "text", "text": "granted"}
|
||||
]
|
||||
held(
|
||||
lambda: rig.events.of(caller.alias),
|
||||
lambda found: _hooks(found) == ("async_success",),
|
||||
holding=_SETTLE,
|
||||
seconds=_CONVERGE,
|
||||
)
|
||||
rows: Final = held(
|
||||
lambda: _rows(rig, caller), lambda found: len(found) == 1, holding=_SETTLE, seconds=_CONVERGE
|
||||
)
|
||||
assert (rows[0]["status"], rows[0]["call_type"], rows[0]["agent_id"]) == (
|
||||
"success",
|
||||
call_type,
|
||||
rig.allowed.agent_id,
|
||||
), rows
|
||||
|
||||
|
||||
def test_guard_zero_budget_key_is_refused_once_without_double_logging(rig: _Rig) -> None:
|
||||
with rig.proxy.scenario() as scenario:
|
||||
user: Final = scenario.user()
|
||||
alias: Final = "a2adeny-budget-" + uuid.uuid4().hex[:8]
|
||||
key: Final = scenario.key(
|
||||
user_id=user, key_alias=alias, max_budget=0, object_permission={"agents": [rig.allowed.agent_id]}
|
||||
)
|
||||
caller: Final = _Caller(key, alias, user, None)
|
||||
message: Final = "budget-" + uuid.uuid4().hex
|
||||
response: Final = _send(
|
||||
rig, key, f"/a2a/{rig.allowed.agent_id}", method="message/send", version=None, message_id=message
|
||||
)
|
||||
assert response.status_code == 422, response.text
|
||||
assert "Budget has been exceeded" in response.text
|
||||
_assert_never_dispatched(rig, scenario, message)
|
||||
held(
|
||||
lambda: rig.events.of(alias),
|
||||
lambda found: _hooks(found) == _ONE_FAILURE,
|
||||
holding=_SETTLE,
|
||||
seconds=_CONVERGE,
|
||||
)
|
||||
rows: Final = held(
|
||||
lambda: _rows(rig, caller), lambda found: len(found) == 1, holding=_SETTLE, seconds=_CONVERGE
|
||||
)
|
||||
assert (rows[0]["status"], rows[0]["call_type"]) == ("failure", "asend_message"), rows
|
||||
|
||||
|
||||
def test_guard_agent_card_discovery_keeps_its_refusals_and_writes_no_failure_row(rig: _Rig) -> None:
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
headers: Final = {"Authorization": f"Bearer {caller.key}"}
|
||||
denied_card: Final = rig.proxy.client.get(
|
||||
f"/a2a/{rig.denied.agent_id}/.well-known/agent-card.json", headers=headers
|
||||
)
|
||||
assert (denied_card.status_code, denied_card.content) == (403, _refusal(rig.denied.agent_id))
|
||||
missing: Final = rig.proxy.client.get("/a2a/no-such-card/.well-known/agent-card.json", headers=headers)
|
||||
assert (missing.status_code, missing.content) == (404, _compact({"detail": "Agent 'no-such-card' not found"}))
|
||||
granted: Final = _send(rig, caller.key, f"/a2a/{rig.allowed.agent_id}", method="message/send", version=None)
|
||||
assert granted.status_code == 200, granted.text
|
||||
rows: Final = held(
|
||||
lambda: _rows(rig, caller), lambda found: len(found) == 1, holding=_SETTLE, seconds=_CONVERGE
|
||||
)
|
||||
assert [row["status"] for row in rows] == ["success"], rows
|
||||
assert _hooks(rig.events.of(caller.alias)) == ("async_success",)
|
||||
|
||||
|
||||
def test_guard_jsonrpc_1_0_envelope_keeps_its_invalid_request_body(rig: _Rig) -> None:
|
||||
with rig.proxy.scenario() as scenario:
|
||||
caller: Final = _caller(rig, scenario)
|
||||
request_id: Final = "v-" + uuid.uuid4().hex
|
||||
response: Final = _send(
|
||||
rig,
|
||||
caller.key,
|
||||
f"/a2a/{rig.allowed.agent_id}",
|
||||
method="message/send",
|
||||
version=None,
|
||||
request_id=request_id,
|
||||
extra={"jsonrpc": "1.0"},
|
||||
)
|
||||
assert (response.status_code, response.content) == (
|
||||
400,
|
||||
_compact(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": -32600, "message": "Invalid Request: jsonrpc must be '2.0'"},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
@ -452,8 +452,13 @@ def test_guard_no_agent_key_or_proxy_caller_key_is_sent(gateway: Gateway) -> Non
|
|||
(("0.3", "message/stream", _MESSAGE_V03), ("1.0", "SendStreamingMessage", _MESSAGE_V10)),
|
||||
ids=("v0.3", "v1.0"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
("/a2a/{identity}", "/a2a/{identity}/message/stream", "/v1/a2a/{identity}/message/stream"),
|
||||
ids=("bare", "message_stream", "v1_message_stream"),
|
||||
)
|
||||
def test_a2a_stream_relays_each_upstream_event_as_its_own_sse_frame_in_the_pinned_dialect(
|
||||
gateway: Gateway, version: str, method: str, message: dict[str, JsonValue]
|
||||
gateway: Gateway, version: str, method: str, message: dict[str, JsonValue], path: str
|
||||
) -> None:
|
||||
marker: Final = "a2astream" + uuid.uuid4().hex
|
||||
first_frame_seen: Final = threading.Event()
|
||||
|
|
@ -482,7 +487,7 @@ def test_a2a_stream_relays_each_upstream_event_as_its_own_sse_frame_in_the_pinne
|
|||
)
|
||||
with gateway.client.stream(
|
||||
"POST",
|
||||
f"/a2a/{identity}",
|
||||
path.format(identity=identity),
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"a2a-version": version,
|
||||
|
|
|
|||
95
tests/integration/configuration/test_multi_worker_reload.py
Normal file
95
tests/integration/configuration/test_multi_worker_reload.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from tests.integration._support.client import Gateway, eventually, object_value
|
||||
from tests.integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
|
||||
|
||||
STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
|
||||
|
||||
|
||||
def _worker_pids(owned: OwnedProxy) -> frozenset[int]:
|
||||
return frozenset(int(match.group(1)) for match in STARTED_WORKER.finditer(owned.log.read_text()))
|
||||
|
||||
|
||||
def _launched_config(owned: OwnedProxy) -> Path:
|
||||
arguments: Final = psutil.Process(owned.process.pid).cmdline()
|
||||
return Path(arguments[arguments.index("--config") + 1])
|
||||
|
||||
|
||||
def _config(model: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"model_list": [{"model_name": model, "litellm_params": {"model": "openai/reload-test"}}],
|
||||
"general_settings": {"master_key": "os.environ/LITELLM_MASTER_KEY"},
|
||||
"router_settings": {"redis_host": "os.environ/REDIS_HOST", "redis_port": "os.environ/REDIS_PORT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _assert_replaced(owned: OwnedProxy, previous: frozenset[int], workers: int) -> frozenset[int]:
|
||||
started: Final = eventually(
|
||||
lambda: _worker_pids(owned),
|
||||
lambda pids: len(pids - previous) == workers,
|
||||
seconds=graceful_stop_seconds(),
|
||||
)
|
||||
assert all(not psutil.pid_exists(pid) for pid in previous), owned.log.read_text()
|
||||
assert owned.process.poll() is None, owned.log.read_text()
|
||||
assert len(started) == len(previous) + workers, owned.log.read_text()
|
||||
return started
|
||||
|
||||
|
||||
def _assert_models(gateway: Gateway) -> None:
|
||||
data: Final = gateway.get("/v1/models")["data"]
|
||||
assert isinstance(data, list), data
|
||||
assert tuple(object_value(model)["id"] for model in data) == ("after-reload",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workers", (1, 2))
|
||||
@pytest.mark.timeout(6 * graceful_stop_seconds())
|
||||
def test_reload_replaces_every_worker_and_shutdown_leaves_none(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, workers: int
|
||||
) -> None:
|
||||
root: Final = Path(__file__).resolve().parents[3]
|
||||
monkeypatch.setenv("INTEGRATION_PROXY_ROOT", str(tmp_path))
|
||||
config: Final = tmp_path / "config.yaml"
|
||||
source: Final = tmp_path / "reload_source.py"
|
||||
env: Final = tmp_path / ".env"
|
||||
config.write_text(_config("before-reload"))
|
||||
source.write_text("VALUE = 1\n")
|
||||
env.write_text("RELOAD_TEST_VALUE=before\n")
|
||||
with owned_proxy_process(
|
||||
gateway,
|
||||
tmp_path,
|
||||
{
|
||||
"PYTHONPATH": os.pathsep.join((str(root), str(root / "tests"), os.environ.get("PYTHONPATH", ""))),
|
||||
"STORE_MODEL_IN_DB": "False",
|
||||
},
|
||||
config=config,
|
||||
remove_environment=("DATABASE_URL", "DATABASE_URL_READ_REPLICA", "PYTHON_DOTENV_DISABLED"),
|
||||
workers=workers,
|
||||
database_setup=(),
|
||||
extra_arguments=("--reload",),
|
||||
) as owned:
|
||||
initial: Final = eventually(
|
||||
lambda: _worker_pids(owned), lambda pids: len(pids) == workers, seconds=graceful_stop_seconds()
|
||||
)
|
||||
_launched_config(owned).write_text(_config("after-reload"))
|
||||
after_config: Final = _assert_replaced(owned, initial, workers)
|
||||
for _ in range(2 * workers):
|
||||
_assert_models(owned.gateway)
|
||||
source.write_text("VALUE = 2\n")
|
||||
after_source: Final = _assert_replaced(owned, after_config, workers)
|
||||
env.write_text("RELOAD_TEST_VALUE=after\n")
|
||||
after_env: Final = _assert_replaced(owned, after_source, workers)
|
||||
response: Final = owned.gateway.request("GET", "/health/readiness")
|
||||
assert response.status_code == 200, response.text
|
||||
owned.begin_shutdown()
|
||||
owned.process.wait(timeout=graceful_stop_seconds())
|
||||
assert all(not psutil.pid_exists(pid) for pid in after_env), owned.log.read_text()
|
||||
assert owned.process.returncode == 0, owned.log.read_text()
|
||||
246
tests/integration/configuration/test_reload_worker_pool.py
Normal file
246
tests/integration/configuration/test_reload_worker_pool.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import multiprocessing
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.dev_reload import multi_worker_reload_supported, stop_pool_when_reloader_exits
|
||||
from tests.integration._support.client import eventually
|
||||
from tests.integration._support.ports import allocate_port
|
||||
from tests.integration._support.process import group_members, kill_stray_process, signal_group, stop_processes
|
||||
|
||||
POOL_WORKERS: Final = 2
|
||||
POOL_TIMEOUT_SECONDS: Final = 120
|
||||
WORKER_HEALTHCHECK_SECONDS: Final = 30
|
||||
WORKER_STARTED: Final = re.compile(r"Started server process \[(\d+)\]")
|
||||
POOL_STOPPED: Final = "failed to start, stopping the parent process"
|
||||
APP_LOAD_ERROR: Final = "Error loading ASGI app"
|
||||
PID_APP: Final = """import os
|
||||
|
||||
|
||||
async def app(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": str(os.getpid()).encode()})
|
||||
"""
|
||||
REQUIRES_UVICORN_STARTUP_FAILURE: Final = pytest.mark.skipif(
|
||||
not multi_worker_reload_supported(), reason="uvicorn<0.50.0 has no startup failure exit code"
|
||||
)
|
||||
CORE_DUMP_SECONDS: Final = 15
|
||||
SLOW_CORE_DUMP_ON_QUIT: Final = f"""
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
|
||||
def freeze_until_killed(signum, frame):
|
||||
subprocess.Popen(("sh", "-c", f"sleep {CORE_DUMP_SECONDS}; kill -9 {{os.getpid()}}"))
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
|
||||
|
||||
signal.signal(signal.SIGQUIT, freeze_until_killed)
|
||||
"""
|
||||
POOL_RUNNER: Final = """import sys
|
||||
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
|
||||
from litellm.proxy.dev_reload import run_multi_worker_reload
|
||||
|
||||
run_multi_worker_reload(
|
||||
{
|
||||
"app": "reload_pool_app:app",
|
||||
"host": "127.0.0.1",
|
||||
"port": int(sys.argv[2]),
|
||||
"timeout_worker_healthcheck": int(sys.argv[3]),
|
||||
"reload": True,
|
||||
"reload_dirs": (sys.argv[1],),
|
||||
"reload_includes": ("*.py",),
|
||||
},
|
||||
workers=int(sys.argv[4]),
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _started_workers(log_text: str) -> frozenset[int]:
|
||||
return frozenset(int(match.group(1)) for match in WORKER_STARTED.finditer(log_text))
|
||||
|
||||
|
||||
def _until(done: Callable[[], bool], log: Path, seconds: float = POOL_TIMEOUT_SECONDS) -> None:
|
||||
eventually(lambda: (done(), log.read_text()), lambda observed: observed[0], seconds=seconds)
|
||||
|
||||
|
||||
def _wait_for_log(log: Path, process: subprocess.Popen[bytes], seen: Callable[[str], bool]) -> str:
|
||||
log_text, _ = eventually(
|
||||
lambda: (log.read_text(), process.poll()),
|
||||
lambda observed: seen(observed[0]) or observed[1] is not None,
|
||||
seconds=POOL_TIMEOUT_SECONDS,
|
||||
)
|
||||
assert seen(log_text), log_text
|
||||
return log_text
|
||||
|
||||
|
||||
def _start_pool(app_dir: Path, port: int, log: Path, runner: str = POOL_RUNNER) -> subprocess.Popen[bytes]:
|
||||
with log.open("wb") as output:
|
||||
return subprocess.Popen(
|
||||
(
|
||||
sys.executable,
|
||||
"-I",
|
||||
"-c",
|
||||
runner,
|
||||
str(app_dir),
|
||||
str(port),
|
||||
str(WORKER_HEALTHCHECK_SECONDS),
|
||||
str(POOL_WORKERS),
|
||||
),
|
||||
stdout=output,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def _port_refuses_connections(port: int) -> bool:
|
||||
with socket.socket() as sock:
|
||||
return sock.connect_ex(("127.0.0.1", port)) != 0
|
||||
|
||||
|
||||
def _next_pool(log: Path, process: subprocess.Popen[bytes], port: int, previous: frozenset[int]) -> frozenset[int]:
|
||||
log_text: Final = _wait_for_log(log, process, lambda text: len(_started_workers(text) - previous) == POOL_WORKERS)
|
||||
pool: Final = _started_workers(log_text) - previous
|
||||
assert not any(psutil.pid_exists(pid) for pid in previous), log_text
|
||||
served: Final = httpx.get(f"http://127.0.0.1:{port}/", timeout=POOL_TIMEOUT_SECONDS)
|
||||
assert int(served.text) in pool, log_text
|
||||
return pool
|
||||
|
||||
|
||||
def test_the_pool_is_told_to_stop_once_the_reloader_process_exits() -> None:
|
||||
reloader: Final = multiprocessing.get_context("spawn").Process(target=time.sleep, args=(POOL_TIMEOUT_SECONDS,))
|
||||
reloader.start()
|
||||
pool_should_exit: Final = threading.Event()
|
||||
threading.Thread(target=stop_pool_when_reloader_exits, args=(reloader, pool_should_exit), daemon=True).start()
|
||||
try:
|
||||
assert not pool_should_exit.wait(0.5)
|
||||
kill_stray_process(reloader.pid)
|
||||
assert pool_should_exit.wait(POOL_TIMEOUT_SECONDS)
|
||||
finally:
|
||||
if reloader.is_alive():
|
||||
kill_stray_process(reloader.pid)
|
||||
reloader.join()
|
||||
|
||||
|
||||
@REQUIRES_UVICORN_STARTUP_FAILURE
|
||||
@pytest.mark.timeout(5 * POOL_TIMEOUT_SECONDS)
|
||||
def test_a_save_replaces_every_worker_and_a_broken_save_stops_the_pool_until_the_next_good_one(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
app: Final = tmp_path / "reload_pool_app.py"
|
||||
app.write_text(PID_APP)
|
||||
log: Final = tmp_path / "reload.log"
|
||||
port: Final = allocate_port()
|
||||
process: Final = _start_pool(tmp_path, port, log)
|
||||
try:
|
||||
first_pool: Final = _next_pool(log, process, port, frozenset())
|
||||
app.write_text("async def app(\n")
|
||||
_wait_for_log(log, process, lambda text: POOL_STOPPED in text)
|
||||
assert not any(psutil.pid_exists(pid) for pid in first_pool), log.read_text()
|
||||
app.write_text(PID_APP)
|
||||
last_pool: Final = _next_pool(log, process, port, first_pool)
|
||||
signal_group(process.pid, signal.SIGINT)
|
||||
assert process.wait(timeout=POOL_TIMEOUT_SECONDS) == 0, log.read_text()
|
||||
assert not any(psutil.pid_exists(pid) for pid in last_pool), log.read_text()
|
||||
assert 1 <= log.read_text().count(APP_LOAD_ERROR) <= POOL_WORKERS, log.read_text()
|
||||
finally:
|
||||
stop_processes(group_members(process.pid))
|
||||
process.wait()
|
||||
|
||||
|
||||
@REQUIRES_UVICORN_STARTUP_FAILURE
|
||||
@pytest.mark.timeout(3 * POOL_TIMEOUT_SECONDS)
|
||||
@pytest.mark.parametrize(
|
||||
("kill_reloader", "slow_core_dump", "shutdown_timeout"),
|
||||
(
|
||||
pytest.param(kill_stray_process, False, POOL_TIMEOUT_SECONDS, id="sigkill-reloader"),
|
||||
pytest.param(
|
||||
lambda reloader: signal_group(reloader, signal.SIGHUP), False, POOL_TIMEOUT_SECONDS, id="terminal-closed"
|
||||
),
|
||||
pytest.param(lambda reloader: signal_group(reloader, signal.SIGQUIT), True, 10, id="ctrl-backslash"),
|
||||
),
|
||||
)
|
||||
def test_the_pool_stops_and_frees_the_port_when_the_reloader_dies(
|
||||
tmp_path: Path, kill_reloader: Callable[[int], None], slow_core_dump: bool, shutdown_timeout: float
|
||||
) -> None:
|
||||
app_source: Final = PID_APP + (SLOW_CORE_DUMP_ON_QUIT if slow_core_dump else "")
|
||||
(tmp_path / "reload_pool_app.py").write_text(app_source)
|
||||
log: Final = tmp_path / "reload.log"
|
||||
port: Final = allocate_port()
|
||||
process: Final = _start_pool(tmp_path, port, log)
|
||||
try:
|
||||
workers: Final = _next_pool(log, process, port, frozenset())
|
||||
pool_parent: Final = psutil.Process(min(workers)).ppid()
|
||||
assert psutil.Process(pool_parent).ppid() == process.pid, log.read_text()
|
||||
kill_reloader(process.pid)
|
||||
process.wait(timeout=POOL_TIMEOUT_SECONDS)
|
||||
_until(
|
||||
lambda: not psutil.pid_exists(pool_parent) and _port_refuses_connections(port),
|
||||
log,
|
||||
seconds=shutdown_timeout,
|
||||
)
|
||||
finally:
|
||||
stop_processes(group_members(process.pid))
|
||||
process.wait()
|
||||
|
||||
|
||||
@REQUIRES_UVICORN_STARTUP_FAILURE
|
||||
@pytest.mark.timeout(3 * POOL_TIMEOUT_SECONDS)
|
||||
def test_a_killed_pool_parent_takes_its_workers_with_it_so_the_next_save_serves_only_new_workers(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
app: Final = tmp_path / "reload_pool_app.py"
|
||||
app.write_text(PID_APP)
|
||||
log: Final = tmp_path / "reload.log"
|
||||
port: Final = allocate_port()
|
||||
process: Final = _start_pool(tmp_path, port, log)
|
||||
try:
|
||||
orphans: Final = _next_pool(log, process, port, frozenset())
|
||||
kill_stray_process(psutil.Process(min(orphans)).ppid())
|
||||
_until(lambda: not any(psutil.pid_exists(pid) for pid in orphans), log)
|
||||
app.write_text(f"{PID_APP}\n")
|
||||
_next_pool(log, process, port, orphans)
|
||||
finally:
|
||||
stop_processes(group_members(process.pid))
|
||||
process.wait()
|
||||
|
||||
|
||||
@REQUIRES_UVICORN_STARTUP_FAILURE
|
||||
@pytest.mark.timeout(3 * POOL_TIMEOUT_SECONDS)
|
||||
def test_ctrl_backslash_frees_the_port_without_waiting_out_the_worker_healthcheck_when_core_dumps_are_slow(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "reload_pool_app.py").write_text(PID_APP + SLOW_CORE_DUMP_ON_QUIT)
|
||||
log: Final = tmp_path / "reload.log"
|
||||
port: Final = allocate_port()
|
||||
process: Final = _start_pool(tmp_path, port, log)
|
||||
try:
|
||||
workers: Final = _next_pool(log, process, port, frozenset())
|
||||
pool_parent: Final = psutil.Process(min(workers)).ppid()
|
||||
signal_group(process.pid, signal.SIGQUIT)
|
||||
_until(
|
||||
lambda: (
|
||||
process.poll() is not None and not psutil.pid_exists(pool_parent) and _port_refuses_connections(port)
|
||||
),
|
||||
log,
|
||||
seconds=WORKER_HEALTHCHECK_SECONDS / 2,
|
||||
)
|
||||
finally:
|
||||
stop_processes(group_members(process.pid))
|
||||
process.wait()
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
from integration._support.client import (
|
||||
JSON_OBJECT,
|
||||
Gateway,
|
||||
gateway_from_environment,
|
||||
list_value,
|
||||
object_value,
|
||||
string_value,
|
||||
)
|
||||
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
pytestmark: Final = pytest.mark.timeout(int(2 * graceful_stop_seconds() + 120))
|
||||
|
||||
_MODEL_NAME: Final = "search-embedding"
|
||||
_AGENT_NOT_CONFIGURED: Final = (
|
||||
"agent search needs litellm_settings.agent_search_embedding_model set to an embedding model from model_list"
|
||||
)
|
||||
_SKILL_NOT_CONFIGURED: Final = (
|
||||
"skill search needs litellm_settings.skill_search_embedding_model set to an embedding model from model_list"
|
||||
)
|
||||
_SKILL_MARKDOWN: Final = """---
|
||||
name: echo-sample
|
||||
description: Echo text for search validation
|
||||
---
|
||||
|
||||
# Echo sample
|
||||
|
||||
Return the supplied text unchanged
|
||||
"""
|
||||
_JSON_ARRAY: Final = TypeAdapter(list[JsonValue])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SearchProxy:
|
||||
owned: OwnedProxy
|
||||
wire: Wire
|
||||
skill_id: str
|
||||
agent_id: str
|
||||
|
||||
|
||||
def _embedding_reply(request: Request) -> Reply:
|
||||
if request.method != "POST" or request.target != "/v1/embeddings":
|
||||
return Reply(body=b'{"object":"list","data":[]}')
|
||||
body: Final = JSON_OBJECT.validate_json(request.body)
|
||||
input_value: Final = body.get("input")
|
||||
input_count: Final = len(input_value) if isinstance(input_value, list) else 1
|
||||
embeddings: Final = [
|
||||
{"object": "embedding", "index": index, "embedding": [0.25, 0.75]} for index in range(input_count)
|
||||
]
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"object": "list",
|
||||
"data": embeddings,
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {"prompt_tokens": input_count, "total_tokens": input_count},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
|
||||
def _write_config(directory: Path, upstream_url: str, agent_model: str, skill_model: str) -> Path:
|
||||
config: Final = directory / f"search_embedding_{uuid.uuid4().hex}.yaml"
|
||||
contents: Final[dict[str, JsonValue]] = {
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": _MODEL_NAME,
|
||||
"litellm_params": {
|
||||
"model": "openai/text-embedding-3-small",
|
||||
"api_base": f"{upstream_url}/v1",
|
||||
"api_key": "synthetic-openai-key",
|
||||
},
|
||||
}
|
||||
],
|
||||
"litellm_settings": {
|
||||
"agent_search_embedding_model": agent_model,
|
||||
"skill_search_embedding_model": skill_model,
|
||||
},
|
||||
"general_settings": {
|
||||
"master_key": "os.environ/LITELLM_MASTER_KEY",
|
||||
"database_url": "os.environ/DATABASE_URL",
|
||||
"store_model_in_db": True,
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"agent_name": "plain",
|
||||
"agent_card_params": {
|
||||
"protocolVersion": "1.0",
|
||||
"name": "plain",
|
||||
"description": "echo agent",
|
||||
"url": "http://agent:9000/plain/",
|
||||
"supportedInterfaces": [
|
||||
{
|
||||
"url": "http://agent:9000/plain/",
|
||||
"protocolBinding": "JSONRPC",
|
||||
"protocolVersion": "1.0",
|
||||
}
|
||||
],
|
||||
"version": "1.0.0",
|
||||
"capabilities": {"streaming": True},
|
||||
"defaultInputModes": ["text/plain"],
|
||||
"defaultOutputModes": ["text/plain"],
|
||||
"skills": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
config.write_text(json.dumps(contents))
|
||||
return config
|
||||
|
||||
|
||||
def _create_skill(candidate: OwnedProxy) -> str:
|
||||
archive_data: Final = BytesIO()
|
||||
with ZipFile(archive_data, "w") as archive:
|
||||
archive.writestr("echo-sample/SKILL.md", _SKILL_MARKDOWN)
|
||||
response: Final = candidate.gateway.request_multipart(
|
||||
"/v1/skills?beta=true&custom_llm_provider=litellm_proxy",
|
||||
{
|
||||
"display_title": "Echo search fixture",
|
||||
"description": "Echo sample skill for search validation",
|
||||
"instructions": "Return the supplied text unchanged",
|
||||
},
|
||||
{"files[]": ("echo-skill.zip", archive_data.getvalue(), "application/zip")},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return string_value(JSON_OBJECT.validate_json(response.content)["id"])
|
||||
|
||||
|
||||
def _agent_id(candidate: OwnedProxy) -> str:
|
||||
response: Final = candidate.gateway.request("GET", "/v1/agents")
|
||||
assert response.status_code == 200, response.text
|
||||
agents: Final = _JSON_ARRAY.validate_json(response.content)
|
||||
assert len(agents) == 1
|
||||
return string_value(object_value(agents[0])["agent_id"])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def empty_search_proxy(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_SearchProxy]:
|
||||
directory: Final = tmp_path_factory.mktemp("search-empty-model")
|
||||
with wire_server(_embedding_reply) as upstream, gateway_from_environment() as gateway:
|
||||
config: Final = _write_config(
|
||||
directory,
|
||||
upstream.url,
|
||||
"os.environ/AGENT_SEARCH_EMBEDDING_MODEL",
|
||||
"os.environ/SKILL_SEARCH_EMBEDDING_MODEL",
|
||||
)
|
||||
with owned_proxy_process(
|
||||
gateway,
|
||||
directory,
|
||||
{"AGENT_SEARCH_EMBEDDING_MODEL": "", "SKILL_SEARCH_EMBEDDING_MODEL": ""},
|
||||
config=config,
|
||||
) as owned:
|
||||
yield _SearchProxy(owned, upstream, _create_skill(owned), _agent_id(owned))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def configured_search_proxy(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_SearchProxy]:
|
||||
directory: Final = tmp_path_factory.mktemp("search-configured-model")
|
||||
with wire_server(_embedding_reply) as upstream, gateway_from_environment() as gateway:
|
||||
config: Final = _write_config(directory, upstream.url, _MODEL_NAME, _MODEL_NAME)
|
||||
with owned_proxy_process(
|
||||
gateway,
|
||||
directory,
|
||||
{"AGENT_SEARCH_EMBEDDING_MODEL": _MODEL_NAME, "SKILL_SEARCH_EMBEDDING_MODEL": _MODEL_NAME},
|
||||
config=config,
|
||||
) as owned:
|
||||
yield _SearchProxy(owned, upstream, _create_skill(owned), _agent_id(owned))
|
||||
|
||||
|
||||
def _assert_not_configured(response_body: JsonValue, expected_error: str, reason: str) -> None:
|
||||
detail: Final = object_value(object_value(response_body)["detail"])
|
||||
assert detail == {"error": expected_error, "message": reason}
|
||||
|
||||
|
||||
def _assert_no_embedding_requests(candidate: _SearchProxy) -> None:
|
||||
embedding_requests: Final = tuple(
|
||||
request for request in candidate.wire.drain() if request.method == "POST" and request.target == "/v1/embeddings"
|
||||
)
|
||||
assert embedding_requests == ()
|
||||
|
||||
|
||||
def _assert_mcp_search_not_configured(gateway: Gateway, key: str, tool_name: str, reason: str) -> None:
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/mcp-rest/tools/call",
|
||||
{"name": tool_name, "arguments": {"query": "echo"}},
|
||||
key=key,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
result: Final = JSON_OBJECT.validate_json(response.content)
|
||||
assert result.get("isError") is True, response.text
|
||||
content: Final = object_value(list_value(result["content"])[0])
|
||||
text: Final = string_value(content["text"])
|
||||
assert reason in text
|
||||
assert "You passed in model=" not in text
|
||||
|
||||
|
||||
def test_empty_agent_embedding_setting_returns_not_configured(empty_search_proxy: _SearchProxy) -> None:
|
||||
response: Final = empty_search_proxy.owned.gateway.request("GET", "/v1/agents?query=echo")
|
||||
assert response.status_code == 400, response.text
|
||||
_assert_not_configured(
|
||||
JSON_OBJECT.validate_json(response.content),
|
||||
"agent_search_not_configured",
|
||||
_AGENT_NOT_CONFIGURED,
|
||||
)
|
||||
_assert_no_embedding_requests(empty_search_proxy)
|
||||
|
||||
|
||||
def test_empty_skill_embedding_setting_returns_not_configured(empty_search_proxy: _SearchProxy) -> None:
|
||||
response: Final = empty_search_proxy.owned.gateway.request(
|
||||
"GET", "/v1/skills?custom_llm_provider=litellm_proxy&query=echo"
|
||||
)
|
||||
assert response.status_code == 400, response.text
|
||||
_assert_not_configured(
|
||||
JSON_OBJECT.validate_json(response.content),
|
||||
"skill_search_not_configured",
|
||||
_SKILL_NOT_CONFIGURED,
|
||||
)
|
||||
_assert_no_embedding_requests(empty_search_proxy)
|
||||
|
||||
|
||||
def test_empty_agent_embedding_setting_returns_not_configured_through_mcp(empty_search_proxy: _SearchProxy) -> None:
|
||||
with empty_search_proxy.owned.gateway.scenario() as scenario:
|
||||
key: Final = scenario.key(
|
||||
object_permission={
|
||||
"agents": [empty_search_proxy.agent_id],
|
||||
"mcp_tool_search_enabled": True,
|
||||
},
|
||||
)
|
||||
_assert_mcp_search_not_configured(
|
||||
empty_search_proxy.owned.gateway,
|
||||
key,
|
||||
"agent_search",
|
||||
_AGENT_NOT_CONFIGURED,
|
||||
)
|
||||
_assert_no_embedding_requests(empty_search_proxy)
|
||||
|
||||
|
||||
def test_empty_skill_embedding_setting_returns_not_configured_through_mcp(empty_search_proxy: _SearchProxy) -> None:
|
||||
with empty_search_proxy.owned.gateway.scenario() as scenario:
|
||||
user_id: Final = scenario.user(user_role="proxy_admin")
|
||||
key: Final = scenario.key(user_id=user_id, object_permission={"mcp_tool_search_enabled": True})
|
||||
_assert_mcp_search_not_configured(
|
||||
empty_search_proxy.owned.gateway,
|
||||
key,
|
||||
"skill_search",
|
||||
_SKILL_NOT_CONFIGURED,
|
||||
)
|
||||
_assert_no_embedding_requests(empty_search_proxy)
|
||||
|
||||
|
||||
def test_configured_agent_embedding_model_returns_search_score(configured_search_proxy: _SearchProxy) -> None:
|
||||
configured_search_proxy.wire.drain()
|
||||
response: Final = configured_search_proxy.owned.gateway.request("GET", "/v1/agents?query=echo")
|
||||
assert response.status_code == 200, response.text
|
||||
agents: Final = _JSON_ARRAY.validate_json(response.content)
|
||||
assert len(agents) == 1
|
||||
agent: Final = object_value(agents[0])
|
||||
assert string_value(agent["agent_name"]) == "plain"
|
||||
assert isinstance(agent["search_score"], (int, float))
|
||||
embedding_requests: Final = tuple(
|
||||
request
|
||||
for request in configured_search_proxy.wire.drain()
|
||||
if request.method == "POST" and request.target == "/v1/embeddings"
|
||||
)
|
||||
assert embedding_requests
|
||||
|
||||
|
||||
def test_configured_skill_embedding_model_returns_search_score(configured_search_proxy: _SearchProxy) -> None:
|
||||
configured_search_proxy.wire.drain()
|
||||
response: Final = configured_search_proxy.owned.gateway.request(
|
||||
"GET", "/v1/skills?custom_llm_provider=litellm_proxy&query=echo"
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
skills: Final[Sequence[JsonValue]] = list_value(JSON_OBJECT.validate_json(response.content)["data"])
|
||||
matching_skills: Final = tuple(
|
||||
object_value(skill) for skill in skills if object_value(skill)["id"] == configured_search_proxy.skill_id
|
||||
)
|
||||
assert len(matching_skills) == 1
|
||||
assert isinstance(matching_skills[0]["search_score"], (int, float))
|
||||
embedding_requests: Final = tuple(
|
||||
request
|
||||
for request in configured_search_proxy.wire.drain()
|
||||
if request.method == "POST" and request.target == "/v1/embeddings"
|
||||
)
|
||||
assert embedding_requests
|
||||
|
|
@ -6,6 +6,7 @@ from collections.abc import Mapping
|
|||
from contextlib import ExitStack
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Final, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -24,6 +25,7 @@ from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_req
|
|||
from integration._support.mcp import (
|
||||
JsonRpc,
|
||||
McpCaller,
|
||||
McpPeer,
|
||||
Outcome,
|
||||
ScriptedTool,
|
||||
call_tool,
|
||||
|
|
@ -46,6 +48,34 @@ _OBJECTS: Final = TypeAdapter(Mapping[str, object])
|
|||
_STRINGS: Final = TypeAdapter(Mapping[str, str])
|
||||
|
||||
|
||||
@pytest.mark.timeout(30)
|
||||
def test_owned_http_peer_cleans_up_a_disconnected_tool_call() -> None:
|
||||
started: Final = Event()
|
||||
|
||||
async def hang_up(peer: McpPeer) -> None:
|
||||
async with (
|
||||
streamable_http_client(peer.url, terminate_on_close=False) as streams,
|
||||
ClientSession(streams[0], streams[1]) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
peer.drain()
|
||||
async with asyncio.TaskGroup() as tasks:
|
||||
running: Final = tasks.create_task(session.call_tool("slow", {"seconds": 60}))
|
||||
received: Final = await asyncio.to_thread(
|
||||
eventually, lambda: tool_calls(peer.drain()), bool, seconds=30
|
||||
)
|
||||
assert len(received) == 1, received
|
||||
params: Final = _OBJECTS.validate_python(_OBJECTS.validate_python(received[0]["body"])["params"])
|
||||
assert (params["name"], params["arguments"]) == ("slow", {"seconds": 60}), received
|
||||
assert await asyncio.to_thread(started.wait, 5), "The MCP tool handler never started"
|
||||
assert not running.done(), running.result().model_dump_json()
|
||||
running.cancel()
|
||||
assert running.cancelled()
|
||||
|
||||
with mcp_peer(rich=True, on_slow=started.set) as peer:
|
||||
asyncio.run(hang_up(peer))
|
||||
|
||||
|
||||
@pytest.mark.covers("mcp.call_tool.saved_headers.reach_actual_transport")
|
||||
def test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
|
|
|
|||
338
tests/integration/observability/test_ovalix_guardrail.py
Normal file
338
tests/integration/observability/test_ovalix_guardrail.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
"""Ovalix guardrail driven through a real proxy.
|
||||
|
||||
The Ovalix tracker is the only guardrail double: an owned wire peer that answers the `/tracking/beta` routes
|
||||
the way the live tracker does, including allowing a checkpoint call whose `actor` is empty without inspecting
|
||||
it. The provider is a second owned peer. The proxy, Postgres and Redis run for real with two workers.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, JsonValue, gateway_from_environment
|
||||
from integration._support.process import owned_proxy_process
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
|
||||
pytestmark: Final = pytest.mark.shares_worker_state
|
||||
|
||||
API_KEY: Final = "synthetic-ovalix-key"
|
||||
APP_ID: Final = "synthetic-ovalix-app"
|
||||
OTHER_APP: Final = "Synthetic Other App"
|
||||
PRE_CHECKPOINT: Final = "synthetic-pre-checkpoint"
|
||||
POST_CHECKPOINT: Final = "synthetic-post-checkpoint"
|
||||
KEYWORD: Final = "Project Falcon"
|
||||
NAME: Final = "David"
|
||||
DENIAL: Final = "synthetic ovalix denial"
|
||||
REPLY: Final = "synthetic provider reply"
|
||||
PINNED: Final = "ovalix-pinned"
|
||||
ENDPOINTS: Final = ("/v1/chat/completions", "/v1/messages", "/v1/responses")
|
||||
CHECKPOINT: Final = "/tracking/beta/checkpoint"
|
||||
OUTAGE_AFTER: Final = 6
|
||||
|
||||
|
||||
class _Outage:
|
||||
"""Takes the tracker double down once it has answered a set number of checkpoint calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock: Final = threading.Lock()
|
||||
self._remaining: int | None = None
|
||||
|
||||
def arm(self, answered: int) -> None:
|
||||
with self._lock:
|
||||
self._remaining = answered
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._remaining = None
|
||||
|
||||
def admits(self, path: str) -> bool:
|
||||
with self._lock:
|
||||
if self._remaining is None:
|
||||
return True
|
||||
if self._remaining == 0:
|
||||
return False
|
||||
if path == CHECKPOINT:
|
||||
self._remaining -= 1
|
||||
return True
|
||||
|
||||
|
||||
OUTAGE: Final = _Outage()
|
||||
|
||||
|
||||
def _checkpoint_verdict(body: dict[str, object]) -> dict[str, object]:
|
||||
data: Final = body["data"]
|
||||
content: Final = str(data.get("content", "")) if isinstance(data, dict) else ""
|
||||
if not body["actor"]:
|
||||
return {"action_type": "allow"}
|
||||
if KEYWORD in content:
|
||||
return {"action_type": "block", "modified_data": {"content": DENIAL}}
|
||||
if NAME in content:
|
||||
return {"action_type": "anonymize", "modified_data": {"content": content.replace(NAME, "{Name}")}}
|
||||
return {"action_type": "allow"}
|
||||
|
||||
|
||||
def _tracker(request: Request) -> Reply:
|
||||
if request.headers.get("x-api-key") != API_KEY:
|
||||
return Reply(status=401, body=b'{"detail": "missing api key"}')
|
||||
path: Final = urlsplit(request.target).path
|
||||
if not OUTAGE.admits(path):
|
||||
return Reply(status=503, body=b'{"detail": "tracker unavailable"}')
|
||||
if path != CHECKPOINT:
|
||||
return Reply(status=404, body=b'{"detail": "not found"}')
|
||||
return Reply(body=json.dumps(_checkpoint_verdict(json.loads(request.body))).encode())
|
||||
|
||||
|
||||
def _responses_reply(marker: str) -> dict[str, object]:
|
||||
return {
|
||||
"id": "resp_" + marker,
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": "completed",
|
||||
"model": "gpt-4o-mini",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_" + marker,
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": REPLY, "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
|
||||
|
||||
def _chat_reply(marker: str) -> dict[str, object]:
|
||||
return {
|
||||
"id": "chatcmpl-" + marker,
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": REPLY}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
|
||||
|
||||
def _provider(request: Request) -> Reply:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
is_responses: Final = urlsplit(request.target).path.endswith("/responses")
|
||||
return Reply(body=json.dumps(_responses_reply(marker) if is_responses else _chat_reply(marker)).encode())
|
||||
|
||||
|
||||
def _alias() -> str:
|
||||
return f"[{OTHER_APP}] integration key {uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _ovalix(name: str, tracker: str, **routing: str) -> dict[str, object]:
|
||||
return {
|
||||
"guardrail_name": name,
|
||||
"litellm_params": {
|
||||
"guardrail": "ovalix",
|
||||
"mode": "pre_call",
|
||||
"default_on": False,
|
||||
"tracker_api_base": tracker,
|
||||
"tracker_api_key": API_KEY,
|
||||
**routing,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Keys:
|
||||
no_user: str
|
||||
user_id: str
|
||||
with_user_id: str
|
||||
email: str
|
||||
with_email: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rig:
|
||||
proxy: Gateway
|
||||
tracker: Wire
|
||||
provider: Wire
|
||||
model: str
|
||||
keys: Keys
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
|
||||
root: Final = tmp_path_factory.mktemp("ovalix")
|
||||
with (
|
||||
gateway_from_environment() as gateway,
|
||||
wire_server(_tracker) as tracker,
|
||||
wire_server(_provider) as provider,
|
||||
):
|
||||
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
config["litellm_settings"]["cache"] = False
|
||||
config["guardrails"] = [
|
||||
_ovalix(
|
||||
PINNED,
|
||||
tracker.url,
|
||||
application_id=APP_ID,
|
||||
pre_checkpoint_id=PRE_CHECKPOINT,
|
||||
post_checkpoint_id=POST_CHECKPOINT,
|
||||
),
|
||||
]
|
||||
path: Final = root / "ovalix.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
with (
|
||||
owned_proxy_process(gateway, root, {}, config=path, workers=2) as owned,
|
||||
owned.gateway.scenario() as scenario,
|
||||
):
|
||||
model: Final = scenario.model(
|
||||
model="openai/gpt-4o-mini", api_base=provider.url + "/v1", api_key="synthetic-openai-key"
|
||||
)
|
||||
user_id: Final = scenario.user()
|
||||
email: Final = f"ovalix-{uuid.uuid4().hex}@example.com"
|
||||
keys: Final = Keys(
|
||||
no_user=scenario.key(key_alias=_alias()),
|
||||
user_id=user_id,
|
||||
with_user_id=scenario.key(key_alias=_alias(), user_id=user_id),
|
||||
email=email,
|
||||
with_email=scenario.key(key_alias=_alias(), user_id=scenario.user(user_email=email)),
|
||||
)
|
||||
yield Rig(owned.gateway, tracker, provider, model, keys)
|
||||
|
||||
|
||||
def _post(rig: Rig, endpoint: str, key: str, text: str, extra: dict[str, JsonValue] | None = None) -> httpx.Response:
|
||||
prompt: Final[dict[str, JsonValue]] = (
|
||||
{"input": text}
|
||||
if endpoint == "/v1/responses"
|
||||
else {"max_tokens": 64, "messages": [{"role": "user", "content": text}]}
|
||||
if endpoint == "/v1/messages"
|
||||
else {"messages": [{"role": "user", "content": text}]}
|
||||
)
|
||||
return rig.proxy.request(
|
||||
"POST", endpoint, {"model": rig.model, "guardrails": [PINNED], **prompt, **(extra or {})}, key=key
|
||||
)
|
||||
|
||||
|
||||
def _send(rig: Rig, endpoint: str, key: str, text: str, extra: dict[str, JsonValue] | None = None) -> httpx.Response:
|
||||
rig.tracker.drain()
|
||||
rig.provider.drain()
|
||||
return _post(rig, endpoint, key, text, extra)
|
||||
|
||||
|
||||
def _checkpoint_actors(rig: Rig) -> list[object]:
|
||||
return [
|
||||
json.loads(request.body)["actor"]
|
||||
for request in rig.tracker.drain()
|
||||
if urlsplit(request.target).path == CHECKPOINT
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", (False, True))
|
||||
@pytest.mark.parametrize("endpoint", ENDPOINTS)
|
||||
def test_a_prompt_from_a_key_with_no_user_is_inspected(rig: Rig, endpoint: str, stream: bool) -> None:
|
||||
response: Final = _send(
|
||||
rig, endpoint, rig.keys.no_user, f"Tell me about {KEYWORD} {uuid.uuid4()}", {"stream": stream}
|
||||
)
|
||||
|
||||
assert response.status_code == 400 and DENIAL in response.text, response.text
|
||||
assert rig.provider.drain() == ()
|
||||
assert _checkpoint_actors(rig) == ["unknown"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("endpoint", "client_metadata"),
|
||||
(("/v1/messages", {"user_id": "client-chosen"}), ("/v1/responses", {"client": "chosen"})),
|
||||
)
|
||||
def test_a_client_metadata_object_does_not_switch_inspection_off(
|
||||
rig: Rig, endpoint: str, client_metadata: dict[str, JsonValue]
|
||||
) -> None:
|
||||
response: Final = _send(
|
||||
rig,
|
||||
endpoint,
|
||||
rig.keys.with_user_id,
|
||||
f"Tell me about {KEYWORD} {uuid.uuid4()}",
|
||||
{"metadata": client_metadata},
|
||||
)
|
||||
|
||||
assert response.status_code == 400 and DENIAL in response.text, response.text
|
||||
assert rig.provider.drain() == ()
|
||||
assert _checkpoint_actors(rig) == ["unknown"]
|
||||
|
||||
|
||||
def test_a_name_from_a_key_with_no_user_reaches_the_provider_anonymized(rig: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
response: Final = _send(rig, "/v1/chat/completions", rig.keys.no_user, f"My name is {NAME} {marker}")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
sent: Final = [json.loads(request.body)["messages"][-1]["content"] for request in rig.provider.drain()]
|
||||
assert sent == [f"My name is {{Name}} {marker}"]
|
||||
|
||||
|
||||
def test_the_actor_is_the_key_users_email_then_their_id(rig: Rig) -> None:
|
||||
by_id: Final = _send(rig, "/v1/chat/completions", rig.keys.with_user_id, f"hello {uuid.uuid4()}")
|
||||
assert by_id.status_code == 200, by_id.text
|
||||
assert _checkpoint_actors(rig) == [rig.keys.user_id]
|
||||
|
||||
by_email: Final = _send(rig, "/v1/chat/completions", rig.keys.with_email, f"hello {uuid.uuid4()}")
|
||||
assert by_email.status_code == 200, by_email.text
|
||||
assert _checkpoint_actors(rig) == [rig.keys.email]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ENDPOINTS)
|
||||
def test_a_key_alias_naming_another_app_does_not_change_the_application_checked(rig: Rig, endpoint: str) -> None:
|
||||
response: Final = _send(rig, endpoint, rig.keys.with_email, f"Tell me about {KEYWORD} {uuid.uuid4()}")
|
||||
|
||||
assert response.status_code == 400 and DENIAL in response.text, response.text
|
||||
assert rig.provider.drain() == ()
|
||||
calls: Final = [(urlsplit(request.target).path, json.loads(request.body)) for request in rig.tracker.drain()]
|
||||
assert [
|
||||
(path, body["application_id"], body["checkpoint_id"], "application_name" in body) for path, body in calls
|
||||
] == [(CHECKPOINT, APP_ID, PRE_CHECKPOINT, False)]
|
||||
|
||||
|
||||
def _burst(rig: Rig) -> tuple[httpx.Response, ...]:
|
||||
plan: Final = tuple(product(ENDPOINTS, (False, True), range(4)))
|
||||
with ThreadPoolExecutor(max_workers=12) as pool:
|
||||
return tuple(
|
||||
pool.map(
|
||||
lambda cell: _post(
|
||||
rig,
|
||||
cell[0],
|
||||
rig.keys.no_user,
|
||||
f"Tell me about {KEYWORD} {uuid.uuid4()}",
|
||||
{"stream": cell[1]},
|
||||
),
|
||||
plan,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_a_tracker_outage_mid_burst_fails_closed_and_recovers(rig: Rig) -> None:
|
||||
rig.tracker.drain()
|
||||
rig.provider.drain()
|
||||
OUTAGE.arm(OUTAGE_AFTER)
|
||||
try:
|
||||
during: Final = _burst(rig)
|
||||
finally:
|
||||
OUTAGE.clear()
|
||||
|
||||
denied: Final = [response for response in during if response.status_code == 400 and DENIAL in response.text]
|
||||
failed: Final = [response for response in during if response.status_code >= 400 and DENIAL not in response.text]
|
||||
assert len(denied) == OUTAGE_AFTER, [response.text for response in during]
|
||||
assert len(failed) == len(during) - OUTAGE_AFTER, [response.text for response in during]
|
||||
assert all("Ovalix guardrail error" in response.text for response in failed), [response.text for response in failed]
|
||||
assert rig.provider.drain() == ()
|
||||
assert set(_checkpoint_actors(rig)) == {"unknown"}
|
||||
|
||||
after: Final = _burst(rig)
|
||||
|
||||
assert all(response.status_code == 400 and DENIAL in response.text for response in after), [
|
||||
response.text for response in after
|
||||
]
|
||||
assert rig.provider.drain() == ()
|
||||
assert _checkpoint_actors(rig) == ["unknown"] * len(after)
|
||||
|
|
@ -8,7 +8,7 @@ import threading
|
|||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import FrameType
|
||||
from types import FrameType, MappingProxyType
|
||||
from typing import Final, TypeVar
|
||||
|
||||
import pytest
|
||||
|
|
@ -28,12 +28,23 @@ _BURST_THREADS: Final = 8
|
|||
_BURST_CALLS: Final = 24
|
||||
_CHURN_UPSERTS: Final = 120
|
||||
_UNREPORTED_MODEL_ID: Final = frozenset({"aresponses-stream", "aanthropic-messages"})
|
||||
_ROUTER_FILE: Final = Router.__init__.__code__.co_filename
|
||||
_INDEX_LOCK_LINES: Final = frozenset(
|
||||
number
|
||||
for number, line in enumerate(linecache.getlines(_ROUTER_FILE), start=1)
|
||||
if "with self._deployment_index_lock" in line
|
||||
)
|
||||
_REINDEX: Final = Router._update_deployment_indices_after_removal.__code__
|
||||
|
||||
|
||||
def _router_source_files() -> frozenset[str]:
|
||||
facade_files: Final = (sys.modules[cls.__module__].__file__ for cls in Router.__mro__ if cls is not object)
|
||||
return frozenset({_REINDEX.co_filename, *(path for path in facade_files if path is not None)})
|
||||
|
||||
|
||||
def _lock_lines(path: str) -> frozenset[int]:
|
||||
return frozenset(
|
||||
number
|
||||
for number, line in enumerate(linecache.getlines(path), start=1)
|
||||
if "with self._deployment_index_lock" in line
|
||||
)
|
||||
|
||||
|
||||
_INDEX_LOCK_LINES: Final = MappingProxyType({path: _lock_lines(path) for path in _router_source_files()})
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
|
@ -80,7 +91,6 @@ def _read_during_held_write(read: Callable[[], T], write: Callable[[], object])
|
|||
"""Run `read` on its own thread while `write` waits where the list has shrunk but the index maps have not caught
|
||||
up. The write resumes once `read` returns or reaches the router's index lock, so a read that skips the lock meets
|
||||
the half-updated list and a read that takes the lock waits for the write"""
|
||||
reindex: Final = Router._update_deployment_indices_after_removal.__code__
|
||||
held: Final = threading.Event()
|
||||
release: Final = threading.Event()
|
||||
settled: Final = threading.Event()
|
||||
|
|
@ -89,7 +99,7 @@ def _read_during_held_write(read: Callable[[], T], write: Callable[[], object])
|
|||
failure: Final[list[BaseException]] = []
|
||||
|
||||
def hold_at_reindex(frame: FrameType, event: str, _arg: object) -> None:
|
||||
if event == "call" and frame.f_code is reindex and not held.is_set():
|
||||
if event == "call" and frame.f_code is _REINDEX and not held.is_set():
|
||||
held.set()
|
||||
released_in_time.append(release.wait(timeout=_SYNC_SECONDS))
|
||||
|
||||
|
|
@ -102,9 +112,10 @@ def _read_during_held_write(read: Callable[[], T], write: Callable[[], object])
|
|||
sys.setprofile(previous)
|
||||
|
||||
def watch_read(frame: FrameType, event: str, _arg: object) -> Callable[..., object] | None:
|
||||
if frame.f_code.co_filename != _ROUTER_FILE:
|
||||
lock_lines: Final = _INDEX_LOCK_LINES.get(frame.f_code.co_filename)
|
||||
if lock_lines is None:
|
||||
return None
|
||||
if event == "line" and frame.f_lineno in _INDEX_LOCK_LINES:
|
||||
if event == "line" and frame.f_lineno in lock_lines:
|
||||
settled.set()
|
||||
return watch_read
|
||||
|
||||
|
|
|
|||
|
|
@ -152,15 +152,14 @@ def _place_delta(partial_json: str) -> SSEFrame:
|
|||
)
|
||||
|
||||
|
||||
def _place_chunk(delta: dict[str, JsonValue], *, finish_reason: str | None = None) -> SSEFrame:
|
||||
finish: Final = {} if finish_reason is None else {"finish_reason": finish_reason}
|
||||
def _place_chunk(delta: dict[str, JsonValue]) -> SSEFrame:
|
||||
return SSEFrame(
|
||||
data={
|
||||
"id": ANY,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": ANY,
|
||||
"model": "anthropic/claude-haiku-5-5",
|
||||
"choices": [{"index": 0, "delta": delta, **finish}],
|
||||
"choices": [{"index": 0, "delta": delta}],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -245,7 +244,19 @@ CLAUDE_HAIKU_5_5_JSON_SCHEMA_CUT_STREAM_TEST_CASE: Final = StreamingTranslationT
|
|||
_place_chunk({"content": 'try": "'}),
|
||||
_place_chunk({"content": 'France"'}),
|
||||
_place_chunk({"content": "}"}),
|
||||
_place_chunk({}, finish_reason="stop"),
|
||||
SSEFrame(
|
||||
data={
|
||||
"error": {
|
||||
"message": (
|
||||
"litellm.APIConnectionError: AnthropicException - "
|
||||
"Stream ended before a finish_reason was received"
|
||||
),
|
||||
"type": "internal_server_error",
|
||||
"param": None,
|
||||
"code": "500",
|
||||
}
|
||||
}
|
||||
),
|
||||
SSEFrame(data="[DONE]"),
|
||||
),
|
||||
expected_litellm_response={
|
||||
|
|
@ -257,7 +268,6 @@ CLAUDE_HAIKU_5_5_JSON_SCHEMA_CUT_STREAM_TEST_CASE: Final = StreamingTranslationT
|
|||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": '{"city": "Paris", "country": "France"}'},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -129,8 +129,9 @@ def _join_stream_frames(endpoint: str, frames: tuple[SSEFrame, ...]) -> Mapping[
|
|||
case "/v1/chat/completions":
|
||||
state: Final = ChatCompletionStreamState()
|
||||
for frame in frames:
|
||||
if frame.data != "[DONE]":
|
||||
state.handle_chunk(ChatCompletionChunk.model_validate(frame.data))
|
||||
if frame.data == "[DONE]" or (isinstance(frame.data, Mapping) and "error" in frame.data):
|
||||
continue
|
||||
state.handle_chunk(ChatCompletionChunk.model_validate(frame.data))
|
||||
return JSON_OBJECT.validate_python(state.get_final_completion().model_dump(mode="json", exclude_none=True))
|
||||
case _:
|
||||
raise AssertionError(f"no stream accumulator for {endpoint}")
|
||||
|
|
|
|||
|
|
@ -290,7 +290,11 @@ def _router(redis_cache: FakeRedisCache, routing_strategy: str = "usage-based-ro
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_armed_routing_read_rides_the_admission_pipeline_and_routing_issues_no_read_of_its_own():
|
||||
async def test_armed_routing_read_rides_the_admission_pipeline_and_routing_issues_no_read_of_its_own(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"litellm.router_strategy.lowest_tpm_rpm_v2.get_utc_datetime",
|
||||
lambda: datetime(2026, 1, 1, 12, 0, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
client = FakeClient(_lua_ok_replies)
|
||||
redis_cache = FakeRedisCache(client)
|
||||
router = _router(redis_cache)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ async def test_apply_guardrail_endpoint_returns_correct_response(
|
|||
mock_proxy_logging_ctx(),
|
||||
):
|
||||
# Create a mock guardrail
|
||||
mock_guardrail = Mock(spec=CustomGuardrail)
|
||||
mock_guardrail = Mock(spec=CustomGuardrail, event_hook=None)
|
||||
# Apply guardrail returns GenericGuardrailAPIInputs (dict with texts key)
|
||||
mock_guardrail.apply_guardrail = AsyncMock(
|
||||
return_value={"texts": ["Redacted text: [REDACTED] and [REDACTED]"]}
|
||||
|
|
@ -116,7 +116,7 @@ async def test_apply_guardrail_endpoint_with_presidio_guardrail(mock_proxy_loggi
|
|||
mock_proxy_logging_ctx(),
|
||||
):
|
||||
# Create a mock guardrail that simulates Presidio behavior
|
||||
mock_guardrail = Mock(spec=CustomGuardrail)
|
||||
mock_guardrail = Mock(spec=CustomGuardrail, event_hook=None)
|
||||
# Simulate masking PII entities - returns GenericGuardrailAPIInputs (dict with texts key)
|
||||
mock_guardrail.apply_guardrail = AsyncMock(
|
||||
return_value={
|
||||
|
|
@ -168,7 +168,7 @@ async def test_apply_guardrail_endpoint_without_optional_params(mock_proxy_loggi
|
|||
mock_proxy_logging_ctx(),
|
||||
):
|
||||
# Create a mock guardrail
|
||||
mock_guardrail = Mock(spec=CustomGuardrail)
|
||||
mock_guardrail = Mock(spec=CustomGuardrail, event_hook=None)
|
||||
# Returns GenericGuardrailAPIInputs (dict with texts key)
|
||||
mock_guardrail.apply_guardrail = AsyncMock(
|
||||
return_value={"texts": ["Processed text"]}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,39 @@ async def test_mcp_notification_ordering_preserves_other_responses(
|
|||
assert outgoing == list(messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_http_disconnect_cancels_an_in_flight_handler(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
|
||||
from integration._support.mcp import _cancel_on_disconnect
|
||||
from starlette.types import Receive, Send
|
||||
|
||||
started: Final = asyncio.Event()
|
||||
stopped: Final = asyncio.Event()
|
||||
body: Final[Message] = {"type": "http.request", "body": b"tool-call", "more_body": False}
|
||||
pending: Final = iter((body,))
|
||||
|
||||
async def receive() -> Message:
|
||||
message: Final = next(pending, None)
|
||||
if message is not None:
|
||||
return message
|
||||
await started.wait()
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pytest.fail(f"Disconnected handler sent a response: {message}")
|
||||
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
assert await receive() == body
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Future[None]()
|
||||
finally:
|
||||
stopped.set()
|
||||
|
||||
await asyncio.wait_for(_cancel_on_disconnect(app)({"type": "http"}, receive, send), 2)
|
||||
assert stopped.is_set(), "The disconnected HTTP handler was left running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure", (RuntimeError("dispatch failed"), asyncio.CancelledError()))
|
||||
async def test_mcp_notification_dispatch_failure_is_not_acknowledged(
|
||||
|
|
|
|||
|
|
@ -5042,3 +5042,50 @@ def test_cache_control_hook_injects_next_to_a_malformed_message_instead_of_raisi
|
|||
{"type": "ephemeral"},
|
||||
{"type": "ephemeral"},
|
||||
]
|
||||
|
||||
|
||||
def _search_conversation_with(follow_up: dict[str, object]) -> list[AllMessageValues]:
|
||||
return [
|
||||
cast(AllMessageValues, {"role": "user", "content": "Weather in Paris?"}),
|
||||
_replayed_search_call(_SEARCH_CALL),
|
||||
cast(AllMessageValues, follow_up),
|
||||
cast(AllMessageValues, _GO_ON),
|
||||
]
|
||||
|
||||
|
||||
def _cache_marks_after_injection(messages: list[AllMessageValues]) -> tuple[int, tuple[int, ...]]:
|
||||
_, processed, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
|
||||
model="anthropic/claude-opus-5-5",
|
||||
messages=messages,
|
||||
non_default_params={"cache_control_injection_points": [{"location": "message", "role": "user"}]},
|
||||
prompt_id=None,
|
||||
prompt_variables=None,
|
||||
dynamic_callback_params={},
|
||||
)
|
||||
return (
|
||||
AnthropicCacheControlHook.count_request_cache_breakpoints(messages),
|
||||
tuple(_marks_in(message) for message in processed),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("malformed", "well_formed"),
|
||||
[
|
||||
pytest.param(
|
||||
{**_SEARCH_ANSWER, "tool_call_id": {"id": "srvtoolu_01Search"}},
|
||||
{**_SEARCH_ANSWER, "tool_call_id": "toolu_01Unrelated"},
|
||||
id="object_tool_call_id",
|
||||
),
|
||||
pytest.param(
|
||||
{**_SEARCH_ANSWER, "tool_call_id": ["srvtoolu_01Search"]},
|
||||
{**_SEARCH_ANSWER, "tool_call_id": "toolu_01Unrelated"},
|
||||
id="list_tool_call_id",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_cache_control_hook_reads_a_non_string_answer_id_as_answering_no_tool_call(
|
||||
malformed: dict[str, object], well_formed: dict[str, object]
|
||||
):
|
||||
assert _cache_marks_after_injection(_search_conversation_with(malformed)) == _cache_marks_after_injection(
|
||||
_search_conversation_with(well_formed)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ Regression coverage for the guardrail route table bugs:
|
|||
take call_types[0] to a handler-less type
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from litellm.litellm_core_utils.api_route_to_call_types import (
|
||||
get_call_types_for_route,
|
||||
)
|
||||
|
|
@ -103,6 +105,16 @@ class TestExistingRouteResolutionUnchanged:
|
|||
assert call_types is not None
|
||||
assert list(call_types) == [CallTypes.asend_message, CallTypes.send_message]
|
||||
|
||||
def test_single_segment_placeholder_message_stream_resolves(self):
|
||||
call_types: Final = get_call_types_for_route("/a2a/my-agent/message/stream")
|
||||
assert call_types is not None
|
||||
assert list(call_types) == [CallTypes.asend_message, CallTypes.send_message]
|
||||
|
||||
def test_v1_single_segment_placeholder_message_stream_resolves(self):
|
||||
call_types: Final = get_call_types_for_route("/v1/a2a/my-agent/message/stream")
|
||||
assert call_types is not None
|
||||
assert list(call_types) == [CallTypes.asend_message, CallTypes.send_message]
|
||||
|
||||
def test_longer_route_does_not_collapse_into_bare_placeholder_pattern(self):
|
||||
call_types = get_call_types_for_route("/responses/resp_123/input_items")
|
||||
assert call_types is not None
|
||||
|
|
|
|||
|
|
@ -319,6 +319,12 @@ class TestMapFinishReasonCohere:
|
|||
def test_error(self):
|
||||
assert map_finish_reason("ERROR") == "stop"
|
||||
|
||||
def test_tool_call(self):
|
||||
assert map_finish_reason("TOOL_CALL") == "tool_calls"
|
||||
|
||||
def test_stop_sequence(self):
|
||||
assert map_finish_reason("STOP_SEQUENCE") == "stop"
|
||||
|
||||
|
||||
class TestMapFinishReasonHuggingFace:
|
||||
def test_eos_token(self):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import json
|
||||
import time
|
||||
import tracemalloc
|
||||
from collections.abc import Iterator
|
||||
from typing import Final
|
||||
from unittest.mock import patch
|
||||
|
|
@ -193,34 +192,15 @@ def _streamed(fragments: tuple[str, ...]) -> JSONFragmentAccumulator:
|
|||
return accumulator
|
||||
|
||||
|
||||
def _drain_peak_bytes(fragments: tuple[str, ...]) -> int:
|
||||
accumulator: Final = _streamed(fragments)
|
||||
first_found, _ = accumulator.pop_next_value()
|
||||
assert first_found is True
|
||||
already_tracing: Final = tracemalloc.is_tracing()
|
||||
if not already_tracing:
|
||||
tracemalloc.start()
|
||||
tracemalloc.reset_peak()
|
||||
baseline, _ = tracemalloc.get_traced_memory()
|
||||
try:
|
||||
drained: Final = sum(1 for _ in _popped_values(accumulator))
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
if not already_tracing:
|
||||
tracemalloc.stop()
|
||||
assert drained == len(fragments) - 1
|
||||
return peak - baseline
|
||||
|
||||
|
||||
def test_draining_many_concatenated_values_never_copies_the_remainder():
|
||||
def test_draining_many_concatenated_values_yields_every_value_in_order():
|
||||
value: Final = '{"text": "' + "x" * 20 + '"}'
|
||||
fragments: Final = tuple(value if index % 2 else f" {value}" for index in range(40_000))
|
||||
payload_bytes: Final = sum(len(fragment) for fragment in fragments)
|
||||
peak_bytes: Final = _drain_peak_bytes(fragments)
|
||||
assert peak_bytes < payload_bytes // 4, (
|
||||
f"draining {len(fragments)} values allocated a peak of {peak_bytes} bytes over a {payload_bytes} byte payload, "
|
||||
"copying the remainder on every pop?"
|
||||
)
|
||||
accumulator: Final = _streamed(fragments)
|
||||
|
||||
drained: Final = tuple(_popped_values(accumulator))
|
||||
|
||||
assert drained == (json.loads(value),) * len(fragments)
|
||||
assert accumulator.pop_next_value() == (False, None)
|
||||
|
||||
|
||||
def test_could_close_json_after_many_blank_fragments_is_not_quadratic():
|
||||
|
|
|
|||
|
|
@ -7176,6 +7176,80 @@ def test_rebuilding_a_priced_stream_bills_the_margin_once(monkeypatch: pytest.Mo
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||||
@pytest.mark.parametrize("has_served_model", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
("wrapper_provider", "logging_provider"),
|
||||
[("openrouter", "openrouter"), ("cached_response", "openrouter"),
|
||||
("openai", "openai"), ("cached_response", "openai")],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_served_model_survives_blank_chunks_usage_and_cache_replay(
|
||||
sync_mode: bool, has_served_model: bool, wrapper_provider: str, logging_provider: str
|
||||
) -> None:
|
||||
selected_model: Final = "selected-model" if has_served_model else ""
|
||||
expected_model: Final = selected_model if logging_provider == "openrouter" and selected_model else "auto"
|
||||
logging_obj: Final = Logging(
|
||||
model="auto",
|
||||
messages=[{"role": "user", "content": "Say hi"}],
|
||||
stream=True,
|
||||
call_type="completion" if sync_mode else "acompletion",
|
||||
start_time=1.0,
|
||||
litellm_call_id="openrouter-model-identity",
|
||||
function_id="test",
|
||||
)
|
||||
logging_obj.stream_options = {"include_usage": True}
|
||||
logging_obj.model_call_details["custom_llm_provider"] = (
|
||||
None if wrapper_provider == "cached_response" else logging_provider
|
||||
)
|
||||
chunks: Final = [
|
||||
ModelResponseStream(
|
||||
id="served-stream", created=1, model=None,
|
||||
choices=[StreamingChoices(index=0, delta=Delta(role="assistant"))],
|
||||
),
|
||||
ModelResponseStream(
|
||||
id="served-stream", created=1, model=selected_model,
|
||||
choices=[StreamingChoices(index=0, delta=Delta(content="hello"))],
|
||||
usage=Usage(prompt_tokens=3, completion_tokens=2, total_tokens=5),
|
||||
),
|
||||
ModelResponseStream(
|
||||
id="served-stream", created=1, model=None,
|
||||
choices=[StreamingChoices(index=0, delta=Delta(content=" world"))],
|
||||
),
|
||||
ModelResponseStream(
|
||||
id="served-stream", created=1, model="",
|
||||
choices=[StreamingChoices(index=0, delta=Delta(), finish_reason="stop")],
|
||||
),
|
||||
ModelResponseStream(
|
||||
id="served-stream", created=1, model=None, choices=[],
|
||||
usage=Usage(prompt_tokens=3, completion_tokens=2, total_tokens=5),
|
||||
),
|
||||
]
|
||||
wrapper: Final = CustomStreamWrapper(
|
||||
completion_stream=ModelResponseListIterator(model_responses=chunks),
|
||||
model="auto", logging_obj=logging_obj, custom_llm_provider=wrapper_provider,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params={}, optional_params={}, model="auto", custom_llm_provider=logging_provider,
|
||||
)
|
||||
first: Final = next(wrapper) if sync_mode else await wrapper.__anext__()
|
||||
second: Final = next(wrapper) if sync_mode else await wrapper.__anext__()
|
||||
partial: Final = wrapper.partial_response()
|
||||
emitted: Final = [first, second, *(await _collect(wrapper, sync_mode))]
|
||||
assembled: Final = litellm.stream_chunk_builder(chunks=emitted)
|
||||
|
||||
assert first.model == "auto"
|
||||
assert {chunk.model for chunk in emitted[1:]} == {expected_model}
|
||||
assert partial is not None and partial.model == expected_model
|
||||
assert assembled is not None and assembled.model == expected_model
|
||||
assert assembled.choices[0].message.content == "hello world"
|
||||
assert emitted[-1].choices == []
|
||||
assert emitted[-1].usage is not None and emitted[-1].usage.total_tokens == 5
|
||||
assert second._hidden_params.get("provider_response_model") == (selected_model or None)
|
||||
assert wrapper.model == logging_obj.model == logging_obj.model_call_details["model"] == "auto"
|
||||
|
||||
|
||||
def test_chunk_creator_keeps_provider_model_private_across_stream():
|
||||
from litellm.litellm_core_utils.add_retry_fallback_headers import (
|
||||
get_hidden_params_dict,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from litellm.constants import (
|
|||
from litellm.litellm_core_utils.prompt_templates.common_utils import encrypted_reasoning_signature
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt
|
||||
from litellm.llms.anthropic.chat import ModelResponseIterator
|
||||
from litellm.llms.anthropic.common_utils import process_anthropic_headers
|
||||
from litellm.llms.anthropic.common_utils import AnthropicError, process_anthropic_headers
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
from litellm.llms.base_llm.base_utils import ResponseFormatSchema, response_format_schema
|
||||
from litellm.llms.anthropic.messages.native.transformation import (
|
||||
|
|
@ -9922,6 +9922,382 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key():
|
|||
assert litellm_params["metadata"] == {}
|
||||
|
||||
|
||||
_KEPT_SEARCH_CALL: Final = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "srvtoolu_01Search",
|
||||
"type": "function",
|
||||
"function": {"name": "tool_search_tool_bm25", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
"provider_specific_fields": {
|
||||
"tool_results": [
|
||||
{
|
||||
"type": "tool_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_01Search",
|
||||
"content": {"type": "tool_search_tool_search_result", "tool_references": []},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
_WEATHER_CALL: Final = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "toolu_01", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}],
|
||||
}
|
||||
_ASK: Final = {"role": "user", "content": "Weather in Paris?"}
|
||||
|
||||
|
||||
def _search_answered_with(tool_call_id: object) -> list[dict[str, object]]:
|
||||
return [
|
||||
_ASK,
|
||||
_KEPT_SEARCH_CALL,
|
||||
{"role": "tool", "tool_call_id": tool_call_id, "content": "found get_weather"},
|
||||
{"role": "user", "content": "Go on"},
|
||||
]
|
||||
|
||||
|
||||
def _orphan_answer_with(tool_call_id: object) -> list[dict[str, object]]:
|
||||
return [
|
||||
_ASK,
|
||||
{"role": "assistant", "content": "Checking"},
|
||||
{"role": "tool", "tool_call_id": tool_call_id, "content": "sunny"},
|
||||
{"role": "user", "content": "Go on"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("messages", "field_path", "type_name"),
|
||||
[
|
||||
pytest.param(
|
||||
[
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
{"role": ["user"], "content": "Go on"},
|
||||
{"role": "system", "content": "Answer in one sentence."},
|
||||
],
|
||||
"messages[2].role",
|
||||
"list",
|
||||
id="list_role_before_a_later_system_message",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
{"role": ["user"], "content": "Go on"},
|
||||
],
|
||||
"messages[2].role",
|
||||
"list",
|
||||
id="list_role",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
{"role": 123, "content": "Go on"},
|
||||
{"role": "system", "content": "Answer in one sentence."},
|
||||
],
|
||||
"messages[2].role",
|
||||
"int",
|
||||
id="int_role_before_a_later_system_message",
|
||||
),
|
||||
pytest.param(
|
||||
[*_orphan_answer_with({"id": "toolu_01"})[:3], {"role": True, "content": "Go on"}],
|
||||
"messages[3].role",
|
||||
"bool",
|
||||
id="bool_role_after_an_orphan_object_tool_call_id",
|
||||
),
|
||||
pytest.param(
|
||||
_search_answered_with({"id": "srvtoolu_01Search"}),
|
||||
"messages[2].tool_call_id",
|
||||
"dict",
|
||||
id="object_tool_call_id_after_a_kept_server_result",
|
||||
),
|
||||
pytest.param(
|
||||
_search_answered_with(["srvtoolu_01Search"]),
|
||||
"messages[2].tool_call_id",
|
||||
"list",
|
||||
id="list_tool_call_id_after_a_kept_server_result",
|
||||
),
|
||||
pytest.param(
|
||||
[_ASK, _WEATHER_CALL, {"role": "tool", "tool_call_id": {"id": "toolu_01"}, "content": "sunny"}],
|
||||
"messages[2].tool_call_id",
|
||||
"dict",
|
||||
id="object_tool_call_id_answering_a_client_tool_call",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_ASK,
|
||||
_WEATHER_CALL,
|
||||
{"role": "function", "name": "get_weather", "tool_call_id": {"id": "toolu_01"}, "content": "sunny"},
|
||||
],
|
||||
"messages[2].tool_call_id",
|
||||
"dict",
|
||||
id="object_tool_call_id_on_a_function_message",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_ASK,
|
||||
{**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]},
|
||||
{"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"},
|
||||
],
|
||||
"messages[1].tool_calls[0].id",
|
||||
"dict",
|
||||
id="object_assistant_tool_call_id",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_ASK,
|
||||
{**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": 7}]},
|
||||
{"role": "tool", "tool_call_id": "7", "content": "sunny"},
|
||||
],
|
||||
"messages[1].tool_calls[0].id",
|
||||
"int",
|
||||
id="int_assistant_tool_call_id",
|
||||
),
|
||||
pytest.param(
|
||||
[*_orphan_answer_with({"id": "toolu_01"})[:3], {"role": ["user"], "content": "Go on"}],
|
||||
"messages[3].role",
|
||||
"list",
|
||||
id="list_role_after_an_orphan_object_tool_call_id",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
*_orphan_answer_with({"id": "toolu_01"})[:3],
|
||||
{**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]},
|
||||
{"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"},
|
||||
],
|
||||
"messages[3].tool_calls[0].id",
|
||||
"dict",
|
||||
id="object_assistant_tool_call_id_after_an_orphan_object_tool_call_id",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{**_ASK, "tool_call_id": {"id": "stray"}},
|
||||
_WEATHER_CALL,
|
||||
{"role": "tool", "tool_call_id": {"id": "toolu_01"}, "content": "sunny"},
|
||||
],
|
||||
"messages[2].tool_call_id",
|
||||
"dict",
|
||||
id="object_tool_call_id_after_a_stray_id_on_a_user_message",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{**_ASK, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "stray"}}]},
|
||||
{**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]},
|
||||
{"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"},
|
||||
],
|
||||
"messages[1].tool_calls[0].id",
|
||||
"dict",
|
||||
id="object_assistant_tool_call_id_after_stray_tool_calls_on_a_user_message",
|
||||
),
|
||||
pytest.param(
|
||||
[_ASK, {"role": [], "content": "ok"}, {"role": "system", "content": "Be terse."}],
|
||||
"messages[1].role",
|
||||
"list",
|
||||
id="empty_list_role_before_a_system_message",
|
||||
),
|
||||
pytest.param(
|
||||
[_ASK, {"role": {}, "content": "ok"}, {"role": "user", "content": "again"}],
|
||||
"messages[1].role",
|
||||
"dict",
|
||||
id="empty_object_role",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("modify_params", [False, True])
|
||||
@pytest.mark.parametrize("model", [UNFLAGGED_CLAUDE, FLAGGED_CLAUDE])
|
||||
def test_chat_transform_request_answers_400_naming_a_non_string_role_or_tool_call_id(
|
||||
local_model_cost_map,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
messages: list[dict[str, object]],
|
||||
field_path: str,
|
||||
type_name: str,
|
||||
modify_params: bool,
|
||||
model: str,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "modify_params", modify_params)
|
||||
|
||||
with pytest.raises(AnthropicError) as raised:
|
||||
_chat_request(AnthropicConfig(), model, copy.deepcopy(messages))
|
||||
|
||||
assert (raised.value.status_code, raised.value.message) == (
|
||||
400,
|
||||
f"Invalid type for '{field_path}': expected a string, got {type_name}",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("role", "type_name"), [(["user"], "list"), ({"name": "user"}, "dict"), ([], "list")])
|
||||
def test_chat_transform_request_names_a_non_string_role_on_a_cache_marked_message_under_drop_params(
|
||||
local_model_cost_map, role: object, type_name: str
|
||||
):
|
||||
marked: Final = {"role": role, "content": [{"type": "text", "text": "ok"}], "cache_control": {"type": "ephemeral"}}
|
||||
|
||||
with pytest.raises(AnthropicError) as raised:
|
||||
AnthropicConfig().transform_request(
|
||||
model=UNFLAGGED_CLAUDE,
|
||||
messages=[_ASK, marked],
|
||||
optional_params={},
|
||||
litellm_params={"drop_params": True},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert (raised.value.status_code, raised.value.message) == (
|
||||
400,
|
||||
f"Invalid type for 'messages[1].role': expected a string, got {type_name}",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("malformed", "well_formed", "modify_params"),
|
||||
[
|
||||
pytest.param(
|
||||
[{**_ASK, "tool_call_id": {"id": "stray"}}], [_ASK], False, id="stray_tool_call_id_on_a_user_message"
|
||||
),
|
||||
pytest.param(
|
||||
[{**_ASK, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]}],
|
||||
[_ASK],
|
||||
False,
|
||||
id="stray_tool_calls_on_a_user_message",
|
||||
),
|
||||
pytest.param(
|
||||
_orphan_answer_with({"id": "toolu_01"}),
|
||||
_orphan_answer_with("toolu_01"),
|
||||
True,
|
||||
id="orphan_answer_dropped_under_modify_params",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_chat_transform_request_keeps_converting_a_non_string_id_it_never_reads(
|
||||
local_model_cost_map,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
malformed: list[dict[str, object]],
|
||||
well_formed: list[dict[str, object]],
|
||||
modify_params: bool,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "modify_params", modify_params)
|
||||
|
||||
converted: Final = _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, copy.deepcopy(malformed))
|
||||
|
||||
assert (
|
||||
converted["messages"]
|
||||
== _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, copy.deepcopy(well_formed))["messages"]
|
||||
)
|
||||
|
||||
|
||||
def test_chat_transform_request_keeps_a_conversion_error_that_a_dropped_orphan_answer_did_not_cause(
|
||||
local_model_cost_map, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "modify_params", True)
|
||||
|
||||
with pytest.raises(AnthropicError) as raised:
|
||||
_chat_request(
|
||||
AnthropicConfig(),
|
||||
UNFLAGGED_CLAUDE,
|
||||
[*_orphan_answer_with({"id": "toolu_01"})[:3], {"role": "bogus", "content": "Go on"}],
|
||||
)
|
||||
|
||||
assert raised.value.status_code == 400
|
||||
assert "Invalid Message passed in {'role': 'bogus'" in raised.value.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("later_turn", "real_cause"),
|
||||
[
|
||||
pytest.param(
|
||||
[{"role": "user", "content": ["hi", "there"]}],
|
||||
"'str' object has no attribute 'get'",
|
||||
id="string_content_parts",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{
|
||||
**_WEATHER_CALL,
|
||||
"tool_calls": [
|
||||
{
|
||||
**_WEATHER_CALL["tool_calls"][0],
|
||||
"function": {"name": "get_weather", "arguments": {"city": "Paris"}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_01", "content": "rain"},
|
||||
],
|
||||
"'dict' object has no attribute 'strip'",
|
||||
id="object_tool_call_arguments",
|
||||
),
|
||||
pytest.param(
|
||||
[{"role": "user", "content": [{"type": "image_url", "image_url": ["x"]}]}],
|
||||
"list indices must be integers or slices, not str",
|
||||
id="list_image_url",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("dropped_answer_id", [7, {"id": "toolu_01"}], ids=["int", "object"])
|
||||
def test_chat_transform_request_never_blames_a_dropped_tool_answer_id_for_another_failure(
|
||||
local_model_cost_map,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
later_turn: list[dict[str, object]],
|
||||
real_cause: str,
|
||||
dropped_answer_id: object,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "modify_params", True)
|
||||
|
||||
with pytest.raises(AnthropicError) as raised:
|
||||
_chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, [*_orphan_answer_with(dropped_answer_id)[:3], *later_turn])
|
||||
|
||||
assert raised.value.status_code == 400
|
||||
assert raised.value.message.startswith(real_cause)
|
||||
|
||||
|
||||
def test_chat_transform_request_never_blames_a_numeric_assistant_tool_call_id_for_an_earlier_failure(
|
||||
local_model_cost_map,
|
||||
):
|
||||
with pytest.raises(AnthropicError) as raised:
|
||||
_chat_request(
|
||||
AnthropicConfig(),
|
||||
UNFLAGGED_CLAUDE,
|
||||
[
|
||||
{"role": "user", "content": ["hi", "there"]},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": 5, "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "5", "content": "rain"},
|
||||
],
|
||||
)
|
||||
|
||||
assert raised.value.status_code == 400
|
||||
assert raised.value.message.startswith("'str' object has no attribute 'get'")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("empty_role", [0, False])
|
||||
def test_chat_transform_request_never_blames_an_empty_role_for_another_failure(local_model_cost_map, empty_role):
|
||||
list_part_type_after_an_empty_role: Final = [
|
||||
_ASK,
|
||||
{"role": empty_role, "content": "ok"},
|
||||
{"role": "user", "content": [{"type": ["text"], "text": "again"}]},
|
||||
{"role": "system", "content": "Be terse."},
|
||||
]
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
_chat_request(AnthropicConfig(), FLAGGED_CLAUDE, list_part_type_after_an_empty_role)
|
||||
|
||||
|
||||
def test_chat_transform_request_names_no_field_for_a_failure_outside_roles_and_tool_call_ids(local_model_cost_map):
|
||||
list_part_type: Final = [
|
||||
_ASK,
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": [{"type": ["text"], "text": "again"}]},
|
||||
{"role": "system", "content": "Be terse."},
|
||||
]
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
_chat_request(AnthropicConfig(), FLAGGED_CLAUDE, list_part_type)
|
||||
|
||||
|
||||
def _five_cache_hint_conversation() -> list[dict[str, object]]:
|
||||
return [
|
||||
{"role": "system", "content": [{"type": "text", "text": "system prompt", "cache_control": {"type": "ephemeral"}}]},
|
||||
|
|
|
|||
|
|
@ -614,9 +614,18 @@ def test_clear_tool_inputs_list_clears_only_the_named_tools():
|
|||
}
|
||||
|
||||
|
||||
def _history_with_first_block_field(index: int, field: str, value: object) -> list[dict[str, object]]:
|
||||
history: Final = _make_history(n_pairs=4)
|
||||
return [
|
||||
{**message, "content": [{**message["content"][0], field: value}, *message["content"][1:]]}
|
||||
if position == index
|
||||
else message
|
||||
for position, message in enumerate(history)
|
||||
]
|
||||
|
||||
|
||||
def test_a_tool_use_with_a_non_string_id_is_left_for_the_provider_to_reject():
|
||||
messages = _make_history(n_pairs=4)
|
||||
messages[1]["content"][0]["id"] = ["toolu_00"]
|
||||
messages: Final = _history_with_first_block_field(1, "id", ["toolu_00"])
|
||||
new_messages, applied = apply_clear_tool_uses_20250919(
|
||||
model=MODEL,
|
||||
messages=messages,
|
||||
|
|
@ -635,6 +644,24 @@ def test_a_tool_use_with_a_non_string_id_is_left_for_the_provider_to_reject():
|
|||
assert (new_messages[3]["content"][0]["input"], new_messages[5]["content"][0]["input"]) == ({}, {})
|
||||
|
||||
|
||||
def test_a_tool_result_with_a_non_string_tool_use_id_is_left_for_the_provider_to_reject():
|
||||
messages: Final = _history_with_first_block_field(2, "tool_use_id", ["toolu_00"])
|
||||
new_messages, applied = apply_clear_tool_uses_20250919(
|
||||
model=MODEL,
|
||||
messages=messages,
|
||||
tools=None,
|
||||
system=None,
|
||||
edit_spec={
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "tool_uses", "value": 0},
|
||||
"keep": {"type": "tool_uses", "value": 1},
|
||||
},
|
||||
)
|
||||
assert applied is not None
|
||||
assert new_messages[2] == messages[2]
|
||||
assert _cleared_ids(new_messages[3:]) == {"toolu_01", "toolu_02"}
|
||||
|
||||
|
||||
async def _malformed_spec_error(edits: list[dict[str, object]]) -> AnthropicContextManagementError:
|
||||
with pytest.raises(AnthropicContextManagementError) as raised:
|
||||
await apply_context_management(
|
||||
|
|
|
|||
|
|
@ -4472,6 +4472,17 @@ def test_file_content_conversion_keeps_a_line_whose_json_holds_unicode_line_sepa
|
|||
assert bedrock_record["modelInput"]["messages"] == [{"role": "user", "content": [{"text": text}]}]
|
||||
|
||||
|
||||
_CLAUDE_BODY_WITH_A_LIST_ROLE_BEFORE_A_SYSTEM_MESSAGE: Final = {
|
||||
"model": "bedrock/us.anthropic.claude-haiku-5-5",
|
||||
"max_tokens": 20,
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": ["user"], "content": "ok"},
|
||||
{"role": "system", "content": "Be terse."},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _batch_line(body: object) -> str:
|
||||
return json.dumps({**_VALID_BATCH_RECORD, "body": body})
|
||||
|
||||
|
|
@ -4516,6 +4527,11 @@ def _batch_line_with_body(field: str, value: object) -> str:
|
|||
"Batch input file line 2 could not be converted to a Bedrock batch record",
|
||||
id="string-messages",
|
||||
),
|
||||
pytest.param(
|
||||
f"{_VALID_BATCH_LINE}\n{_batch_line(_CLAUDE_BODY_WITH_A_LIST_ROLE_BEFORE_A_SYSTEM_MESSAGE)}\n".encode(),
|
||||
"Batch input file line 2 could not be converted to a Bedrock batch record",
|
||||
id="claude-list-role-before-a-system-message",
|
||||
),
|
||||
pytest.param(
|
||||
f"{_VALID_BATCH_LINE}\n{'[' * 1_000_000}{']' * 1_000_000}\n".encode(),
|
||||
"Batch input file line 2 could not be parsed",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import itertools
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Final
|
||||
|
|
@ -653,9 +654,7 @@ async def test_cohere_v2_annotations(respx_mock: respx.MockRouter) -> None:
|
|||
def test_cohere_v2_parameter_mapping(respx_mock: respx.MockRouter) -> None:
|
||||
messages: Final = [{"role": "user", "content": "Generate a creative story."}]
|
||||
route: Final = respx_mock.post(COHERE_V2_CHAT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
200, json=_cohere_v2_response({"content": [{"type": "text", "text": "A story."}]})
|
||||
)
|
||||
return_value=httpx.Response(200, json=_cohere_v2_response({"content": [{"type": "text", "text": "A story."}]}))
|
||||
)
|
||||
|
||||
litellm.completion(
|
||||
|
|
@ -705,3 +704,123 @@ def test_cohere_v2_error_handling(respx_mock: respx.MockRouter) -> None:
|
|||
assert error.value.status_code == 400
|
||||
assert error.value.llm_provider == "cohere"
|
||||
assert "invalid request: model 'invalid-model' not found" in error.value.message
|
||||
|
||||
|
||||
def _cohere_v2_tool_call_events(index: int, call_id: str, city: str) -> tuple[dict[str, object], ...]:
|
||||
return (
|
||||
{
|
||||
"type": "tool-call-start",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"message": {
|
||||
"tool_calls": {
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": ""},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "tool-call-delta",
|
||||
"index": index,
|
||||
"delta": {"message": {"tool_calls": {"function": {"arguments": '{"city": '}}}},
|
||||
},
|
||||
{
|
||||
"type": "tool-call-delta",
|
||||
"index": index,
|
||||
"delta": {"message": {"tool_calls": {"function": {"arguments": json.dumps(city) + "}"}}}},
|
||||
},
|
||||
{"type": "tool-call-end", "index": index},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_cohere_httpx_transport")
|
||||
@pytest.mark.respx(assert_all_called=True)
|
||||
async def test_cohere_v2_stream_returns_parallel_tool_calls(respx_mock: respx.MockRouter) -> None:
|
||||
stream_events: Final = (
|
||||
{"type": "message-start", "id": "c0a2b6e1", "delta": {"message": {"role": "assistant"}}},
|
||||
*_cohere_v2_tool_call_events(0, "get_weather_paris", "Paris"),
|
||||
*_cohere_v2_tool_call_events(1, "get_weather_tokyo", "Tokyo"),
|
||||
{
|
||||
"type": "message-end",
|
||||
"delta": {"finish_reason": "TOOL_CALL", "usage": {"tokens": {"input_tokens": 40, "output_tokens": 30}}},
|
||||
},
|
||||
)
|
||||
respx_mock.post(COHERE_V2_CHAT_URL).mock(
|
||||
return_value=httpx.Response(200, content="".join(json.dumps(event) + "\n" for event in stream_events).encode())
|
||||
)
|
||||
|
||||
response: Final = await litellm.acompletion(
|
||||
model="cohere_chat/v2/command-a-plus-05-2026",
|
||||
messages=[{"role": "user", "content": "What is the weather in Paris and in Tokyo?"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
},
|
||||
}
|
||||
],
|
||||
stream=True,
|
||||
api_key="cohere-test-key",
|
||||
)
|
||||
chunks: Final = [chunk async for chunk in response]
|
||||
rebuilt: Final = litellm.stream_chunk_builder(chunks)
|
||||
|
||||
assert rebuilt is not None
|
||||
assert [
|
||||
(call.id, call.function.name, json.loads(call.function.arguments))
|
||||
for call in rebuilt.choices[0].message.tool_calls
|
||||
] == [
|
||||
("get_weather_paris", "get_weather", {"city": "Paris"}),
|
||||
("get_weather_tokyo", "get_weather", {"city": "Tokyo"}),
|
||||
]
|
||||
assert chunks[-1].choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_cohere_httpx_transport")
|
||||
@pytest.mark.respx(assert_all_called=True)
|
||||
async def test_cohere_v2_stream_closes_argument_less_tool_call_with_empty_object(respx_mock: respx.MockRouter) -> None:
|
||||
stream_events: Final = (
|
||||
{"type": "message-start", "id": "5d1e9f20", "delta": {"message": {"role": "assistant"}}},
|
||||
{
|
||||
"type": "tool-call-start",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"message": {
|
||||
"tool_calls": {
|
||||
"id": "get_server_time_0",
|
||||
"type": "function",
|
||||
"function": {"name": "get_server_time", "arguments": ""},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{"type": "tool-call-end", "index": 0},
|
||||
{"type": "message-end", "delta": {"finish_reason": "TOOL_CALL"}},
|
||||
)
|
||||
respx_mock.post(COHERE_V2_CHAT_URL).mock(
|
||||
return_value=httpx.Response(200, content="".join(json.dumps(event) + "\n" for event in stream_events).encode())
|
||||
)
|
||||
|
||||
response: Final = await litellm.acompletion(
|
||||
model="cohere_chat/v2/command-a-plus-05-2026",
|
||||
messages=[{"role": "user", "content": "What time is it on the server?"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "get_server_time", "parameters": {"type": "object", "properties": {}}},
|
||||
}
|
||||
],
|
||||
stream=True,
|
||||
api_key="cohere-test-key",
|
||||
)
|
||||
deltas_per_chunk: Final = [chunk.choices[0].delta.tool_calls or () async for chunk in response]
|
||||
streamed_calls: Final = tuple(itertools.chain.from_iterable(deltas_per_chunk))
|
||||
|
||||
assert [(call.id, call.function.name) for call in streamed_calls if call.id] == [
|
||||
("get_server_time_0", "get_server_time")
|
||||
]
|
||||
assert json.loads("".join(call.function.arguments for call in streamed_calls)) == {}
|
||||
|
|
|
|||
|
|
@ -307,20 +307,12 @@ class TestBaseLLMAIOHTTPHandler:
|
|||
# Should not call aiohttp.ClientSession directly
|
||||
mock_client_session.assert_not_called()
|
||||
|
||||
@patch("aiohttp.ClientSession")
|
||||
def test_create_client_session_with_connector(self, mock_client_session):
|
||||
"""Test session creation using connector"""
|
||||
mock_connector = Mock(spec=aiohttp.BaseConnector)
|
||||
mock_session_instance = Mock()
|
||||
mock_client_session.return_value = mock_session_instance
|
||||
|
||||
handler = BaseLLMAIOHTTPHandler(connector=mock_connector)
|
||||
|
||||
result = handler._create_client_session_with_transport()
|
||||
|
||||
# Should create session with connector
|
||||
mock_client_session.assert_called_once_with(connector=mock_connector)
|
||||
assert result is mock_session_instance
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_client_session_with_connector(self) -> None:
|
||||
connector: Final = aiohttp.TCPConnector()
|
||||
handler: Final = BaseLLMAIOHTTPHandler(connector=connector)
|
||||
async with handler._create_client_session_with_transport() as session:
|
||||
assert session.connector is connector
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_client_session_default_honors_global_ssl_verify_false(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import asyncio, importlib, json, time
|
||||
import gc
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import ssl
|
||||
import threading
|
||||
import weakref
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import Context
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -15,7 +18,10 @@ import certifi
|
|||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from aiohttp import ClientSession, TCPConnector
|
||||
from aiohttp import ClientResponse, ClientSession, RequestInfo, TCPConnector
|
||||
from aiohttp.tracing import Trace
|
||||
from multidict import CIMultiDict, CIMultiDictProxy
|
||||
from yarl import URL
|
||||
|
||||
import litellm
|
||||
from litellm.caching.llm_caching_handler import LLMClientCache
|
||||
|
|
@ -2304,3 +2310,217 @@ async def test_async_timeout_reports_the_per_call_timeout_as_a_float_when_one_is
|
|||
await handler.close()
|
||||
|
||||
assert "Read timed out. Timeout passed=30.0, time taken=" in str(raised.value), str(raised.value)
|
||||
|
||||
|
||||
class _PoolDiagnosticClock:
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self.now = 1000.0
|
||||
self.loop = loop
|
||||
self.schedule = loop.call_later
|
||||
self.timers: list[asyncio.TimerHandle] = []
|
||||
|
||||
def time(self) -> float:
|
||||
return self.now
|
||||
|
||||
def call_later(
|
||||
self, delay: float, callback: Callable[..., object],
|
||||
*args: object, context: Context | None = None,
|
||||
) -> asyncio.TimerHandle:
|
||||
timer: Final = self.schedule(delay, callback, *args, context=context)
|
||||
self.timers.append(timer)
|
||||
return timer
|
||||
|
||||
async def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
checkpoint: Final[asyncio.Future[None]] = self.loop.create_future()
|
||||
self.loop.call_soon(checkpoint.set_result, None)
|
||||
await checkpoint
|
||||
|
||||
|
||||
def _pool_diagnostic_clock(monkeypatch: pytest.MonkeyPatch) -> _PoolDiagnosticClock:
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
clock: Final = _PoolDiagnosticClock(loop)
|
||||
monkeypatch.setattr(loop, "time", clock.time)
|
||||
monkeypatch.setattr(loop, "call_later", clock.call_later)
|
||||
return clock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _pool_diagnostic_session(source: str) -> AsyncIterator[ClientSession]:
|
||||
if source == "proxy":
|
||||
from litellm.proxy.proxy_server import initialize_shared_aiohttp_session
|
||||
|
||||
session: Final = await initialize_shared_aiohttp_session()
|
||||
assert session is not None
|
||||
async with session:
|
||||
yield session
|
||||
return
|
||||
if source == "native-connector":
|
||||
from litellm.llms.custom_httpx.aiohttp_handler import BaseLLMAIOHTTPHandler
|
||||
|
||||
handler: Final = BaseLLMAIOHTTPHandler(connector=TCPConnector(limit=7, limit_per_host=2))
|
||||
try:
|
||||
yield handler._get_async_client_session()
|
||||
finally:
|
||||
await handler.close()
|
||||
return
|
||||
transport: Final = AsyncHTTPHandler.create_aiohttp_transport()
|
||||
if source == "rebuilt":
|
||||
await transport.get_valid_client_session().close()
|
||||
try:
|
||||
yield transport.get_valid_client_session()
|
||||
finally:
|
||||
await transport.aclose()
|
||||
|
||||
|
||||
async def _pool_diagnostic_request(session: ClientSession, url: URL) -> tuple[Trace, ...]:
|
||||
traces: Final = tuple(Trace(session, config, config.trace_config_ctx()) for config in session.trace_configs)
|
||||
for trace in traces:
|
||||
await trace.send_request_start("POST", url, CIMultiDict())
|
||||
return traces
|
||||
|
||||
|
||||
def _pool_diagnostic_messages(caplog: pytest.LogCaptureFixture) -> tuple[str, ...]:
|
||||
return tuple(record.getMessage() for record in caplog.records if "connection pool" in record.getMessage())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["sdk", "proxy", "native-connector", "rebuilt"])
|
||||
@pytest.mark.parametrize("error", [TimeoutError("private timeout"), asyncio.CancelledError("private cancellation")])
|
||||
async def test_aiohttp_pool_failure_identifies_queue_wait_and_cleans_up_timer(
|
||||
source: str, error: BaseException, monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
pool_diagnostic_clock: Final = _pool_diagnostic_clock(monkeypatch)
|
||||
caplog.set_level(logging.WARNING, logger="LiteLLM")
|
||||
url: Final = URL("https://private-user:private-password@pool.test:8443/private-path?token=private-query")
|
||||
async with _pool_diagnostic_session(source) as session:
|
||||
connector: Final = session.connector
|
||||
assert connector is not None
|
||||
traces: Final = await _pool_diagnostic_request(session, url)
|
||||
for trace in traces:
|
||||
await trace.send_connection_queued_start()
|
||||
await pool_diagnostic_clock.advance(0.25)
|
||||
for trace in traces:
|
||||
await trace.send_request_exception("POST", url, CIMultiDict(), error)
|
||||
messages: Final = _pool_diagnostic_messages(caplog)
|
||||
assert len(messages) == 1, messages
|
||||
message: Final = messages[0]
|
||||
assert "host=pool.test" in message
|
||||
assert "port=8443" in message
|
||||
assert "0.25" in message
|
||||
assert "acquired_total=0" in message
|
||||
assert f"limit={connector.limit}" in message
|
||||
assert f"limit_per_host={connector.limit_per_host}" in message
|
||||
assert "private" not in message
|
||||
assert all(timer.cancelled() for timer in pool_diagnostic_clock.timers)
|
||||
await pool_diagnostic_clock.advance(2.0)
|
||||
assert _pool_diagnostic_messages(caplog) == messages
|
||||
|
||||
|
||||
@pytest.mark.parametrize("queued", [False, True])
|
||||
async def test_aiohttp_pool_diagnostics_do_not_label_slow_upstream_as_pool_saturation(
|
||||
queued: bool, monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
pool_diagnostic_clock: Final = _pool_diagnostic_clock(monkeypatch)
|
||||
caplog.set_level(logging.WARNING, logger="LiteLLM")
|
||||
url: Final = URL("https://healthy.test/completions")
|
||||
async with _pool_diagnostic_session("sdk") as session:
|
||||
traces: Final = await _pool_diagnostic_request(session, url)
|
||||
if queued:
|
||||
for trace in traces:
|
||||
await trace.send_connection_queued_start()
|
||||
await pool_diagnostic_clock.advance(0.25)
|
||||
for trace in traces:
|
||||
await trace.send_connection_queued_end()
|
||||
await pool_diagnostic_clock.advance(2.0)
|
||||
for trace in traces:
|
||||
await trace.send_request_exception("POST", url, CIMultiDict(), TimeoutError("slow upstream"))
|
||||
assert _pool_diagnostic_messages(caplog) == ()
|
||||
assert all(timer.cancelled() for timer in pool_diagnostic_clock.timers)
|
||||
|
||||
|
||||
async def test_aiohttp_pool_warns_while_queued_once_per_request_with_isolated_concurrent_waits(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
pool_diagnostic_clock: Final = _pool_diagnostic_clock(monkeypatch)
|
||||
caplog.set_level(logging.WARNING, logger="LiteLLM")
|
||||
slow_url: Final = URL("https://blocked.test/completions")
|
||||
fast_url: Final = URL("https://available.test/completions")
|
||||
async with _pool_diagnostic_session("sdk") as session:
|
||||
blocked: Final = await _pool_diagnostic_request(session, slow_url)
|
||||
available: Final = await _pool_diagnostic_request(session, fast_url)
|
||||
for trace in blocked + available:
|
||||
await trace.send_connection_queued_start()
|
||||
await pool_diagnostic_clock.advance(0.25)
|
||||
for trace in available:
|
||||
await trace.send_connection_queued_end()
|
||||
for trace in blocked:
|
||||
await trace.send_connection_queued_end()
|
||||
await trace.send_connection_queued_start()
|
||||
await pool_diagnostic_clock.advance(0.8)
|
||||
messages: Final = _pool_diagnostic_messages(caplog)
|
||||
assert len(messages) == 1, messages
|
||||
assert "host=blocked.test" in messages[0]
|
||||
assert "1.05" in messages[0]
|
||||
assert "available.test" not in messages[0]
|
||||
for trace in blocked:
|
||||
await trace.send_connection_queued_end()
|
||||
await trace.send_connection_queued_start()
|
||||
await pool_diagnostic_clock.advance(2.0)
|
||||
for trace in blocked:
|
||||
await trace.send_request_exception("POST", slow_url, CIMultiDict(), TimeoutError())
|
||||
for trace in available:
|
||||
await trace.send_request_exception("POST", fast_url, CIMultiDict(), TimeoutError())
|
||||
await pool_diagnostic_clock.advance(2.0)
|
||||
assert _pool_diagnostic_messages(caplog) == messages
|
||||
assert all(timer.cancelled() for timer in pool_diagnostic_clock.timers)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("header", "location", "host", "port"), [
|
||||
("Location", "https://redirected.test:9443/private-path?token=private-query", "redirected.test", 9443),
|
||||
("Location", "/private-path?token=private-query", "initial.test", 8443),
|
||||
("URI", "https://redirected.test/private-path", "redirected.test", 443),
|
||||
("Location", "http://[invalid", None, None),
|
||||
])
|
||||
async def test_aiohttp_pool_warning_tracks_redirect_destination_without_masking_invalid_redirects(
|
||||
header: str, location: str, host: str | None, port: int | None,
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
from aiohttp.client_proto import ResponseHandler
|
||||
from aiohttp.helpers import TimerNoop
|
||||
from aiohttp.http_writer import StreamWriter
|
||||
|
||||
clock: Final = _pool_diagnostic_clock(monkeypatch)
|
||||
caplog.set_level(logging.WARNING, logger="LiteLLM")
|
||||
url: Final = URL("https://initial.test:8443/completions")
|
||||
async with _pool_diagnostic_session("sdk") as session:
|
||||
traces: Final = await _pool_diagnostic_request(session, url)
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
response: Final = ClientResponse(
|
||||
"POST", url, writer=None, continue100=None, timer=TimerNoop(),
|
||||
request_info=RequestInfo(url, "POST", CIMultiDictProxy(CIMultiDict()), url),
|
||||
traces=[], loop=loop, session=session,
|
||||
stream_writer=StreamWriter(ResponseHandler(loop), loop),
|
||||
)
|
||||
response._headers = CIMultiDictProxy(CIMultiDict({header: location}))
|
||||
try:
|
||||
for trace in traces:
|
||||
await trace.send_request_redirect("POST", url, CIMultiDict(), response)
|
||||
if host is None:
|
||||
for trace in traces:
|
||||
await trace.send_request_exception("POST", url, CIMultiDict(), ValueError("invalid redirect"))
|
||||
assert _pool_diagnostic_messages(caplog) == ()
|
||||
return
|
||||
for trace in traces:
|
||||
await trace.send_connection_queued_start()
|
||||
await clock.advance(0.25)
|
||||
for trace in traces:
|
||||
await trace.send_request_exception("POST", url, CIMultiDict(), TimeoutError())
|
||||
messages: Final = _pool_diagnostic_messages(caplog)
|
||||
assert len(messages) == 1, messages
|
||||
assert f"host={host}" in messages[0]
|
||||
assert f"port={port}" in messages[0]
|
||||
assert "private" not in messages[0]
|
||||
finally:
|
||||
response.close()
|
||||
|
|
|
|||
|
|
@ -281,6 +281,36 @@ class TestSearchSkills:
|
|||
assert isinstance(outcome, SkillSearchNotConfigured)
|
||||
assert "skill_search_embedding_model" in outcome.reason
|
||||
|
||||
@pytest.mark.parametrize("embedding_model", ("", " "), ids=("empty", "whitespace"))
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_embedding_model_is_not_configured(self, embedding_model: str) -> None:
|
||||
router: Final = _embedding_router()
|
||||
not_configured: Final = await search_skills(
|
||||
"language translation",
|
||||
SKILLS,
|
||||
5,
|
||||
router=router,
|
||||
embedding_model=None,
|
||||
index=SkillSearchIndex(),
|
||||
user_api_key_dict=CALLER,
|
||||
proxy_logging_obj=_pass_through_key_limits(),
|
||||
)
|
||||
outcome: Final = await search_skills(
|
||||
"language translation",
|
||||
SKILLS,
|
||||
5,
|
||||
router=router,
|
||||
embedding_model=embedding_model,
|
||||
index=SkillSearchIndex(),
|
||||
user_api_key_dict=CALLER,
|
||||
proxy_logging_obj=_pass_through_key_limits(),
|
||||
)
|
||||
|
||||
assert isinstance(not_configured, SkillSearchNotConfigured)
|
||||
assert isinstance(outcome, SkillSearchNotConfigured)
|
||||
assert outcome.reason == not_configured.reason
|
||||
router.aembedding.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_router_is_not_configured(self) -> None:
|
||||
outcome = await search_skills(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import pytest
|
|||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
|
||||
from litellm.llms.openrouter.chat.transformation import (
|
||||
OpenRouterChatCompletionStreamingHandler,
|
||||
|
|
@ -742,7 +742,11 @@ def test_openrouter_completion_with_non_object_usage_raises_api_connection_error
|
|||
|
||||
|
||||
@respx.mock
|
||||
def test_openrouter_streaming_completion_assembles_text_and_finish_reason() -> None:
|
||||
@pytest.mark.parametrize("async_stream", [False, True])
|
||||
@pytest.mark.parametrize("requested_model", ["openrouter/openai/gpt-4o-mini", "openrouter/auto"])
|
||||
async def test_openrouter_streaming_completion_assembles_text_and_finish_reason(
|
||||
async_stream: bool, requested_model: str
|
||||
) -> None:
|
||||
chunks: Final = (
|
||||
{
|
||||
"id": "gen-stream-model",
|
||||
|
|
@ -785,14 +789,20 @@ def test_openrouter_streaming_completion_assembles_text_and_finish_reason() -> N
|
|||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "How does a case reach the Supreme Court?"},
|
||||
]
|
||||
stream: Final = litellm.completion(
|
||||
model="openrouter/openai/gpt-4o-mini",
|
||||
kwargs: Final = dict(
|
||||
model=requested_model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
max_tokens=50,
|
||||
api_key="test-openrouter-key",
|
||||
)
|
||||
stream_chunks: Final = tuple(stream)
|
||||
client: Final = AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport())
|
||||
try:
|
||||
stream: Final = await litellm.acompletion(**kwargs, client=client) if async_stream else litellm.completion(**kwargs)
|
||||
stream_chunks: Final = tuple([chunk async for chunk in stream]) if async_stream else tuple(stream)
|
||||
finally:
|
||||
await client.client.aclose()
|
||||
assert {chunk.model for chunk in stream_chunks} == {chunks[0]["model"]}
|
||||
assert stream_chunks[0].choices[0].delta.role == "assistant"
|
||||
assert tuple(
|
||||
chunk.choices[0].finish_reason for chunk in stream_chunks if chunk.choices[0].finish_reason is not None
|
||||
|
|
@ -802,6 +812,7 @@ def test_openrouter_streaming_completion_assembles_text_and_finish_reason() -> N
|
|||
) == "The Supreme Court hears federal cases."
|
||||
assembled: Final = litellm.stream_chunk_builder(chunks=list(stream_chunks), messages=messages)
|
||||
assert assembled is not None
|
||||
assert assembled.model == chunks[0]["model"]
|
||||
assert assembled.choices[0].message.content == "The Supreme Court hears federal cases."
|
||||
assert assembled.choices[0].finish_reason == "stop"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from litellm.proxy.a2a.agent_card import (
|
|||
LITELLM_SECURITY_SCHEMES,
|
||||
merge_agent_card,
|
||||
normalize_protocol_version,
|
||||
point_agent_card_at_proxy,
|
||||
resolve_served_protocol_version,
|
||||
)
|
||||
|
||||
|
|
@ -15,6 +16,32 @@ PROXY_URL = "https://proxy.example/a2a/agent-xyz"
|
|||
PROXY_BASE = "https://proxy.example"
|
||||
|
||||
|
||||
def test_point_agent_card_at_proxy_returns_a_deep_copy() -> None:
|
||||
card: dict[str, object] = {
|
||||
"url": "http://internal/",
|
||||
"metadata": {"nested": ["original"]},
|
||||
"supportedInterfaces": [
|
||||
{"url": "http://internal/", "nested": {"value": "original"}},
|
||||
{"url": "http://secondary/"},
|
||||
],
|
||||
}
|
||||
|
||||
pointed: dict[str, object] = point_agent_card_at_proxy(card, PROXY_URL)
|
||||
|
||||
assert pointed["url"] == PROXY_URL
|
||||
assert pointed["metadata"] == card["metadata"]
|
||||
assert pointed["metadata"] is not card["metadata"]
|
||||
assert pointed["supportedInterfaces"] == [
|
||||
{"url": PROXY_URL, "nested": {"value": "original"}},
|
||||
{"url": "http://secondary/"},
|
||||
]
|
||||
assert card["url"] == "http://internal/"
|
||||
assert card["supportedInterfaces"] == [
|
||||
{"url": "http://internal/", "nested": {"value": "original"}},
|
||||
{"url": "http://secondary/"},
|
||||
]
|
||||
|
||||
|
||||
def _full_upstream_card() -> dict:
|
||||
return {
|
||||
"protocolVersion": "0.9",
|
||||
|
|
|
|||
|
|
@ -12,19 +12,22 @@ import sys
|
|||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
||||
from contextlib import AbstractContextManager, ExitStack
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.agents import AgentCaller
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
|
||||
from litellm.types.agents import AgentCaller, AgentResponse
|
||||
|
||||
AddLiteLLMData = Callable[..., Awaitable[dict[str, object]]]
|
||||
|
||||
|
|
@ -2782,7 +2785,9 @@ async def test_forward_jsonrpc_sse_is_untouched_while_keepalives_are_unconfigure
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_jsonrpc_sse_keeps_unicode_line_separators_inside_an_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_forward_jsonrpc_sse_keeps_unicode_line_separators_inside_an_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "sse_keepalive_ping_interval_seconds", None)
|
||||
separated_text: Final = "before\u2028between\u2029after"
|
||||
event: Final = {
|
||||
|
|
@ -2944,6 +2949,202 @@ async def test_failed_upstream_stream_relays_its_body_only_when_that_is_a_json_o
|
|||
}
|
||||
|
||||
|
||||
class _RejectionRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.failure_payloads: Final[list[Mapping[str, object]]] = [] # mutable-ok: collects callback events
|
||||
self.hook_calls: Final[list[tuple[Mapping[str, object], Exception]]] = [] # mutable-ok: collects hook calls
|
||||
|
||||
async def async_log_failure_event(
|
||||
self,
|
||||
kwargs: dict[str, object],
|
||||
response_obj: object,
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
) -> None:
|
||||
payload: Final = kwargs["standard_logging_object"]
|
||||
assert isinstance(payload, Mapping)
|
||||
self.failure_payloads.append(payload)
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
self,
|
||||
request_data: dict[str, object],
|
||||
original_exception: Exception,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
traceback_str: str | None = None,
|
||||
) -> HTTPException:
|
||||
self.hook_calls.append((dict(request_data), original_exception))
|
||||
return HTTPException(status_code=418, detail="transformed")
|
||||
|
||||
|
||||
_SPOOFED_BODY_FIELDS: Final = MappingProxyType(
|
||||
{
|
||||
"agent_id": "spoof",
|
||||
"litellm_call_id": "spoofed-call-id",
|
||||
"metadata": {"agent_id": "spoof", "user_api_key_alias": "spoof", "user_api_key_team_id": "spoof"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _rejection_request(path_agent_id: str, request_id: object) -> MagicMock:
|
||||
req: Final = MagicMock()
|
||||
req.headers = {}
|
||||
req.json = AsyncMock(
|
||||
return_value={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "SendMessage",
|
||||
"params": {**_HELLO_MESSAGE_PARAMS, "litellm_call_id": "spoofed-param-call-id"},
|
||||
**_SPOOFED_BODY_FIELDS,
|
||||
}
|
||||
)
|
||||
return req
|
||||
|
||||
|
||||
async def _invoke_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
registered: bool,
|
||||
proxy_logging_obj: object,
|
||||
request_id: object = "rej-1",
|
||||
) -> tuple[int, object]:
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.agent_endpoints import agent_registry
|
||||
from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a
|
||||
|
||||
path_agent_id: Final = "denied-agent" if registered else "no-such-agent"
|
||||
registry: Final = agent_registry.AgentRegistry()
|
||||
if registered:
|
||||
registry.register_agent(
|
||||
AgentResponse(
|
||||
agent_id="registered-denied-id",
|
||||
agent_name=path_agent_id,
|
||||
agent_card_params={"name": "Test Agent", "url": "https://agent.example.com"},
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(agent_registry, "global_agent_registry", registry)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", proxy_logging_obj)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="hashed-caller",
|
||||
key_alias="caller-alias",
|
||||
user_id="caller-user",
|
||||
team_id="caller-team",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="caller-grants", agents=["other-agent"]),
|
||||
request_route=f"/a2a/{path_agent_id}",
|
||||
)
|
||||
with respx.mock as upstream:
|
||||
try:
|
||||
response: Final = await invoke_agent_a2a(
|
||||
agent_id=path_agent_id,
|
||||
request=_rejection_request(path_agent_id, request_id),
|
||||
fastapi_response=MagicMock(),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert not upstream.calls
|
||||
return e.status_code, e.detail
|
||||
assert not upstream.calls
|
||||
return response.status_code, json.loads(response.body.decode())
|
||||
|
||||
|
||||
_EXPECTED_REJECTIONS: Final = (
|
||||
pytest.param(
|
||||
True,
|
||||
403,
|
||||
"Agent 'denied-agent' is not allowed for your key/team. Contact proxy admin for access.",
|
||||
"registered-denied-id",
|
||||
"a2a_agent/Test Agent",
|
||||
id="not-granted",
|
||||
),
|
||||
pytest.param(
|
||||
False,
|
||||
404,
|
||||
{"jsonrpc": "2.0", "id": "rej-1", "error": {"code": -32000, "message": "Agent 'no-such-agent' not found"}},
|
||||
"no-such-agent",
|
||||
"a2a_agent/no-such-agent",
|
||||
id="unknown",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("registered", "status", "body", "target", "model"), _EXPECTED_REJECTIONS)
|
||||
async def test_rejected_agent_call_is_logged_once_as_the_caller_without_dispatch(
|
||||
monkeypatch: pytest.MonkeyPatch, registered: bool, status: int, body: object, target: str, model: str
|
||||
) -> None:
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
recorder: Final = _RejectionRecorder()
|
||||
for name in ("callbacks", "failure_callback", "_async_failure_callback"):
|
||||
monkeypatch.setattr(litellm, name, [recorder])
|
||||
|
||||
got_status, got_body = await _invoke_rejected(
|
||||
monkeypatch, registered=registered, proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache())
|
||||
)
|
||||
|
||||
assert (got_status, got_body) == (status, body)
|
||||
assert len(recorder.failure_payloads) == 1
|
||||
payload: Final = recorder.failure_payloads[0]
|
||||
metadata: Final = payload["metadata"]
|
||||
assert (payload["status"], payload["call_type"], payload["model"], payload["response_cost"]) == (
|
||||
"failure",
|
||||
"asend_message",
|
||||
model,
|
||||
0,
|
||||
)
|
||||
assert (
|
||||
metadata["user_api_key_alias"],
|
||||
metadata["user_api_key_user_id"],
|
||||
metadata["user_api_key_team_id"],
|
||||
metadata["user_api_key_hash"],
|
||||
) == ("caller-alias", "caller-user", "caller-team", "hashed-caller")
|
||||
assert payload["id"] not in {"spoofed-call-id", "spoofed-param-call-id", "rej-1"}
|
||||
assert len(recorder.hook_calls) == 1
|
||||
hook_data, hook_exception = recorder.hook_calls[0]
|
||||
assert (hook_data["agent_id"], hook_data["metadata"]["agent_id"]) == (target, target)
|
||||
assert isinstance(hook_exception, HTTPException) and hook_exception.status_code == status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("registered", "status", "body", "target", "model"), _EXPECTED_REJECTIONS)
|
||||
async def test_rejected_agent_call_response_survives_a_failing_failure_hook(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
registered: bool,
|
||||
status: int,
|
||||
body: object,
|
||||
target: str,
|
||||
model: str,
|
||||
) -> None:
|
||||
proxy_logging_obj: Final = MagicMock()
|
||||
proxy_logging_obj.post_call_failure_hook = AsyncMock(side_effect=RuntimeError("logging down"))
|
||||
|
||||
got_status, got_body = await _invoke_rejected(
|
||||
monkeypatch, registered=registered, proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
|
||||
assert (got_status, got_body) == (status, body)
|
||||
proxy_logging_obj.post_call_failure_hook.assert_awaited_once()
|
||||
assert any(record.getMessage() == "Failed to log rejected A2A request" for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("request_id", [0, 7, "abc"])
|
||||
async def test_unknown_agent_rejection_echoes_the_jsonrpc_id(
|
||||
monkeypatch: pytest.MonkeyPatch, request_id: object
|
||||
) -> None:
|
||||
proxy_logging_obj: Final = MagicMock()
|
||||
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
|
||||
_, got_body = await _invoke_rejected(
|
||||
monkeypatch, registered=False, proxy_logging_obj=proxy_logging_obj, request_id=request_id
|
||||
)
|
||||
|
||||
assert isinstance(got_body, dict) and got_body["id"] == request_id and type(got_body["id"]) is type(request_id)
|
||||
|
||||
|
||||
def _a2a_app_refusing_with(refusal: Exception) -> TestClient:
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
|
|
|||
|
|
@ -261,6 +261,42 @@ class TestSearchAgents:
|
|||
assert isinstance(outcome, AgentSearchNotConfigured)
|
||||
assert "agent_search_embedding_model" in outcome.reason
|
||||
|
||||
@pytest.mark.parametrize("embedding_model", ("", " "), ids=("empty", "whitespace"))
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_embedding_model_is_not_configured(self, embedding_model: str) -> None:
|
||||
router: Final = MagicMock()
|
||||
router.aembedding = AsyncMock(
|
||||
side_effect=lambda model, input, metadata: litellm.EmbeddingResponse(
|
||||
model=model,
|
||||
data=[{"object": "embedding", "index": i, "embedding": list(VECTORS[t])} for i, t in enumerate(input)],
|
||||
)
|
||||
)
|
||||
not_configured: Final = await search_agents(
|
||||
"language translation",
|
||||
AGENTS,
|
||||
5,
|
||||
router=router,
|
||||
embedding_model=None,
|
||||
index=AgentSearchIndex(),
|
||||
user_api_key_dict=CALLER,
|
||||
proxy_logging_obj=_pass_through_key_limits(),
|
||||
)
|
||||
outcome: Final = await search_agents(
|
||||
"language translation",
|
||||
AGENTS,
|
||||
5,
|
||||
router=router,
|
||||
embedding_model=embedding_model,
|
||||
index=AgentSearchIndex(),
|
||||
user_api_key_dict=CALLER,
|
||||
proxy_logging_obj=_pass_through_key_limits(),
|
||||
)
|
||||
|
||||
assert isinstance(not_configured, AgentSearchNotConfigured)
|
||||
assert isinstance(outcome, AgentSearchNotConfigured)
|
||||
assert outcome.reason == not_configured.reason
|
||||
router.aembedding.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_router_is_not_configured(self) -> None:
|
||||
outcome = await search_agents(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -13,7 +12,12 @@ from fastapi.testclient import TestClient
|
|||
from prisma.models import LiteLLM_AgentsTable
|
||||
|
||||
from litellm.constants import REDACTED_BY_LITELM_STRING
|
||||
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_AuditLogs,
|
||||
LitellmTableNames,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.agent_endpoints import endpoints as agent_endpoints
|
||||
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
|
||||
RestrictedAgentAccess,
|
||||
|
|
@ -43,6 +47,29 @@ def _sample_agent_card_params() -> dict:
|
|||
}
|
||||
|
||||
|
||||
_AGENT_CARD_BACKEND_URL: Final = "http://agent.internal:9000/plain/"
|
||||
|
||||
|
||||
def _sample_agent_card_params_with_backend_url() -> dict[str, object]:
|
||||
return {
|
||||
**_sample_agent_card_params(),
|
||||
"url": _AGENT_CARD_BACKEND_URL,
|
||||
"supportedInterfaces": [
|
||||
{
|
||||
"url": _AGENT_CARD_BACKEND_URL,
|
||||
"protocolBinding": "JSONRPC",
|
||||
"protocolVersion": "1.0",
|
||||
},
|
||||
{
|
||||
"url": _AGENT_CARD_BACKEND_URL,
|
||||
"protocolBinding": "GRPC",
|
||||
"protocolVersion": "1.0",
|
||||
},
|
||||
],
|
||||
"additionalInterfaces": [{"url": _AGENT_CARD_BACKEND_URL, "transport": "GRPC"}],
|
||||
}
|
||||
|
||||
|
||||
def _sample_agent_config() -> dict:
|
||||
return {
|
||||
"agent_name": "Test Agent",
|
||||
|
|
@ -641,7 +668,7 @@ class TestAgentRBACProxyAdmin:
|
|||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", self.mock_registry)
|
||||
|
||||
def test_should_allow_admin_to_create_agent(self, monkeypatch):
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||||
with patch("litellm.proxy.proxy_server.prisma_client"):
|
||||
self.mock_registry.get_agent_by_name = MagicMock(return_value=None)
|
||||
self.mock_registry.add_agent_to_db = AsyncMock(
|
||||
return_value=_sample_agent_response()
|
||||
|
|
@ -1015,6 +1042,129 @@ class TestAgentHealthCheck:
|
|||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
def _client_with_agent_card(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
role: LitellmUserRoles,
|
||||
agent: AgentResponse,
|
||||
) -> TestClient:
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.agent_endpoints import agent_registry
|
||||
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(return_value=agent)
|
||||
registry.get_agent_list = MagicMock(return_value=(agent,))
|
||||
registry.ids_for_agent = MagicMock(return_value=frozenset({agent.agent_id}))
|
||||
registry.stable_agent_id = MagicMock(side_effect=lambda agent_id: agent_id)
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
monkeypatch.setattr(agent_registry, "global_agent_registry", registry)
|
||||
prisma: Final = MagicMock()
|
||||
prisma.writer_db = prisma.db
|
||||
prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=[])
|
||||
prisma.db.litellm_agentstable.find_unique = AsyncMock(return_value=None)
|
||||
prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
test_app: Final = FastAPI()
|
||||
test_app.include_router(router)
|
||||
test_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="test-user",
|
||||
user_role=role,
|
||||
object_permission={"object_permission_id": "agent-card-test", "agents": [agent.agent_id]},
|
||||
)
|
||||
return TestClient(test_app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/agents", "/v1/agents/agent-123"])
|
||||
def test_non_admin_agent_card_urls_point_at_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
path: str,
|
||||
) -> None:
|
||||
agent: Final = AgentResponse(
|
||||
agent_id="agent-123",
|
||||
agent_name="Test Agent",
|
||||
agent_card_params=_sample_agent_card_params_with_backend_url(),
|
||||
)
|
||||
test_client: Final = _client_with_agent_card(monkeypatch, LitellmUserRoles.INTERNAL_USER, agent)
|
||||
|
||||
response: Final = test_client.get(path, headers={"Authorization": "Bearer k"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
agents: Final = response.json()
|
||||
result: Final = agents[0] if path == "/v1/agents" else agents
|
||||
card: Final = result["agent_card_params"]
|
||||
proxy_url: Final = "http://testserver/a2a/agent-123"
|
||||
assert card["url"] == proxy_url
|
||||
assert "agent.internal" not in response.text
|
||||
assert "additionalInterfaces" not in card
|
||||
assert card["supportedInterfaces"] == [
|
||||
{
|
||||
"url": proxy_url,
|
||||
"protocolBinding": "JSONRPC",
|
||||
"protocolVersion": "1.0",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_proxy_admin_agent_listing_keeps_backend_card_urls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent: Final = AgentResponse(
|
||||
agent_id="agent-123",
|
||||
agent_name="Test Agent",
|
||||
agent_card_params=_sample_agent_card_params_with_backend_url(),
|
||||
)
|
||||
test_client: Final = _client_with_agent_card(monkeypatch, LitellmUserRoles.PROXY_ADMIN, agent)
|
||||
|
||||
response: Final = test_client.get("/v1/agents", headers={"Authorization": "Bearer k"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
card: Final = response.json()[0]["agent_card_params"]
|
||||
assert card["url"] == _AGENT_CARD_BACKEND_URL
|
||||
assert card["supportedInterfaces"] == [
|
||||
{
|
||||
"url": _AGENT_CARD_BACKEND_URL,
|
||||
"protocolBinding": "JSONRPC",
|
||||
"protocolVersion": "1.0",
|
||||
},
|
||||
{
|
||||
"url": _AGENT_CARD_BACKEND_URL,
|
||||
"protocolBinding": "GRPC",
|
||||
"protocolVersion": "1.0",
|
||||
},
|
||||
]
|
||||
assert card["additionalInterfaces"] == [{"url": _AGENT_CARD_BACKEND_URL, "transport": "GRPC"}]
|
||||
|
||||
|
||||
def test_non_admin_health_check_probes_backend_and_returns_proxy_card(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent: Final = AgentResponse(
|
||||
agent_id="agent-123",
|
||||
agent_name="Test Agent",
|
||||
agent_card_params=_sample_agent_card_params_with_backend_url(),
|
||||
)
|
||||
test_client: Final = _client_with_agent_card(monkeypatch, LitellmUserRoles.INTERNAL_USER, agent)
|
||||
http_get: Final = AsyncMock(return_value=httpx.Response(200))
|
||||
http_client: Final = MagicMock(get=http_get)
|
||||
monkeypatch.setattr(
|
||||
agent_endpoints,
|
||||
"get_async_httpx_client",
|
||||
MagicMock(return_value=http_client),
|
||||
)
|
||||
|
||||
response: Final = test_client.get("/v1/agents?health_check=true", headers={"Authorization": "Bearer k"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert http_get.await_args.args[0] == _AGENT_CARD_BACKEND_URL
|
||||
assert response.json()[0]["agent_card_params"]["url"] == "http://testserver/a2a/agent-123"
|
||||
|
||||
|
||||
def test_non_admin_agent_card_without_interfaces_does_not_add_them(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent: Final = _sample_agent_response()
|
||||
test_client: Final = _client_with_agent_card(monkeypatch, LitellmUserRoles.INTERNAL_USER, agent)
|
||||
|
||||
response: Final = test_client.get("/v1/agents/agent-123", headers={"Authorization": "Bearer k"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["agent_card_params"]["url"] == "http://testserver/a2a/agent-123"
|
||||
assert "supportedInterfaces" not in response.json()["agent_card_params"]
|
||||
|
||||
|
||||
class TestCheckAgentUrlHealth:
|
||||
"""Unit tests for the _check_agent_url_health helper."""
|
||||
|
||||
|
|
@ -1230,7 +1380,11 @@ def test_jwt_authentication_status_does_not_require_virtual_keys(
|
|||
monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": enabled})
|
||||
monkeypatch.setattr(proxy_server, "jwt_handler", handler)
|
||||
agent: Final = _sample_agent_response()
|
||||
response: Final = agent_endpoints._redact_sensitive_agent_fields((agent,), is_admin=True)[0]
|
||||
response: Final = agent_endpoints._redact_sensitive_agent_fields(
|
||||
(agent,),
|
||||
is_admin=True,
|
||||
request_base_url="http://testserver",
|
||||
)[0]
|
||||
assert response.jwt_auth_configured is expected
|
||||
assert agent.jwt_auth_configured is False
|
||||
|
||||
|
|
|
|||
|
|
@ -3925,6 +3925,7 @@ AGENT_INFERENCE_ROUTES = [
|
|||
"/a2a/abc-123",
|
||||
"/a2a/abc-123/message/send",
|
||||
"/a2a/abc-123/message/stream",
|
||||
"/v1/a2a/abc-123/message/stream",
|
||||
"/a2a/abc-123/.well-known/agent-card.json",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
|||
BedrockGuardrail,
|
||||
_redact_pii_matches,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_initializers import initialize_bedrock
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.guardrails import BedrockGuardrailOutputScope, Guardrail, GuardrailEventHooks, LitellmParams
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
||||
BedrockContentItem,
|
||||
BedrockTextContent,
|
||||
|
|
@ -1020,6 +1021,64 @@ def _anthropic_tool_result_conversation(
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scope", [None, "INTERVENTIONS", "FULL"])
|
||||
async def test_configured_output_scope_reaches_bedrock_and_preserves_healthy_assessments(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, scope: str | None
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
params: Final = LitellmParams.model_validate(
|
||||
{
|
||||
"guardrail": "bedrock",
|
||||
"mode": "post_call",
|
||||
"guardrailIdentifier": "test-guardrail",
|
||||
"guardrailVersion": "DRAFT",
|
||||
"contextual_grounding_from_messages": True,
|
||||
"aws_access_key_id": "test-access-key",
|
||||
"aws_secret_access_key": "test-secret-key",
|
||||
"aws_region_name": "us-east-1",
|
||||
"aws_bedrock_runtime_endpoint": "https://bedrock.example",
|
||||
**({"outputScope": scope} if scope is not None else {}),
|
||||
}
|
||||
)
|
||||
guardrail: Final = initialize_bedrock(params, Guardrail(guardrail_name="grounding", litellm_params=params))
|
||||
assessment: Final = {
|
||||
"contextualGroundingPolicy": {
|
||||
"filters": [{"type": "GROUNDING", "score": 0.96, "threshold": 0.8, "detected": False, "action": "NONE"}]
|
||||
}
|
||||
}
|
||||
|
||||
def provider(request: httpx.Request) -> httpx.Response:
|
||||
body: Final = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"action": "NONE", "assessments": [assessment] if body.get("outputScope") == "FULL" else []},
|
||||
)
|
||||
|
||||
route: Final = respx_mock.post("https://bedrock.example/guardrail/test-guardrail/version/DRAFT/apply").mock(
|
||||
side_effect=provider
|
||||
)
|
||||
request_data: Final = {
|
||||
"messages": [
|
||||
{"role": "system", "content": "The tower is 330 metres tall."},
|
||||
{"role": "user", "content": "How tall is the tower?"},
|
||||
]
|
||||
}
|
||||
result: Final = await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["The tower is 330 metres tall."]}, request_data=request_data, input_type="response"
|
||||
)
|
||||
|
||||
sent: Final = json.loads(route.calls.last.request.content)
|
||||
if scope is None:
|
||||
assert "outputScope" not in sent
|
||||
else:
|
||||
assert sent["outputScope"] == scope
|
||||
assert sent["source"] == "OUTPUT"
|
||||
assert result["texts"] == ["The tower is 330 metres tall."]
|
||||
recorded: Final = request_data["metadata"]["standard_logging_guardrail_information"][-1]["guardrail_response"]
|
||||
assert recorded["assessments"] == ([assessment] if scope == "FULL" else [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_during_call_hook_skips_bedrock_call_for_tool_result_only_turn():
|
||||
"""A tool_result-only latest user turn must not post an empty content list to Bedrock.
|
||||
|
|
@ -1805,7 +1864,11 @@ async def test_make_bedrock_api_request_logging_event_type_for_spend_logs():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_bedrock_api_request_filters_dynamic_evaluation_overrides():
|
||||
guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT")
|
||||
guardrail = BedrockGuardrail(
|
||||
guardrailIdentifier="test-guardrail",
|
||||
guardrailVersion="DRAFT",
|
||||
outputScope=BedrockGuardrailOutputScope.INTERVENTIONS,
|
||||
)
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.access_key = "test-access-key"
|
||||
mock_credentials.secret_key = "test-secret-key"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,443 @@
|
|||
import base64
|
||||
from typing import Final
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.ovalix.ovalix_extraction import (
|
||||
extract_file_parts_from_images,
|
||||
extract_file_parts_from_messages,
|
||||
extract_tool_results,
|
||||
make_tool_data,
|
||||
tool_call_to_tool_data,
|
||||
tool_result_text_indices,
|
||||
)
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
def test_file_block_data_url_decoded():
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "file", "file": {"filename": "a.txt", "file_data": f"data:text/plain;base64,{_b64(b'hi')}"}}
|
||||
],
|
||||
}
|
||||
]
|
||||
parts = extract_file_parts_from_messages(msgs, size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"hi" and parts[0].name == "a.txt" and parts[0].inline
|
||||
|
||||
|
||||
def test_image_url_reference_tracked_by_name():
|
||||
msgs = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://x.test/pic.png"}}]}]
|
||||
parts = extract_file_parts_from_messages(msgs, size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data is None and parts[0].inline is False and parts[0].name == "pic.png"
|
||||
|
||||
|
||||
def test_oversize_file_flagged_no_data():
|
||||
big = _b64(b"x" * 100)
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "file",
|
||||
"file": {"filename": "big.bin", "file_data": f"data:application/octet-stream;base64,{big}"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
parts = extract_file_parts_from_messages(msgs, size_limit=10)
|
||||
assert len(parts) == 1 and parts[0].oversize is True and parts[0].data is None
|
||||
|
||||
|
||||
def test_images_field_data_url():
|
||||
parts = extract_file_parts_from_images([f"data:image/png;base64,{_b64(b'png')}"], size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"png" and parts[0].inline
|
||||
|
||||
|
||||
def test_tool_call_to_tool_data_parses_arguments():
|
||||
td = tool_call_to_tool_data(
|
||||
{"id": "c1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "TLV"}'}}
|
||||
)
|
||||
assert (
|
||||
td["content"] == '{"city": "TLV"}'
|
||||
and td["tool_name"] == "get_weather"
|
||||
and td["action_name"] == "get_weather"
|
||||
and td["tool_input"] == {"city": "TLV"}
|
||||
)
|
||||
|
||||
|
||||
def test_tool_call_malformed_dropped():
|
||||
assert tool_call_to_tool_data({"id": "c1", "type": "function", "function": {"name": ""}}) is None
|
||||
assert tool_call_to_tool_data({"id": "c1"}) is None
|
||||
|
||||
|
||||
def test_extract_tool_results_correlates_name():
|
||||
msgs = [
|
||||
{"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "get_weather"}}]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "sunny"},
|
||||
{"role": "tool", "tool_call_id": "unknown", "content": "orphan"},
|
||||
]
|
||||
results = extract_tool_results(msgs)
|
||||
assert ("get_weather", "sunny", "c1") in results
|
||||
assert ("tool_result", "orphan", "unknown") in results
|
||||
|
||||
|
||||
def test_make_tool_data_truncates_and_defaults_name():
|
||||
td = make_tool_data(" ", "content")
|
||||
assert td["tool_name"] == "tool_result" and td["action_name"] == "tool_result"
|
||||
long = "x" * 200
|
||||
td2 = make_tool_data(long, "c")
|
||||
assert len(td2["tool_name"]) == 100 and td2["action_name"] == long
|
||||
|
||||
|
||||
def test_unhashable_block_type_skipped_without_raising():
|
||||
msgs = [{"role": "user", "content": [{"type": ["file"], "file": {"filename": "a.txt"}}]}]
|
||||
parts = extract_file_parts_from_messages(msgs, size_limit=1000)
|
||||
assert parts == ()
|
||||
|
||||
|
||||
def test_unhashable_tool_call_id_skipped_without_raising():
|
||||
msgs = [
|
||||
{"role": "assistant", "tool_calls": [{"id": ["c1"], "function": {"name": "get_weather"}}]},
|
||||
{"role": "tool", "tool_call_id": ["c1"], "content": "sunny"},
|
||||
]
|
||||
results = extract_tool_results(msgs)
|
||||
assert results == (("tool_result", "sunny", ["c1"]),)
|
||||
|
||||
|
||||
def test_extract_tool_results_list_form_content():
|
||||
msgs = [{"role": "tool", "tool_call_id": "c1", "content": [{"type": "text", "text": "part1"}, "part2"]}]
|
||||
results = extract_tool_results(msgs)
|
||||
assert ("tool_result", "part1\npart2", "c1") in results
|
||||
|
||||
|
||||
def test_extract_tool_results_dict_form_content():
|
||||
msgs = [{"role": "tool", "tool_call_id": "c1", "content": {"city": "TLV"}}]
|
||||
results = extract_tool_results(msgs)
|
||||
assert ("tool_result", '{"city": "TLV"}', "c1") in results
|
||||
|
||||
|
||||
def test_images_field_oversize_flagged_no_data():
|
||||
big = _b64(b"x" * 100)
|
||||
parts = extract_file_parts_from_images([f"data:image/png;base64,{big}"], size_limit=10)
|
||||
assert len(parts) == 1 and parts[0].oversize is True and parts[0].data is None
|
||||
|
||||
|
||||
class _StubFunction:
|
||||
def __init__(self, name, arguments):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
|
||||
class _StubToolCall:
|
||||
def __init__(self, function):
|
||||
self.function = function
|
||||
|
||||
|
||||
def test_tool_call_to_tool_data_accepts_object_style_tool_call():
|
||||
tool_call = _StubToolCall(_StubFunction("get_weather", '{"city": "TLV"}'))
|
||||
td = tool_call_to_tool_data(tool_call)
|
||||
assert td["tool_name"] == "get_weather" and td["tool_input"] == {"city": "TLV"}
|
||||
|
||||
|
||||
def _msgs(block):
|
||||
return [{"role": "user", "content": [block]}]
|
||||
|
||||
|
||||
def test_image_url_block_data_url_decoded_from_messages():
|
||||
block = {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_b64(b'png')}"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"png" and parts[0].inline and parts[0].name is None
|
||||
|
||||
|
||||
def test_image_url_block_non_string_url_skipped():
|
||||
block = {"type": "image_url", "image_url": {"url": 123}}
|
||||
assert extract_file_parts_from_messages(_msgs(block), size_limit=1000) == ()
|
||||
|
||||
|
||||
def test_input_image_block_data_url_decoded():
|
||||
block = {"type": "input_image", "image_url": f"data:image/png;base64,{_b64(b'img')}"}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"img" and parts[0].inline
|
||||
|
||||
|
||||
def test_file_block_non_dict_file_skipped():
|
||||
block = {"type": "file", "file": "not-a-dict"}
|
||||
assert extract_file_parts_from_messages(_msgs(block), size_limit=1000) == ()
|
||||
|
||||
|
||||
def test_file_block_reference_without_bytes_is_name_only():
|
||||
block = {"type": "file", "file": {"file_id": "file-abc"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].name == "file-abc" and parts[0].data is None and parts[0].inline is False
|
||||
|
||||
|
||||
def test_file_block_urlsafe_base64_decoded_via_fallback():
|
||||
raw = b"\xff\xff\xfe" # encodes with url-unsafe chars '+'/'/' in standard b64
|
||||
urlsafe = base64.urlsafe_b64encode(raw).decode()
|
||||
assert "-" in urlsafe or "_" in urlsafe
|
||||
block = {
|
||||
"type": "file",
|
||||
"file": {"filename": "b.bin", "file_data": f"data:application/octet-stream;base64,{urlsafe}"},
|
||||
}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == raw
|
||||
|
||||
|
||||
def test_file_block_data_url_without_base64_marker_is_undecodable():
|
||||
block = {"type": "file", "file": {"filename": "n.txt", "file_data": "data:text/plain,hello"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].undecodable and parts[0].data is None and parts[0].name == "n.txt"
|
||||
|
||||
|
||||
def test_input_file_block_data_url_decoded():
|
||||
block = {"type": "input_file", "filename": "doc.pdf", "file_data": f"data:application/pdf;base64,{_b64(b'pdf')}"}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"pdf" and parts[0].name == "doc.pdf" and parts[0].inline
|
||||
|
||||
|
||||
def test_input_file_block_file_url_reference_name_only():
|
||||
block = {"type": "input_file", "file_url": "https://x.test/report.csv"}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].name == "report.csv" and parts[0].data is None and parts[0].inline is False
|
||||
|
||||
|
||||
def test_input_audio_block_decoded():
|
||||
block = {"type": "input_audio", "input_audio": {"data": _b64(b"wav"), "format": "wav"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"wav" and parts[0].name == "audio.wav" and parts[0].inline
|
||||
|
||||
|
||||
def test_input_audio_block_undecodable_is_flagged():
|
||||
block = {"type": "input_audio", "input_audio": {"data": "!!!not-base64!!!"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].undecodable and parts[0].data is None and parts[0].name == "audio.bin"
|
||||
|
||||
|
||||
def test_input_audio_block_non_dict_skipped():
|
||||
block = {"type": "input_audio", "input_audio": "nope"}
|
||||
assert extract_file_parts_from_messages(_msgs(block), size_limit=1000) == ()
|
||||
|
||||
|
||||
def test_tool_call_dict_arguments_serialized_and_parsed():
|
||||
td = tool_call_to_tool_data({"function": {"name": "f", "arguments": {"a": 1}}})
|
||||
assert td["content"] == '{"a": 1}' and td["tool_input"] == {"a": 1}
|
||||
|
||||
|
||||
def test_tool_call_none_arguments_yields_empty_content():
|
||||
td = tool_call_to_tool_data({"function": {"name": "f", "arguments": None}})
|
||||
assert td["content"] == "" and td["tool_input"] == {}
|
||||
|
||||
|
||||
def test_tool_call_non_string_non_dict_arguments_serialized():
|
||||
td = tool_call_to_tool_data({"function": {"name": "f", "arguments": [1, 2]}})
|
||||
assert td["content"] == "[1, 2]" and td["tool_input"] == {}
|
||||
|
||||
|
||||
def test_tool_call_invalid_json_string_arguments_kept_as_content():
|
||||
td = tool_call_to_tool_data({"function": {"name": "f", "arguments": "{not json"}})
|
||||
assert td["content"] == "{not json" and td["tool_input"] == {}
|
||||
|
||||
|
||||
def test_tool_result_with_non_string_tool_call_id_uses_default_name():
|
||||
msgs = [{"role": "tool", "tool_call_id": ["c1"], "content": "orphan"}]
|
||||
results = extract_tool_results(msgs)
|
||||
assert results == (("tool_result", "orphan", ["c1"]),)
|
||||
|
||||
|
||||
def test_images_field_http_url_is_name_only_reference():
|
||||
parts = extract_file_parts_from_images(["https://x.test/pic.png"], size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].name == "pic.png" and parts[0].data is None and parts[0].inline is False
|
||||
|
||||
|
||||
def test_messages_skip_non_dict_and_unknown_blocks():
|
||||
msgs = [
|
||||
"not-a-message",
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
"bare-string-block",
|
||||
{"type": "text", "text": "hi"},
|
||||
{"type": "file", "file": {"filename": "a.txt", "file_data": f"data:text/plain;base64,{_b64(b'x')}"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
parts = extract_file_parts_from_messages(msgs, size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].name == "a.txt" and parts[0].data == b"x"
|
||||
|
||||
|
||||
def test_image_url_block_invalid_data_url_is_flagged():
|
||||
block = {"type": "image_url", "image_url": {"url": "data:image/png;base64,%%%invalid%%%"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].undecodable and parts[0].data is None
|
||||
|
||||
|
||||
def test_tool_message_with_empty_content_is_skipped():
|
||||
msgs = [{"role": "tool", "tool_call_id": "c1", "content": " "}]
|
||||
assert extract_tool_results(msgs) == ()
|
||||
|
||||
|
||||
def test_extract_tool_results_skips_non_dict_messages_and_tool_calls():
|
||||
msgs = [
|
||||
"junk",
|
||||
{"role": "assistant", "tool_calls": ["not-a-dict", {"id": "c1", "function": {"name": "f"}}]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
|
||||
]
|
||||
assert extract_tool_results(msgs) == (("f", "ok", "c1"),)
|
||||
|
||||
|
||||
def test_tool_result_takes_its_name_from_the_nearest_earlier_declaration():
|
||||
msgs: Final = [
|
||||
{"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "first"}}]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "r1"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [{"id": "c1", "function": {"name": "second"}}, {"id": "c1", "function": {"name": "third"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "r2"},
|
||||
{"role": "tool", "tool_call_id": "c2", "content": "r3"},
|
||||
{"role": "assistant", "tool_calls": [{"id": "c2", "function": {"name": "too-late"}}]},
|
||||
]
|
||||
assert extract_tool_results(msgs) == (("first", "r1", "c1"), ("third", "r2", "c1"), ("tool_result", "r3", "c2"))
|
||||
|
||||
|
||||
class _ReadCountingMessage(dict):
|
||||
def __init__(self, **fields):
|
||||
super().__init__(**fields)
|
||||
self.reads = 0
|
||||
|
||||
def get(self, key, default=None):
|
||||
self.reads += 1
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
def _reads_of_a_declaration_followed_by(tool_results):
|
||||
declaration: Final = _ReadCountingMessage(role="assistant", tool_calls=[{"id": "c0", "function": {"name": "f"}}])
|
||||
tool_messages: Final = [
|
||||
{"role": "tool", "tool_call_id": f"c{index}", "content": "x"} for index in range(tool_results)
|
||||
]
|
||||
extract_tool_results([declaration, *tool_messages])
|
||||
return declaration.reads
|
||||
|
||||
|
||||
def test_tool_result_names_resolve_without_rescanning_earlier_messages():
|
||||
assert _reads_of_a_declaration_followed_by(1000) == _reads_of_a_declaration_followed_by(1)
|
||||
|
||||
|
||||
def test_malformed_data_url_is_undecodable():
|
||||
block = {"type": "file", "file": {"filename": "x.bin", "file_data": "data:garbage-no-comma"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].undecodable and parts[0].data is None and parts[0].name == "x.bin"
|
||||
|
||||
|
||||
def test_input_audio_block_without_data_skipped():
|
||||
block = {"type": "input_audio", "input_audio": {"format": "wav"}}
|
||||
assert extract_file_parts_from_messages(_msgs(block), size_limit=1000) == ()
|
||||
|
||||
|
||||
def test_images_field_non_string_entries_skipped():
|
||||
assert extract_file_parts_from_images([123, None, ""], size_limit=1000) == ()
|
||||
|
||||
|
||||
def test_make_tool_data_whitespace_after_truncation_defaults_name():
|
||||
name = " " * 100 + "x"
|
||||
td = make_tool_data(name, "c")
|
||||
assert td["tool_name"] == "tool_result" and td["action_name"] == name
|
||||
|
||||
|
||||
def test_raw_base64_file_data_without_data_url_prefix_decoded():
|
||||
block = {"type": "file", "file": {"filename": "a.bin", "file_data": _b64(b"rawbytes")}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"rawbytes" and parts[0].mime_hint is None
|
||||
|
||||
|
||||
def test_images_field_raw_base64_without_data_url_prefix_decoded():
|
||||
parts = extract_file_parts_from_images([_b64(b"rawimg")], size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data == b"rawimg"
|
||||
|
||||
|
||||
def test_whitespace_only_base64_payload_yields_no_bytes():
|
||||
block = {"type": "file", "file": {"filename": "n.txt", "file_data": "data:text/plain;base64, "}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].data is None and parts[0].inline is False and parts[0].name == "n.txt"
|
||||
|
||||
|
||||
def test_base64_invalid_even_after_urlsafe_translate_is_undecodable():
|
||||
block = {"type": "file", "file": {"filename": "b.bin", "file_data": "_@@@"}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=1000)
|
||||
assert len(parts) == 1 and parts[0].undecodable and parts[0].data is None and parts[0].name == "b.bin"
|
||||
|
||||
|
||||
def test_file_oversize_detected_after_decode_when_estimate_passes():
|
||||
block = {"type": "file", "file": {"filename": "s.bin", "file_data": _b64(b"abc")}}
|
||||
parts = extract_file_parts_from_messages(_msgs(block), size_limit=2)
|
||||
assert len(parts) == 1 and parts[0].oversize is True and parts[0].data is None
|
||||
|
||||
|
||||
def test_image_http_url_that_urlparse_rejects_is_dropped():
|
||||
parts = extract_file_parts_from_images(["http://["], size_limit=1000)
|
||||
assert parts == ()
|
||||
|
||||
|
||||
def test_message_content_not_a_list_is_skipped():
|
||||
parts = extract_file_parts_from_messages(
|
||||
[{"role": "user", "content": "just a plain string prompt"}], size_limit=1000
|
||||
)
|
||||
assert parts == ()
|
||||
|
||||
|
||||
def test_tool_call_dict_arguments_non_serializable_falls_back_to_str():
|
||||
td = tool_call_to_tool_data({"function": {"name": "f", "arguments": {"a": {1, 2}}}})
|
||||
assert isinstance(td["content"], str) and td["tool_input"] == {"a": {1, 2}}
|
||||
|
||||
|
||||
def test_tool_call_non_serializable_other_arguments_falls_back_to_str():
|
||||
td = tool_call_to_tool_data({"function": {"name": "f", "arguments": {1, 2}}})
|
||||
assert isinstance(td["content"], str) and td["tool_input"] == {}
|
||||
|
||||
|
||||
def test_tool_result_dict_content_non_serializable_falls_back_to_str():
|
||||
results = extract_tool_results([{"role": "tool", "tool_call_id": "c1", "content": {"x": {1, 2}}}])
|
||||
assert len(results) == 1 and isinstance(results[0][1], str) and results[0][1].strip()
|
||||
|
||||
|
||||
def test_tool_result_text_indices_marks_only_tool_role_positions():
|
||||
messages = [
|
||||
{"role": "user", "content": "ask"},
|
||||
{"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "f"}}]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "result"},
|
||||
]
|
||||
assert tool_result_text_indices(messages, ["ask", "result"]) == frozenset({1})
|
||||
|
||||
|
||||
def test_tool_result_text_indices_covers_every_text_block_of_a_tool_message():
|
||||
messages = [
|
||||
{"role": "user", "content": "ask"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1",
|
||||
"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}],
|
||||
},
|
||||
]
|
||||
assert tool_result_text_indices(messages, ["ask", "a", "b"]) == frozenset({1, 2})
|
||||
|
||||
|
||||
def test_tool_result_text_indices_empty_when_texts_do_not_replay_messages():
|
||||
messages = [
|
||||
{"role": "user", "content": "ask"},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "result"},
|
||||
]
|
||||
assert tool_result_text_indices(messages, ["result"]) == frozenset()
|
||||
assert tool_result_text_indices(messages, ["ask", "tampered"]) == frozenset()
|
||||
|
||||
|
||||
def test_tool_result_text_indices_skips_blank_tool_content_never_submitted_as_tool():
|
||||
messages = [{"role": "tool", "tool_call_id": "c1", "content": " "}]
|
||||
assert extract_tool_results(messages) == ()
|
||||
assert tool_result_text_indices(messages, [" "]) == frozenset()
|
||||
|
||||
|
||||
def test_tool_result_text_indices_empty_without_structured_messages():
|
||||
assert tool_result_text_indices(None, ["ask"]) == frozenset()
|
||||
|
|
@ -2025,6 +2025,28 @@ class TestStreamingHttpErrorFrames:
|
|||
def _use_real_mappings(self, monkeypatch):
|
||||
_patch_translation_mappings(monkeypatch, load_guardrail_translation_mappings())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v1_a2a_stream_route_scans_response_chunks(self):
|
||||
guardrail: Final = RecordingGuardrail()
|
||||
guardrail.streaming_sampling_rate = 1
|
||||
chunk: Final = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "stream-1",
|
||||
"result": {
|
||||
"kind": "task",
|
||||
"artifacts": [{"parts": [{"kind": "text", "text": "streamed text"}]}],
|
||||
},
|
||||
}
|
||||
|
||||
await _drive_stream(
|
||||
UnifiedLLMGuardrails(),
|
||||
guardrail,
|
||||
[chunk],
|
||||
request_route="/v1/a2a/my-agent/message/stream",
|
||||
)
|
||||
|
||||
assert guardrail.apply_calls[0]["inputs"]["texts"] == ["streamed text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_eos_block_emits_data_error_frame(self):
|
||||
import json as _json
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue