unique-search-proxy-core 2026.38.0.dev2__tar.gz → 2026.38.0.dev4__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 (62) hide show
  1. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/PKG-INFO +4 -4
  2. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/README.md +2 -2
  3. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/pyproject.toml +2 -2
  4. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/bing/enums.py +0 -54
  5. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/bing/grounding.py +0 -1
  6. unique_search_proxy_core-2026.38.0.dev4/unique_search_proxy_core/agent_engines/bing/schema.py +157 -0
  7. unique_search_proxy_core-2026.38.0.dev4/unique_search_proxy_core/agent_engines/bing/settings.py +61 -0
  8. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/context.py +48 -3
  9. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/param_policy/__init__.py +4 -10
  10. unique_search_proxy_core-2026.38.0.dev2/unique_search_proxy_core/agent_engines/bing/schema.py +0 -184
  11. unique_search_proxy_core-2026.38.0.dev2/unique_search_proxy_core/agent_engines/bing/settings.py +0 -47
  12. unique_search_proxy_core-2026.38.0.dev2/unique_search_proxy_core/param_policy/ui_tags.py +0 -41
  13. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/__init__.py +0 -0
  14. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/__init__.py +0 -0
  15. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/base.py +0 -0
  16. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/bing/__init__.py +0 -0
  17. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/config_types.py +0 -0
  18. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/output_schema.py +0 -0
  19. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/resolve.py +0 -0
  20. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/response_parsing.py +0 -0
  21. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/vertexai/__init__.py +0 -0
  22. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/vertexai/schema.py +0 -0
  23. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/agent_engines/vertexai/settings.py +0 -0
  24. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/__init__.py +0 -0
  25. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/base.py +0 -0
  26. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/basic/__init__.py +0 -0
  27. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/basic/content_types.py +0 -0
  28. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/basic/processing/policy.py +0 -0
  29. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/basic/schema.py +0 -0
  30. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/config_types.py +0 -0
  31. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/firecrawl/schema.py +0 -0
  32. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/jina/schema.py +0 -0
  33. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/crawlers/tavily/schema.py +0 -0
  34. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/errors.py +0 -0
  35. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/logging.py +0 -0
  36. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/param_policy/annotations.py +0 -0
  37. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/param_policy/derive.py +0 -0
  38. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/param_policy/exposable_config.py +0 -0
  39. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/param_policy/exposable_param.py +0 -0
  40. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/param_policy/exposed_params.py +0 -0
  41. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/param_policy/request_base.py +0 -0
  42. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/providers/schema.py +0 -0
  43. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/schema.py +0 -0
  44. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/__init__.py +0 -0
  45. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/base.py +0 -0
  46. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/brave/__init__.py +0 -0
  47. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/brave/schema.py +0 -0
  48. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/config_types.py +0 -0
  49. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/google/__init__.py +0 -0
  50. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/google/schema.py +0 -0
  51. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/perplexity/__init__.py +0 -0
  52. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/search_engines/perplexity/schema.py +0 -0
  53. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/__init__.py +0 -0
  54. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/dns.py +0 -0
  55. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/egress.py +0 -0
  56. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/models.py +0 -0
  57. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/netloc.py +0 -0
  58. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/policy.py +0 -0
  59. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/redirect.py +0 -0
  60. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/resolver.py +0 -0
  61. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/unique_search_proxy_core/url_safety/service.py +0 -0
  62. {unique_search_proxy_core-2026.38.0.dev2 → unique_search_proxy_core-2026.38.0.dev4}/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.38.0.dev2
3
+ Version: 2026.38.0.dev4
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.38.0.dev5,<2026.38.0rc0
11
+ Requires-Dist: unique-toolkit[monitoring]>=2026.38.0.dev18,<2026.38.0rc0
12
12
  Requires-Python: >=3.12
13
13
  Description-Content-Type: text/markdown
14
14
 
@@ -224,8 +224,8 @@ from unique_search_proxy_core import (
224
224
  ## 7. Features summary
225
225
 
226
226
  - Discriminated provider configs (`engine`, `crawler` Literal discriminators)
227
- - **Search & agent:** `ExposableParam` policy; config-owned `request_model` / `exposed_params_model` / `merge` (`provider_query_params` is search-only)
228
- - **Agent:** `BingAgentConfig.request_model()` (injects `query`; excludes `output_schema`); Bing carries `market` / `setLang` / `freshness`, which are baked into the hashed agent name (`market` and `setLang` are `Literal`s over Bing's documented codes, like `BraveCountry`). All three are tagged `ui:widget: hidden`: they are not cleared for release, so the admin form does not render them
227
+ - **Search & agent:** config-owned `request_model` / `exposed_params_model` / `merge` (`provider_query_params` is search-only); standard engines put optional provider knobs behind the `ExposableParam` policy
228
+ - **Agent:** `BingAgentConfig.request_model()` (injects `query`; excludes `output_schema`); Bing's `market` / `freshness` are fixed admin values, never exposed to the LLM, and are baked into the hashed agent name (both are `Literal`s over Bing's documented values, like `BraveCountry`). A blank `market` falls back to the `BING_AGENT_DEFAULT_MARKET` environment variable
229
229
  - **Crawl:** `BasicConfig.request_model()` (injects `urls`); no exposable params / no merge
230
230
  - CamelCase JSON aliases on all models
231
231
  - Zero server dependencies (import-linter enforced in the client package)
@@ -210,8 +210,8 @@ from unique_search_proxy_core import (
210
210
  ## 7. Features summary
211
211
 
212
212
  - Discriminated provider configs (`engine`, `crawler` Literal discriminators)
213
- - **Search & agent:** `ExposableParam` policy; config-owned `request_model` / `exposed_params_model` / `merge` (`provider_query_params` is search-only)
214
- - **Agent:** `BingAgentConfig.request_model()` (injects `query`; excludes `output_schema`); Bing carries `market` / `setLang` / `freshness`, which are baked into the hashed agent name (`market` and `setLang` are `Literal`s over Bing's documented codes, like `BraveCountry`). All three are tagged `ui:widget: hidden`: they are not cleared for release, so the admin form does not render them
213
+ - **Search & agent:** config-owned `request_model` / `exposed_params_model` / `merge` (`provider_query_params` is search-only); standard engines put optional provider knobs behind the `ExposableParam` policy
214
+ - **Agent:** `BingAgentConfig.request_model()` (injects `query`; excludes `output_schema`); Bing's `market` / `freshness` are fixed admin values, never exposed to the LLM, and are baked into the hashed agent name (both are `Literal`s over Bing's documented values, like `BraveCountry`). A blank `market` falls back to the `BING_AGENT_DEFAULT_MARKET` environment variable
215
215
  - **Crawl:** `BasicConfig.request_model()` (injects `urls`); no exposable params / no merge
216
216
  - CamelCase JSON aliases on all models
217
217
  - Zero server dependencies (import-linter enforced in the client package)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique-search-proxy-core"
3
- version = "2026.38.0.dev2"
3
+ version = "2026.38.0.dev4"
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.38.0.dev5,<2026.38.0rc0",
13
+ "unique-toolkit[monitoring]>=2026.38.0.dev18,<2026.38.0rc0",
14
14
 
15
15
  ]
16
16
 
@@ -42,59 +42,5 @@ BingMarket: TypeAlias = Literal[
42
42
  "es-US",
43
43
  ]
44
44
 
45
- #: Languages Bing localizes its interface strings into (``setLang``).
46
- BingSetLang: TypeAlias = Literal[
47
- "ar",
48
- "eu",
49
- "bn",
50
- "bg",
51
- "ca",
52
- "zh-hans",
53
- "zh-hant",
54
- "hr",
55
- "cs",
56
- "da",
57
- "nl",
58
- "en",
59
- "en-gb",
60
- "et",
61
- "fi",
62
- "fr",
63
- "gl",
64
- "de",
65
- "gu",
66
- "he",
67
- "hi",
68
- "hu",
69
- "is",
70
- "it",
71
- "jp",
72
- "kn",
73
- "ko",
74
- "lv",
75
- "lt",
76
- "ms",
77
- "ml",
78
- "mr",
79
- "nb",
80
- "pl",
81
- "pt-br",
82
- "pt-pt",
83
- "pa",
84
- "ro",
85
- "ru",
86
- "sr",
87
- "sk",
88
- "sl",
89
- "es",
90
- "sv",
91
- "ta",
92
- "te",
93
- "th",
94
- "tr",
95
- "uk",
96
- "vi",
97
- ]
98
-
99
45
  #: Named recency windows accepted by the Bing ``freshness`` knob.
100
46
  BingFreshnessPreset: TypeAlias = Literal["Day", "Week", "Month"]
@@ -24,7 +24,6 @@ class BingGroundingConfiguration:
24
24
 
25
25
  fetch_size: int
26
26
  market: str | None = None
27
- set_lang: str | None = None
28
27
  freshness: str | None = None
29
28
 
30
29
  def hash_payload(self) -> str:
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any, ClassVar, Literal, Mapping, get_args
4
+
5
+ from pydantic import Field, GetJsonSchemaHandler, field_validator
6
+ from pydantic.json_schema import JsonSchemaValue
7
+ from pydantic_core import CoreSchema
8
+ from unique_toolkit._common.pydantic.rjsf_tags import RJSFMetaTag
9
+
10
+ from unique_search_proxy_core.agent_engines.base import (
11
+ AgentEngineType,
12
+ BaseAgentEngineConfig,
13
+ )
14
+ from unique_search_proxy_core.agent_engines.bing.enums import (
15
+ BingFreshnessPreset,
16
+ BingMarket,
17
+ )
18
+
19
+ #: Admin-facing labels for the recency presets, whose wire values are terse.
20
+ _FRESHNESS_PRESET_TITLES: Mapping[str, str] = {
21
+ "Day": "Past 24 hours",
22
+ "Week": "Past 7 days",
23
+ "Month": "Past 30 days",
24
+ }
25
+
26
+
27
+ def _single_dropdown(
28
+ property_schema: JsonSchemaValue,
29
+ *,
30
+ values: tuple[Any, ...],
31
+ blank_title: str,
32
+ titles: Mapping[Any, str] | None = None,
33
+ ) -> JsonSchemaValue:
34
+ """Render an optional literal as one dropdown instead of a union selector.
35
+
36
+ ``Literal[...] | None`` reaches RJSF as an ``anyOf``, drawn as a branch
37
+ picker wrapping a second control; a single ``oneOf`` of constants collapses
38
+ that into one select whose first entry is the blank value. ``titles``
39
+ relabels options whose wire values are not admin-facing copy.
40
+ """
41
+ schema = dict(property_schema)
42
+ schema.pop("anyOf", None)
43
+ schema["type"] = ["string", "null"]
44
+ schema["oneOf"] = [
45
+ {"const": None, "title": blank_title},
46
+ *(
47
+ {"const": value, "title": (titles or {}).get(value, value)}
48
+ for value in values
49
+ ),
50
+ ]
51
+ return schema
52
+
53
+
54
+ class BingAgentConfig(BaseAgentEngineConfig[Literal[AgentEngineType.BING]]):
55
+ """Deployment + request defaults for Bing grounding via Azure AI Projects.
56
+
57
+ The grounding knobs are fixed values chosen by an admin: whatever is set
58
+ here applies to every search in the space and is never offered to the LLM
59
+ to steer per call.
60
+ """
61
+
62
+ _request_model_name: ClassVar[str] = "BingAgentSearchRequest"
63
+ _exposed_params_model_name: ClassVar[str] = "BingAgentExposedParams"
64
+
65
+ engine: Annotated[
66
+ Literal[AgentEngineType.BING], RJSFMetaTag.SpecialWidget.hidden()
67
+ ] = Field(
68
+ default=AgentEngineType.BING,
69
+ title="Agent engine",
70
+ description="Provider discriminator; must be `bing` for this config.",
71
+ )
72
+ fetch_size: int = Field(
73
+ default=5,
74
+ ge=1,
75
+ le=50,
76
+ description="Maximum number of Bing grounding results per query",
77
+ )
78
+ market: BingMarket | None = Field(
79
+ default=None,
80
+ title="Preferred region and language",
81
+ description=(
82
+ "Region and language Bing should favour, as `<language>-<country>`. "
83
+ "Examples: `de-CH`, `fr-CH`, `fr-FR`. This biases the results rather "
84
+ "than restricting them — sources from other regions can still "
85
+ "appear. If left blank, the deployment default set in the "
86
+ "environment variable `BING_AGENT_DEFAULT_MARKET` applies. If left "
87
+ "blank and the environment variable is not set, Bing may favour the "
88
+ "region where the underlying Microsoft Foundry resource is deployed."
89
+ ),
90
+ )
91
+ freshness: BingFreshnessPreset | None = Field(
92
+ default=None,
93
+ title="Only results published recently",
94
+ description=(
95
+ "Only includes results Bing discovered within the chosen timeframe, "
96
+ "counted back from each search. This is a hard filter and it applies "
97
+ "to every search in the space."
98
+ ),
99
+ )
100
+
101
+ # Spaces saved on 2026.36 hold an ExposableParam `{expose, value}` object
102
+ # under `market` and `freshness`, where these fields now expect a scalar.
103
+ # node-chat data migration `20260908120000_drop_bing_grounding_exposable_params`
104
+ # deletes those keys; the validator below reads the same shape as unset for
105
+ # any row the migration has not reached, because an invalid tool config
106
+ # silently disables the whole tool instead of reporting the problem.
107
+ @field_validator("market", "freshness", mode="before")
108
+ @classmethod
109
+ def _coerce_unset(cls, value: Any) -> Any:
110
+ """Read a cleared control, or a retired `{expose, value}` object, as unset."""
111
+ if isinstance(value, Mapping):
112
+ return None
113
+ if isinstance(value, str) and not value.strip():
114
+ return None
115
+ return value
116
+
117
+ @classmethod
118
+ def __get_pydantic_json_schema__(
119
+ cls,
120
+ core_schema: CoreSchema,
121
+ handler: GetJsonSchemaHandler,
122
+ ) -> JsonSchemaValue:
123
+ """Keep the optional grounding knobs to one control each in the admin form."""
124
+ schema = handler(core_schema)
125
+ properties = schema.get("properties")
126
+ if not isinstance(properties, dict):
127
+ return schema
128
+
129
+ market = properties.get("market")
130
+ if isinstance(market, dict):
131
+ properties["market"] = _single_dropdown(
132
+ market,
133
+ values=get_args(BingMarket),
134
+ blank_title="Not set",
135
+ )
136
+
137
+ freshness = properties.get("freshness")
138
+ if isinstance(freshness, dict):
139
+ properties["freshness"] = _single_dropdown(
140
+ freshness,
141
+ values=get_args(BingFreshnessPreset),
142
+ blank_title="Not set",
143
+ titles=_FRESHNESS_PRESET_TITLES,
144
+ )
145
+
146
+ return schema
147
+
148
+
149
+ BingAgentSearchRequest = BingAgentConfig.request_model()
150
+
151
+
152
+ __all__ = [
153
+ "BingAgentConfig",
154
+ "BingAgentSearchRequest",
155
+ "BingFreshnessPreset",
156
+ "BingMarket",
157
+ ]
@@ -0,0 +1,61 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pydantic import Field, field_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ from unique_search_proxy_core.agent_engines.bing.enums import BingMarket
10
+
11
+ _ENV_PREFIX = "BING_AGENT_"
12
+
13
+
14
+ class _BingAgentEnvSettings(BaseSettings):
15
+ default_market: BingMarket | None = Field(
16
+ default=None,
17
+ description=(
18
+ "Bing market applied to grounded searches whose space leaves "
19
+ "Market blank. Unset means no market is sent at all."
20
+ ),
21
+ )
22
+
23
+ @field_validator("default_market", mode="before")
24
+ @classmethod
25
+ def _blank_is_unset(cls, value: Any) -> Any:
26
+ """Treat an empty variable as unset instead of failing to boot.
27
+
28
+ Helm renders an unconfigured optional value as ``""``, which
29
+ ``BingMarket`` would reject. A non-blank typo still fails at startup.
30
+ """
31
+ if isinstance(value, str) and not value.strip():
32
+ return None
33
+ return value
34
+
35
+
36
+ def _get_settings() -> _BingAgentEnvSettings:
37
+ if "pytest" in sys.modules:
38
+ env_file = Path(os.getcwd()) / "tests/test.env"
39
+ else:
40
+ env_file = Path(os.getcwd()) / ".env"
41
+
42
+ class _Settings(_BingAgentEnvSettings):
43
+ model_config = SettingsConfigDict(
44
+ env_file=env_file,
45
+ env_prefix=_ENV_PREFIX,
46
+ extra="ignore",
47
+ )
48
+
49
+ return _Settings()
50
+
51
+
52
+ bing_agent_env_settings = _get_settings()
53
+
54
+
55
+ def resolve_market(value: str | None = None) -> str | None:
56
+ """Return the market to send to Bing, or ``None`` to send none at all.
57
+
58
+ The space's fixed market wins, falling back to ``BING_AGENT_DEFAULT_MARKET``.
59
+ With neither set, ``mkt`` is left off the grounding tool entirely.
60
+ """
61
+ return value or bing_agent_env_settings.default_market
@@ -2,14 +2,17 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ import logging
5
7
  from collections.abc import Mapping
6
8
  from typing import Any
7
9
 
8
- from pydantic import BaseModel, ConfigDict
10
+ from pydantic import BaseModel, ConfigDict, Field
9
11
 
10
12
  COMPANY_ID_HEADER = "x-unique-company-id"
11
13
  USER_ID_HEADER = "x-unique-user-id"
12
14
  CHAT_ID_HEADER = "x-unique-chat-id"
15
+ USER_METADATA_HEADER = "x-unique-user-metadata"
13
16
 
14
17
  _CONTEXT_HEADER_FIELDS: tuple[tuple[str, str], ...] = (
15
18
  ("company_id", COMPANY_ID_HEADER),
@@ -17,6 +20,8 @@ _CONTEXT_HEADER_FIELDS: tuple[tuple[str, str], ...] = (
17
20
  ("chat_id", CHAT_ID_HEADER),
18
21
  )
19
22
 
23
+ _LOGGER = logging.getLogger(__name__)
24
+
20
25
 
21
26
  class RequestContext(BaseModel):
22
27
  """Caller identity for search-proxy requests."""
@@ -26,14 +31,23 @@ class RequestContext(BaseModel):
26
31
  company_id: str
27
32
  user_id: str
28
33
  chat_id: str
34
+ user_metadata: dict[str, Any] = Field(default_factory=dict)
29
35
 
30
36
  def to_headers(self) -> dict[str, str]:
31
37
  """Serialize context to the canonical HTTP header names."""
32
- return {
38
+ headers = {
33
39
  COMPANY_ID_HEADER: self.company_id,
34
40
  USER_ID_HEADER: self.user_id,
35
41
  CHAT_ID_HEADER: self.chat_id,
36
42
  }
43
+ if self.user_metadata:
44
+ # ``ensure_ascii`` keeps the value latin-1 encodable, which HTTP
45
+ # headers require.
46
+ headers[USER_METADATA_HEADER] = json.dumps(
47
+ self.user_metadata,
48
+ ensure_ascii=True,
49
+ )
50
+ return headers
37
51
 
38
52
  @classmethod
39
53
  def missing_headers(cls, headers: Mapping[str, Any]) -> list[str]:
@@ -62,7 +76,37 @@ class RequestContext(BaseModel):
62
76
  values[field_name] = getattr(fallback, field_name)
63
77
  else:
64
78
  values[field_name] = str(raw)
65
- return cls(**values)
79
+ return cls(
80
+ **values,
81
+ user_metadata=_parse_user_metadata(
82
+ normalized.get(USER_METADATA_HEADER),
83
+ fallback=fallback.user_metadata,
84
+ ),
85
+ )
86
+
87
+
88
+ def _parse_user_metadata(
89
+ raw: Any,
90
+ *,
91
+ fallback: dict[str, Any],
92
+ ) -> dict[str, Any]:
93
+ """Decode the JSON user-metadata header; absent → fallback, malformed → {}."""
94
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
95
+ return fallback
96
+ try:
97
+ decoded = json.loads(raw)
98
+ except (TypeError, ValueError) as exc:
99
+ # Log the error class only — never the header body (may contain PII).
100
+ _LOGGER.warning(
101
+ "Ignoring malformed %s header (%s)",
102
+ USER_METADATA_HEADER,
103
+ type(exc).__name__,
104
+ )
105
+ return {}
106
+ if not isinstance(decoded, dict):
107
+ _LOGGER.warning("Ignoring non-object %s header", USER_METADATA_HEADER)
108
+ return {}
109
+ return decoded
66
110
 
67
111
 
68
112
  LOCAL_REQUEST_CONTEXT = RequestContext(
@@ -78,4 +122,5 @@ __all__ = [
78
122
  "LOCAL_REQUEST_CONTEXT",
79
123
  "RequestContext",
80
124
  "USER_ID_HEADER",
125
+ "USER_METADATA_HEADER",
81
126
  ]
@@ -2,18 +2,14 @@
2
2
 
3
3
  Public surface: :class:`ExposableParam` (the admin-side ``{expose, value}``
4
4
  deployment value), :class:`ExposedParams` (the base class of LLM-facing
5
- parameter models), :func:`enforced_by_infra` (RJSF tag to lock env-enforced
6
- knobs), and the shared leading-field names. Everything else callers need lives
7
- as methods on the config base classes (``BaseSearchEngineConfig.request_model()``
8
- / ``exposed_params_model()`` / ``merge()`` / ``provider_query_params()``).
5
+ parameter models), and the shared leading-field names. Everything else callers
6
+ need lives as methods on the config base classes
7
+ (``BaseSearchEngineConfig.request_model()`` / ``exposed_params_model()`` /
8
+ ``merge()`` / ``provider_query_params()``).
9
9
  """
10
10
 
11
11
  from unique_search_proxy_core.param_policy.exposable_param import ExposableParam
12
12
  from unique_search_proxy_core.param_policy.exposed_params import ExposedParams
13
- from unique_search_proxy_core.param_policy.ui_tags import (
14
- dynamic_enforced_by_infra,
15
- enforced_by_infra,
16
- )
17
13
 
18
14
  #: Required leading field of every search / agent-search request.
19
15
  QUERY_FIELD = "query"
@@ -25,6 +21,4 @@ __all__ = [
25
21
  "URLS_FIELD",
26
22
  "ExposableParam",
27
23
  "ExposedParams",
28
- "enforced_by_infra",
29
- "dynamic_enforced_by_infra",
30
24
  ]
@@ -1,184 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Annotated, Any, ClassVar, Literal, TypeAlias
4
-
5
- from pydantic import Field, GetCoreSchemaHandler, field_validator
6
- from pydantic_core import CoreSchema, core_schema
7
- from unique_toolkit._common.pydantic.rjsf_tags import RJSFMetaTag
8
-
9
- from unique_search_proxy_core.agent_engines.base import (
10
- AgentEngineType,
11
- BaseAgentEngineConfig,
12
- )
13
- from unique_search_proxy_core.agent_engines.bing.enums import (
14
- BingFreshnessPreset,
15
- BingMarket,
16
- BingSetLang,
17
- )
18
- from unique_search_proxy_core.agent_engines.bing.settings import (
19
- bing_agent_env_settings,
20
- )
21
- from unique_search_proxy_core.param_policy.exposable_param import ExposableParam
22
- from unique_search_proxy_core.param_policy.ui_tags import dynamic_enforced_by_infra
23
- from unique_search_proxy_core.schema import DeactivatedNone
24
-
25
- _BING_DOCS_BASE_URL = (
26
- "https://learn.microsoft.com/en-us/previous-versions/bing/search-apis/"
27
- "bing-web-search/reference"
28
- )
29
- _BING_QUERY_PARAMS_DOCS_URL = f"{_BING_DOCS_BASE_URL}/query-parameters"
30
- _BING_MARKET_CODES_DOCS_URL = f"{_BING_DOCS_BASE_URL}/market-codes"
31
-
32
-
33
- _FRESHNESS_DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}(?:\.\.\d{4}-\d{2}-\d{2})?$"
34
-
35
-
36
- class BingFreshnessDate(str):
37
- """A single ``YYYY-MM-DD`` day or an inclusive ``YYYY-MM-DD..YYYY-MM-DD`` span.
38
-
39
- The constraint lives in the type instead of ``Field(pattern=...)`` because
40
- request-model derivation strips ``Annotated`` metadata off union branches,
41
- which would leave exactly the surface the LLM writes to unchecked. Freshness
42
- is baked into the hashed agent name, so free text here would mint a Foundry
43
- agent version per distinct string while Bing ignored the value itself.
44
- """
45
-
46
- @classmethod
47
- def __get_pydantic_core_schema__(
48
- cls,
49
- source: type[Any],
50
- handler: GetCoreSchemaHandler,
51
- ) -> CoreSchema:
52
- return core_schema.str_schema(pattern=_FRESHNESS_DATE_PATTERN)
53
-
54
-
55
- MarketOrNone: TypeAlias = Annotated[BingMarket, Field(title="Market")] | DeactivatedNone
56
- SetLangOrNone: TypeAlias = (
57
- Annotated[BingSetLang, Field(title="Language")] | DeactivatedNone
58
- )
59
- FreshnessOrNone: TypeAlias = (
60
- Annotated[BingFreshnessPreset, Field(title="Preset")]
61
- | Annotated[BingFreshnessDate, Field(title="Day or range")]
62
- | DeactivatedNone
63
- )
64
-
65
- ExposableMarket = ExposableParam[MarketOrNone]
66
- ExposableSetLang = ExposableParam[SetLangOrNone]
67
- ExposableFreshness = ExposableParam[FreshnessOrNone]
68
-
69
-
70
- def _default_market() -> ExposableMarket:
71
- return ExposableMarket(
72
- expose=False,
73
- value=bing_agent_env_settings.market.default,
74
- )
75
-
76
-
77
- def _market_is_enforced() -> bool:
78
- return bing_agent_env_settings.market.enforce
79
-
80
-
81
- EnforcedExposableMarket = Annotated[
82
- ExposableMarket,
83
- dynamic_enforced_by_infra(
84
- _market_is_enforced,
85
- help=(
86
- "Market is pinned for this deployment by "
87
- '`BING_AGENT_MARKET={"default": "<mkt>", "enforce": true}`.'
88
- ),
89
- ),
90
- ]
91
-
92
- # The grounding knobs are not cleared for release yet, so they are kept out of
93
- # the admin form instead of being removed: the fields, the values stored configs
94
- # already carry and the runtime path stay intact, but with no widget to render,
95
- # no admin can pin a value or expose a knob to the LLM. Drop the tag to release.
96
- _hidden_until_released = RJSFMetaTag.SpecialWidget.hidden()
97
-
98
- HiddenExposableMarket = Annotated[EnforcedExposableMarket, _hidden_until_released]
99
- HiddenExposableSetLang = Annotated[ExposableSetLang, _hidden_until_released]
100
- HiddenExposableFreshness = Annotated[ExposableFreshness, _hidden_until_released]
101
-
102
-
103
- class BingAgentConfig(BaseAgentEngineConfig[Literal[AgentEngineType.BING]]):
104
- """Deployment + request defaults for Bing grounding via Azure AI Projects."""
105
-
106
- _request_model_name: ClassVar[str] = "BingAgentSearchRequest"
107
- _exposed_params_model_name: ClassVar[str] = "BingAgentExposedParams"
108
-
109
- engine: Annotated[
110
- Literal[AgentEngineType.BING], RJSFMetaTag.SpecialWidget.hidden()
111
- ] = Field(
112
- default=AgentEngineType.BING,
113
- title="Agent engine",
114
- description="Provider discriminator; must be `bing` for this config.",
115
- )
116
- fetch_size: int = Field(
117
- default=5,
118
- ge=1,
119
- le=50,
120
- description="Maximum number of Bing grounding results per query",
121
- )
122
- market: HiddenExposableMarket = Field(
123
- default=_default_market(),
124
- title="Market",
125
- description=(
126
- "Country/region **and** language the results come from (Bing `mkt`), "
127
- "as `<language>-<country>`: `de-CH` returns German-language Swiss "
128
- "results, `fr-CH` French-language Swiss ones, `en-GB` UK English. "
129
- "Set it when the question is about a specific country or expects an "
130
- "answer in that country's language. Left unset, Bing guesses the "
131
- "market from the caller and may answer from another country. "
132
- f"[Market codes]({_BING_MARKET_CODES_DOCS_URL})"
133
- ),
134
- )
135
- set_lang: HiddenExposableSetLang = Field(
136
- default=ExposableSetLang(expose=False, value=None),
137
- title="Interface language",
138
- description=(
139
- "Language of Bing's own interface strings in the response (Bing "
140
- "`setLang`), e.g. `de`, `fr`, `pt-br`. It changes neither which "
141
- "results come back nor the language they are written in — use the "
142
- "market for that. Bing falls back to English for codes it does not "
143
- "support. "
144
- f"[Supported languages]({_BING_MARKET_CODES_DOCS_URL}#bing-supported-language-codes)"
145
- ),
146
- )
147
- freshness: HiddenExposableFreshness = Field(
148
- default=ExposableFreshness(expose=False, value=None),
149
- title="Freshness",
150
- description=(
151
- "Keep only pages Bing discovered recently (Bing `freshness`): `Day` "
152
- "(last 24 hours), `Week` (last 7 days), `Month` (last 30 days), a "
153
- "single `YYYY-MM-DD` day, or an inclusive "
154
- "`YYYY-MM-DD..YYYY-MM-DD` range. Use it for news and fast-moving "
155
- "topics; leave it unset for background or reference questions, where "
156
- "it would hide older pages that are still correct. "
157
- f"[Accepted values]({_BING_QUERY_PARAMS_DOCS_URL}#freshness)"
158
- ),
159
- )
160
-
161
- @field_validator("market", mode="before")
162
- @classmethod
163
- def validate_market(cls, v: ExposableMarket) -> ExposableMarket:
164
- if bing_agent_env_settings.market.enforce:
165
- return ExposableMarket(
166
- expose=False, value=bing_agent_env_settings.market.default
167
- )
168
- return v
169
-
170
-
171
- BingAgentSearchRequest = BingAgentConfig.request_model()
172
-
173
-
174
- __all__ = [
175
- "BingAgentConfig",
176
- "BingAgentSearchRequest",
177
- "BingFreshnessDate",
178
- "BingFreshnessPreset",
179
- "BingMarket",
180
- "BingSetLang",
181
- "ExposableFreshness",
182
- "ExposableMarket",
183
- "ExposableSetLang",
184
- ]
@@ -1,47 +0,0 @@
1
- import os
2
- import sys
3
- from pathlib import Path
4
-
5
- from pydantic import Field
6
- from pydantic_settings import BaseSettings, SettingsConfigDict
7
-
8
- from unique_search_proxy_core.agent_engines.bing.enums import BingMarket
9
-
10
- _ENV_PREFIX = "BING_AGENT_"
11
-
12
-
13
- class MarketSettings(BaseSettings):
14
- default: BingMarket | None = Field(
15
- default=None,
16
- description="Default Bing market for grounded searches",
17
- )
18
- enforce: bool = Field(
19
- default=False,
20
- description="Enforce the default market for grounded searches",
21
- )
22
-
23
-
24
- class _BingAgentEnvSettings(BaseSettings):
25
- market: MarketSettings = Field(
26
- default=MarketSettings(),
27
- description="Bing market settings",
28
- )
29
-
30
-
31
- def _get_settings() -> _BingAgentEnvSettings:
32
- if "pytest" in sys.modules:
33
- env_file = Path(os.getcwd()) / "tests/test.env"
34
- else:
35
- env_file = Path(os.getcwd()) / ".env"
36
-
37
- class _Settings(_BingAgentEnvSettings):
38
- model_config = SettingsConfigDict(
39
- env_file=env_file,
40
- env_prefix=_ENV_PREFIX,
41
- extra="ignore",
42
- )
43
-
44
- return _Settings()
45
-
46
-
47
- bing_agent_env_settings = _get_settings()
@@ -1,41 +0,0 @@
1
- """RJSF tags for deployment knobs the environment has taken out of admin hands."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable
6
- from typing import Any
7
-
8
- from unique_toolkit._common.pydantic.rjsf_tags import DynamicRJSFTag, RJSFMetaTag
9
-
10
- _ENFORCED_HELP = "This parameter has been enforced by the infra team."
11
-
12
-
13
- def enforced_by_infra(enforced: bool, *, help: str | None = None) -> RJSFMetaTag:
14
- """Grey out a whole config field (including every child of an ``ExposableParam``).
15
-
16
- Apply to the *field* annotation, not the ``ExposableParam`` type argument:
17
- ``Annotated[ExposableMarket, DynamicRJSFTag(enforced_by_infra_factory)]``.
18
- Tags inside ``ExposableParam[Annotated[T, ...]]`` are dropped, because
19
- ``ui_schema_for_model`` resolves annotations with ``get_type_hints``, which
20
- does not substitute TypeVars on Pydantic generics.
21
-
22
- Prefer wrapping the factory in :class:`DynamicRJSFTag` so env-driven locks
23
- are re-evaluated whenever the admin UI schema is generated.
24
- """
25
- attrs: dict[str, Any] = {"ui:disabled": enforced}
26
- if enforced:
27
- attrs["ui:help"] = help or _ENFORCED_HELP
28
- return RJSFMetaTag(attrs)
29
-
30
-
31
- def dynamic_enforced_by_infra(
32
- factory: Callable[[], bool],
33
- *,
34
- help: str | None = None,
35
- ) -> DynamicRJSFTag:
36
- """Like :func:`enforced_by_infra`, but re-reads ``factory()`` at uiSchema time."""
37
-
38
- def _tag() -> RJSFMetaTag:
39
- return enforced_by_infra(factory(), help=help)
40
-
41
- return DynamicRJSFTag(_tag)