unique-search-proxy-core 2026.44.0.dev2__tar.gz → 2026.44.0.dev4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/PKG-INFO +2 -2
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/pyproject.toml +2 -2
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/schema.py +4 -2
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/__init__.py +8 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/base.py +2 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/config_types.py +13 -9
- unique_search_proxy_core-2026.44.0.dev4/unique_search_proxy_core/search_engines/custom_api/__init__.py +11 -0
- unique_search_proxy_core-2026.44.0.dev4/unique_search_proxy_core/search_engines/custom_api/schema.py +81 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/__init__.py +4 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/dns.py +30 -7
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/models.py +15 -2
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/policy.py +32 -5
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/resolver.py +19 -4
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/service.py +19 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/settings.py +39 -2
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/README.md +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/bing/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/bing/enums.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/bing/grounding.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/bing/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/bing/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/output_schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/resolve.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/response_parsing.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/vertexai/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/vertexai/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/agent_engines/vertexai/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/context.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/basic/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/basic/content_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/basic/processing/policy.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/basic/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/firecrawl/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/jina/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/crawlers/tavily/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/errors.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/http_client/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/http_client/client.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/http_client/credentials.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/http_client/secrets.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/http_client/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/logging.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/param_policy/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/param_policy/annotations.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/param_policy/derive.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/param_policy/exposable_config.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/param_policy/exposable_param.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/param_policy/exposed_params.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/param_policy/request_base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/providers/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/brave/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/brave/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/google/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/google/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/perplexity/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/search_engines/perplexity/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/egress.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/netloc.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/url_safety/redirect.py +0 -0
{unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-search-proxy-core
|
|
3
|
-
Version: 2026.44.0.
|
|
3
|
+
Version: 2026.44.0.dev4
|
|
4
4
|
Summary: Shared Pydantic types for the Unique Search Proxy API
|
|
5
5
|
Author: ThePhilAz
|
|
6
6
|
Author-email: ThePhilAz <rami.azouz@philico.com>
|
|
@@ -8,7 +8,7 @@ Requires-Dist: httpx>=0.28.0,<0.29
|
|
|
8
8
|
Requires-Dist: pydantic>=2.12.5,<3.0.0
|
|
9
9
|
Requires-Dist: pydantic-settings>=2.10.1,<3
|
|
10
10
|
Requires-Dist: pyhumps>=3.8.0,<4
|
|
11
|
-
Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.
|
|
11
|
+
Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev9,<2026.44.0rc0
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
|
{unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/pyproject.toml
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique-search-proxy-core"
|
|
3
|
-
version = "2026.44.0.
|
|
3
|
+
version = "2026.44.0.dev4"
|
|
4
4
|
description = "Shared Pydantic types for the Unique Search Proxy API"
|
|
5
5
|
authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -10,7 +10,7 @@ dependencies = [
|
|
|
10
10
|
"pydantic>=2.12.5,<3.0.0",
|
|
11
11
|
"pydantic-settings>=2.10.1,<3",
|
|
12
12
|
"pyhumps>=3.8.0,<4",
|
|
13
|
-
"unique-toolkit[monitoring]>=2026.44.0.
|
|
13
|
+
"unique-toolkit[monitoring]>=2026.44.0.dev9,<2026.44.0rc0",
|
|
14
14
|
|
|
15
15
|
]
|
|
16
16
|
|
|
@@ -4,7 +4,7 @@ from enum import StrEnum
|
|
|
4
4
|
from typing import Annotated, Any, Literal, overload
|
|
5
5
|
|
|
6
6
|
import humps
|
|
7
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
8
8
|
from pydantic.alias_generators import to_camel
|
|
9
9
|
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
|
10
10
|
|
|
@@ -97,7 +97,9 @@ class WebSearchResult(BaseModel):
|
|
|
97
97
|
|
|
98
98
|
class WebSearchResults(BaseModel):
|
|
99
99
|
model_config = camelized_model_config
|
|
100
|
-
results: list[WebSearchResult]
|
|
100
|
+
results: list[WebSearchResult] = Field(
|
|
101
|
+
validation_alias=AliasChoices("results", "curated")
|
|
102
|
+
)
|
|
101
103
|
|
|
102
104
|
def __len__(self) -> int:
|
|
103
105
|
return len(self.results)
|
|
@@ -19,6 +19,11 @@ from unique_search_proxy_core.search_engines.config_types import (
|
|
|
19
19
|
parse_search_engine_config,
|
|
20
20
|
parse_search_request,
|
|
21
21
|
)
|
|
22
|
+
from unique_search_proxy_core.search_engines.custom_api.schema import (
|
|
23
|
+
CustomApiConfig,
|
|
24
|
+
CustomApiRequestMethod,
|
|
25
|
+
CustomApiSearchRequest,
|
|
26
|
+
)
|
|
22
27
|
from unique_search_proxy_core.search_engines.google.schema import (
|
|
23
28
|
GoogleConfig,
|
|
24
29
|
GoogleSearchRequest,
|
|
@@ -32,6 +37,9 @@ __all__ = [
|
|
|
32
37
|
"BaseSearchEngineConfig",
|
|
33
38
|
"BraveConfig",
|
|
34
39
|
"BraveSearchRequest",
|
|
40
|
+
"CustomApiConfig",
|
|
41
|
+
"CustomApiRequestMethod",
|
|
42
|
+
"CustomApiSearchRequest",
|
|
35
43
|
"GoogleConfig",
|
|
36
44
|
"GoogleSearchRequest",
|
|
37
45
|
"PerplexityConfig",
|
|
@@ -36,12 +36,14 @@ class SearchEngineType(StrEnum):
|
|
|
36
36
|
GOOGLE = "google"
|
|
37
37
|
BRAVE = "brave"
|
|
38
38
|
PERPLEXITY = "perplexity"
|
|
39
|
+
CUSTOM_API = "custom_api"
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
_SEARCH_ENGINE_MODE_MAP: dict[SearchEngineType, SearchEngineMode] = {
|
|
42
43
|
SearchEngineType.GOOGLE: SearchEngineMode.STANDARD,
|
|
43
44
|
SearchEngineType.BRAVE: SearchEngineMode.STANDARD,
|
|
44
45
|
SearchEngineType.PERPLEXITY: SearchEngineMode.STANDARD,
|
|
46
|
+
SearchEngineType.CUSTOM_API: SearchEngineMode.STANDARD,
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
|
|
@@ -6,20 +6,24 @@ from typing import Annotated, Any, Mapping, TypeAlias, Union, cast
|
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, Field, TypeAdapter
|
|
8
8
|
|
|
9
|
-
from unique_search_proxy_core.
|
|
10
|
-
|
|
11
|
-
SearchEngineType,
|
|
9
|
+
from unique_search_proxy_core.param_policy.exposable_config import (
|
|
10
|
+
ExposableParamsConfig,
|
|
12
11
|
)
|
|
12
|
+
from unique_search_proxy_core.search_engines.base import SearchEngineType
|
|
13
13
|
from unique_search_proxy_core.search_engines.brave.schema import BraveConfig
|
|
14
|
+
from unique_search_proxy_core.search_engines.custom_api.schema import CustomApiConfig
|
|
14
15
|
from unique_search_proxy_core.search_engines.google.schema import GoogleConfig
|
|
15
16
|
from unique_search_proxy_core.search_engines.perplexity.schema import PerplexityConfig
|
|
16
17
|
|
|
17
|
-
SearchEngineConfigTypes: TypeAlias =
|
|
18
|
+
SearchEngineConfigTypes: TypeAlias = (
|
|
19
|
+
GoogleConfig | BraveConfig | PerplexityConfig | CustomApiConfig
|
|
20
|
+
)
|
|
18
21
|
|
|
19
|
-
ENGINE_NAME_TO_CONFIG: dict[str, type[
|
|
22
|
+
ENGINE_NAME_TO_CONFIG: dict[str, type[ExposableParamsConfig]] = {
|
|
20
23
|
SearchEngineType.GOOGLE.value: GoogleConfig,
|
|
21
24
|
SearchEngineType.BRAVE.value: BraveConfig,
|
|
22
25
|
SearchEngineType.PERPLEXITY.value: PerplexityConfig,
|
|
26
|
+
SearchEngineType.CUSTOM_API.value: CustomApiConfig,
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
_search_engine_config_adapter: TypeAdapter[SearchEngineConfigTypes] = TypeAdapter(
|
|
@@ -33,7 +37,7 @@ def parse_search_engine_config(data: object) -> SearchEngineConfigTypes:
|
|
|
33
37
|
|
|
34
38
|
def get_search_engine_config_types_from_names(
|
|
35
39
|
engine_names: list[str],
|
|
36
|
-
) -> type[
|
|
40
|
+
) -> type[ExposableParamsConfig]:
|
|
37
41
|
"""Build a union of config models for the given engine slugs (runtime narrowing)."""
|
|
38
42
|
assert len(engine_names) >= 1, "At least one search engine must be active"
|
|
39
43
|
|
|
@@ -46,12 +50,12 @@ def get_search_engine_config_types_from_names(
|
|
|
46
50
|
raise ValueError(f"No search engine config found for names: {engine_names}")
|
|
47
51
|
if len(selected_types) == 1:
|
|
48
52
|
return selected_types[0]
|
|
49
|
-
return cast(type[
|
|
53
|
+
return cast(type[ExposableParamsConfig], reduce(operator.or_, selected_types))
|
|
50
54
|
|
|
51
55
|
|
|
52
56
|
def _union_members_from_mapping(
|
|
53
|
-
mapping: Mapping[str, type[
|
|
54
|
-
) -> tuple[type[
|
|
57
|
+
mapping: Mapping[str, type[ExposableParamsConfig]],
|
|
58
|
+
) -> tuple[type[ExposableParamsConfig], ...]:
|
|
55
59
|
return tuple(mapping.values())
|
|
56
60
|
|
|
57
61
|
|
unique_search_proxy_core-2026.44.0.dev4/unique_search_proxy_core/search_engines/custom_api/schema.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Annotated, ClassVar, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from unique_toolkit._common.pydantic.rjsf_tags import RJSFMetaTag
|
|
8
|
+
|
|
9
|
+
from unique_search_proxy_core.param_policy.derive import derive_request_model
|
|
10
|
+
from unique_search_proxy_core.param_policy.exposable_config import (
|
|
11
|
+
ExposableParamsConfig,
|
|
12
|
+
)
|
|
13
|
+
from unique_search_proxy_core.param_policy.request_base import SearchRequestBase
|
|
14
|
+
from unique_search_proxy_core.search_engines.base import SearchEngineType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CustomApiRequestMethod(StrEnum):
|
|
18
|
+
GET = "GET"
|
|
19
|
+
POST = "POST"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CustomApiConfig(ExposableParamsConfig):
|
|
23
|
+
"""Tenant-configured search endpoint executed by Search Proxy."""
|
|
24
|
+
|
|
25
|
+
_request_model_name: ClassVar[str] = "CustomApiSearchRequest"
|
|
26
|
+
_exposed_params_model_name: ClassVar[str] = "CustomApiExposedParams"
|
|
27
|
+
|
|
28
|
+
engine: Annotated[
|
|
29
|
+
Literal[SearchEngineType.CUSTOM_API], RJSFMetaTag.SpecialWidget.hidden()
|
|
30
|
+
] = Field(
|
|
31
|
+
default=SearchEngineType.CUSTOM_API,
|
|
32
|
+
title="Search engine",
|
|
33
|
+
description="Provider discriminator; must be `custom_api` for this config.",
|
|
34
|
+
)
|
|
35
|
+
api_endpoint: str = Field(
|
|
36
|
+
default="https://api.example.com/search",
|
|
37
|
+
title="API endpoint",
|
|
38
|
+
description="HTTP(S) endpoint called by Search Proxy.",
|
|
39
|
+
)
|
|
40
|
+
api_headers: str = Field(
|
|
41
|
+
default='{"Content-Type": "application/json"}',
|
|
42
|
+
title="API headers",
|
|
43
|
+
description="JSON object containing headers sent to the custom API.",
|
|
44
|
+
)
|
|
45
|
+
api_additional_query_params: str = Field(
|
|
46
|
+
default="{}",
|
|
47
|
+
title="Additional query parameters",
|
|
48
|
+
description="JSON object merged into the custom API query parameters.",
|
|
49
|
+
)
|
|
50
|
+
api_additional_body_params: str = Field(
|
|
51
|
+
default="{}",
|
|
52
|
+
title="Additional body parameters",
|
|
53
|
+
description="JSON object merged into the custom API JSON body.",
|
|
54
|
+
)
|
|
55
|
+
api_request_method: CustomApiRequestMethod = Field(
|
|
56
|
+
default=CustomApiRequestMethod.GET,
|
|
57
|
+
title="Request method",
|
|
58
|
+
description="HTTP method used to call the custom API.",
|
|
59
|
+
)
|
|
60
|
+
timeout: int = Field(
|
|
61
|
+
default=120,
|
|
62
|
+
description="Custom API request timeout in seconds.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def request_model(cls) -> type[BaseModel]:
|
|
67
|
+
return derive_request_model(
|
|
68
|
+
cls,
|
|
69
|
+
base=SearchRequestBase,
|
|
70
|
+
name=cls._request_model_name,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
CustomApiSearchRequest = CustomApiConfig.request_model()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"CustomApiConfig",
|
|
79
|
+
"CustomApiRequestMethod",
|
|
80
|
+
"CustomApiSearchRequest",
|
|
81
|
+
]
|
|
@@ -5,8 +5,10 @@ from unique_search_proxy_core.url_safety.egress import (
|
|
|
5
5
|
from unique_search_proxy_core.url_safety.models import (
|
|
6
6
|
BlockedCrawlTarget,
|
|
7
7
|
CrawlTargetValidationError,
|
|
8
|
+
CustomApiTargetValidationError,
|
|
8
9
|
ResolvedCrawlTarget,
|
|
9
10
|
UrlSafetyOutcome,
|
|
11
|
+
UrlSafetyValidationError,
|
|
10
12
|
bypass_crawl_target,
|
|
11
13
|
)
|
|
12
14
|
from unique_search_proxy_core.url_safety.service import UrlSafetyService
|
|
@@ -18,7 +20,9 @@ from unique_search_proxy_core.url_safety.settings import (
|
|
|
18
20
|
__all__ = [
|
|
19
21
|
"BlockedCrawlTarget",
|
|
20
22
|
"CrawlTargetValidationError",
|
|
23
|
+
"CustomApiTargetValidationError",
|
|
21
24
|
"ResolvedCrawlTarget",
|
|
25
|
+
"UrlSafetyValidationError",
|
|
22
26
|
"UrlSafetyOutcome",
|
|
23
27
|
"UrlSafetyService",
|
|
24
28
|
"UrlSafetySettings",
|
|
@@ -2,7 +2,19 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import socket
|
|
5
|
-
from ipaddress import ip_address
|
|
5
|
+
from ipaddress import IPv4Network, IPv6Network, ip_address, ip_network
|
|
6
|
+
|
|
7
|
+
_TRUSTED_PRIVATE_NETWORKS: tuple[IPv4Network | IPv6Network, ...] = (
|
|
8
|
+
ip_network("10.0.0.0/8"),
|
|
9
|
+
ip_network("172.16.0.0/12"),
|
|
10
|
+
ip_network("192.168.0.0/16"),
|
|
11
|
+
ip_network("fc00::/7"),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_trusted_private_address(address: str) -> bool:
|
|
16
|
+
parsed_address = ip_address(address)
|
|
17
|
+
return any(parsed_address in network for network in _TRUSTED_PRIVATE_NETWORKS)
|
|
6
18
|
|
|
7
19
|
|
|
8
20
|
async def resolve_host_addresses(host: str) -> tuple[str, ...]:
|
|
@@ -17,6 +29,8 @@ async def resolve_host_addresses(host: str) -> tuple[str, ...]:
|
|
|
17
29
|
|
|
18
30
|
def block_reason_for_resolved_addresses(
|
|
19
31
|
resolved_addresses: tuple[str, ...],
|
|
32
|
+
*,
|
|
33
|
+
allow_trusted_private: bool = False,
|
|
20
34
|
) -> tuple[str, str] | None:
|
|
21
35
|
if not resolved_addresses:
|
|
22
36
|
return (
|
|
@@ -25,17 +39,23 @@ def block_reason_for_resolved_addresses(
|
|
|
25
39
|
)
|
|
26
40
|
|
|
27
41
|
for resolved_address in resolved_addresses:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
address = ip_address(resolved_address)
|
|
43
|
+
if address.is_global:
|
|
44
|
+
continue
|
|
45
|
+
if allow_trusted_private and is_trusted_private_address(resolved_address):
|
|
46
|
+
continue
|
|
47
|
+
return (
|
|
48
|
+
"private",
|
|
49
|
+
"Target host resolves to a private or special-use IP address",
|
|
50
|
+
)
|
|
33
51
|
|
|
34
52
|
return None
|
|
35
53
|
|
|
36
54
|
|
|
37
55
|
async def resolve_and_validate_host(
|
|
38
56
|
host: str,
|
|
57
|
+
*,
|
|
58
|
+
allow_trusted_private: bool = False,
|
|
39
59
|
) -> tuple[tuple[str, ...], tuple[str, str] | None]:
|
|
40
60
|
try:
|
|
41
61
|
resolved_addresses = await resolve_host_addresses(host)
|
|
@@ -45,7 +65,10 @@ async def resolve_and_validate_host(
|
|
|
45
65
|
"Target host could not be resolved during safety validation",
|
|
46
66
|
)
|
|
47
67
|
|
|
48
|
-
return resolved_addresses, block_reason_for_resolved_addresses(
|
|
68
|
+
return resolved_addresses, block_reason_for_resolved_addresses(
|
|
69
|
+
resolved_addresses,
|
|
70
|
+
allow_trusted_private=allow_trusted_private,
|
|
71
|
+
)
|
|
49
72
|
|
|
50
73
|
|
|
51
74
|
async def validate_resolved_host(host: str) -> tuple[str, str] | None:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
4
5
|
from urllib.parse import urlsplit
|
|
5
6
|
|
|
6
7
|
from unique_search_proxy_core.url_safety.netloc import (
|
|
@@ -60,7 +61,9 @@ class UrlSafetyOutcome:
|
|
|
60
61
|
blocked: BlockedCrawlTarget | None = None
|
|
61
62
|
|
|
62
63
|
|
|
63
|
-
class
|
|
64
|
+
class UrlSafetyValidationError(ValueError):
|
|
65
|
+
target_label: ClassVar[str] = "URL"
|
|
66
|
+
|
|
64
67
|
def __init__(self, blocked_targets: list[BlockedCrawlTarget]) -> None:
|
|
65
68
|
self.blocked_targets: list[BlockedCrawlTarget] = blocked_targets
|
|
66
69
|
|
|
@@ -68,7 +71,17 @@ class CrawlTargetValidationError(ValueError):
|
|
|
68
71
|
f"{target.display_target} ({target.reason})"
|
|
69
72
|
for target in self.blocked_targets
|
|
70
73
|
)
|
|
71
|
-
super().__init__(
|
|
74
|
+
super().__init__(
|
|
75
|
+
f"Blocked {self.target_label} target(s) due to URL safety policy: {details}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CrawlTargetValidationError(UrlSafetyValidationError):
|
|
80
|
+
target_label = "crawl"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CustomApiTargetValidationError(UrlSafetyValidationError):
|
|
84
|
+
target_label = "Custom API"
|
|
72
85
|
|
|
73
86
|
|
|
74
87
|
def bypass_crawl_target(url: str) -> ResolvedCrawlTarget:
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Collection
|
|
3
4
|
from ipaddress import ip_address
|
|
4
5
|
from urllib.parse import urlsplit
|
|
5
6
|
|
|
7
|
+
from unique_search_proxy_core.url_safety import dns
|
|
6
8
|
from unique_search_proxy_core.url_safety.settings import url_safety_settings
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
def
|
|
11
|
+
def is_trusted_private_host(
|
|
12
|
+
hostname: str,
|
|
13
|
+
trusted_private_hosts: Collection[str],
|
|
14
|
+
) -> bool:
|
|
15
|
+
normalized_host = hostname.rstrip(".").lower()
|
|
16
|
+
return normalized_host in {
|
|
17
|
+
host.rstrip(".").lower() for host in trusted_private_hosts
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_target_cheap(
|
|
22
|
+
url: str,
|
|
23
|
+
*,
|
|
24
|
+
trusted_private_hosts: Collection[str] = (),
|
|
25
|
+
) -> tuple[str, str] | None:
|
|
10
26
|
"""Static validation only — no DNS. Returns (category, reason) if blocked, None otherwise."""
|
|
11
27
|
if not url:
|
|
12
28
|
return "empty", "URL is empty"
|
|
@@ -30,10 +46,19 @@ def validate_target_cheap(url: str) -> tuple[str, str] | None:
|
|
|
30
46
|
):
|
|
31
47
|
return "localhost", "Target points to a localhost host"
|
|
32
48
|
|
|
33
|
-
|
|
49
|
+
trusted_private_host = is_trusted_private_host(
|
|
50
|
+
normalized_host,
|
|
51
|
+
trusted_private_hosts,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not trusted_private_host and normalized_host.endswith(
|
|
55
|
+
url_safety_settings.service_suffix
|
|
56
|
+
):
|
|
34
57
|
return "cluster", "Target points to an internal service host"
|
|
35
58
|
|
|
36
|
-
if normalized_host.endswith(
|
|
59
|
+
if not trusted_private_host and normalized_host.endswith(
|
|
60
|
+
url_safety_settings.cluster_local_suffix
|
|
61
|
+
):
|
|
37
62
|
return "cluster", "Target points to an internal cluster-local host"
|
|
38
63
|
|
|
39
64
|
try:
|
|
@@ -41,12 +66,14 @@ def validate_target_cheap(url: str) -> tuple[str, str] | None:
|
|
|
41
66
|
except ValueError:
|
|
42
67
|
pass
|
|
43
68
|
else:
|
|
44
|
-
if not target_ip.is_global
|
|
69
|
+
if not target_ip.is_global and not (
|
|
70
|
+
trusted_private_host and dns.is_trusted_private_address(normalized_host)
|
|
71
|
+
):
|
|
45
72
|
return "private", "Target points to a private or special-use IP address"
|
|
46
73
|
|
|
47
74
|
return None
|
|
48
75
|
|
|
49
|
-
if "." not in normalized_host:
|
|
76
|
+
if not trusted_private_host and "." not in normalized_host:
|
|
50
77
|
return "cluster", "Target points to a single-label internal host"
|
|
51
78
|
|
|
52
79
|
return None
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
from collections.abc import Collection
|
|
4
5
|
from ipaddress import ip_address
|
|
5
6
|
from urllib.parse import urlsplit
|
|
6
7
|
|
|
@@ -11,14 +12,24 @@ from unique_search_proxy_core.url_safety.models import (
|
|
|
11
12
|
ResolvedCrawlTarget,
|
|
12
13
|
)
|
|
13
14
|
from unique_search_proxy_core.url_safety.netloc import extract_hostname
|
|
14
|
-
from unique_search_proxy_core.url_safety.policy import
|
|
15
|
+
from unique_search_proxy_core.url_safety.policy import (
|
|
16
|
+
is_trusted_private_host,
|
|
17
|
+
validate_target_cheap,
|
|
18
|
+
)
|
|
15
19
|
|
|
16
20
|
|
|
17
|
-
async def resolve_crawl_target(
|
|
21
|
+
async def resolve_crawl_target(
|
|
22
|
+
url: str,
|
|
23
|
+
*,
|
|
24
|
+
trusted_private_hosts: Collection[str] = (),
|
|
25
|
+
) -> ResolvedCrawlTarget:
|
|
18
26
|
"""Validate and resolve a single URL, performing DNS exactly once."""
|
|
19
27
|
normalized_url = url.strip()
|
|
20
28
|
|
|
21
|
-
validation_error = validate_target_cheap(
|
|
29
|
+
validation_error = validate_target_cheap(
|
|
30
|
+
normalized_url,
|
|
31
|
+
trusted_private_hosts=trusted_private_hosts,
|
|
32
|
+
)
|
|
22
33
|
if validation_error is not None:
|
|
23
34
|
category, reason = validation_error
|
|
24
35
|
raise CrawlTargetValidationError(
|
|
@@ -50,7 +61,11 @@ async def resolve_crawl_target(url: str) -> ResolvedCrawlTarget:
|
|
|
50
61
|
target_ip = ip_address(normalized_host)
|
|
51
62
|
except ValueError:
|
|
52
63
|
resolved_addresses, validation_error = await dns.resolve_and_validate_host(
|
|
53
|
-
normalized_host
|
|
64
|
+
normalized_host,
|
|
65
|
+
allow_trusted_private=is_trusted_private_host(
|
|
66
|
+
normalized_host,
|
|
67
|
+
trusted_private_hosts,
|
|
68
|
+
),
|
|
54
69
|
)
|
|
55
70
|
if validation_error is not None:
|
|
56
71
|
category, reason = validation_error
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from collections.abc import Collection
|
|
4
5
|
from ipaddress import ip_address
|
|
5
6
|
from urllib.parse import urlsplit
|
|
6
7
|
|
|
@@ -10,6 +11,7 @@ from unique_search_proxy_core.url_safety import dns, redirect, resolver
|
|
|
10
11
|
from unique_search_proxy_core.url_safety.models import (
|
|
11
12
|
BlockedCrawlTarget,
|
|
12
13
|
CrawlTargetValidationError,
|
|
14
|
+
CustomApiTargetValidationError,
|
|
13
15
|
ResolvedCrawlTarget,
|
|
14
16
|
UrlSafetyOutcome,
|
|
15
17
|
bypass_crawl_target,
|
|
@@ -124,3 +126,20 @@ class UrlSafetyService:
|
|
|
124
126
|
@staticmethod
|
|
125
127
|
async def resolve_crawl_target(url: str) -> ResolvedCrawlTarget:
|
|
126
128
|
return await resolver.resolve_crawl_target(url)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
async def resolve_custom_api_target(
|
|
132
|
+
url: str,
|
|
133
|
+
*,
|
|
134
|
+
trusted_private_hosts: Collection[str],
|
|
135
|
+
) -> ResolvedCrawlTarget:
|
|
136
|
+
if not url_safety_settings.enabled:
|
|
137
|
+
return bypass_crawl_target(url)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
return await resolver.resolve_crawl_target(
|
|
141
|
+
url,
|
|
142
|
+
trusted_private_hosts=trusted_private_hosts,
|
|
143
|
+
)
|
|
144
|
+
except CrawlTargetValidationError as exc:
|
|
145
|
+
raise CustomApiTargetValidationError(exc.blocked_targets) from exc
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import AliasChoices, Field
|
|
4
8
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
9
|
|
|
6
10
|
|
|
@@ -27,6 +31,17 @@ class UrlSafetySettings(BaseSettings):
|
|
|
27
31
|
"metadata.azure.internal",
|
|
28
32
|
"metadata.google.internal",
|
|
29
33
|
]
|
|
34
|
+
custom_api_trusted_private_hosts: list[str] = Field(
|
|
35
|
+
default_factory=list,
|
|
36
|
+
validation_alias=AliasChoices(
|
|
37
|
+
"CUSTOM_WEB_SEARCH_API_TRUSTED_PRIVATE_HOSTS",
|
|
38
|
+
"custom_api_trusted_private_hosts",
|
|
39
|
+
),
|
|
40
|
+
description=(
|
|
41
|
+
"Exact operator-approved Custom API hostnames or IPs that may resolve "
|
|
42
|
+
"to RFC 1918 or IPv6 unique-local addresses."
|
|
43
|
+
),
|
|
44
|
+
)
|
|
30
45
|
cluster_local_suffix: str = ".cluster.local"
|
|
31
46
|
service_suffix: str = ".svc"
|
|
32
47
|
max_redirect_hops: int = 10
|
|
@@ -40,4 +55,26 @@ class UrlSafetySettings(BaseSettings):
|
|
|
40
55
|
)
|
|
41
56
|
|
|
42
57
|
|
|
43
|
-
|
|
58
|
+
def _get_settings() -> UrlSafetySettings:
|
|
59
|
+
if "pytest" in sys.modules:
|
|
60
|
+
env_file = Path(os.getcwd()) / "tests/test.env"
|
|
61
|
+
else:
|
|
62
|
+
env_file = Path(os.getcwd()) / ".env"
|
|
63
|
+
|
|
64
|
+
class _Settings(UrlSafetySettings):
|
|
65
|
+
model_config = SettingsConfigDict(
|
|
66
|
+
title=UrlSafetySettings.__name__,
|
|
67
|
+
env_file=env_file,
|
|
68
|
+
env_file_encoding="utf-8",
|
|
69
|
+
env_prefix="URL_SAFETY_",
|
|
70
|
+
extra="ignore",
|
|
71
|
+
case_sensitive=False,
|
|
72
|
+
frozen=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# This subclass only selects the env file; keep generated config docs stable.
|
|
76
|
+
_Settings.__name__ = UrlSafetySettings.__name__
|
|
77
|
+
return _Settings()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
url_safety_settings = _get_settings()
|
{unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|