unique-search-proxy 2026.44.0.dev1__tar.gz → 2026.44.0.dev3__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 (117) hide show
  1. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/PKG-INFO +25 -3
  2. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/README.md +22 -0
  3. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/pyproject.toml +3 -3
  4. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/__init__.py +14 -0
  5. unique_search_proxy-2026.44.0.dev3/unique_search_proxy_client/web/core/search_engines/custom_api/__init__.py +5 -0
  6. unique_search_proxy-2026.44.0.dev3/unique_search_proxy_client/web/core/search_engines/custom_api/service.py +173 -0
  7. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/factory.py +5 -0
  8. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/generator/introspect.py +13 -2
  9. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/url_safety_registration.py +2 -0
  10. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/startup_report.py +6 -5
  11. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/__init__.py +0 -0
  12. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/__init__.py +0 -0
  13. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/__init__.py +0 -0
  14. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/health.py +0 -0
  15. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/v1/__init__.py +0 -0
  16. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/v1/agent_search.py +0 -0
  17. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/v1/configuration.py +0 -0
  18. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/v1/context_headers.py +0 -0
  19. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/v1/crawl.py +0 -0
  20. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/v1/openapi_examples.py +0 -0
  21. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/api/v1/search.py +0 -0
  22. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/app.py +0 -0
  23. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/context.py +0 -0
  24. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/__init__.py +0 -0
  25. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/__init__.py +0 -0
  26. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/bing/cleanup.py +0 -0
  27. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/bing/client.py +0 -0
  28. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/bing/runner.py +0 -0
  29. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/bing/service.py +0 -0
  30. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/factory.py +0 -0
  31. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/service_base.py +0 -0
  32. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/structured_output.py +0 -0
  33. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/vertexai/client.py +0 -0
  34. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/vertexai/gemini.py +0 -0
  35. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/agent_engines/vertexai/service.py +0 -0
  36. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/client/__init__.py +0 -0
  37. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/client/azure_transport.py +0 -0
  38. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/client/credentials.py +0 -0
  39. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/client/service.py +0 -0
  40. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/__init__.py +0 -0
  41. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/__init__.py +0 -0
  42. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/__init__.py +0 -0
  43. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/errors.py +0 -0
  44. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/html_markdown.py +0 -0
  45. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/__init__.py +0 -0
  46. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/html.py +0 -0
  47. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/pdf.py +0 -0
  48. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/plain_text.py +0 -0
  49. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/processing/registry.py +0 -0
  50. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/service.py +0 -0
  51. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/settings.py +0 -0
  52. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/basic/user_agent.py +0 -0
  53. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/factory.py +0 -0
  54. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/firecrawl/polling.py +0 -0
  55. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/firecrawl/request_body.py +0 -0
  56. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/firecrawl/service.py +0 -0
  57. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/jina/request_body.py +0 -0
  58. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/jina/service.py +0 -0
  59. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/pinned_egress.py +0 -0
  60. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/tavily/request_body.py +0 -0
  61. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/crawlers/tavily/service.py +0 -0
  62. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/provider_response.py +0 -0
  63. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/providers.py +0 -0
  64. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/registry.py +0 -0
  65. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/brave/__init__.py +0 -0
  66. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/brave/pagination.py +0 -0
  67. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/brave/query_params.py +0 -0
  68. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/brave/service.py +0 -0
  69. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/descriptor.py +0 -0
  70. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/google/__init__.py +0 -0
  71. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/google/pagination.py +0 -0
  72. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/google/query_params.py +0 -0
  73. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/google/service.py +0 -0
  74. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/pagination.py +0 -0
  75. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/perplexity/__init__.py +0 -0
  76. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/perplexity/request_body.py +0 -0
  77. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/perplexity/service.py +0 -0
  78. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/search_engines/service_base.py +0 -0
  79. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/url_safety/__init__.py +0 -0
  80. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/core/url_safety/gate.py +0 -0
  81. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/error_handlers.py +0 -0
  82. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/__init__.py +0 -0
  83. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/generator/__init__.py +0 -0
  84. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/generator/schema.py +0 -0
  85. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/generator/template.py +0 -0
  86. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/generator/templates/provider_values_yaml.j2 +0 -0
  87. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/generator/values_yaml.py +0 -0
  88. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/metadata.py +0 -0
  89. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/helm/registry.py +0 -0
  90. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/logging_config.py +0 -0
  91. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/middleware/context.py +0 -0
  92. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/monitoring/__init__.py +0 -0
  93. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/monitoring/metrics.py +0 -0
  94. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/monitoring/setup.py +0 -0
  95. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/presets/__init__.py +0 -0
  96. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/presets/common.py +0 -0
  97. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/presets/crawl.py +0 -0
  98. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/presets/search.py +0 -0
  99. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/presets/types.py +0 -0
  100. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/__init__.py +0 -0
  101. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/app.py +0 -0
  102. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/base.py +0 -0
  103. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/client.py +0 -0
  104. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/monitoring.py +0 -0
  105. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/__init__.py +0 -0
  106. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/base.py +0 -0
  107. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/bing_agent.py +0 -0
  108. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/brave.py +0 -0
  109. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/firecrawl.py +0 -0
  110. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/google.py +0 -0
  111. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/jina.py +0 -0
  112. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/perplexity.py +0 -0
  113. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/tavily.py +0 -0
  114. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/providers/vertexai_agent.py +0 -0
  115. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/settings/secret_str.py +0 -0
  116. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/utils/__init__.py +0 -0
  117. {unique_search_proxy-2026.44.0.dev1 → unique_search_proxy-2026.44.0.dev3}/unique_search_proxy_client/web/utils/url.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-search-proxy
3
- Version: 2026.44.0.dev1
3
+ Version: 2026.44.0.dev3
4
4
  Summary: Web Search Proxy implementation
5
5
  Author: ThePhilAz
6
6
  Author-email: ThePhilAz <rami.azouz@philico.com>
@@ -20,8 +20,8 @@ Requires-Dist: certifi>=2025.11.12,<2027
20
20
  Requires-Dist: google-genai>=1.73.0,<2
21
21
  Requires-Dist: google-auth>=2.43.0,<3
22
22
  Requires-Dist: jinja2>=3.1.6,<4
23
- Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev2,<2026.44.0rc0
24
- Requires-Dist: unique-search-proxy-core>=2026.44.0.dev1,<2026.44.0rc0
23
+ Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev7,<2026.44.0rc0
24
+ Requires-Dist: unique-search-proxy-core>=2026.44.0.dev3,<2026.44.0rc0
25
25
  Requires-Dist: openai>=1.105.0,<3
26
26
  Requires-Python: >=3.12
27
27
  Description-Content-Type: text/markdown
@@ -172,6 +172,23 @@ Response includes **curated** normalised results and opaque **raw** provider pay
172
172
  }
173
173
  ```
174
174
 
175
+ Customized API endpoints must return a JSON object with a `results` array:
176
+
177
+ ```json
178
+ {
179
+ "results": [
180
+ {
181
+ "url": "https://example.com",
182
+ "title": "Example",
183
+ "snippet": "Result summary",
184
+ "content": "Optional full content"
185
+ }
186
+ ]
187
+ }
188
+ ```
189
+
190
+ Each result requires `url`, `title`, and `snippet`; `content` is optional.
191
+
175
192
  ### 5.3 Agent search (`POST /v1/agent-search`)
176
193
 
177
194
  Thin egress — proxy returns opaque agent text; callers own parsing and citation extraction:
@@ -248,6 +265,11 @@ proxy routing fails during startup. The application cannot verify that the
248
265
  proxy implements equivalent URL-safety controls, so this state is highlighted
249
266
  in the Prometheus metric and Grafana dashboard.
250
267
 
268
+ `CUSTOM_WEB_SEARCH_API_TRUSTED_PRIVATE_HOSTS` is a JSON array of exact
269
+ operator-approved Customized API hostnames or IPs that may use RFC 1918 or IPv6
270
+ unique-local addresses. It does not apply to crawler targets, permit localhost
271
+ or cloud metadata targets, or configure network routing or private DNS.
272
+
251
273
  With `WORKERS > 1`, the entrypoint sets `PROMETHEUS_MULTIPROC_DIR` for correct metric aggregation.
252
274
 
253
275
  ---
@@ -144,6 +144,23 @@ Response includes **curated** normalised results and opaque **raw** provider pay
144
144
  }
145
145
  ```
146
146
 
147
+ Customized API endpoints must return a JSON object with a `results` array:
148
+
149
+ ```json
150
+ {
151
+ "results": [
152
+ {
153
+ "url": "https://example.com",
154
+ "title": "Example",
155
+ "snippet": "Result summary",
156
+ "content": "Optional full content"
157
+ }
158
+ ]
159
+ }
160
+ ```
161
+
162
+ Each result requires `url`, `title`, and `snippet`; `content` is optional.
163
+
147
164
  ### 5.3 Agent search (`POST /v1/agent-search`)
148
165
 
149
166
  Thin egress — proxy returns opaque agent text; callers own parsing and citation extraction:
@@ -220,6 +237,11 @@ proxy routing fails during startup. The application cannot verify that the
220
237
  proxy implements equivalent URL-safety controls, so this state is highlighted
221
238
  in the Prometheus metric and Grafana dashboard.
222
239
 
240
+ `CUSTOM_WEB_SEARCH_API_TRUSTED_PRIVATE_HOSTS` is a JSON array of exact
241
+ operator-approved Customized API hostnames or IPs that may use RFC 1918 or IPv6
242
+ unique-local addresses. It does not apply to crawler targets, permit localhost
243
+ or cloud metadata targets, or configure network routing or private DNS.
244
+
223
245
  With `WORKERS > 1`, the entrypoint sets `PROMETHEUS_MULTIPROC_DIR` for correct metric aggregation.
224
246
 
225
247
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique-search-proxy"
3
- version = "2026.44.0.dev1"
3
+ version = "2026.44.0.dev3"
4
4
  description = "Web Search Proxy implementation"
5
5
  authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
6
6
  readme = "README.md"
@@ -22,8 +22,8 @@ dependencies = [
22
22
  "google-genai>=1.73.0,<2",
23
23
  "google-auth>=2.43.0,<3",
24
24
  "jinja2>=3.1.6,<4",
25
- "unique-toolkit[monitoring]>=2026.44.0.dev2,<2026.44.0rc0",
26
- "unique-search-proxy-core>=2026.44.0.dev1,<2026.44.0rc0",
25
+ "unique-toolkit[monitoring]>=2026.44.0.dev7,<2026.44.0rc0",
26
+ "unique-search-proxy-core>=2026.44.0.dev3,<2026.44.0rc0",
27
27
  "openai>=1.105.0,<3",
28
28
  ]
29
29
 
@@ -1,11 +1,15 @@
1
1
  from unique_search_proxy_core.search_engines.base import SearchEngineType
2
2
  from unique_search_proxy_core.search_engines.brave.schema import BraveConfig
3
+ from unique_search_proxy_core.search_engines.custom_api.schema import CustomApiConfig
3
4
  from unique_search_proxy_core.search_engines.google.schema import GoogleConfig
4
5
  from unique_search_proxy_core.search_engines.perplexity.schema import PerplexityConfig
5
6
 
6
7
  from unique_search_proxy_client.web.core.search_engines.brave.service import (
7
8
  BraveSearchService,
8
9
  )
10
+ from unique_search_proxy_client.web.core.search_engines.custom_api.service import (
11
+ CustomApiSearchService,
12
+ )
9
13
  from unique_search_proxy_client.web.core.search_engines.descriptor import (
10
14
  SearchEngineDescriptor,
11
15
  )
@@ -47,11 +51,21 @@ def register_builtin_search_engines() -> None:
47
51
  service_cls=PerplexitySearchService,
48
52
  ),
49
53
  )
54
+ register_search_engine(
55
+ SearchEngineType.CUSTOM_API.value,
56
+ CustomApiSearchService,
57
+ descriptor=SearchEngineDescriptor(
58
+ config_model=CustomApiConfig,
59
+ service_cls=CustomApiSearchService,
60
+ ),
61
+ )
50
62
 
51
63
 
52
64
  __all__ = [
53
65
  "BraveConfig",
54
66
  "BraveSearchService",
67
+ "CustomApiConfig",
68
+ "CustomApiSearchService",
55
69
  "GoogleConfig",
56
70
  "GoogleSearchService",
57
71
  "PerplexityConfig",
@@ -0,0 +1,5 @@
1
+ from unique_search_proxy_client.web.core.search_engines.custom_api.service import (
2
+ CustomApiSearchService,
3
+ )
4
+
5
+ __all__ = ["CustomApiSearchService"]
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from pydantic import ValidationError
9
+ from unique_search_proxy_core.errors import (
10
+ BadRequestProxyError,
11
+ ForbiddenTargetError,
12
+ UpstreamError,
13
+ UpstreamTimeoutError,
14
+ )
15
+ from unique_search_proxy_core.schema import SearchEngineRaw, WebSearchResults
16
+ from unique_search_proxy_core.search_engines.base import (
17
+ SearchEngineType,
18
+ get_search_engine_mode,
19
+ )
20
+ from unique_search_proxy_core.search_engines.custom_api.schema import (
21
+ CustomApiRequestMethod,
22
+ CustomApiSearchRequest,
23
+ )
24
+ from unique_search_proxy_core.url_safety import (
25
+ CustomApiTargetValidationError,
26
+ UrlSafetyService,
27
+ pinned_httpx_get_args,
28
+ url_safety_settings,
29
+ )
30
+
31
+ from unique_search_proxy_client.web.core.provider_response import (
32
+ raise_for_upstream_response,
33
+ transport_error_raw,
34
+ )
35
+ from unique_search_proxy_client.web.core.search_engines.service_base import (
36
+ SearchEngineService,
37
+ )
38
+
39
+ _LOGGER = logging.getLogger(__name__)
40
+ _CUSTOM_API_PROVIDER_LABEL = "Custom API"
41
+
42
+
43
+ def _parse_json_object(value: str, *, field_name: str) -> dict[str, Any]:
44
+ try:
45
+ parsed = json.loads(value)
46
+ except (TypeError, json.JSONDecodeError) as exc:
47
+ raise BadRequestProxyError(
48
+ f"{field_name} must be a valid JSON object",
49
+ ) from exc
50
+ if not isinstance(parsed, dict):
51
+ raise BadRequestProxyError(f"{field_name} must be a JSON object")
52
+ return parsed
53
+
54
+
55
+ def _parse_headers(value: str) -> dict[str, str]:
56
+ parsed = _parse_json_object(value, field_name="apiHeaders")
57
+ if not all(
58
+ isinstance(key, str) and isinstance(item, str) for key, item in parsed.items()
59
+ ):
60
+ raise BadRequestProxyError("apiHeaders keys and values must be strings")
61
+ return parsed
62
+
63
+
64
+ class CustomApiSearchService(SearchEngineService[CustomApiSearchRequest]):
65
+ """Tenant-configured API provider with fail-closed outbound URL controls."""
66
+
67
+ engine_id = SearchEngineType.CUSTOM_API.value
68
+
69
+ @property
70
+ def mode(self) -> str:
71
+ return get_search_engine_mode(SearchEngineType.CUSTOM_API).value
72
+
73
+ async def search(
74
+ self,
75
+ request: CustomApiSearchRequest, # type: ignore[valid-type]
76
+ ) -> tuple[SearchEngineRaw, WebSearchResults]:
77
+ client = self._http_client
78
+ if client is None:
79
+ raise RuntimeError("HTTP client is required for Custom API search")
80
+
81
+ # Admin configuration must not grant access to unapproved private targets.
82
+ try:
83
+ resolved_target = await UrlSafetyService.resolve_custom_api_target(
84
+ request.api_endpoint,
85
+ trusted_private_hosts=(
86
+ url_safety_settings.custom_api_trusted_private_hosts
87
+ ),
88
+ )
89
+ except CustomApiTargetValidationError as exc:
90
+ blocked = exc.blocked_targets[0]
91
+ raise ForbiddenTargetError(
92
+ "Custom API endpoint is blocked by URL safety policy",
93
+ details=[
94
+ {
95
+ "category": blocked.category,
96
+ "reason": blocked.reason,
97
+ }
98
+ ],
99
+ ) from exc
100
+
101
+ request_url, pin_headers, extensions = pinned_httpx_get_args(resolved_target)
102
+ headers = _parse_headers(request.api_headers)
103
+ headers.update(pin_headers)
104
+ params = _parse_json_object(
105
+ request.api_additional_query_params,
106
+ field_name="apiAdditionalQueryParams",
107
+ )
108
+ body = _parse_json_object(
109
+ request.api_additional_body_params,
110
+ field_name="apiAdditionalBodyParams",
111
+ )
112
+
113
+ try:
114
+ if request.api_request_method == CustomApiRequestMethod.GET:
115
+ params["query"] = request.query
116
+ response = await client.request(
117
+ method=CustomApiRequestMethod.GET,
118
+ url=request_url,
119
+ headers=headers,
120
+ params=params,
121
+ json=body,
122
+ extensions=extensions or None,
123
+ timeout=request.timeout,
124
+ follow_redirects=False,
125
+ )
126
+ elif request.api_request_method == CustomApiRequestMethod.POST:
127
+ body["query"] = request.query
128
+ response = await client.post(
129
+ url=request_url,
130
+ headers=headers,
131
+ params=params,
132
+ json=body,
133
+ extensions=extensions or None,
134
+ timeout=request.timeout,
135
+ follow_redirects=False,
136
+ )
137
+ else:
138
+ raise BadRequestProxyError("Unsupported Custom API request method")
139
+ except httpx.TimeoutException as exc:
140
+ raise UpstreamTimeoutError(
141
+ f"Custom API search timed out after {request.timeout}s",
142
+ upstream_raw=transport_error_raw(exc),
143
+ ) from exc
144
+ except httpx.HTTPError as exc:
145
+ raise UpstreamError(
146
+ "Custom API search request failed",
147
+ upstream_raw=transport_error_raw(exc),
148
+ ) from exc
149
+
150
+ if response.is_redirect:
151
+ raise ForbiddenTargetError("Custom API redirects are not allowed")
152
+ raise_for_upstream_response(
153
+ response,
154
+ provider_label=_CUSTOM_API_PROVIDER_LABEL,
155
+ )
156
+
157
+ try:
158
+ payload = response.json()
159
+ except ValueError as exc:
160
+ raise UpstreamError("Custom API returned invalid JSON") from exc
161
+ if not isinstance(payload, dict):
162
+ raise UpstreamError("Custom API response must be a JSON object")
163
+
164
+ try:
165
+ curated = WebSearchResults.model_validate(payload)
166
+ except ValidationError as exc:
167
+ raise UpstreamError("Custom API returned an invalid result schema") from exc
168
+
169
+ _LOGGER.info("Custom API search returned %s curated results", len(curated))
170
+ return SearchEngineRaw(pages=[payload]), curated
171
+
172
+
173
+ __all__ = ["CustomApiSearchService"]
@@ -7,6 +7,9 @@ from unique_search_proxy_core.search_engines.base import SearchEngine, SearchEng
7
7
  from unique_search_proxy_client.web.core.search_engines.brave.service import (
8
8
  BraveSearchService,
9
9
  )
10
+ from unique_search_proxy_client.web.core.search_engines.custom_api.service import (
11
+ CustomApiSearchService,
12
+ )
10
13
  from unique_search_proxy_client.web.core.search_engines.google.service import (
11
14
  GoogleSearchService,
12
15
  )
@@ -31,6 +34,8 @@ def get_search_engine_service(
31
34
  return BraveSearchService(http_client=http_client)
32
35
  case SearchEngineType.PERPLEXITY:
33
36
  return PerplexitySearchService(http_client=http_client)
37
+ case SearchEngineType.CUSTOM_API:
38
+ return CustomApiSearchService(http_client=http_client)
34
39
  case _:
35
40
  raise ValueError(f"Unsupported search engine: {engine}")
36
41
 
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from typing import Any, Literal, cast, get_args, get_origin
8
8
 
9
9
  from pydantic import SecretStr
10
+ from pydantic.aliases import AliasChoices
10
11
  from pydantic.fields import FieldInfo
11
12
 
12
13
  from unique_search_proxy_client.web.settings.secret_str import (
@@ -25,7 +26,17 @@ def snake_to_camel(name: str) -> str:
25
26
  return parts[0] + "".join(part.capitalize() for part in parts[1:])
26
27
 
27
28
 
28
- def env_var_name(field_name: str, env_prefix: str) -> str:
29
+ def env_var_name(
30
+ field_name: str,
31
+ env_prefix: str,
32
+ field_info: FieldInfo | None = None,
33
+ ) -> str:
34
+ if field_info is not None:
35
+ aliases = field_info.validation_alias
36
+ choices = aliases.choices if isinstance(aliases, AliasChoices) else [aliases]
37
+ for choice in choices:
38
+ if isinstance(choice, str) and choice.isupper():
39
+ return choice
29
40
  return f"{env_prefix}{field_name.upper()}"
30
41
 
31
42
 
@@ -201,7 +212,7 @@ def iter_helm_fields(
201
212
  spec = HelmFieldSpec(
202
213
  python_name=field_name,
203
214
  helm_name=helm_name,
204
- env_var=env_var_name(field_name, env_prefix),
215
+ env_var=env_var_name(field_name, env_prefix, field_info),
205
216
  sensitive=sensitive,
206
217
  required_when_enabled=required_when_enabled,
207
218
  schema_ref=schema_ref,
@@ -33,6 +33,7 @@ _URL_SAFETY_SECTIONS: dict[str, list[str]] = {
33
33
  "allowed_schemes",
34
34
  "localhost_hosts",
35
35
  "metadata_hosts",
36
+ "custom_api_trusted_private_hosts",
36
37
  ],
37
38
  }
38
39
 
@@ -76,6 +77,7 @@ def configure_url_safety_helm_model(model: type[UrlSafetySettings]) -> None:
76
77
  "allowed_schemes": {"overridable": True},
77
78
  "localhost_hosts": {"overridable": True},
78
79
  "metadata_hosts": {"overridable": True},
80
+ "custom_api_trusted_private_hosts": {"overridable": True},
79
81
  },
80
82
  )
81
83
 
@@ -6,6 +6,7 @@ from collections import defaultdict
6
6
 
7
7
  from pydantic import BaseModel
8
8
 
9
+ from unique_search_proxy_client.web.helm.generator.introspect import env_var_name
9
10
  from unique_search_proxy_client.web.helm.registry import startup_report_groups
10
11
  from unique_search_proxy_client.web.settings.secret_str import (
11
12
  field_has_not_provided_default,
@@ -15,8 +16,8 @@ from unique_search_proxy_client.web.settings.secret_str import (
15
16
  _LOGGER = logging.getLogger(__name__)
16
17
 
17
18
 
18
- def _env_var_name(field_name: str, env_prefix: str) -> str:
19
- return f"{env_prefix}{field_name.upper()}"
19
+ def _env_var_name(model: BaseModel, field_name: str, env_prefix: str) -> str:
20
+ return env_var_name(field_name, env_prefix, model.model_fields[field_name])
20
21
 
21
22
 
22
23
  def _field_status(
@@ -26,7 +27,7 @@ def _field_status(
26
27
  ) -> str:
27
28
  value = getattr(model, field_name)
28
29
  field_info = model.model_fields[field_name]
29
- env_var = _env_var_name(field_name, env_prefix)
30
+ env_var = _env_var_name(model, field_name, env_prefix)
30
31
 
31
32
  if field_has_not_provided_default(field_info):
32
33
  return "configured" if is_secret_configured(value) else "missing"
@@ -46,7 +47,7 @@ def _group_fields_by_status(
46
47
  ) -> dict[str, list[str]]:
47
48
  grouped: dict[str, list[str]] = defaultdict(list)
48
49
  for field_name in model.model_fields:
49
- env_var = _env_var_name(field_name, env_prefix)
50
+ env_var = _env_var_name(model, field_name, env_prefix)
50
51
  status = _field_status(model, field_name, env_prefix)
51
52
  grouped[status].append(env_var)
52
53
  return grouped
@@ -72,7 +73,7 @@ def _format_group(title: str, model: BaseModel, env_prefix: str) -> list[str]:
72
73
  grouped = _group_fields_by_status(model, env_prefix)
73
74
  lines = [f" [{title}] {_group_summary(grouped)}"]
74
75
  for field_name in model.model_fields:
75
- env_var = _env_var_name(field_name, env_prefix)
76
+ env_var = _env_var_name(model, field_name, env_prefix)
76
77
  value = getattr(model, field_name)
77
78
  lines.append(f" {env_var}={_format_settings_value(value)}")
78
79
  return lines