unique-search-proxy 2026.44.0.dev12__tar.gz → 2026.44.0.dev14__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 (122) hide show
  1. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/PKG-INFO +7 -7
  2. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/README.md +4 -4
  3. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/pyproject.toml +3 -3
  4. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/app.py +4 -0
  5. unique_search_proxy-2026.44.0.dev14/unique_search_proxy_client/web/core/agent_engines/bing/citation_instructions.j2 +18 -0
  6. unique_search_proxy-2026.44.0.dev14/unique_search_proxy_client/web/core/agent_engines/bing/instructions.py +26 -0
  7. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/bing/runner.py +121 -37
  8. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/bing/service.py +23 -20
  9. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/generator/schema.py +10 -0
  10. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/generator/template.py +66 -7
  11. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/metadata.py +9 -1
  12. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/app.py +1 -0
  13. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/client.py +2 -2
  14. unique_search_proxy-2026.44.0.dev12/unique_search_proxy_client/web/core/agent_engines/structured_output.py +0 -31
  15. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/__init__.py +0 -0
  16. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/__init__.py +0 -0
  17. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/__init__.py +0 -0
  18. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/health.py +0 -0
  19. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/v1/__init__.py +0 -0
  20. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/v1/agent_search.py +0 -0
  21. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/v1/configuration.py +0 -0
  22. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/v1/context_headers.py +0 -0
  23. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/v1/crawl.py +0 -0
  24. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/v1/openapi_examples.py +0 -0
  25. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/api/v1/search.py +0 -0
  26. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/context.py +0 -0
  27. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/__init__.py +0 -0
  28. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/__init__.py +0 -0
  29. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/bing/cleanup.py +0 -0
  30. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/bing/client.py +0 -0
  31. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/factory.py +0 -0
  32. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/service_base.py +0 -0
  33. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/vertexai/client.py +0 -0
  34. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/vertexai/gemini.py +0 -0
  35. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/vertexai/redirects.py +0 -0
  36. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/agent_engines/vertexai/service.py +0 -0
  37. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/client/__init__.py +0 -0
  38. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/client/azure_transport.py +0 -0
  39. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/client/credentials.py +0 -0
  40. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/client/service.py +0 -0
  41. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawl_limiter.py +0 -0
  42. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/__init__.py +0 -0
  43. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/__init__.py +0 -0
  44. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/__init__.py +0 -0
  45. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/errors.py +0 -0
  46. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/html_markdown.py +0 -0
  47. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/__init__.py +0 -0
  48. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/html.py +0 -0
  49. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/pdf.py +0 -0
  50. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/plain_text.py +0 -0
  51. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/processing/registry.py +0 -0
  52. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/service.py +0 -0
  53. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/settings.py +0 -0
  54. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/basic/user_agent.py +0 -0
  55. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/factory.py +0 -0
  56. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/firecrawl/polling.py +0 -0
  57. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/firecrawl/request_body.py +0 -0
  58. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/firecrawl/service.py +0 -0
  59. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/jina/request_body.py +0 -0
  60. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/jina/service.py +0 -0
  61. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/pinned_egress.py +0 -0
  62. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/tavily/request_body.py +0 -0
  63. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/crawlers/tavily/service.py +0 -0
  64. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/provider_response.py +0 -0
  65. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/providers.py +0 -0
  66. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/registry.py +0 -0
  67. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/__init__.py +0 -0
  68. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/brave/__init__.py +0 -0
  69. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/brave/pagination.py +0 -0
  70. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/brave/query_params.py +0 -0
  71. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/brave/service.py +0 -0
  72. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/custom_api/__init__.py +0 -0
  73. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/custom_api/service.py +0 -0
  74. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/descriptor.py +0 -0
  75. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/factory.py +0 -0
  76. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/google/__init__.py +0 -0
  77. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/google/pagination.py +0 -0
  78. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/google/query_params.py +0 -0
  79. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/google/service.py +0 -0
  80. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/pagination.py +0 -0
  81. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/perplexity/__init__.py +0 -0
  82. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/perplexity/request_body.py +0 -0
  83. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/perplexity/service.py +0 -0
  84. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/search_engines/service_base.py +0 -0
  85. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/url_safety/__init__.py +0 -0
  86. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/core/url_safety/gate.py +0 -0
  87. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/error_handlers.py +0 -0
  88. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/__init__.py +0 -0
  89. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/generator/__init__.py +0 -0
  90. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/generator/introspect.py +0 -0
  91. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/generator/templates/provider_values_yaml.j2 +0 -0
  92. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/generator/values_yaml.py +0 -0
  93. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/registry.py +0 -0
  94. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/helm/url_safety_registration.py +0 -0
  95. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/logging_config.py +0 -0
  96. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/middleware/context.py +0 -0
  97. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/monitoring/__init__.py +0 -0
  98. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/monitoring/metrics.py +0 -0
  99. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/monitoring/setup.py +0 -0
  100. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/presets/__init__.py +0 -0
  101. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/presets/common.py +0 -0
  102. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/presets/crawl.py +0 -0
  103. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/presets/search.py +0 -0
  104. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/presets/types.py +0 -0
  105. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/__init__.py +0 -0
  106. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/base.py +0 -0
  107. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/monitoring.py +0 -0
  108. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/__init__.py +0 -0
  109. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/base.py +0 -0
  110. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/bing_agent.py +0 -0
  111. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/brave.py +0 -0
  112. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/firecrawl.py +0 -0
  113. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/google.py +0 -0
  114. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/jina.py +0 -0
  115. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/perplexity.py +0 -0
  116. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/tavily.py +0 -0
  117. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/providers/vertexai_agent.py +0 -0
  118. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/request_limits.py +0 -0
  119. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/settings/secret_str.py +0 -0
  120. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/startup_report.py +0 -0
  121. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/unique_search_proxy_client/web/utils/__init__.py +0 -0
  122. {unique_search_proxy-2026.44.0.dev12 → unique_search_proxy-2026.44.0.dev14}/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.dev12
3
+ Version: 2026.44.0.dev14
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.dev9,<2026.44.0rc0
24
- Requires-Dist: unique-search-proxy-core>=2026.44.0.dev9,<2026.44.0rc0
23
+ Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0
24
+ Requires-Dist: unique-search-proxy-core>=2026.44.0.dev10,<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
@@ -116,11 +116,11 @@ Built-in providers register in `register_builtin_providers()` during `create_app
116
116
  | `GET /ready` | Readiness (httpx pool + registered providers) |
117
117
  | `GET /v1/configuration/providers` | Registered search engine, agent engine, and crawler ids |
118
118
  | `POST /v1/search` | Standard search (flat body: `engine`, `query`, provider params, `timeout`) |
119
- | `POST /v1/agent-search` | Grounded agent search — opaque `answer` + `raw` |
119
+ | `POST /v1/agent-search` | Grounded agent search — opaque `answer` + provider `citations` + `raw` |
120
120
  | `POST /v1/agent-search/stream` | Same, streamed as SSE (`delta` + `done`) |
121
121
  | `POST /v1/crawl` | Crawl URLs (flat body: `crawler`, `urls`, `timeout`, …) |
122
122
  | `GET /metrics` | Prometheus (when enabled) |
123
- | `/docs` | Swagger UI with preset examples |
123
+ | `/docs` | Swagger UI with preset examples (only when `EXPOSE_DOCS=true`) |
124
124
 
125
125
  OpenAPI spec: `openapi.json` (input for [SDK codegen](../unique_search_proxy_sdk/README.md)).
126
126
 
@@ -191,7 +191,7 @@ Each result requires `url`, `title`, and `snippet`; `content` is optional.
191
191
 
192
192
  ### 5.3 Agent search (`POST /v1/agent-search`)
193
193
 
194
- Thin egress — proxy returns opaque agent text; callers own parsing and citation extraction:
194
+ Thin egress — proxy returns opaque agent text and the citations the provider attached to it (Bing: Foundry `url_citation` annotations, with offsets into the answer); callers own parsing:
195
195
 
196
196
  ```json
197
197
  {
@@ -310,7 +310,7 @@ uv run python scripts/generate_sdk.py
310
310
  ## 8. Dev testing
311
311
 
312
312
  1. Start the server and configure `.env` (`.env.example` sets `REQUIRE_CONTEXT_HEADERS=false` for local use; production keeps enforcement enabled).
313
- 2. **Swagger** — `/docs` → **Try it out** on `/v1/search` or `/v1/crawl` → pick an **Examples** preset. Context headers default to `local` in the UI.
313
+ 2. **Swagger** (if `EXPOSE_DOCS=true`) — `/docs` → **Try it out** on `/v1/search` or `/v1/crawl` → pick an **Examples** preset. Context headers default to `local` in the UI.
314
314
  3. **CLI** — same presets from the terminal (`scripts/try_presets.py` sends the same local context headers):
315
315
 
316
316
  ```bash
@@ -88,11 +88,11 @@ Built-in providers register in `register_builtin_providers()` during `create_app
88
88
  | `GET /ready` | Readiness (httpx pool + registered providers) |
89
89
  | `GET /v1/configuration/providers` | Registered search engine, agent engine, and crawler ids |
90
90
  | `POST /v1/search` | Standard search (flat body: `engine`, `query`, provider params, `timeout`) |
91
- | `POST /v1/agent-search` | Grounded agent search — opaque `answer` + `raw` |
91
+ | `POST /v1/agent-search` | Grounded agent search — opaque `answer` + provider `citations` + `raw` |
92
92
  | `POST /v1/agent-search/stream` | Same, streamed as SSE (`delta` + `done`) |
93
93
  | `POST /v1/crawl` | Crawl URLs (flat body: `crawler`, `urls`, `timeout`, …) |
94
94
  | `GET /metrics` | Prometheus (when enabled) |
95
- | `/docs` | Swagger UI with preset examples |
95
+ | `/docs` | Swagger UI with preset examples (only when `EXPOSE_DOCS=true`) |
96
96
 
97
97
  OpenAPI spec: `openapi.json` (input for [SDK codegen](../unique_search_proxy_sdk/README.md)).
98
98
 
@@ -163,7 +163,7 @@ Each result requires `url`, `title`, and `snippet`; `content` is optional.
163
163
 
164
164
  ### 5.3 Agent search (`POST /v1/agent-search`)
165
165
 
166
- Thin egress — proxy returns opaque agent text; callers own parsing and citation extraction:
166
+ Thin egress — proxy returns opaque agent text and the citations the provider attached to it (Bing: Foundry `url_citation` annotations, with offsets into the answer); callers own parsing:
167
167
 
168
168
  ```json
169
169
  {
@@ -282,7 +282,7 @@ uv run python scripts/generate_sdk.py
282
282
  ## 8. Dev testing
283
283
 
284
284
  1. Start the server and configure `.env` (`.env.example` sets `REQUIRE_CONTEXT_HEADERS=false` for local use; production keeps enforcement enabled).
285
- 2. **Swagger** — `/docs` → **Try it out** on `/v1/search` or `/v1/crawl` → pick an **Examples** preset. Context headers default to `local` in the UI.
285
+ 2. **Swagger** (if `EXPOSE_DOCS=true`) — `/docs` → **Try it out** on `/v1/search` or `/v1/crawl` → pick an **Examples** preset. Context headers default to `local` in the UI.
286
286
  3. **CLI** — same presets from the terminal (`scripts/try_presets.py` sends the same local context headers):
287
287
 
288
288
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique-search-proxy"
3
- version = "2026.44.0.dev12"
3
+ version = "2026.44.0.dev14"
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.dev9,<2026.44.0rc0",
26
- "unique-search-proxy-core>=2026.44.0.dev9,<2026.44.0rc0",
25
+ "unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0",
26
+ "unique-search-proxy-core>=2026.44.0.dev10,<2026.44.0rc0",
27
27
  "openai>=1.105.0,<3",
28
28
  ]
29
29
 
@@ -28,6 +28,7 @@ from unique_search_proxy_client.web.logging_config import (
28
28
  )
29
29
  from unique_search_proxy_client.web.middleware.context import RequestContextMiddleware
30
30
  from unique_search_proxy_client.web.monitoring import setup_prometheus
31
+ from unique_search_proxy_client.web.settings.app import app_settings
31
32
  from unique_search_proxy_client.web.settings.request_limits import (
32
33
  request_limits_settings,
33
34
  )
@@ -92,6 +93,9 @@ def create_app() -> FastAPI:
92
93
  ),
93
94
  version="0.2.0",
94
95
  lifespan=lifespan,
96
+ docs_url="/docs" if app_settings.expose_docs else None,
97
+ redoc_url="/redoc" if app_settings.expose_docs else None,
98
+ openapi_url="/openapi.json" if app_settings.expose_docs else None,
95
99
  swagger_ui_parameters={
96
100
  "defaultModelsExpandDepth": 1,
97
101
  "defaultModelExpandDepth": 2,
@@ -0,0 +1,18 @@
1
+ {{ generation_instructions }}
2
+
3
+ ## Source-isolated evidence list
4
+ Use Bing Grounding to select distinct useful sources.
5
+ The complete response must consist only of lines in this form:
6
+ - <a paragraph of facts supported only by one source> <that source's citation>
7
+
8
+ Each line is a paragraph, not a sentence. Write several sentences per line that report every relevant fact, figure, date, name and quote from that source, with as much detail as the source offers, often 100 to 200 words. Lines can be long; do not shorten them.
9
+
10
+ Hard requirements:
11
+ 1. Exactly one line per distinct source and exactly one distinct source per line.
12
+ 2. Every line begins with `- ` and contains exactly one citation marker as its final content.
13
+ 3. Never synthesize, compare, or combine facts across sources within a line.
14
+ 4. Put all useful facts from a source into that source's one line; never cite it again.
15
+ 5. No headings, preface, conclusion, line breaks inside a paragraph, nested bullets, JSON, or uncited claims.
16
+ 6. Never invent, reconstruct, type, or guess citation markers or source URLs.
17
+
18
+ Before responding, silently verify that the number of lines equals the number of distinct cited sources.
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.resources import files
4
+
5
+ from jinja2 import StrictUndefined, Template
6
+
7
+ # Describes the output format we want from the agent. Models do not always
8
+ # follow it, so downstream parsing should tolerate deviations and keep what it
9
+ # can rather than reject the whole answer.
10
+ _CITATION_INSTRUCTIONS = Template(
11
+ files(__package__)
12
+ .joinpath("citation_instructions.j2")
13
+ .read_text(encoding="utf-8")
14
+ .strip(),
15
+ undefined=StrictUndefined,
16
+ )
17
+
18
+
19
+ def build_bing_citation_instructions(generation_instructions: str) -> str:
20
+ """Add citation-safe output rules without suppressing Foundry annotations."""
21
+ return _CITATION_INSTRUCTIONS.render(
22
+ generation_instructions=generation_instructions.rstrip()
23
+ )
24
+
25
+
26
+ __all__ = ["build_bing_citation_instructions"]
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import time
5
- from collections.abc import AsyncIterator
5
+ from collections.abc import AsyncIterator, Sequence
6
+ from typing import NamedTuple
6
7
 
7
8
  import openai
8
9
  from azure.ai.projects.aio import AIProjectClient
@@ -14,10 +15,8 @@ from azure.ai.projects.models import (
14
15
  )
15
16
  from httpx import AsyncClient
16
17
  from openai.types.responses import ResponseStreamEvent
18
+ from openai.types.responses.response import Response
17
19
  from openai.types.responses.response_completed_event import ResponseCompletedEvent
18
- from openai.types.responses.response_output_item_done_event import (
19
- ResponseOutputItemDoneEvent,
20
- )
21
20
  from openai.types.responses.response_output_message import ResponseOutputMessage
22
21
  from openai.types.responses.response_output_text import (
23
22
  AnnotationURLCitation,
@@ -28,6 +27,7 @@ from unique_search_proxy_core.agent_engines.bing.grounding import (
28
27
  BingGroundingConfiguration,
29
28
  bing_agent_name,
30
29
  )
30
+ from unique_search_proxy_core.schema import AnswerSpanCitation
31
31
 
32
32
  from unique_search_proxy_client.web.core.agent_engines.bing.client import (
33
33
  get_openai_client,
@@ -38,6 +38,7 @@ from unique_search_proxy_client.web.settings.secret_str import NOT_PROVIDED, rea
38
38
  _LOGGER = logging.getLogger(__name__)
39
39
 
40
40
  __all__ = [
41
+ "BingStreamChunk",
41
42
  "create_bing_agent",
42
43
  "get_bing_grounding_tool",
43
44
  "resolve_bing_agent_name",
@@ -45,6 +46,19 @@ __all__ = [
45
46
  ]
46
47
 
47
48
 
49
+ class BingStreamChunk(NamedTuple):
50
+ """One step of a Bing Responses stream.
51
+
52
+ ``answer`` and ``citations`` are set only for the completed response, and
53
+ the citation offsets point into ``answer``.
54
+ """
55
+
56
+ delta: str
57
+ raw_event: dict
58
+ answer: str | None = None
59
+ citations: Sequence[AnswerSpanCitation] = ()
60
+
61
+
48
62
  def resolve_bing_agent_name(
49
63
  *,
50
64
  model: str,
@@ -147,8 +161,8 @@ async def stream_bing_grounding_agent(
147
161
  instructions: str,
148
162
  grounding: BingGroundingConfiguration,
149
163
  timeout: float,
150
- ) -> AsyncIterator[tuple[str, dict]]:
151
- """Stream Bing-grounded Responses events as ``(delta_text, raw_event)`` pairs.
164
+ ) -> AsyncIterator[BingStreamChunk]:
165
+ """Stream Bing-grounded Responses events as :class:`BingStreamChunk` items.
152
166
 
153
167
  Optimistically calls Responses with a hash-derived agent name. If the agent
154
168
  is missing, creates it once and retries.
@@ -181,13 +195,23 @@ async def stream_bing_grounding_agent(
181
195
  resolved_name,
182
196
  exc,
183
197
  )
184
- await create_bing_agent(
185
- project_client,
186
- agent_name=resolved_name,
187
- model=model,
188
- instructions=instructions,
189
- grounding=grounding,
190
- )
198
+ try:
199
+ await create_bing_agent(
200
+ project_client,
201
+ agent_name=resolved_name,
202
+ model=model,
203
+ instructions=instructions,
204
+ grounding=grounding,
205
+ )
206
+ except UnicodeDecodeError:
207
+ # Foundry can persist the agent but return a compressed create response
208
+ # that azure-core fails to decode. The Responses retry below verifies
209
+ # whether creation actually succeeded.
210
+ _LOGGER.warning(
211
+ "Could not decode create response for Bing agent %s; "
212
+ "verifying creation through Responses",
213
+ resolved_name,
214
+ )
191
215
  stream = await _create_responses_stream(
192
216
  openai_client,
193
217
  agent_name=resolved_name,
@@ -201,21 +225,13 @@ async def stream_bing_grounding_agent(
201
225
  if isinstance(event, ResponseTextDeltaEvent):
202
226
  if event.delta:
203
227
  emitted_text = True
204
- yield event.delta, event.model_dump(mode="json", warnings="none")
205
- elif isinstance(event, ResponseOutputItemDoneEvent):
206
- citations = _extract_url_citations(event)
207
- if citations:
208
- yield (
209
- "",
210
- {
211
- "type": event.type,
212
- "citations": citations,
213
- "event": event.model_dump(mode="json", warnings="none"),
214
- },
228
+ yield BingStreamChunk(
229
+ event.delta, event.model_dump(mode="json", warnings="none")
215
230
  )
216
231
  elif isinstance(event, ResponseCompletedEvent):
217
232
  response = event.response
218
233
  output_text = response.output_text
234
+ citations = _extract_response_url_citations(response)
219
235
  raw_completed = {
220
236
  "type": event.type,
221
237
  "response": response.model_dump(mode="json", warnings="none"),
@@ -223,9 +239,16 @@ async def stream_bing_grounding_agent(
223
239
  # Foundry sometimes delivers the full answer only on completion.
224
240
  if output_text and not emitted_text:
225
241
  emitted_text = True
226
- yield output_text, raw_completed
242
+ yield BingStreamChunk(
243
+ output_text,
244
+ raw_completed,
245
+ answer=output_text,
246
+ citations=citations,
247
+ )
227
248
  else:
228
- yield "", raw_completed
249
+ yield BingStreamChunk(
250
+ "", raw_completed, answer=output_text, citations=citations
251
+ )
229
252
 
230
253
 
231
254
  async def _create_responses_stream(
@@ -258,15 +281,76 @@ async def _create_responses_stream(
258
281
  return stream
259
282
 
260
283
 
261
- def _extract_url_citations(event: ResponseOutputItemDoneEvent) -> list[str]:
262
- item = event.item
263
- if not isinstance(item, ResponseOutputMessage) or not item.content:
264
- return []
265
- text_content = item.content[-1]
266
- if not isinstance(text_content, ResponseOutputText):
284
+ def _extract_message_url_citations(
285
+ item: ResponseOutputMessage,
286
+ *,
287
+ start_index_offset: int = 0,
288
+ ) -> list[AnswerSpanCitation]:
289
+ """Turn the URL annotations on one output message into citations.
290
+
291
+ Args:
292
+ item: A completed assistant message. Its text can be split across
293
+ several ``output_text`` parts, and each annotation's offsets are
294
+ relative to the part it is attached to.
295
+ start_index_offset: Length of the answer text before this message, so
296
+ the returned offsets point into the full answer.
297
+
298
+ Returns:
299
+ One citation per URL annotation, carrying the marker text the
300
+ annotation spans and its offsets into the full answer. Annotations
301
+ without a URL or with an empty span are skipped.
302
+ """
303
+ if not item.content:
267
304
  return []
268
- return [
269
- annotation.url
270
- for annotation in text_content.annotations
271
- if isinstance(annotation, AnnotationURLCitation) and annotation.url
272
- ]
305
+
306
+ citations: list[AnswerSpanCitation] = []
307
+ text_offset = start_index_offset
308
+ for text_content in item.content:
309
+ if not isinstance(text_content, ResponseOutputText):
310
+ continue
311
+ for annotation in text_content.annotations or []:
312
+ if not isinstance(annotation, AnnotationURLCitation):
313
+ continue
314
+ marker = text_content.text[annotation.start_index : annotation.end_index]
315
+ if annotation.url and marker:
316
+ citations.append(
317
+ AnswerSpanCitation(
318
+ url=annotation.url,
319
+ title=annotation.title,
320
+ marker=marker,
321
+ start_index=text_offset + annotation.start_index,
322
+ end_index=text_offset + annotation.end_index,
323
+ output_item_id=item.id,
324
+ )
325
+ )
326
+ text_offset += len(text_content.text)
327
+ return citations
328
+
329
+
330
+ def _extract_response_url_citations(
331
+ response: Response,
332
+ ) -> list[AnswerSpanCitation]:
333
+ """Extract citations with offsets into ``response.output_text``."""
334
+ citations: list[AnswerSpanCitation] = []
335
+ text_offset = 0
336
+ for item in response.output or []:
337
+ if not isinstance(item, ResponseOutputMessage):
338
+ continue
339
+ citations.extend(
340
+ _extract_message_url_citations(
341
+ item,
342
+ start_index_offset=text_offset,
343
+ )
344
+ )
345
+ text_offset += sum(
346
+ len(content.text)
347
+ for content in item.content or []
348
+ if isinstance(content, ResponseOutputText)
349
+ )
350
+
351
+ _LOGGER.info(
352
+ "Foundry Bing citation annotations response_id=%s citation_count=%d",
353
+ response.id,
354
+ len(citations),
355
+ )
356
+ return citations
@@ -12,15 +12,13 @@ from unique_search_proxy_core.agent_engines.bing.grounding import (
12
12
  )
13
13
  from unique_search_proxy_core.agent_engines.bing.schema import BingAgentSearchRequest
14
14
  from unique_search_proxy_core.agent_engines.bing.settings import resolve_market
15
- from unique_search_proxy_core.agent_engines.resolve import (
16
- resolve_output_schema_for_engine,
17
- )
18
15
  from unique_search_proxy_core.errors import UpstreamError
19
16
  from unique_search_proxy_core.schema import (
20
17
  AgentSearchDelta,
21
18
  AgentSearchDone,
22
19
  AgentSearchResponse,
23
20
  AgentSearchStreamEvent,
21
+ AnswerSpanCitation,
24
22
  )
25
23
 
26
24
  from unique_search_proxy_client.web.core.agent_engines.bing.client import (
@@ -28,15 +26,15 @@ from unique_search_proxy_client.web.core.agent_engines.bing.client import (
28
26
  get_credentials,
29
27
  get_project_client,
30
28
  )
29
+ from unique_search_proxy_client.web.core.agent_engines.bing.instructions import (
30
+ build_bing_citation_instructions,
31
+ )
31
32
  from unique_search_proxy_client.web.core.agent_engines.bing.runner import (
32
33
  stream_bing_grounding_agent,
33
34
  )
34
35
  from unique_search_proxy_client.web.core.agent_engines.service_base import (
35
36
  AgentSearchEngineService,
36
37
  )
37
- from unique_search_proxy_client.web.core.agent_engines.structured_output import (
38
- build_agent_instructions,
39
- )
40
38
  from unique_search_proxy_client.web.core.provider_response import transport_error_raw
41
39
  from unique_search_proxy_client.web.settings.providers.bing_agent import (
42
40
  bing_agent_credentials,
@@ -110,12 +108,12 @@ class BingAgentSearchService(AgentSearchEngineService[BingAgentSearchRequest]):
110
108
  raise RuntimeError("HTTP client is required for Bing")
111
109
 
112
110
  answer_parts: list[str] = []
111
+ completed_answer: str | None = None
113
112
  raw_chunks: list[dict] = []
113
+ citations: list[AnswerSpanCitation] = []
114
114
 
115
- output_schema = resolve_output_schema_for_engine(request.engine)
116
- instructions = build_agent_instructions(
117
- generation_instructions=request.generation_instructions,
118
- output_schema=output_schema,
115
+ instructions = build_bing_citation_instructions(
116
+ request.generation_instructions,
119
117
  )
120
118
  grounding = BingGroundingConfiguration(
121
119
  fetch_size=request.fetch_size,
@@ -132,10 +130,7 @@ class BingAgentSearchService(AgentSearchEngineService[BingAgentSearchRequest]):
132
130
  endpoint=read_secret(creds.endpoint),
133
131
  transport=azure_transport,
134
132
  ) as project_client:
135
- async for (
136
- delta,
137
- raw_event,
138
- ) in stream_bing_grounding_agent(
133
+ async for chunk in stream_bing_grounding_agent(
139
134
  project_client,
140
135
  http_client=http_client,
141
136
  query=request.query,
@@ -144,11 +139,14 @@ class BingAgentSearchService(AgentSearchEngineService[BingAgentSearchRequest]):
144
139
  grounding=grounding,
145
140
  timeout=request.timeout,
146
141
  ):
147
- if delta:
148
- answer_parts.append(delta)
149
- yield AgentSearchDelta(text=delta)
150
- if raw_event:
151
- raw_chunks.append(raw_event)
142
+ if chunk.delta:
143
+ answer_parts.append(chunk.delta)
144
+ yield AgentSearchDelta(text=chunk.delta)
145
+ if chunk.raw_event:
146
+ raw_chunks.append(chunk.raw_event)
147
+ if chunk.answer is not None:
148
+ completed_answer = chunk.answer
149
+ citations = list(chunk.citations)
152
150
  break
153
151
  except Exception as exc:
154
152
  retryable = (
@@ -163,6 +161,8 @@ class BingAgentSearchService(AgentSearchEngineService[BingAgentSearchRequest]):
163
161
  ) from exc
164
162
 
165
163
  raw_chunks.clear()
164
+ completed_answer = None
165
+ citations = []
166
166
  retry_delay = _STREAM_RETRY_DELAYS_SECONDS[attempt - 1]
167
167
  _LOGGER.warning(
168
168
  "Bing response failed before output; retrying attempt %d/%d "
@@ -175,11 +175,14 @@ class BingAgentSearchService(AgentSearchEngineService[BingAgentSearchRequest]):
175
175
  )
176
176
  await asyncio.sleep(retry_delay)
177
177
 
178
- answer = "".join(answer_parts)
178
+ answer = (
179
+ completed_answer if completed_answer is not None else "".join(answer_parts)
180
+ )
179
181
  response = AgentSearchResponse(
180
182
  engine=AgentEngineType.BING.value,
181
183
  query=request.query,
182
184
  answer=answer,
185
+ citations=citations,
183
186
  raw=raw_chunks if len(raw_chunks) != 1 else raw_chunks[0],
184
187
  )
185
188
  yield AgentSearchDone(response=response)
@@ -129,6 +129,16 @@ def build_additional_schema(groups: tuple[HelmSettingsGroup, ...]) -> dict[str,
129
129
  for group in groups
130
130
  if group.helm_key is not None
131
131
  }
132
+ properties["networkPolicy"] = {
133
+ "properties": {
134
+ "enabled": {"type": "boolean", "default": True},
135
+ "allowDisabled": {
136
+ "type": "boolean",
137
+ "default": False,
138
+ "description": "Allow networkPolicy.enabled=false. Set only if another control restricts who can reach search-proxy.",
139
+ },
140
+ }
141
+ }
132
142
  return {
133
143
  "$schema": "https://json-schema.org/draft-07/schema#",
134
144
  "properties": properties,
@@ -7,6 +7,7 @@ from unique_search_proxy_client.web.helm.generator.introspect import (
7
7
  from unique_search_proxy_client.web.helm.metadata import (
8
8
  EgressDomainWildcard,
9
9
  EgressEndpointField,
10
+ EgressProxyHost,
10
11
  )
11
12
  from unique_search_proxy_client.web.helm.registry import HelmSettingsGroup
12
13
 
@@ -199,25 +200,83 @@ def _emit_domain_wildcard_egress(
199
200
  ]
200
201
 
201
202
 
203
+ def _proxy_values_paths(
204
+ group: HelmSettingsGroup, egress: EgressProxyHost
205
+ ) -> tuple[str, str] | None:
206
+ host = _find_helm_field(group, egress.host_field)
207
+ port = _find_helm_field(group, egress.port_field)
208
+ if host is None or port is None:
209
+ return None
210
+ root = f".Values.{group.helm_key}"
211
+ return (
212
+ f"{root}.{host.section}.{host.helm_name}",
213
+ f"{root}.{port.section}.{port.helm_name}",
214
+ )
215
+
216
+
217
+ def _emit_proxy_host_egress(
218
+ group: HelmSettingsGroup,
219
+ egress: EgressProxyHost,
220
+ ) -> list[str]:
221
+ paths = _proxy_values_paths(group, egress)
222
+ if paths is None:
223
+ return []
224
+
225
+ host, port = paths
226
+ ipv4_octet = r"(25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})"
227
+ return [
228
+ f"{{{{ if and {host} {port} }}}}",
229
+ f"{{{{- $host := {host} -}}}}",
230
+ f"{{{{- $port := {port} -}}}}",
231
+ r'{{- if not (and (kindIs "string" $host) (regexMatch "^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$" $host)) -}}',
232
+ '{{- fail "httpClient.connection.proxyHost must be a plain hostname or IPv4 address (no scheme, port, path or IPv6 literal)." -}}',
233
+ "{{- end -}}",
234
+ '{{- if not (regexMatch "^[0-9]+$" (toString $port)) -}}',
235
+ '{{- fail "httpClient.connection.proxyPort must be a number." -}}',
236
+ "{{- else if or (lt (int $port) 1) (gt (int $port) 65535) -}}",
237
+ '{{- fail "httpClient.connection.proxyPort must be between 1 and 65535." -}}',
238
+ "{{- end -}}",
239
+ f'{{{{- if regexMatch "^({ipv4_octet}\\\\.){{3}}{ipv4_octet}$" $host }}}}',
240
+ "- toCIDR:",
241
+ ' - {{ printf "%s/32" $host | quote }}',
242
+ "{{- else }}",
243
+ '{{- if regexMatch "^[0-9.]+$" $host -}}',
244
+ '{{- fail "httpClient.connection.proxyHost is not a valid IPv4 address." -}}',
245
+ "{{- end }}",
246
+ "- toFQDNs:",
247
+ " - matchName: {{ $host | quote }}",
248
+ "{{- end }}",
249
+ " toPorts:",
250
+ " - ports:",
251
+ " - port: {{ $port | quote }}",
252
+ " protocol: TCP",
253
+ "{{- end }}",
254
+ ]
255
+
256
+
202
257
  def _emit_egress_block(group: HelmSettingsGroup) -> list[str]:
203
258
  egress = group.egress
204
259
  if isinstance(egress, EgressEndpointField):
205
260
  return _emit_endpoint_field_egress(group, egress)
206
261
  if isinstance(egress, EgressDomainWildcard):
207
262
  return _emit_domain_wildcard_egress(group, egress)
263
+ if isinstance(egress, EgressProxyHost):
264
+ return _emit_proxy_host_egress(group, egress)
208
265
  return []
209
266
 
210
267
 
268
+ def _egress_active_check(group: HelmSettingsGroup) -> str:
269
+ if isinstance(group.egress, EgressProxyHost):
270
+ paths = _proxy_values_paths(group, group.egress)
271
+ if paths is not None:
272
+ return f"(and {paths[0]} {paths[1]})"
273
+ return f"(and .Values.{group.helm_key} .Values.{group.helm_key}.enabled)"
274
+
275
+
211
276
  def _emit_has_rules_block(groups: tuple[HelmSettingsGroup, ...]) -> list[str]:
212
277
  checks = [
213
- f"(and .Values.{group.helm_key} .Values.{group.helm_key}.enabled)"
214
- for group in groups
215
- if group.egress is not None
278
+ _egress_active_check(group) for group in groups if group.egress is not None
216
279
  ]
217
- if not checks:
218
- return ["{{/* no auto egress rules */}}"]
219
- if len(checks) == 1:
220
- return [f"{{{{- if {checks[0]} -}}}}true{{{{- end -}}}}"]
221
280
  joined = " ".join(checks)
222
281
  return [f"{{{{- if or {joined} -}}}}true{{{{- end -}}}}"]
223
282
 
@@ -43,7 +43,15 @@ class EgressDomainWildcard:
43
43
  port: str = "443"
44
44
 
45
45
 
46
- EgressRule = EgressEndpointField | EgressDomainWildcard
46
+ @dataclass(frozen=True)
47
+ class EgressProxyHost:
48
+ """Proxy egress when host and port are set: /32 ``toCIDR`` for IPv4, else ``toFQDNs``."""
49
+
50
+ host_field: str = "proxy_host"
51
+ port_field: str = "proxy_port"
52
+
53
+
54
+ EgressRule = EgressEndpointField | EgressDomainWildcard | EgressProxyHost
47
55
  """How a settings group's outbound egress hosts are derived from its settings."""
48
56
 
49
57
  _DEFAULT_EGRESS: EgressRule = EgressEndpointField()
@@ -15,6 +15,7 @@ APP_ENV_PREFIX = ""
15
15
  )
16
16
  class AppSettings(BaseSettings):
17
17
  require_context_headers: bool = True
18
+ expose_docs: bool = False
18
19
 
19
20
 
20
21
  def get_app_settings() -> AppSettings:
@@ -7,7 +7,7 @@ from unique_search_proxy_core.http_client import (
7
7
  ProxyUsernameSource,
8
8
  )
9
9
 
10
- from unique_search_proxy_client.web.helm.metadata import helm_settings
10
+ from unique_search_proxy_client.web.helm.metadata import EgressProxyHost, helm_settings
11
11
  from unique_search_proxy_client.web.settings.base import get_settings
12
12
 
13
13
  HTTP_CLIENT_ENV_PREFIX = "HTTP_CLIENT_"
@@ -21,7 +21,7 @@ HTTP_CLIENT_ENV_PREFIX = "HTTP_CLIENT_"
21
21
  # the proxy fields, not by a chart-only toggle. Gating it would silently drop
22
22
  # proxy config from overlays that set it without ``enabled: true``.
23
23
  gated=False,
24
- egress=None,
24
+ egress=EgressProxyHost(),
25
25
  env_prefix=HTTP_CLIENT_ENV_PREFIX,
26
26
  sections={
27
27
  "tuning": [
@@ -1,31 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
-
5
- from pydantic import BaseModel
6
-
7
-
8
- def build_json_output_format_rule(output_schema: type[BaseModel]) -> str:
9
- """Instruction block that pins agent output to a JSON schema (Bing path)."""
10
- schema_json = json.dumps(output_schema.model_json_schema(), indent=2)
11
- return (
12
- "## Output Format\n"
13
- "Respond with a JSON object matching the schema below. "
14
- "Do NOT include any text outside the JSON.\n\n"
15
- f"JSON Schema:\n```json\n{schema_json}\n```"
16
- )
17
-
18
-
19
- def build_agent_instructions(
20
- *,
21
- generation_instructions: str,
22
- output_schema: type[BaseModel],
23
- ) -> str:
24
- """Merge generation instructions with structured-output formatting rules."""
25
- return (
26
- f"{generation_instructions.rstrip()}\n\n"
27
- f"{build_json_output_format_rule(output_schema)}"
28
- )
29
-
30
-
31
- __all__ = ["build_agent_instructions", "build_json_output_format_rule"]