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.
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/PKG-INFO +24 -4
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/README.md +22 -2
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/pyproject.toml +2 -2
- {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
- {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
- {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
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/context.py +7 -1
- unique_search_proxy-2026.44.0.dev15/unique_search_proxy_client/web/core/agent_engines/bing/citation_instructions.j2 +18 -0
- unique_search_proxy-2026.44.0.dev15/unique_search_proxy_client/web/core/agent_engines/bing/instructions.py +26 -0
- {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
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/logging_config.py +16 -1
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/middleware/context.py +33 -1
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/monitoring/metrics.py +31 -26
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/app.py +1 -0
- unique_search_proxy-2026.44.0.dev15/unique_search_proxy_client/web/usage.py +40 -0
- unique_search_proxy-2026.44.0.dev13/unique_search_proxy_client/web/core/agent_engines/structured_output.py +0 -31
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/__init__.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/__init__.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/__init__.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/api/health.py +0 -0
- {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
- {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
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/app.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/providers.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/core/registry.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/error_handlers.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/metadata.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/helm/registry.py +0 -0
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/monitoring/__init__.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/monitoring/setup.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/__init__.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/common.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/crawl.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/search.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/presets/types.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/__init__.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/base.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/client.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/settings/monitoring.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/startup_report.py +0 -0
- {unique_search_proxy-2026.44.0.dev13 → unique_search_proxy-2026.44.0.dev15}/unique_search_proxy_client/web/utils/__init__.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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, ...]] = (
|
|
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[
|
|
151
|
-
"""Stream Bing-grounded Responses events as
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
205
|
-
|
|
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
|
|
242
|
+
yield BingStreamChunk(
|
|
243
|
+
output_text,
|
|
244
|
+
raw_completed,
|
|
245
|
+
answer=output_text,
|
|
246
|
+
citations=citations,
|
|
247
|
+
)
|
|
227
248
|
else:
|
|
228
|
-
yield
|
|
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
|
|
262
|
-
item
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|