unique-search-proxy-core 2026.44.0.dev9__tar.gz → 2026.44.0.dev11__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 (68) hide show
  1. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/PKG-INFO +3 -3
  2. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/README.md +1 -1
  3. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/pyproject.toml +2 -2
  4. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/__init__.py +4 -0
  5. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/base.py +3 -2
  6. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/context.py +57 -0
  7. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/schema.py +40 -2
  8. unique_search_proxy_core-2026.44.0.dev11/unique_search_proxy_core/usage.py +45 -0
  9. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/__init__.py +0 -0
  10. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/bing/__init__.py +0 -0
  11. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/bing/enums.py +0 -0
  12. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/bing/grounding.py +0 -0
  13. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/bing/schema.py +0 -0
  14. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/bing/settings.py +0 -0
  15. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/config_types.py +0 -0
  16. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/output_schema.py +0 -0
  17. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/resolve.py +0 -0
  18. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/response_parsing.py +0 -0
  19. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/vertexai/__init__.py +0 -0
  20. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/vertexai/schema.py +0 -0
  21. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/agent_engines/vertexai/settings.py +0 -0
  22. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/__init__.py +0 -0
  23. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/base.py +0 -0
  24. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/basic/__init__.py +0 -0
  25. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/basic/content_types.py +0 -0
  26. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/basic/processing/policy.py +0 -0
  27. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/basic/schema.py +0 -0
  28. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/config_types.py +0 -0
  29. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/firecrawl/schema.py +0 -0
  30. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/jina/schema.py +0 -0
  31. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/crawlers/tavily/schema.py +0 -0
  32. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/errors.py +0 -0
  33. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/http_client/__init__.py +0 -0
  34. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/http_client/client.py +0 -0
  35. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/http_client/credentials.py +0 -0
  36. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/http_client/response_cap.py +0 -0
  37. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/http_client/secrets.py +0 -0
  38. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/http_client/settings.py +0 -0
  39. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/logging.py +0 -0
  40. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/param_policy/__init__.py +0 -0
  41. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/param_policy/annotations.py +0 -0
  42. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/param_policy/derive.py +0 -0
  43. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/param_policy/exposable_config.py +0 -0
  44. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/param_policy/exposable_param.py +0 -0
  45. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/param_policy/exposed_params.py +0 -0
  46. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/param_policy/request_base.py +0 -0
  47. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/providers/schema.py +0 -0
  48. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/__init__.py +0 -0
  49. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/base.py +0 -0
  50. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/brave/__init__.py +0 -0
  51. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/brave/schema.py +0 -0
  52. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/config_types.py +0 -0
  53. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/custom_api/__init__.py +0 -0
  54. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/custom_api/schema.py +0 -0
  55. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/google/__init__.py +0 -0
  56. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/google/schema.py +0 -0
  57. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/perplexity/__init__.py +0 -0
  58. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/search_engines/perplexity/schema.py +0 -0
  59. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/__init__.py +0 -0
  60. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/dns.py +0 -0
  61. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/egress.py +0 -0
  62. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/models.py +0 -0
  63. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/netloc.py +0 -0
  64. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/policy.py +0 -0
  65. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/redirect.py +0 -0
  66. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/resolver.py +0 -0
  67. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/service.py +0 -0
  68. {unique_search_proxy_core-2026.44.0.dev9 → unique_search_proxy_core-2026.44.0.dev11}/unique_search_proxy_core/url_safety/settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-search-proxy-core
3
- Version: 2026.44.0.dev9
3
+ Version: 2026.44.0.dev11
4
4
  Summary: Shared Pydantic types for the Unique Search Proxy API
5
5
  Author: ThePhilAz
6
6
  Author-email: ThePhilAz <rami.azouz@philico.com>
@@ -8,7 +8,7 @@ Requires-Dist: httpx>=0.28.0,<0.29
8
8
  Requires-Dist: pydantic>=2.12.5,<3.0.0
9
9
  Requires-Dist: pydantic-settings>=2.10.1,<3
10
10
  Requires-Dist: pyhumps>=3.8.0,<4
11
- Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev9,<2026.44.0rc0
11
+ Requires-Dist: unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0
12
12
  Requires-Python: >=3.12
13
13
  Description-Content-Type: text/markdown
14
14
 
@@ -150,7 +150,7 @@ flowchart TB
150
150
 
151
151
  | Module | Responsibility |
152
152
  |--------|----------------|
153
- | `schema.py` | Shared API models: `SearchResponse`, `AgentSearchResponse`, `CrawlResponse`, `WebSearchResult`, `ErrorResponse`, SSE events |
153
+ | `schema.py` | Shared API models: `SearchResponse`, `AgentSearchResponse` (+ `AnswerSpanCitation`), `CrawlResponse`, `WebSearchResult`, `ErrorResponse`, SSE events |
154
154
  | `errors.py` | `ProxyError` hierarchy and stable `ProxyErrorCode` enum |
155
155
  | `param_policy/exposable_param.py` | `ExposableParam` value object, factory-default merge, type introspection, OpenAPI naming |
156
156
  | `param_policy/exposable_config.py` | `ExposableParamsConfig` — the `exposed_params_model()` / `merge()` lifecycle shared by search and agent config bases |
@@ -136,7 +136,7 @@ flowchart TB
136
136
 
137
137
  | Module | Responsibility |
138
138
  |--------|----------------|
139
- | `schema.py` | Shared API models: `SearchResponse`, `AgentSearchResponse`, `CrawlResponse`, `WebSearchResult`, `ErrorResponse`, SSE events |
139
+ | `schema.py` | Shared API models: `SearchResponse`, `AgentSearchResponse` (+ `AnswerSpanCitation`), `CrawlResponse`, `WebSearchResult`, `ErrorResponse`, SSE events |
140
140
  | `errors.py` | `ProxyError` hierarchy and stable `ProxyErrorCode` enum |
141
141
  | `param_policy/exposable_param.py` | `ExposableParam` value object, factory-default merge, type introspection, OpenAPI naming |
142
142
  | `param_policy/exposable_config.py` | `ExposableParamsConfig` — the `exposed_params_model()` / `merge()` lifecycle shared by search and agent config bases |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique-search-proxy-core"
3
- version = "2026.44.0.dev9"
3
+ version = "2026.44.0.dev11"
4
4
  description = "Shared Pydantic types for the Unique Search Proxy API"
5
5
  authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
6
6
  readme = "README.md"
@@ -10,7 +10,7 @@ dependencies = [
10
10
  "pydantic>=2.12.5,<3.0.0",
11
11
  "pydantic-settings>=2.10.1,<3",
12
12
  "pyhumps>=3.8.0,<4",
13
- "unique-toolkit[monitoring]>=2026.44.0.dev9,<2026.44.0rc0",
13
+ "unique-toolkit[monitoring]>=2026.44.0.dev10,<2026.44.0rc0",
14
14
 
15
15
  ]
16
16
 
@@ -19,6 +19,8 @@ from unique_search_proxy_core.errors import (
19
19
  ValidationProxyError,
20
20
  )
21
21
  from unique_search_proxy_core.schema import (
22
+ AgentSearchResponse,
23
+ AnswerSpanCitation,
22
24
  CrawlResponse,
23
25
  ErrorDetail,
24
26
  ErrorResponse,
@@ -35,6 +37,8 @@ __all__ = [
35
37
  "RequestContext",
36
38
  "USER_ID_HEADER",
37
39
  "BadRequestProxyError",
40
+ "AgentSearchResponse",
41
+ "AnswerSpanCitation",
38
42
  "CrawlResponse",
39
43
  "EmptySearchResultsError",
40
44
  "EngineNotConfiguredError",
@@ -112,8 +112,9 @@ class AgentSearchEngine(ABC, Generic[AgentRequestT]):
112
112
  """Agent (grounded) search engine contract for v1 providers.
113
113
 
114
114
  Implementations call the upstream agent/grounding provider and return opaque
115
- ``answer`` text plus ``raw`` provider payload. Content resolution is left to
116
- consumers; this layer stays provider-egress only.
115
+ ``answer`` text, the ``citations`` the provider attached to it, and ``raw``
116
+ provider payload. Content resolution is left to consumers; this layer stays
117
+ provider-egress only.
117
118
  """
118
119
 
119
120
  engine_id: str
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import logging
7
7
  from collections.abc import Mapping
8
+ from enum import StrEnum
8
9
  from typing import Any
9
10
 
10
11
  from pydantic import BaseModel, ConfigDict, Field
@@ -13,6 +14,9 @@ COMPANY_ID_HEADER = "x-unique-company-id"
13
14
  USER_ID_HEADER = "x-unique-user-id"
14
15
  CHAT_ID_HEADER = "x-unique-chat-id"
15
16
  USER_METADATA_HEADER = "x-unique-user-metadata"
17
+ ENTRY_POINT_HEADER = "x-unique-entry-point"
18
+ MESSAGE_ID_HEADER = "x-unique-message-id"
19
+ SERVICE_ID_HEADER = "x-service-id"
16
20
 
17
21
  _CONTEXT_HEADER_FIELDS: tuple[tuple[str, str], ...] = (
18
22
  ("company_id", COMPANY_ID_HEADER),
@@ -23,6 +27,23 @@ _CONTEXT_HEADER_FIELDS: tuple[tuple[str, str], ...] = (
23
27
  _LOGGER = logging.getLogger(__name__)
24
28
 
25
29
 
30
+ class EntryPoint(StrEnum):
31
+ """Service where a search request entered the platform."""
32
+
33
+ CHAT_TOOL = "chat_tool"
34
+ PUBLIC_API = "public_api"
35
+ CONDUCT = "conduct"
36
+ UNIQUE_API = "unique_api"
37
+ GRAPHQL = "graphql"
38
+ DEEP_RESEARCH = "deep_research"
39
+ WEB_SEARCH_SERVICE = "web_search_service"
40
+ UNKNOWN = "unknown"
41
+
42
+ @classmethod
43
+ def _missing_(cls, value: object) -> EntryPoint:
44
+ return cls.UNKNOWN
45
+
46
+
26
47
  class RequestContext(BaseModel):
27
48
  """Caller identity for search-proxy requests."""
28
49
 
@@ -31,8 +52,29 @@ class RequestContext(BaseModel):
31
52
  company_id: str
32
53
  user_id: str
33
54
  chat_id: str
55
+ entry_point: EntryPoint = EntryPoint.UNKNOWN
56
+ # Calling service from ``x-service-id``; logged only, never enforced.
57
+ caller: str = "unknown"
58
+ # Reserved for analytics; optional until callers send it.
59
+ message_id: str | None = None
34
60
  user_metadata: dict[str, Any] = Field(default_factory=dict)
35
61
 
62
+ @property
63
+ def invalid_identity_headers(self) -> list[str]:
64
+ """Identity headers whose value is not a numeric id (``local``, blank, junk)."""
65
+ return [
66
+ header_name
67
+ for header_name, value in (
68
+ (COMPANY_ID_HEADER, self.company_id),
69
+ (USER_ID_HEADER, self.user_id),
70
+ )
71
+ if not (value.isascii() and value.isdecimal())
72
+ ]
73
+
74
+ @property
75
+ def is_attributed(self) -> bool:
76
+ return not self.invalid_identity_headers
77
+
36
78
  def to_headers(self) -> dict[str, str]:
37
79
  """Serialize context to the canonical HTTP header names."""
38
80
  headers = {
@@ -40,6 +82,10 @@ class RequestContext(BaseModel):
40
82
  USER_ID_HEADER: self.user_id,
41
83
  CHAT_ID_HEADER: self.chat_id,
42
84
  }
85
+ if self.entry_point is not EntryPoint.UNKNOWN:
86
+ headers[ENTRY_POINT_HEADER] = self.entry_point.value
87
+ if self.message_id:
88
+ headers[MESSAGE_ID_HEADER] = self.message_id
43
89
  if self.user_metadata:
44
90
  # ``ensure_ascii`` keeps the value latin-1 encodable, which HTTP
45
91
  # headers require.
@@ -78,6 +124,13 @@ class RequestContext(BaseModel):
78
124
  values[field_name] = str(raw)
79
125
  return cls(
80
126
  **values,
127
+ entry_point=normalized.get(
128
+ ENTRY_POINT_HEADER.lower(),
129
+ fallback.entry_point,
130
+ ),
131
+ caller=normalized.get(SERVICE_ID_HEADER) or fallback.caller,
132
+ message_id=str(normalized.get(MESSAGE_ID_HEADER) or "").strip()
133
+ or fallback.message_id,
81
134
  user_metadata=_parse_user_metadata(
82
135
  normalized.get(USER_METADATA_HEADER),
83
136
  fallback=fallback.user_metadata,
@@ -119,8 +172,12 @@ LOCAL_REQUEST_CONTEXT = RequestContext(
119
172
  __all__ = [
120
173
  "CHAT_ID_HEADER",
121
174
  "COMPANY_ID_HEADER",
175
+ "ENTRY_POINT_HEADER",
176
+ "EntryPoint",
122
177
  "LOCAL_REQUEST_CONTEXT",
178
+ "MESSAGE_ID_HEADER",
123
179
  "RequestContext",
180
+ "SERVICE_ID_HEADER",
124
181
  "USER_ID_HEADER",
125
182
  "USER_METADATA_HEADER",
126
183
  ]
@@ -186,11 +186,42 @@ class SearchResponse(BaseModel):
186
186
  curated: list[WebSearchResult]
187
187
 
188
188
 
189
+ class AnswerSpanCitation(BaseModel):
190
+ """A source cited at a specific span of the answer text.
191
+
192
+ Grounding with Bing Search reports these as ``url_citation`` annotations:
193
+ the agent writes a marker such as ``【4:0†source】`` into its answer, and
194
+ Foundry attaches the source to that marker's character span.
195
+ """
196
+
197
+ model_config = camelized_model_config
198
+
199
+ url: str = Field(description="URL of the cited source")
200
+ title: str | None = Field(
201
+ default=None,
202
+ description="Title of the cited source",
203
+ )
204
+ marker: str = Field(
205
+ description="Answer text between start_index and end_index",
206
+ )
207
+ start_index: int = Field(
208
+ description="Character offset in the answer where the marker starts",
209
+ )
210
+ end_index: int = Field(
211
+ description="Character offset in the answer just past the marker",
212
+ )
213
+ output_item_id: str | None = Field(
214
+ default=None,
215
+ description="Output item the citation is attached to, if the provider reports one",
216
+ )
217
+
218
+
189
219
  class AgentSearchResponse(BaseModel):
190
220
  """Result of an agent-based (grounded) search.
191
221
 
192
- Thin egress contract: opaque ``answer`` text from the provider plus ``raw``
193
- for debugging. Consumers interpret ``answer`` (JSON parsing, citations, etc.).
222
+ Thin egress contract: opaque ``answer`` text from the provider, the
223
+ citations the provider attached to it, and ``raw`` for debugging. Consumers
224
+ interpret ``answer`` (JSON parsing, mapping citations, etc.).
194
225
  """
195
226
 
196
227
  model_config = camelized_model_config
@@ -201,6 +232,13 @@ class AgentSearchResponse(BaseModel):
201
232
  default="",
202
233
  description="Agent response text as returned by the provider (opaque to the proxy)",
203
234
  )
235
+ citations: list[AnswerSpanCitation] = Field(
236
+ default_factory=list,
237
+ description=(
238
+ "Sources cited in the answer. Only Grounding with Bing Search "
239
+ "reports citations; other engines return an empty list."
240
+ ),
241
+ )
204
242
  raw: Any = Field(default=None, description="Opaque provider payload")
205
243
 
206
244
 
@@ -0,0 +1,45 @@
1
+ """Per-request usage record emitted as a structured log line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import UTC, datetime
7
+ from typing import Literal
8
+ from uuid import uuid4
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from unique_search_proxy_core.context import EntryPoint
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class UsageRecord(BaseModel):
18
+ """Who triggered a billable provider operation. Never holds queries or URLs."""
19
+
20
+ event: Literal["web_search_usage"] = "web_search_usage"
21
+ # Unique per usage line, not joinable to other log lines yet. Take the trace id
22
+ # from unique_toolkit.monitoring.tracing once the proxy adopts tracing.
23
+ request_id: str = Field(default_factory=lambda: str(uuid4()))
24
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
25
+ company_id: str
26
+ user_id: str
27
+ chat_id: str
28
+ message_id: str | None = None
29
+ entry_point: EntryPoint
30
+ caller: str
31
+ endpoint: Literal["search", "agent_search", "crawl"]
32
+ provider: str
33
+ units: int
34
+ # Results requested on /v1/search; Google and Brave page, so one unit can be several calls.
35
+ fetch_size: int | None = None
36
+ status: Literal["success", "error"]
37
+ duration_ms: int
38
+
39
+
40
+ def record_usage(record: UsageRecord) -> None:
41
+ """Write ``record`` as one JSON log line."""
42
+ _LOGGER.info(record.model_dump_json())
43
+
44
+
45
+ __all__ = ["UsageRecord", "record_usage"]