mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(types): correct annotations that were false about their runtime values
An adversarial review of the previous commit found annotations that described what the code wished were true rather than what flows through. A false annotation is worse than the Any it replaced, since it launders a wrong assumption past the type checker. - purview: `_resolve_user_id` claimed every request-body value was a Mapping, contradicting `_resolve_trusted_user_id` one method over, which types the same argument `Mapping[str, object]`. `_should_block` claimed every Graph response value was a sequence of str->str mappings and was not assignable from its own producer's return type. - cato: `_CatoAnalyzeResponse.required_action` was required and non-nullable while the API returns null, as seven fixtures in the guardrail's own suite assert. `analysis_result` had the same problem. The streaming hook narrowed an override parameter below what `ProxyLogging` actually passes it. - marketplace: `_PluginRecord.manifest_json` was `str` against a nullable column. Making it honest surfaced a latent crash, covered below. - ownership: two functions took an attribute Protocol while their own bodies branch on `isinstance(response, dict)`, which no Protocol can satisfy. - openapi generator: `paths` claimed every path-item value was an operation, though path items also carry `parameters`, `summary` and `$ref`. - custom openapi spec: a TypedDict asserted a shape that the function returns raw Pydantic sub-schemas out of. Reverted to Any, which is imprecise but not false. `get_marketplace` did an unguarded `json.loads` on the nullable `manifest_json` inside an `except json.JSONDecodeError`, which cannot catch the TypeError a NULL raises, so one NULL row 500s the endpoint. It now skips the plugin like the file's other two read sites already do, with a regression test that fails without the guard. Where honesty cost precision, precision lost. `_should_block` went back to its original signature entirely: the narrowing needed to type it turned a fail-closed DLP control fail-open, because the TypeError it used to raise on a malformed response reached `except Exception` and became a 400.
This commit is contained in:
parent
6b5c7f92ce
commit
e2cb01c87c
8 changed files with 98 additions and 70 deletions
|
|
@ -6,7 +6,7 @@ import re
|
|||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone, tzinfo
|
||||
from typing import Any, Final, cast
|
||||
from typing import Any, Final, TypedDict, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -35,6 +35,17 @@ GALILEO_CLOUD_API_BASE_URL: Final = "https://api.galileo.ai"
|
|||
GALILEO_MAX_IN_MEMORY_RECORDS: Final = 1000
|
||||
|
||||
|
||||
class GalileoStandardLoggingFields(TypedDict, total=False):
|
||||
call_type: str
|
||||
model: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
response_cost: float
|
||||
startTime: float
|
||||
endTime: float
|
||||
|
||||
|
||||
class LLMResponse(BaseModel):
|
||||
latency_ms: int
|
||||
status_code: int
|
||||
|
|
@ -60,7 +71,7 @@ class LLMResponse(BaseModel):
|
|||
|
||||
class GalileoObserve(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
self.in_memory_records: list[dict[str, Any]] = []
|
||||
self.in_memory_records: list[Mapping[str, object]] = []
|
||||
self.batch_size = 1
|
||||
self.api_key = os.getenv("GALILEO_API_KEY")
|
||||
self.project_id = os.getenv("GALILEO_PROJECT_ID")
|
||||
|
|
@ -648,7 +659,7 @@ class GalileoObserve(CustomLogger):
|
|||
)
|
||||
return
|
||||
|
||||
slo: Final[Mapping[str, Any] | None] = kwargs.get("standard_logging_object")
|
||||
slo: Final[GalileoStandardLoggingFields | None] = kwargs.get("standard_logging_object")
|
||||
if slo is None:
|
||||
verbose_logger.debug("Galileo Logger: no standard_logging_object in kwargs, skipping")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -47,11 +47,7 @@ from litellm.proxy._experimental.mcp_server.tool_registry import (
|
|||
global_mcp_tool_registry,
|
||||
)
|
||||
|
||||
_OpenAPIObject: TypeAlias = Mapping[str, Any]
|
||||
|
||||
|
||||
class _OpenAPIParameterSchema(TypedDict, total=False):
|
||||
type: str
|
||||
_OpenAPIParameter: TypeAlias = Mapping[str, Any]
|
||||
|
||||
|
||||
class _OpenAPIJSONSchema(TypedDict, total=False):
|
||||
|
|
@ -72,16 +68,18 @@ class _OpenAPIOperation(TypedDict, total=False):
|
|||
operationId: str
|
||||
summary: str
|
||||
description: str
|
||||
parameters: Sequence[_OpenAPIObject]
|
||||
parameters: Sequence[_OpenAPIParameter]
|
||||
requestBody: _OpenAPIRequestBody
|
||||
|
||||
|
||||
class _OpenAPIPathItem(TypedDict, total=False):
|
||||
parameters: Sequence[_OpenAPIObject]
|
||||
summary: str
|
||||
description: str
|
||||
parameters: Sequence[_OpenAPIParameter]
|
||||
|
||||
|
||||
class _OpenAPIComponents(TypedDict, total=False):
|
||||
parameters: Mapping[str, _OpenAPIObject]
|
||||
parameters: Mapping[str, _OpenAPIParameter]
|
||||
|
||||
|
||||
# Store the base URL and headers globally
|
||||
|
|
@ -161,7 +159,7 @@ async def load_openapi_spec_async(filepath: str) -> dict[str, Any]:
|
|||
return json.load(f)
|
||||
|
||||
|
||||
def get_base_url(spec: _OpenAPIObject, spec_path: str | None = None) -> str:
|
||||
def get_base_url(spec: Mapping[str, Any], spec_path: str | None = None) -> str:
|
||||
"""Extract base URL from OpenAPI spec."""
|
||||
# OpenAPI 3.x
|
||||
if "servers" in spec and spec["servers"]:
|
||||
|
|
@ -212,7 +210,9 @@ def get_base_url(spec: _OpenAPIObject, spec_path: str | None = None) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _resolve_ref(param: _OpenAPIObject, component_params: Mapping[str, _OpenAPIObject]) -> _OpenAPIObject | None:
|
||||
def _resolve_ref(
|
||||
param: _OpenAPIParameter, component_params: Mapping[str, _OpenAPIParameter]
|
||||
) -> _OpenAPIParameter | None:
|
||||
"""Resolve a single parameter, following a $ref if present.
|
||||
|
||||
Returns the resolved param dict, or None if the $ref target is absent from
|
||||
|
|
@ -226,8 +226,8 @@ def _resolve_ref(param: _OpenAPIObject, component_params: Mapping[str, _OpenAPIO
|
|||
|
||||
|
||||
def _resolve_param_list(
|
||||
raw: Sequence[_OpenAPIObject], component_params: Mapping[str, _OpenAPIObject]
|
||||
) -> list[_OpenAPIObject]:
|
||||
raw: Sequence[_OpenAPIParameter], component_params: Mapping[str, _OpenAPIParameter]
|
||||
) -> list[_OpenAPIParameter]:
|
||||
"""Resolve $refs in a parameter list, dropping any unresolvable entries."""
|
||||
result: Final = []
|
||||
for p in raw:
|
||||
|
|
@ -256,7 +256,7 @@ def resolve_operation_params(
|
|||
merged with the operation-level params; operation-level wins when the
|
||||
same ``name`` + ``in`` combination appears in both.
|
||||
"""
|
||||
component_params: Final[Mapping[str, _OpenAPIObject]] = components.get("parameters", {})
|
||||
component_params: Final[Mapping[str, _OpenAPIParameter]] = components.get("parameters", {})
|
||||
path_level: Final = _resolve_param_list(path_item.get("parameters", []), component_params)
|
||||
op_level: Final = _resolve_param_list(operation.get("parameters", []), component_params)
|
||||
op_keys: Final = {(p["name"], p.get("in")) for p in op_level}
|
||||
|
|
@ -266,9 +266,8 @@ def resolve_operation_params(
|
|||
return result
|
||||
|
||||
|
||||
def extract_parameters(operation: _OpenAPIObject) -> tuple[Sequence[str], Sequence[str], Sequence[str]]:
|
||||
def extract_parameters(operation: Mapping[str, Any]) -> tuple[Sequence[str], Sequence[str], Sequence[str]]:
|
||||
"""Extract parameter names from OpenAPI operation."""
|
||||
param: _OpenAPIObject
|
||||
path_params: Final = []
|
||||
query_params: Final = []
|
||||
body_params: Final = []
|
||||
|
|
@ -278,7 +277,7 @@ def extract_parameters(operation: _OpenAPIObject) -> tuple[Sequence[str], Sequen
|
|||
for param in operation["parameters"]:
|
||||
if "name" not in param:
|
||||
continue
|
||||
param_name: str = param["name"]
|
||||
param_name = param["name"]
|
||||
if param.get("in") == "path":
|
||||
path_params.append(param_name)
|
||||
elif param.get("in") == "query":
|
||||
|
|
@ -293,9 +292,8 @@ def extract_parameters(operation: _OpenAPIObject) -> tuple[Sequence[str], Sequen
|
|||
return path_params, query_params, body_params
|
||||
|
||||
|
||||
def build_input_schema(operation: _OpenAPIObject) -> dict[str, Any]:
|
||||
def build_input_schema(operation: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Build MCP input schema from OpenAPI operation."""
|
||||
param: _OpenAPIObject
|
||||
properties: Final = {}
|
||||
required: Final = []
|
||||
|
||||
|
|
@ -304,9 +302,9 @@ def build_input_schema(operation: _OpenAPIObject) -> dict[str, Any]:
|
|||
for param in operation["parameters"]:
|
||||
if "name" not in param:
|
||||
continue
|
||||
param_name: str = param["name"]
|
||||
param_schema: _OpenAPIParameterSchema = param.get("schema", {})
|
||||
param_type: str = param_schema.get("type", "string")
|
||||
param_name = param["name"]
|
||||
param_schema = param.get("schema", {})
|
||||
param_type = param_schema.get("type", "string")
|
||||
|
||||
properties[param_name] = {
|
||||
"type": param_type,
|
||||
|
|
@ -391,7 +389,7 @@ def _merge_openapi_tool_request_headers(
|
|||
def create_tool_function(
|
||||
path: str,
|
||||
method: str,
|
||||
operation: _OpenAPIObject,
|
||||
operation: Mapping[str, Any],
|
||||
base_url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
):
|
||||
|
|
@ -492,9 +490,9 @@ def create_tool_function(
|
|||
return tool_function
|
||||
|
||||
|
||||
def register_tools_from_openapi(spec: _OpenAPIObject, base_url: str) -> None:
|
||||
def register_tools_from_openapi(spec: Mapping[str, Any], base_url: str) -> None:
|
||||
"""Register MCP tools from OpenAPI specification."""
|
||||
paths: Final[Mapping[str, Mapping[str, _OpenAPIOperation]]] = spec.get("paths", {})
|
||||
paths: Final[Mapping[str, Mapping[str, Any]]] = spec.get("paths", {})
|
||||
used_names: Final = set()
|
||||
|
||||
for path, path_item in paths.items():
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class _PluginRecord(Protocol):
|
|||
name: str
|
||||
version: str | None
|
||||
description: str | None
|
||||
manifest_json: str
|
||||
manifest_json: str | None
|
||||
enabled: bool
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
|
@ -108,7 +108,7 @@ async def get_marketplace():
|
|||
plugin_list: Final = []
|
||||
for plugin in plugins:
|
||||
try:
|
||||
manifest: Mapping[str, object] = json.loads(plugin.manifest_json)
|
||||
manifest: Mapping[str, object] = json.loads(plugin.manifest_json or "{}")
|
||||
except json.JSONDecodeError:
|
||||
verbose_proxy_logger.warning("Plugin %s has invalid manifest JSON, skipping", plugin.name)
|
||||
continue
|
||||
|
|
@ -431,7 +431,7 @@ async def get_plugin(
|
|||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
manifest: Final[Mapping[str, object]] = json.loads(plugin.manifest_json) if plugin.manifest_json else {}
|
||||
manifest: Final[Mapping[str, object]] = json.loads(plugin.manifest_json or "{}") if plugin.manifest_json else {}
|
||||
|
||||
return {
|
||||
"id": plugin.id,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Final, TypedDict
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
|
||||
class _FieldSchema(TypedDict, total=False):
|
||||
type: str
|
||||
anyOf: Sequence["_FieldSchema"]
|
||||
|
||||
|
||||
class CustomOpenAPISpec:
|
||||
"""
|
||||
Handler for customizing OpenAPI specifications with Pydantic models
|
||||
|
|
@ -198,7 +193,7 @@ class CustomOpenAPISpec:
|
|||
return schema
|
||||
|
||||
@staticmethod
|
||||
def _extract_field_schema(field_def: _FieldSchema) -> _FieldSchema:
|
||||
def _extract_field_schema(field_def: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Extract a simple schema from a Pydantic field definition for parameter display.
|
||||
|
||||
|
|
|
|||
|
|
@ -40,13 +40,6 @@ class _ManagedObjectTable(Protocol):
|
|||
async def update(self, *, where: Mapping[str, str], data: Mapping[str, str]) -> _ManagedObjectRow | None: ...
|
||||
|
||||
|
||||
class _ContainerListResponse(Protocol):
|
||||
data: Sequence[object]
|
||||
first_id: str | None
|
||||
last_id: str | None
|
||||
has_more: bool
|
||||
|
||||
|
||||
CONTAINER_OBJECT_PURPOSE: Final = "container"
|
||||
|
||||
# 60s LRU/TTL cache absorbs every container access check before it reaches
|
||||
|
|
@ -279,7 +272,8 @@ async def _get_container_owner(original_container_id: str, custom_llm_provider:
|
|||
if prisma_client is None:
|
||||
return None
|
||||
|
||||
row: Final[_ManagedObjectRow | None] = await ManagedObjectRepository(prisma_client).table.find_first(
|
||||
table: Final[_ManagedObjectTable] = ManagedObjectRepository(prisma_client).table
|
||||
row: Final[_ManagedObjectRow | None] = await table.find_first(
|
||||
where={
|
||||
"model_object_id": model_object_id,
|
||||
"file_purpose": CONTAINER_OBJECT_PURPOSE,
|
||||
|
|
@ -315,7 +309,8 @@ async def _get_stored_container_id(original_container_id: str, custom_llm_provid
|
|||
if prisma_client is None:
|
||||
return None
|
||||
|
||||
row: Final[_ManagedObjectRow | None] = await ManagedObjectRepository(prisma_client).table.find_first(
|
||||
table: Final[_ManagedObjectTable] = ManagedObjectRepository(prisma_client).table
|
||||
row: Final[_ManagedObjectRow | None] = await table.find_first(
|
||||
where={
|
||||
"model_object_id": model_object_id,
|
||||
"file_purpose": CONTAINER_OBJECT_PURPOSE,
|
||||
|
|
@ -359,9 +354,7 @@ def _get_container_list_data(response: object) -> Sequence[object] | None:
|
|||
return data if isinstance(data, list) else None
|
||||
|
||||
|
||||
def _set_container_list_data(
|
||||
response: _ContainerListResponse, data: list[object], removed_filtered_items: bool = False
|
||||
) -> _ContainerListResponse:
|
||||
def _set_container_list_data(response: Any, data: list[object], removed_filtered_items: bool = False) -> object:
|
||||
if isinstance(response, dict):
|
||||
response["data"] = data
|
||||
if data:
|
||||
|
|
@ -401,7 +394,8 @@ async def _get_allowed_container_ids(
|
|||
if prisma_client is None:
|
||||
return set()
|
||||
|
||||
rows: Final[Sequence[_ManagedObjectRow]] = await ManagedObjectRepository(prisma_client).table.find_many(
|
||||
table: Final[_ManagedObjectTable] = ManagedObjectRepository(prisma_client).table
|
||||
rows: Final[Sequence[_ManagedObjectRow]] = await table.find_many(
|
||||
where={
|
||||
"file_purpose": CONTAINER_OBJECT_PURPOSE,
|
||||
"created_by": {"in": owner_scopes},
|
||||
|
|
@ -416,10 +410,10 @@ async def _get_allowed_container_ids(
|
|||
|
||||
|
||||
async def filter_container_list_response(
|
||||
response: _ContainerListResponse,
|
||||
response: object,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
custom_llm_provider: str,
|
||||
) -> _ContainerListResponse:
|
||||
) -> object:
|
||||
if is_proxy_admin(user_api_key_dict):
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import contextlib
|
|||
import json
|
||||
import os
|
||||
import ssl
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from collections.abc import AsyncGenerator, AsyncIterable, Mapping, Sequence
|
||||
from ssl import SSLContext
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
|
|
@ -70,9 +70,13 @@ class _CatoRedactedChat(TypedDict, total=False):
|
|||
all_redacted_messages: Sequence[_CatoRedactedMessage]
|
||||
|
||||
|
||||
class _CatoAnalysisResult(TypedDict, total=False):
|
||||
policy_drill_down: Mapping[str, object]
|
||||
|
||||
|
||||
class _CatoAnalyzeResponse(TypedDict):
|
||||
required_action: _CatoRequiredAction
|
||||
analysis_result: NotRequired[Mapping[str, Mapping[str, object]]]
|
||||
required_action: NotRequired[_CatoRequiredAction | None]
|
||||
analysis_result: NotRequired[_CatoAnalysisResult]
|
||||
redacted_chat: NotRequired[_CatoRedactedChat]
|
||||
|
||||
|
||||
|
|
@ -202,7 +206,7 @@ class CatoNetworksGuardrail(CustomGuardrail):
|
|||
return []
|
||||
|
||||
@staticmethod
|
||||
def _iter_schema_string_refs(data: dict):
|
||||
def _iter_schema_string_refs(data: Mapping[str, Any]):
|
||||
"""Yield ``(container, key)`` for every non-empty schema string the proxy
|
||||
forwards to the model inside tool/function and structured-output schemas:
|
||||
each ``tools[].function`` and legacy ``functions[]`` entry plus the
|
||||
|
|
@ -244,7 +248,7 @@ class CatoNetworksGuardrail(CustomGuardrail):
|
|||
stack.extend(reversed(node))
|
||||
|
||||
@classmethod
|
||||
def _extra_inspection_sources(cls, data: dict) -> Sequence[tuple[str, Sequence[Mapping[str, str]]]]:
|
||||
def _extra_inspection_sources(cls, data: Mapping[str, Any]) -> Sequence[tuple[str, Sequence[Mapping[str, str]]]]:
|
||||
"""Text the proxy forwards to the model outside chat ``messages``:
|
||||
Responses-API ``input`` and ``instructions``, legacy completion
|
||||
``prompt`` and tool/function/``response_format`` schema strings. Returned
|
||||
|
|
@ -305,8 +309,8 @@ class CatoNetworksGuardrail(CustomGuardrail):
|
|||
|
||||
def _handle_block_action(
|
||||
self,
|
||||
analysis_result: Mapping[str, Mapping[str, object]],
|
||||
required_action: _CatoRequiredAction,
|
||||
analysis_result: _CatoAnalysisResult,
|
||||
required_action: Any,
|
||||
) -> None:
|
||||
detection_message: Final = required_action.get("detection_message", None)
|
||||
verbose_proxy_logger.info(
|
||||
|
|
@ -420,8 +424,8 @@ class CatoNetworksGuardrail(CustomGuardrail):
|
|||
|
||||
def _handle_block_action_on_output(
|
||||
self,
|
||||
analysis_result: Mapping[str, Mapping[str, object]],
|
||||
required_action: _CatoRequiredAction,
|
||||
analysis_result: _CatoAnalysisResult,
|
||||
required_action: Any,
|
||||
) -> None:
|
||||
detection_message: Final = required_action.get("detection_message", None)
|
||||
verbose_proxy_logger.info(
|
||||
|
|
@ -570,7 +574,7 @@ class CatoNetworksGuardrail(CustomGuardrail):
|
|||
async def async_post_call_streaming_iterator_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: AsyncGenerator[object, None],
|
||||
response: AsyncIterable[object],
|
||||
request_data: dict,
|
||||
) -> AsyncGenerator[ModelResponseStream, None]:
|
||||
from litellm.proxy.proxy_server import StreamingCallbackError
|
||||
|
|
@ -622,7 +626,7 @@ class CatoNetworksGuardrail(CustomGuardrail):
|
|||
async def forward_the_stream_to_cato(
|
||||
self,
|
||||
websocket: ClientConnection,
|
||||
response_iter: AsyncGenerator[object, None],
|
||||
response_iter: AsyncIterable[object],
|
||||
) -> None:
|
||||
async for chunk in response_iter:
|
||||
if isinstance(chunk, BaseModel):
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import time
|
|||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
|
|
@ -270,9 +270,7 @@ class PurviewGuardrailBase:
|
|||
# User ID resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_user_id(
|
||||
self, data: Mapping[str, Mapping[str, object]], user_api_key_dict: "UserAPIKeyAuth"
|
||||
) -> str | None:
|
||||
def _resolve_user_id(self, data: Mapping[str, object], user_api_key_dict: "UserAPIKeyAuth") -> str | None:
|
||||
"""Resolve the Entra user object ID from request data or auth context.
|
||||
|
||||
Returns the strongest available identity walking down four sources, in
|
||||
|
|
@ -295,7 +293,10 @@ class PurviewGuardrailBase:
|
|||
if hasattr(user_api_key_dict, "end_user_id") and user_api_key_dict.end_user_id:
|
||||
return str(user_api_key_dict.end_user_id)
|
||||
|
||||
metadata: Final[Mapping[str, object]] = data.get("metadata") or data.get("litellm_metadata") or {}
|
||||
metadata_value: Final[object] = data.get("metadata") or data.get("litellm_metadata") or {}
|
||||
if not isinstance(metadata_value, Mapping):
|
||||
return None
|
||||
metadata: Final[Mapping[str, object]] = metadata_value
|
||||
uid = metadata.get("user_api_key_user_id")
|
||||
if uid:
|
||||
return str(uid)
|
||||
|
|
@ -359,7 +360,7 @@ class PurviewGuardrailBase:
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _should_block(response: Mapping[str, Sequence[Mapping[str, str]]]) -> bool:
|
||||
def _should_block(response: dict[str, Any]) -> bool:
|
||||
"""Return True if any policyAction requires blocking."""
|
||||
for action in response.get("policyActions", []):
|
||||
odata_type = action.get("@odata.type", "")
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@ from litellm.types.proxy.claude_code_endpoints import (
|
|||
UpdatePluginRequest,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
get_marketplace,
|
||||
register_plugin,
|
||||
update_plugin,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_prisma():
|
||||
"""Stateful prisma mock that supports find_unique, create, and update."""
|
||||
"""Stateful prisma mock that supports find_unique, find_many, create, and update."""
|
||||
store: dict = {}
|
||||
|
||||
mock_client = MagicMock()
|
||||
|
|
@ -34,6 +35,12 @@ def _make_mock_prisma():
|
|||
async def _find_unique(where):
|
||||
return store.get(where.get("name"))
|
||||
|
||||
async def _find_many(where=None):
|
||||
records = list(store.values())
|
||||
if where and "enabled" in where:
|
||||
return [r for r in records if r.enabled == where["enabled"]]
|
||||
return records
|
||||
|
||||
async def _create(data):
|
||||
record = MagicMock()
|
||||
record.id = "test-id"
|
||||
|
|
@ -52,6 +59,7 @@ def _make_mock_prisma():
|
|||
return record
|
||||
|
||||
mock_table.find_unique = AsyncMock(side_effect=_find_unique)
|
||||
mock_table.find_many = AsyncMock(side_effect=_find_many)
|
||||
mock_table.create = AsyncMock(side_effect=_create)
|
||||
mock_table.update = AsyncMock(side_effect=_update)
|
||||
mock_client.db.litellm_claudecodeplugintable = mock_table
|
||||
|
|
@ -211,6 +219,23 @@ async def test_update_plugin_db_error_maps_to_structured_500():
|
|||
assert "connection lost" in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_marketplace_skips_plugin_with_null_manifest():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="good-plugin", source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
await table.create(data={"name": "null-manifest-plugin", "manifest_json": None, "enabled": True})
|
||||
|
||||
response = await get_marketplace()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert [plugin["name"] for plugin in body["plugins"]] == ["good-plugin"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_plugin_git_subdir_missing_url():
|
||||
"""git-subdir without url field raises HTTP 400."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue