unique-search-proxy 2026.44.0.dev13__tar.gz → 2026.44.0.dev15__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 (123) hide show
  1. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/PKG-INFO +24 -4
  2. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/README.md +22 -2
  3. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/pyproject.toml +2 -2
  4. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/v1/agent_search.py +36 -1
  5. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/v1/context_headers.py +16 -0
  6. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/v1/crawl.py +14 -0
  7. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/v1/search.py +13 -0
  8. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/context.py +7 -1
  9. unique_search_proxy-2026.44.0.dev15/unique_search_proxy_client/web/core/agent_engines/bing/citation_instructions.j2 +18 -0
  10. unique_search_proxy-2026.44.0.dev15/unique_search_proxy_client/web/core/agent_engines/bing/instructions.py +26 -0
  11. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/bing/runner.py +121 -37
  12. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/bing/service.py +23 -20
  13. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/logging_config.py +16 -1
  14. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/middleware/context.py +33 -1
  15. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/monitoring/metrics.py +31 -26
  16. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/app.py +1 -0
  17. unique_search_proxy-2026.44.0.dev15/unique_search_proxy_client/web/usage.py +40 -0
  18. unique_search_proxy-2026.44.0.dev13/unique_search_proxy_client/web/core/agent_engines/structured_output.py +0 -31
  19. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/__init__.py +0 -0
  20. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/__init__.py +0 -0
  21. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/__init__.py +0 -0
  22. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/health.py +0 -0
  23. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/v1/__init__.py +0 -0
  24. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/v1/configuration.py +0 -0
  25. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/v1/openapi_examples.py +0 -0
  26. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/app.py +0 -0
  27. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/__init__.py +0 -0
  28. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/__init__.py +0 -0
  29. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/bing/cleanup.py +0 -0
  30. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/bing/client.py +0 -0
  31. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/factory.py +0 -0
  32. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/service_base.py +0 -0
  33. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/vertexai/client.py +0 -0
  34. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/vertexai/gemini.py +0 -0
  35. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/vertexai/redirects.py +0 -0
  36. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/agent_engines/vertexai/service.py +0 -0
  37. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/client/__init__.py +0 -0
  38. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/client/azure_transport.py +0 -0
  39. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/client/credentials.py +0 -0
  40. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/client/service.py +0 -0
  41. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawl_limiter.py +0 -0
  42. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/__init__.py +0 -0
  43. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/__init__.py +0 -0
  44. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/__init__.py +0 -0
  45. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/errors.py +0 -0
  46. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/html_markdown.py +0 -0
  47. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/__init__.py +0 -0
  48. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/html.py +0 -0
  49. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/pdf.py +0 -0
  50. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/plain_text.py +0 -0
  51. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/processing/registry.py +0 -0
  52. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/service.py +0 -0
  53. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/settings.py +0 -0
  54. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/basic/user_agent.py +0 -0
  55. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/factory.py +0 -0
  56. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/firecrawl/polling.py +0 -0
  57. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/firecrawl/request_body.py +0 -0
  58. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/firecrawl/service.py +0 -0
  59. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/jina/request_body.py +0 -0
  60. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/jina/service.py +0 -0
  61. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/pinned_egress.py +0 -0
  62. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/tavily/request_body.py +0 -0
  63. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/crawlers/tavily/service.py +0 -0
  64. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/provider_response.py +0 -0
  65. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/providers.py +0 -0
  66. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/registry.py +0 -0
  67. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/__init__.py +0 -0
  68. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/brave/__init__.py +0 -0
  69. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/brave/pagination.py +0 -0
  70. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/brave/query_params.py +0 -0
  71. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/brave/service.py +0 -0
  72. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/custom_api/__init__.py +0 -0
  73. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/custom_api/service.py +0 -0
  74. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/descriptor.py +0 -0
  75. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/factory.py +0 -0
  76. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/google/__init__.py +0 -0
  77. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/google/pagination.py +0 -0
  78. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/google/query_params.py +0 -0
  79. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/google/service.py +0 -0
  80. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/pagination.py +0 -0
  81. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/perplexity/__init__.py +0 -0
  82. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/perplexity/request_body.py +0 -0
  83. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/perplexity/service.py +0 -0
  84. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/search_engines/service_base.py +0 -0
  85. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/url_safety/__init__.py +0 -0
  86. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/url_safety/gate.py +0 -0
  87. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/error_handlers.py +0 -0
  88. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/__init__.py +0 -0
  89. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/generator/__init__.py +0 -0
  90. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/generator/introspect.py +0 -0
  91. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/generator/schema.py +0 -0
  92. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/generator/template.py +0 -0
  93. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/generator/templates/provider_values_yaml.j2 +0 -0
  94. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/generator/values_yaml.py +0 -0
  95. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/metadata.py +0 -0
  96. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/registry.py +0 -0
  97. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/url_safety_registration.py +0 -0
  98. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/monitoring/__init__.py +0 -0
  99. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/monitoring/setup.py +0 -0
  100. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/__init__.py +0 -0
  101. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/common.py +0 -0
  102. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/crawl.py +0 -0
  103. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/search.py +0 -0
  104. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/types.py +0 -0
  105. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/__init__.py +0 -0
  106. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/base.py +0 -0
  107. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/client.py +0 -0
  108. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/monitoring.py +0 -0
  109. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/__init__.py +0 -0
  110. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/base.py +0 -0
  111. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/bing_agent.py +0 -0
  112. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/brave.py +0 -0
  113. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/firecrawl.py +0 -0
  114. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/google.py +0 -0
  115. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/jina.py +0 -0
  116. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/perplexity.py +0 -0
  117. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/tavily.py +0 -0
  118. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/providers/vertexai_agent.py +0 -0
  119. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/request_limits.py +0 -0
  120. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/secret_str.py +0 -0
  121. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/startup_report.py +0 -0
  122. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/utils/__init__.py +0 -0
  123. {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/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.dev13
3
+ Version: 2026.44.0.dev15
4
4
  Summary: Web Search Proxy implementation
5
5
  Author: ThePhilAz
6
6
  Author-email: ThePhilAz <rami.azouz@philico.com>
@@ -21,7 +21,7 @@ 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
23
  Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0
24
- Requires-Dist: unique-search-proxy-core>=2026.44.0.dev9,<2026.44.0rc0
24
+ Requires-Dist: unique-search-proxy-core>=2026.44.0.dev11,<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,7 +116,7 @@ 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) |
@@ -124,6 +124,25 @@ Built-in providers register in `register_builtin_providers()` during `create_app
124
124
 
125
125
  OpenAPI spec: `openapi.json` (input for [SDK codegen](../unique_search_proxy_sdk/README.md)).
126
126
 
127
+ ### 4.1 Request context and usage
128
+
129
+ `/v1/*` routes read these headers into a request context that prefixes every log line:
130
+
131
+ | Header | Required | Use |
132
+ |--------|----------|-----|
133
+ | `x-unique-company-id`, `x-unique-user-id`, `x-unique-chat-id` | Yes (`REQUIRE_CONTEXT_HEADERS`, default `true`) | Tenant identity |
134
+ | `x-unique-entry-point` | No, defaults to `unknown` | Where the request entered the platform (`chat_tool`, `public_api`, `conduct`, ...) |
135
+ | `x-unique-message-id` | No | Reserved for analytics; recorded in the usage line only |
136
+ | `x-service-id` | No, defaults to `unknown` | Calling service; logged as `caller=`, never enforced |
137
+
138
+ A request whose company or user id is not numeric (`local`, blank, junk) is unattributed: it logs a warning and increments `unique_search_proxy_unattributed_requests_total{entry_point}`. Set `REJECT_UNATTRIBUTED_REQUESTS=true` to return 400 instead. `/v1/configuration/*` skips this check.
139
+
140
+ `/v1/search`, `/v1/agent-search` (sync and stream) and `/v1/crawl` write one JSON usage line per request (`event="web_search_usage"`) and increment `unique_search_proxy_requests_total{company_id, entry_point, endpoint, provider, status}`. `company_id` is `unattributed` unless both ids are numeric. The line holds no query text or URLs.
141
+
142
+ `units` is 1 per search or agent search. For a crawl it is the number of URLs that passed URL safety, so 0 when nothing reached the provider. Failed and timed-out requests keep their units, so filter on `status` when counting billable work. Search records also carry `fetch_size`, the number of results requested: Google and Brave page through results (10 and 20 per call), so one search unit can be several provider calls.
143
+
144
+ The usage line goes through its own logger (`unique_search_proxy_core.usage`) with a bare message format, fixed at INFO, so Loki `| json` can parse it regardless of `LOG_LEVEL`. Nothing verifies these headers: any in-cluster caller can set them.
145
+
127
146
  ---
128
147
 
129
148
  ## 5. Providers
@@ -191,7 +210,7 @@ Each result requires `url`, `title`, and `snippet`; `content` is optional.
191
210
 
192
211
  ### 5.3 Agent search (`POST /v1/agent-search`)
193
212
 
194
- Thin egress — proxy returns opaque agent text; callers own parsing and citation extraction:
213
+ 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
214
 
196
215
  ```json
197
216
  {
@@ -254,6 +273,7 @@ Settings use pydantic-settings with per-provider env vars. Copy `.env.example` t
254
273
  | Bing agent | `BING_AGENT_` | `BING_AGENT_ENDPOINT`, `BING_AGENT_BING_RESOURCE_CONNECTION_STRING`, optional `BING_AGENT_CLEANUP_ON_START`, `BING_AGENT_DEFAULT_MARKET` |
255
274
  | VertexAI agent | `VERTEXAI_AGENT_` | `VERTEXAI_AGENT_SERVICE_ACCOUNT_CREDENTIALS` (optional) |
256
275
  | HTTP client | `HTTP_CLIENT_` | `HTTP_CLIENT_PROXY_HOST`, `HTTP_CLIENT_POOL_TIMEOUT_SECONDS` |
276
+ | Request context | (none) | `REQUIRE_CONTEXT_HEADERS`, `REJECT_UNATTRIBUTED_REQUESTS` |
257
277
  | Prometheus | `PROMETHEUS_` | `PROMETHEUS_ENABLED` |
258
278
  | URL safety | `URL_SAFETY_` | `URL_SAFETY_ENABLED` |
259
279
  | Container | (shell) | `HOST`, `PORT`, `WORKERS`, `LOG_LEVEL` |
@@ -88,7 +88,7 @@ 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) |
@@ -96,6 +96,25 @@ Built-in providers register in `register_builtin_providers()` during `create_app
96
96
 
97
97
  OpenAPI spec: `openapi.json` (input for [SDK codegen](../unique_search_proxy_sdk/README.md)).
98
98
 
99
+ ### 4.1 Request context and usage
100
+
101
+ `/v1/*` routes read these headers into a request context that prefixes every log line:
102
+
103
+ | Header | Required | Use |
104
+ |--------|----------|-----|
105
+ | `x-unique-company-id`, `x-unique-user-id`, `x-unique-chat-id` | Yes (`REQUIRE_CONTEXT_HEADERS`, default `true`) | Tenant identity |
106
+ | `x-unique-entry-point` | No, defaults to `unknown` | Where the request entered the platform (`chat_tool`, `public_api`, `conduct`, ...) |
107
+ | `x-unique-message-id` | No | Reserved for analytics; recorded in the usage line only |
108
+ | `x-service-id` | No, defaults to `unknown` | Calling service; logged as `caller=`, never enforced |
109
+
110
+ A request whose company or user id is not numeric (`local`, blank, junk) is unattributed: it logs a warning and increments `unique_search_proxy_unattributed_requests_total{entry_point}`. Set `REJECT_UNATTRIBUTED_REQUESTS=true` to return 400 instead. `/v1/configuration/*` skips this check.
111
+
112
+ `/v1/search`, `/v1/agent-search` (sync and stream) and `/v1/crawl` write one JSON usage line per request (`event="web_search_usage"`) and increment `unique_search_proxy_requests_total{company_id, entry_point, endpoint, provider, status}`. `company_id` is `unattributed` unless both ids are numeric. The line holds no query text or URLs.
113
+
114
+ `units` is 1 per search or agent search. For a crawl it is the number of URLs that passed URL safety, so 0 when nothing reached the provider. Failed and timed-out requests keep their units, so filter on `status` when counting billable work. Search records also carry `fetch_size`, the number of results requested: Google and Brave page through results (10 and 20 per call), so one search unit can be several provider calls.
115
+
116
+ The usage line goes through its own logger (`unique_search_proxy_core.usage`) with a bare message format, fixed at INFO, so Loki `| json` can parse it regardless of `LOG_LEVEL`. Nothing verifies these headers: any in-cluster caller can set them.
117
+
99
118
  ---
100
119
 
101
120
  ## 5. Providers
@@ -163,7 +182,7 @@ Each result requires `url`, `title`, and `snippet`; `content` is optional.
163
182
 
164
183
  ### 5.3 Agent search (`POST /v1/agent-search`)
165
184
 
166
- Thin egress — proxy returns opaque agent text; callers own parsing and citation extraction:
185
+ 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
186
 
168
187
  ```json
169
188
  {
@@ -226,6 +245,7 @@ Settings use pydantic-settings with per-provider env vars. Copy `.env.example` t
226
245
  | Bing agent | `BING_AGENT_` | `BING_AGENT_ENDPOINT`, `BING_AGENT_BING_RESOURCE_CONNECTION_STRING`, optional `BING_AGENT_CLEANUP_ON_START`, `BING_AGENT_DEFAULT_MARKET` |
227
246
  | VertexAI agent | `VERTEXAI_AGENT_` | `VERTEXAI_AGENT_SERVICE_ACCOUNT_CREDENTIALS` (optional) |
228
247
  | HTTP client | `HTTP_CLIENT_` | `HTTP_CLIENT_PROXY_HOST`, `HTTP_CLIENT_POOL_TIMEOUT_SECONDS` |
248
+ | Request context | (none) | `REQUIRE_CONTEXT_HEADERS`, `REJECT_UNATTRIBUTED_REQUESTS` |
229
249
  | Prometheus | `PROMETHEUS_` | `PROMETHEUS_ENABLED` |
230
250
  | URL safety | `URL_SAFETY_` | `URL_SAFETY_ENABLED` |
231
251
  | Container | (shell) | `HOST`, `PORT`, `WORKERS`, `LOG_LEVEL` |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique-search-proxy"
3
- version = "2026.44.0.dev13"
3
+ version = "2026.44.0.dev15"
4
4
  description = "Web Search Proxy implementation"
5
5
  authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
6
6
  readme = "README.md"
@@ -23,7 +23,7 @@ dependencies = [
23
23
  "google-auth>=2.43.0,<3",
24
24
  "jinja2>=3.1.6,<4",
25
25
  "unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0",
26
- "unique-search-proxy-core>=2026.44.0.dev9,<2026.44.0rc0",
26
+ "unique-search-proxy-core>=2026.44.0.dev11,<2026.44.0rc0",
27
27
  "openai>=1.105.0,<3",
28
28
  ]
29
29
 
@@ -32,6 +32,7 @@ from unique_search_proxy_client.web.monitoring.metrics import (
32
32
  record_agent_search_error,
33
33
  record_agent_search_success,
34
34
  )
35
+ from unique_search_proxy_client.web.usage import record_request_usage
35
36
 
36
37
  router = APIRouter(tags=["agent-search"])
37
38
  _LOGGER = logging.getLogger(__name__)
@@ -79,6 +80,7 @@ async def agent_search(
79
80
  engine_id = engine.value if hasattr(engine, "value") else str(engine)
80
81
  timeout = body.timeout
81
82
  started = time.perf_counter()
83
+ succeeded = False
82
84
  _LOGGER.info(
83
85
  "agent-search start mode=sync engine=%s timeout=%ss",
84
86
  engine_id,
@@ -95,6 +97,7 @@ async def agent_search(
95
97
  )
96
98
  async with asyncio.timeout(timeout):
97
99
  result = await engine_service.search(body)
100
+ succeeded = True
98
101
  except TimeoutError as exc:
99
102
  record_agent_search_error(
100
103
  engine_id,
@@ -138,6 +141,14 @@ async def agent_search(
138
141
  (time.perf_counter() - started) * 1000,
139
142
  )
140
143
  raise
144
+ finally:
145
+ record_request_usage(
146
+ "agent_search",
147
+ engine_id,
148
+ units=1,
149
+ succeeded=succeeded,
150
+ started=started,
151
+ )
141
152
 
142
153
  duration = time.perf_counter() - started
143
154
  record_agent_search_success(engine_id, duration)
@@ -161,6 +172,7 @@ async def agent_search_stream(
161
172
  engine_id = engine.value if hasattr(engine, "value") else str(engine)
162
173
  timeout = body.timeout
163
174
  started = time.perf_counter()
175
+ succeeded = False
164
176
  _LOGGER.info(
165
177
  "agent-search start mode=stream engine=%s timeout=%ss",
166
178
  engine_id,
@@ -181,9 +193,23 @@ async def agent_search_stream(
181
193
  exc.code.value if hasattr(exc.code, "value") else str(exc.code),
182
194
  time.perf_counter() - started,
183
195
  )
196
+ record_request_usage(
197
+ "agent_search",
198
+ engine_id,
199
+ units=1,
200
+ succeeded=succeeded,
201
+ started=started,
202
+ )
184
203
  raise _agent_search_request_context(exc, engine_id=engine_id) from exc
185
204
  except Exception as exc:
186
205
  record_agent_search_error(engine_id, "INTERNAL_ERROR", 0.0)
206
+ record_request_usage(
207
+ "agent_search",
208
+ engine_id,
209
+ units=1,
210
+ succeeded=succeeded,
211
+ started=started,
212
+ )
187
213
  _LOGGER.exception(
188
214
  "agent-search setup error mode=stream engine=%s",
189
215
  engine_id,
@@ -194,11 +220,12 @@ async def agent_search_stream(
194
220
  ) from exc
195
221
 
196
222
  async def event_generator() -> AsyncIterator[str]:
197
- nonlocal started
223
+ nonlocal started, succeeded
198
224
  try:
199
225
  async with asyncio.timeout(timeout):
200
226
  async for event in engine_service.stream(body):
201
227
  yield _format_sse_event(event)
228
+ succeeded = True
202
229
  record_agent_search_success(engine_id, time.perf_counter() - started)
203
230
  _LOGGER.info(
204
231
  "agent-search success mode=stream engine=%s duration=%.0fms",
@@ -256,6 +283,14 @@ async def agent_search_stream(
256
283
  engine_id=engine_id,
257
284
  ),
258
285
  )
286
+ finally:
287
+ record_request_usage(
288
+ "agent_search",
289
+ engine_id,
290
+ units=1,
291
+ succeeded=succeeded,
292
+ started=started,
293
+ )
259
294
 
260
295
  return StreamingResponse(
261
296
  event_generator(),
@@ -8,7 +8,9 @@ from fastapi import Header
8
8
  from unique_search_proxy_core.context import (
9
9
  CHAT_ID_HEADER,
10
10
  COMPANY_ID_HEADER,
11
+ ENTRY_POINT_HEADER,
11
12
  LOCAL_REQUEST_CONTEXT,
13
+ MESSAGE_ID_HEADER,
12
14
  USER_ID_HEADER,
13
15
  )
14
16
 
@@ -35,6 +37,20 @@ async def document_request_context_headers(
35
37
  description="Tenant chat or session identifier.",
36
38
  ),
37
39
  ] = LOCAL_REQUEST_CONTEXT.chat_id,
40
+ x_unique_entry_point: Annotated[
41
+ str,
42
+ Header(
43
+ alias=ENTRY_POINT_HEADER,
44
+ description="Service where the search entered the platform.",
45
+ ),
46
+ ] = LOCAL_REQUEST_CONTEXT.entry_point.value,
47
+ x_unique_message_id: Annotated[
48
+ str | None,
49
+ Header(
50
+ alias=MESSAGE_ID_HEADER,
51
+ description="Message identifier, reserved for analytics.",
52
+ ),
53
+ ] = None,
38
54
  ) -> None:
39
55
  """Declare context headers on /v1 routes for Swagger; enforcement is in middleware."""
40
56
  return None
@@ -40,6 +40,7 @@ from unique_search_proxy_client.web.monitoring.metrics import (
40
40
  from unique_search_proxy_client.web.settings.request_limits import (
41
41
  request_limits_settings,
42
42
  )
43
+ from unique_search_proxy_client.web.usage import record_request_usage
43
44
 
44
45
  router = APIRouter(tags=["crawl"])
45
46
  _LOGGER = logging.getLogger(__name__)
@@ -96,6 +97,8 @@ async def crawl(
96
97
  crawler_id = body.crawler
97
98
  timeout = min(body.timeout, request_limits_settings.max_timeout_seconds)
98
99
  started = time.perf_counter()
100
+ succeeded = False
101
+ units = 0
99
102
  _LOGGER.info(
100
103
  "crawl start crawler=%s urls=%d timeout=%ss",
101
104
  crawler_id,
@@ -112,7 +115,9 @@ async def crawl(
112
115
  body.urls,
113
116
  redirect_http_client=client,
114
117
  )
118
+ units = len(gate.allowed_targets)
115
119
  if not gate.allowed_targets:
120
+ succeeded = True
116
121
  duration = time.perf_counter() - started
117
122
  record_crawl_success(
118
123
  crawler_id,
@@ -155,6 +160,7 @@ async def crawl(
155
160
  )
156
161
  else:
157
162
  crawler_results = await crawler.crawl(crawl_body)
163
+ succeeded = True
158
164
  except TimeoutError as exc:
159
165
  record_crawl_error(
160
166
  crawler_id,
@@ -198,6 +204,14 @@ async def crawl(
198
204
  (time.perf_counter() - started) * 1000,
199
205
  )
200
206
  raise
207
+ finally:
208
+ record_request_usage(
209
+ "crawl",
210
+ crawler_id,
211
+ units=units,
212
+ succeeded=succeeded,
213
+ started=started,
214
+ )
201
215
 
202
216
  duration = time.perf_counter() - started
203
217
  record_crawl_success(crawler_id, len(body.urls), duration)
@@ -34,6 +34,7 @@ from unique_search_proxy_client.web.monitoring.metrics import (
34
34
  from unique_search_proxy_client.web.settings.request_limits import (
35
35
  request_limits_settings,
36
36
  )
37
+ from unique_search_proxy_client.web.usage import record_request_usage
37
38
 
38
39
  router = APIRouter(tags=["search"])
39
40
  _LOGGER = logging.getLogger(__name__)
@@ -68,6 +69,7 @@ async def search(
68
69
  engine_id = engine.value if hasattr(engine, "value") else str(engine)
69
70
  timeout = min(body.timeout, request_limits_settings.max_timeout_seconds)
70
71
  started = time.perf_counter()
72
+ succeeded = False
71
73
  _LOGGER.info("search start engine=%s timeout=%ss", engine_id, timeout)
72
74
 
73
75
  try:
@@ -79,6 +81,7 @@ async def search(
79
81
  )
80
82
  async with asyncio.timeout(timeout):
81
83
  raw, curated = await engine.search(body)
84
+ succeeded = True
82
85
  except TimeoutError as exc:
83
86
  record_search_error(
84
87
  engine_id,
@@ -122,6 +125,16 @@ async def search(
122
125
  (time.perf_counter() - started) * 1000,
123
126
  )
124
127
  raise
128
+ finally:
129
+ record_request_usage(
130
+ "search",
131
+ engine_id,
132
+ # TODO(UN-24394): count provider pages fetched if billing needs exact call counts.
133
+ units=1,
134
+ fetch_size=body.fetch_size,
135
+ succeeded=succeeded,
136
+ started=started,
137
+ )
125
138
 
126
139
  duration = time.perf_counter() - started
127
140
  record_search_success(engine_id, duration)
@@ -13,7 +13,13 @@ _REQUEST_CONTEXT: ContextVar[RequestContext] = ContextVar(
13
13
  default=LOCAL_REQUEST_CONTEXT,
14
14
  )
15
15
 
16
- _CONTEXT_FIELDS: Final[tuple[str, ...]] = ("company_id", "user_id", "chat_id")
16
+ _CONTEXT_FIELDS: Final[tuple[str, ...]] = (
17
+ "company_id",
18
+ "user_id",
19
+ "chat_id",
20
+ "entry_point",
21
+ "caller",
22
+ )
17
23
 
18
24
 
19
25
  def bind_request_context(context: RequestContext) -> Token[RequestContext]:
@@ -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