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.
Files changed (66) hide show
  1. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/PKG-INFO +2 -2
  2. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/pyproject.toml +2 -2
  3. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/schema.py +4 -2
  4. {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
  5. {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
  6. {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
  7. unique_search_proxy_core-2026.44.0.dev4/unique_search_proxy_core/search_engines/custom_api/__init__.py +11 -0
  8. unique_search_proxy_core-2026.44.0.dev4/unique_search_proxy_core/search_engines/custom_api/schema.py +81 -0
  9. {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
  10. {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
  11. {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
  12. {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
  13. {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
  14. {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
  15. {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
  16. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/README.md +0 -0
  17. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/__init__.py +0 -0
  18. {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
  19. {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
  20. {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
  21. {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
  22. {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
  23. {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
  24. {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
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
  30. {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
  31. {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
  32. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/context.py +0 -0
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/errors.py +0 -0
  44. {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
  45. {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
  46. {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
  47. {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
  48. {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
  49. {unique_search_proxy_core-2026.44.0.dev2 → unique_search_proxy_core-2026.44.0.dev4}/unique_search_proxy_core/logging.py +0 -0
  50. {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
  51. {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
  52. {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
  53. {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
  54. {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
  55. {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
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {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
  66. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-search-proxy-core
3
- Version: 2026.44.0.dev2
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.dev7,<2026.44.0rc0
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique-search-proxy-core"
3
- version = "2026.44.0.dev2"
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.dev7,<2026.44.0rc0",
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.search_engines.base import (
10
- BaseSearchEngineConfig,
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 = GoogleConfig | BraveConfig | PerplexityConfig
18
+ SearchEngineConfigTypes: TypeAlias = (
19
+ GoogleConfig | BraveConfig | PerplexityConfig | CustomApiConfig
20
+ )
18
21
 
19
- ENGINE_NAME_TO_CONFIG: dict[str, type[BaseSearchEngineConfig]] = {
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[BaseSearchEngineConfig]:
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[BaseSearchEngineConfig], reduce(operator.or_, selected_types))
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[BaseSearchEngineConfig]],
54
- ) -> tuple[type[BaseSearchEngineConfig], ...]:
57
+ mapping: Mapping[str, type[ExposableParamsConfig]],
58
+ ) -> tuple[type[ExposableParamsConfig], ...]:
55
59
  return tuple(mapping.values())
56
60
 
57
61
 
@@ -0,0 +1,11 @@
1
+ from unique_search_proxy_core.search_engines.custom_api.schema import (
2
+ CustomApiConfig,
3
+ CustomApiRequestMethod,
4
+ CustomApiSearchRequest,
5
+ )
6
+
7
+ __all__ = [
8
+ "CustomApiConfig",
9
+ "CustomApiRequestMethod",
10
+ "CustomApiSearchRequest",
11
+ ]
@@ -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
- if not ip_address(resolved_address).is_global:
29
- return (
30
- "private",
31
- "Target host resolves to a private or special-use IP address",
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(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 CrawlTargetValidationError(ValueError):
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__(f"Blocked crawl target(s) due to URL safety policy: {details}")
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 validate_target_cheap(url: str) -> tuple[str, str] | None:
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
- if normalized_host.endswith(url_safety_settings.service_suffix):
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(url_safety_settings.cluster_local_suffix):
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 validate_target_cheap
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(url: str) -> ResolvedCrawlTarget:
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(normalized_url)
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
- from pydantic import Field
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
- url_safety_settings = UrlSafetySettings()
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()