diff --git a/openviking/server/mcp_endpoint.py b/openviking/server/mcp_endpoint.py index 13f22ee695..5b981a9390 100644 --- a/openviking/server/mcp_endpoint.py +++ b/openviking/server/mcp_endpoint.py @@ -73,7 +73,7 @@ looks_like_local_path, ) from openviking.server.resource_ingest import ingest_temp_upload -from openviking.server.routers.search import context_only_fields_error +from openviking.server.routers.search import FiniteSearchThreshold, context_only_fields_error from openviking.server.skill_ingest import install_skills from openviking.server.temp_upload_store import TempUploadStore from openviking.server.upload_token_store import upload_token_store @@ -445,7 +445,7 @@ async def find( query: str, target_uri: str = "", limit: int = 10, - min_score: float = 0.35, + min_score: FiniteSearchThreshold = 0.35, level: Optional[List[int]] = None, context_type: Optional[Union[str, List[str]]] = None, read_content: bool = False, @@ -507,7 +507,7 @@ async def search( target_uri: str = "", session_id: Optional[str] = None, limit: int = 10, - min_score: Optional[float] = None, + min_score: Optional[FiniteSearchThreshold] = None, level: Optional[List[int]] = None, context_type: Optional[Union[str, List[str]]] = None, mode: Literal["list", "context"] = "list", diff --git a/openviking/server/routers/search.py b/openviking/server/routers/search.py index 26e4fe9d6d..d860651eed 100644 --- a/openviking/server/routers/search.py +++ b/openviking/server/routers/search.py @@ -4,7 +4,7 @@ import asyncio import math -from typing import Any, Dict, List, Literal, Optional, Sequence, Union +from typing import Annotated, Any, Dict, List, Literal, Optional, Sequence, Union from fastapi import APIRouter, Depends, Request from fastapi import Response as FastAPIResponse @@ -65,6 +65,7 @@ def _sanitize_floats(obj: Any) -> Any: router = APIRouter(prefix="/api/v1/search", tags=["search"]) TimeField = Literal["updated_at", "created_at"] +FiniteSearchThreshold = Annotated[float, Field(allow_inf_nan=False)] def _resolve_search_limit(limit: int, node_limit: Optional[int]) -> int: @@ -132,7 +133,7 @@ class FindRequest(BaseModel): context_type: Optional[Union[str, List[str]]] = None limit: int = Field(default=DEFAULT_LIMIT, ge=1) node_limit: Optional[int] = Field(default=None, ge=1) - score_threshold: Optional[float] = None + score_threshold: Optional[FiniteSearchThreshold] = None filter: Optional[Dict[str, Any]] = None include_provenance: bool = False tags: Optional[List[str]] = None @@ -226,7 +227,7 @@ class SearchRequest(BaseModel): session_id: Optional[str] = None limit: int = Field(default=DEFAULT_LIMIT, ge=1) node_limit: Optional[int] = Field(default=None, ge=1) - score_threshold: Optional[float] = None + score_threshold: Optional[FiniteSearchThreshold] = None filter: Optional[Dict[str, Any]] = None include_provenance: bool = False tags: Optional[List[str]] = None @@ -317,7 +318,7 @@ class RecallRequest(BaseModel): query: str quotas: Optional[Dict[str, int]] = None max_chars: int = DEFAULT_MAX_CHARS - min_score: float = DEFAULT_MIN_SCORE + min_score: FiniteSearchThreshold = DEFAULT_MIN_SCORE peer_scope: Literal["actor", "all"] = "all" other_peer_penalty: Optional[Union[float, Dict[str, float]]] = None render: Union[bool, Literal["full", "compact"]] = True @@ -328,7 +329,7 @@ class RecallRequest(BaseModel): max_tokens: Optional[int] = Field(default=None, ge=64, le=32000) purpose: Optional[Literal["chat", "coding"]] = None detail: Optional[DetailRequest] = None - score_threshold: Optional[float] = None + score_threshold: Optional[FiniteSearchThreshold] = None dedup_turns: Optional[int] = Field(default=None, ge=0, le=100) exclude_uris: List[str] = Field(default_factory=list, max_length=MAX_EXCLUDE_URIS) rewrite: Union[bool, Literal["auto"]] = False diff --git a/openviking/server/routers/skills.py b/openviking/server/routers/skills.py index 09659519fe..cd30820ff4 100644 --- a/openviking/server/routers/skills.py +++ b/openviking/server/routers/skills.py @@ -28,6 +28,7 @@ from openviking.server.dependencies import get_service from openviking.server.identity import RequestContext from openviking.server.models import Response +from openviking.server.routers.search import FiniteSearchThreshold from openviking.server.skill_package_update import ( clear_skill_package_contents, transfer_skill_package, @@ -89,7 +90,7 @@ class FindSkillsRequest(BaseModel): query: str limit: int = 10 - score_threshold: Optional[float] = None + score_threshold: Optional[FiniteSearchThreshold] = None level: Optional[list[int]] = None telemetry: TelemetryRequest = False target_uri: Optional[str] = None diff --git a/tests/server/test_search_threshold_bounds.py b/tests/server/test_search_threshold_bounds.py new file mode 100644 index 0000000000..6f66db20b6 --- /dev/null +++ b/tests/server/test_search_threshold_bounds.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 + +"""Public search thresholds must reject non-finite values before retrieval.""" + +import math + +import pytest +from pydantic import ValidationError + +import openviking.server.mcp_endpoint as mcp_endpoint +from openviking.server.routers.search import FindRequest, RecallRequest, SearchRequest +from openviking.server.routers.skills import FindSkillsRequest + +REQUEST_FIELDS = ( + (FindRequest, "score_threshold"), + (SearchRequest, "score_threshold"), + (RecallRequest, "min_score"), + (RecallRequest, "score_threshold"), + (FindSkillsRequest, "score_threshold"), +) + + +@pytest.mark.parametrize("model,field", REQUEST_FIELDS) +@pytest.mark.parametrize("value", (math.nan, math.inf, -math.inf)) +def test_request_models_reject_non_finite_thresholds(model, field, value): + with pytest.raises(ValidationError, match="finite number"): + model.model_validate({"query": "q", field: value}) + + +@pytest.mark.parametrize("model,field", REQUEST_FIELDS) +@pytest.mark.parametrize("value", (-2.0, 2.0)) +def test_request_models_preserve_unbounded_finite_score_scales(model, field, value): + request = model.model_validate({"query": "q", field: value}) + + assert getattr(request, field) == value + + +@pytest.mark.parametrize("value", ("1e999", "-1e999", "NaN")) +def test_search_request_rejects_non_finite_json_thresholds(value): + with pytest.raises(ValidationError, match="finite number"): + SearchRequest.model_validate_json(f'{{"query":"q","score_threshold":{value}}}') + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_name", ("find", "search")) +@pytest.mark.parametrize("value", (math.nan, math.inf, -math.inf)) +async def test_mcp_tools_reject_non_finite_thresholds(tool_name, value): + with pytest.raises(Exception) as exc_info: + await mcp_endpoint.mcp.call_tool(tool_name, {"query": "q", "min_score": value}) + + message = str(exc_info.value) + assert "min_score" in message + assert "finite number" in message