Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions openviking/server/mcp_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 6 additions & 5 deletions openviking/server/routers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion openviking/server/routers/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions tests/server/test_search_threshold_bounds.py
Original file line number Diff line number Diff line change
@@ -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