unique_toolkit 2026.24.0.dev6__py3-none-any.whl → 2026.24.0.dev8__py3-none-any.whl

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.
@@ -31,7 +31,7 @@ flowchart TD
31
31
  end
32
32
 
33
33
  internal["<b>_internal/</b><br/><i>package-private primitives</i>"]
34
- sdk[("<b>unique_sdk</b><br/><i>external</i>")]
34
+ sdk[("<b>unique_sdk / config-backend / …</b><br/><i>internal Unique AI backends</i>")]
35
35
 
36
36
  integrations ==> facades
37
37
  integrations -.-> primitives
@@ -75,9 +75,9 @@ API. Think: HTTP plumbing, Pydantic helpers, time/string/exception utilities.
75
75
  users, promote it into `resources/`, `components/`, or
76
76
  `integrations/` — whichever layer fits — and re-export it there.
77
77
 
78
- ### `resources/<name>/` — typed adapters over `unique_sdk`
78
+ ### `resources/<name>/` — typed adapters over internal Unique AI backends
79
79
 
80
- One subfolder per SDK resource family. The canonical shape is:
80
+ One subfolder per backend resource family. The canonical shape is:
81
81
 
82
82
  ```
83
83
  resources/<name>/
@@ -87,15 +87,17 @@ resources/<name>/
87
87
  __init__.py # re-exports
88
88
  ```
89
89
 
90
- A module belongs here **iff** it could be deleted without losing any
91
- functionality relative to raw `unique_sdk` usage — it's pure typing + a
92
- slightly nicer call surface, nothing more.
90
+ "Backend" is deliberately broad: `unique_sdk`, configuration-backend,
91
+ and any other internal Unique AI service are all valid targets. A module
92
+ belongs here **iff** it is a thin, typed adapter — pure schemas + a
93
+ slightly nicer call surface — with no cross-cutting orchestration of its
94
+ own.
93
95
 
94
96
  - **May import:** `unique_sdk`, `_internal/`, stdlib, third-party libs.
95
97
  - **Must not import:** sibling resources (see `facades/` below for the
96
98
  exception), `components/`, `integrations/`.
97
99
  - **Concrete examples in-tree today:** `user/`, `group/`,
98
- `content_folder/`, `scheduled_task/`.
100
+ `content_folder/`, `scheduled_task/`, `feature_flags/`.
99
101
  - **Resources** should be stateless classes
100
102
 
101
103
  ### `resources/facades/<name>/` — aggregating resources
@@ -208,7 +210,7 @@ way to the bottom is a signal that the module probably does not belong in
208
210
  ```mermaid
209
211
  flowchart TD
210
212
  start(["I'm adding new code"])
211
- q1{"Calls <b>unique_sdk</b> directly?"}
213
+ q1{"Typed adapter over an internal<br/>Unique AI backend<br/>(unique_sdk, config-backend, …)?"}
212
214
  q2{"Resource-shaped facade —<br/>composes 2+ primitives with<br/>one-liner delegation methods?"}
213
215
  q3{"Adds behaviour beyond the SDK<br/>(orchestration, parallelism,<br/>derived views, evaluation, …)<br/><b>and</b> framework-agnostic?"}
214
216
  q4{"Adapter for a specific<br/>third-party framework?"}
@@ -223,7 +225,7 @@ flowchart TD
223
225
 
224
226
  start --> q1
225
227
  q1 -- yes --> primitive
226
- q1 -- no --> q2
228
+ q1 -- no --> q2
227
229
  q2 -- yes --> facade
228
230
  q2 -- no --> q3
229
231
  q3 -- yes --> component
@@ -1,4 +1,4 @@
1
- """Typed adapters over ``unique_sdk``.
1
+ """Typed adapters over internal Unique AI backends (``unique_sdk``, configuration-backend, etc.).
2
2
 
3
3
  Thin wrappers around individual SDK resources that add little beyond
4
4
  Pydantic schemas and sync/async function pairs. The canonical shape is::
@@ -53,6 +53,8 @@ Planned contents (current home → new home):
53
53
  * ``scheduled_task`` ← :mod:`unique_toolkit.experimental.scheduled_task`
54
54
  * ``facades.identity`` ← :mod:`unique_toolkit.experimental.identity`
55
55
  (the :class:`Identity` facade bundling the :class:`Users` and :class:`Groups` services)
56
+ * ``feature_flags`` — configuration-backend GraphQL adapter; evaluates feature flags
57
+ with TTL cache, stale-value fallback, and env-var fallback
56
58
 
57
59
  One thing that *looks* like it should live here but does not:
58
60
 
@@ -0,0 +1,183 @@
1
+ # `feature_flags` — Python feature flag client
2
+
3
+ Remote feature flag evaluation backed by configuration-backend's GraphQL API,
4
+ with in-process TTL caching and graceful env-var fallback.
5
+
6
+ Mirrors the OpenFeature `evaluate` / `isEnabled` semantics used by the
7
+ Node.js `@unique/feature-flags` package. The OpenFeature Python SDK is
8
+ deliberately **not** used as a dependency — the interface is implemented
9
+ directly to avoid SDK overhead.
10
+
11
+ ---
12
+
13
+ ## Environment variables
14
+
15
+ | Variable | Required | Default | Description |
16
+ |---|---|---|---|
17
+ | `CONFIGURATION_BACKEND_URL` | **Yes** | — | Base URL of your configuration-backend instance. |
18
+ | `FEATURE_FLAG_SERVICE_ID` | **Yes** | — | Service identifier sent as `x-service-id`. Must match a registered value in configuration-backend's `Service` enum. |
19
+ | `FEATURE_FLAG_CACHE_TTL_MS` | No | `5000` | In-process cache TTL in milliseconds. |
20
+
21
+ ---
22
+
23
+ ## `flag` argument convention
24
+
25
+ The `flag` argument to `evaluate()` / `is_enabled()` must be the
26
+ **upper-snake env-var-style key**, e.g.:
27
+
28
+ ```
29
+ FEATURE_FLAG_ENABLE_MY_FEATURE
30
+ FEATURE_FLAG_ENABLE_OTHER_FEATURE_UN_12345
31
+ ```
32
+
33
+ This matches both the configuration-backend registry key convention and the
34
+ existing env-var names in Python services, so the env-var fallback path
35
+ requires no transformation.
36
+
37
+ Define flag name constants in your service (not in the toolkit) to avoid
38
+ magic strings:
39
+
40
+ ```python
41
+ # in your service
42
+ class MyServiceFlags:
43
+ ENABLE_MY_FEATURE = "FEATURE_FLAG_ENABLE_MY_FEATURE"
44
+ ENABLE_OTHER_FEATURE = "FEATURE_FLAG_ENABLE_OTHER_FEATURE_UN_12345"
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Usage
50
+
51
+ ### Constructor injection (recommended for testing)
52
+
53
+ ```python
54
+ from unique_toolkit.experimental.resources.feature_flags import FeatureFlagClient
55
+
56
+ client = FeatureFlagClient(
57
+ url="https://your-configuration-backend",
58
+ service_id="my-service",
59
+ ttl_ms=5_000,
60
+ )
61
+
62
+ result = await client.evaluate(
63
+ "FEATURE_FLAG_ENABLE_MY_FEATURE",
64
+ company_id=company_id,
65
+ user_id=user_id,
66
+ )
67
+ # result.value → bool
68
+ # result.reason → "remote" | "cached" | "stale" | "fallback"
69
+
70
+ enabled = await client.is_enabled(
71
+ "FEATURE_FLAG_ENABLE_MY_FEATURE",
72
+ company_id=company_id,
73
+ user_id=user_id,
74
+ )
75
+ ```
76
+
77
+ ### `from_settings()` factory (recommended for services)
78
+
79
+ `from_settings()` is a process-level singleton — repeated calls always return
80
+ the same instance, keeping the TTL cache warm across requests.
81
+
82
+ ```python
83
+ # Call lazily — inside a request handler or app startup hook,
84
+ # NOT at module import time. Env vars must be fully injected before
85
+ # the first call or a ValueError is raised.
86
+ client = FeatureFlagClient.from_settings()
87
+ ```
88
+
89
+ > **⚠️ Do not call `from_settings()` at module level.** In some container
90
+ > runtimes, env vars are not yet available when Python modules are imported.
91
+ > Call it inside your app's startup hook or on first request.
92
+
93
+ ### `bind_settings()` — per-request bound client
94
+
95
+ For services that use `UniqueSettings` / `AuthContext`, bind the singleton
96
+ once per request to avoid passing `company_id` / `user_id` at every call site:
97
+
98
+ ```python
99
+ from unique_toolkit.experimental.resources.feature_flags import (
100
+ BoundFeatureFlagClient,
101
+ FeatureFlagClient,
102
+ )
103
+
104
+ # once at startup
105
+ client = FeatureFlagClient.from_settings()
106
+
107
+ # once per request (settings carries company_id / user_id)
108
+ bound: BoundFeatureFlagClient = client.bind_settings(settings)
109
+
110
+ if await bound.is_enabled("FEATURE_FLAG_ENABLE_MY_FEATURE"):
111
+ ...
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Evaluation order
117
+
118
+ 1. **TTL cache** — returns the cached value if present (within the TTL window).
119
+ 2. **Remote** — calls configuration-backend's `evaluateFlag` GraphQL query.
120
+ One retry on transient 5xx errors.
121
+ 3. **Stale cache** — on failure, returns the last successfully fetched value
122
+ for this `(flag, company_id, user_id)` key if one exists.
123
+ 4. **Env-var fallback** — if no stale value is available, reads the flag's
124
+ env var. Supports plain booleans (`"true"` / `"false"`) and
125
+ comma-separated company-ID allowlists (`"company1,company2"`), consistent
126
+ with `unique_toolkit.agentic.feature_flags.FeatureFlags`.
127
+
128
+ ```mermaid
129
+ flowchart TD
130
+ A(["evaluate(flag, company_id)"]) --> B{TTL cache hit?}
131
+
132
+ B -- yes --> C([return value, reason=cached])
133
+
134
+ B -- no --> D[fetch from\nconfiguration-backend GraphQL]
135
+ D -- 200 OK --> E[store in TTL cache\n+ stale cache]
136
+ E --> F([return value, reason=remote])
137
+
138
+ D -- 5xx --> G[retry once]
139
+ G -- 200 OK --> E
140
+ G -- 5xx again --> L[⚠ log.warning\nfetch failed — trying stale cache]
141
+
142
+ D -- timeout / network error --> L
143
+ L --> H{stale cache hit?}
144
+
145
+ H -- yes --> M[⚠ log.warning\nstale cache hit]
146
+ M --> I([return stale value, reason=stale])
147
+
148
+ H -- no --> N[⚠ log.warning\nno stale value — env var fallback]
149
+ N --> J[read env var\nbool or company-ID allowlist]
150
+ J --> K([return env value, reason=fallback])
151
+
152
+ style C fill:#22c55e,color:#fff
153
+ style F fill:#22c55e,color:#fff
154
+ style I fill:#f59e0b,color:#fff
155
+ style K fill:#ef4444,color:#fff
156
+ style L fill:#fef9c3,color:#713f12
157
+ style M fill:#fef9c3,color:#713f12
158
+ style N fill:#fef9c3,color:#713f12
159
+ ```
160
+
161
+ ---
162
+
163
+ ## `FlagEvaluation.reason` values
164
+
165
+ | Value | Meaning |
166
+ |---|---|
167
+ | `"remote"` | Value was freshly fetched from configuration-backend. |
168
+ | `"cached"` | Value was returned from the in-process TTL cache. |
169
+ | `"stale"` | Transport error; last-known-good value for this company returned. |
170
+ | `"fallback"` | No prior value available; env-var default used. |
171
+
172
+ ---
173
+
174
+ ## Adopting in another Python service
175
+
176
+ 1. Install `unique-toolkit` (cachetools is a core dep, no extra group needed): `uv add unique-toolkit`
177
+ 2. Set env vars: `CONFIGURATION_BACKEND_URL`, `FEATURE_FLAG_SERVICE_ID`
178
+ 3. Add the service ID to configuration-backend's `Service` enum (`tyk-auth`)
179
+ and to the `@AllowAccess` whitelist on the `evaluateFlag` resolver.
180
+ 4. Register any new flag keys in `feature-flag.registry.ts`.
181
+ 5. Define flag name constants in your service (see convention above).
182
+ 6. Replace `os.getenv("FEATURE_FLAG_*", "false")` call sites with
183
+ `await client.is_enabled("FEATURE_FLAG_*", company_id=..., user_id=...)`.
@@ -0,0 +1,43 @@
1
+ """Feature flag client backed by configuration-backend's GraphQL API.
2
+
3
+ Evaluates flags remotely with a 5 s TTL cache, stale-value fallback on
4
+ transport errors, and env-var fallback when no prior value exists.
5
+ Env-var values support both plain booleans and comma-separated company-ID
6
+ allowlists, consistent with ``unique_toolkit.agentic.feature_flags``.
7
+
8
+ Required env vars::
9
+
10
+ CONFIGURATION_BACKEND_URL=https://<your-configuration-backend>
11
+ FEATURE_FLAG_SERVICE_ID=your-service-name
12
+
13
+ Quick start — explicit IDs::
14
+
15
+ from unique_toolkit.experimental.resources.feature_flags import FeatureFlagClient
16
+
17
+ client = FeatureFlagClient.from_settings() # singleton; call at startup or first request
18
+ enabled = await client.is_enabled("FEATURE_FLAG_ENABLE_X", company_id=company_id)
19
+
20
+ Per-request binding (when you have UniqueSettings / AuthContext)::
21
+
22
+ bound = client.bind_settings(settings) # bind once per request
23
+ enabled = await bound.is_enabled("FEATURE_FLAG_ENABLE_X")
24
+
25
+ Define flag name constants in your service to avoid magic strings::
26
+
27
+ class MyServiceFlags:
28
+ ENABLE_X = "FEATURE_FLAG_ENABLE_X"
29
+ ENABLE_Y = "FEATURE_FLAG_ENABLE_Y"
30
+
31
+ See README.md for the full evaluation-order diagram and adoption steps.
32
+ """
33
+
34
+ from .client import BoundFeatureFlagClient, FeatureFlagClient
35
+ from .schemas import FlagEvaluation
36
+ from .settings import FeatureFlagSettings
37
+
38
+ __all__ = [
39
+ "BoundFeatureFlagClient",
40
+ "FeatureFlagClient",
41
+ "FlagEvaluation",
42
+ "FeatureFlagSettings",
43
+ ]
@@ -0,0 +1,57 @@
1
+ import httpx
2
+
3
+
4
+ async def evaluate_flag(
5
+ *,
6
+ http: httpx.AsyncClient,
7
+ url: str,
8
+ flag: str,
9
+ service_id: str,
10
+ company_id: str,
11
+ user_id: str | None = None,
12
+ ) -> bool:
13
+ """Call configuration-backend's ``evaluateFlag`` GraphQL query.
14
+
15
+ Args:
16
+ http: Shared HTTP client (caller owns lifecycle).
17
+ url: Base URL of configuration-backend (no trailing slash).
18
+ flag: Upper-snake flag key, e.g. ``FEATURE_FLAG_ENABLE_MY_FEATURE``.
19
+ service_id: Value sent as ``x-service-id`` header.
20
+ company_id: Value sent as ``x-company-id`` header.
21
+ user_id: Value sent as ``x-user-id`` header. Omitted when ``None``.
22
+
23
+ Returns:
24
+ The boolean evaluation result from the server.
25
+
26
+ Raises:
27
+ httpx.HTTPError: On transport or HTTP-level errors.
28
+ RuntimeError: On GraphQL errors or a non-boolean response value.
29
+ """
30
+ headers: dict[str, str] = {
31
+ "x-service-id": service_id,
32
+ "x-company-id": company_id,
33
+ "Content-Type": "application/json",
34
+ }
35
+ if user_id is not None:
36
+ headers["x-user-id"] = user_id
37
+
38
+ response = await http.post(
39
+ f"{url}/graphql",
40
+ json={
41
+ "query": "query EvaluateFlag($key: String!) { evaluateFlag(key: $key) }",
42
+ "variables": {"key": flag},
43
+ },
44
+ headers=headers,
45
+ )
46
+ response.raise_for_status()
47
+ data = response.json()
48
+
49
+ if "errors" in data:
50
+ raise RuntimeError(f"GraphQL error evaluating '{flag}': {data['errors']}")
51
+
52
+ value = data.get("data", {}).get("evaluateFlag")
53
+ if not isinstance(value, bool):
54
+ raise RuntimeError(
55
+ f"evaluateFlag returned unexpected value for '{flag}': {value!r}"
56
+ )
57
+ return value
@@ -0,0 +1,65 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from typing import Any
4
+
5
+ from cachetools import LRUCache, TTLCache
6
+
7
+ # Sentinel for distinguishing a stored False/None from a missing key.
8
+ # Never stored as a value; only used as the default in .get() calls.
9
+ _MISSING = object()
10
+
11
+
12
+ class AsyncTTLCache:
13
+ """Per-key async TTL cache with stampede protection.
14
+
15
+ Uses a per-key asyncio.Lock to ensure only one coroutine fetches a
16
+ value on a cache miss; subsequent waiters reuse the fetched result.
17
+ Both the value cache and the lock dict are bounded by ``maxsize``.
18
+
19
+ Keys may be any hashable — strings, tuples, etc.
20
+ """
21
+
22
+ def __init__(self, *, maxsize: int = 1024, ttl_ms: float = 5_000) -> None:
23
+ self._cache: TTLCache[Any, Any] = TTLCache(maxsize=maxsize, ttl=ttl_ms / 1000)
24
+ self._stale: LRUCache[Any, Any] = LRUCache(maxsize=maxsize)
25
+ self._locks: LRUCache[Any, asyncio.Lock] = LRUCache(maxsize=maxsize)
26
+ self._dict_lock = asyncio.Lock()
27
+
28
+ async def _get_key_lock(self, key: Any) -> asyncio.Lock:
29
+ async with self._dict_lock:
30
+ lock = self._locks.get(key)
31
+ if lock is None:
32
+ lock = asyncio.Lock()
33
+ self._locks[key] = lock
34
+ return lock
35
+
36
+ async def get_or_fetch(
37
+ self,
38
+ key: Any,
39
+ fetcher: Callable[[], Awaitable[Any]],
40
+ ) -> tuple[Any, bool]:
41
+ """Return ``(value, from_cache)``, fetching on a miss with per-key stampede protection."""
42
+ cached = self._cache.get(key, _MISSING)
43
+ if cached is not _MISSING:
44
+ self._stale[key] = cached # keep stale warm while TTL cache is hot
45
+ return cached, True
46
+
47
+ # Acquire per-key lock; re-check inside in case another waiter already fetched.
48
+ key_lock = await self._get_key_lock(key)
49
+ async with key_lock:
50
+ cached = self._cache.get(key, _MISSING)
51
+ if cached is not _MISSING:
52
+ self._stale[key] = cached # keep stale warm while TTL cache is hot
53
+ return cached, True
54
+
55
+ value = await fetcher()
56
+ self._cache[key] = value
57
+ self._stale[key] = value
58
+ return value, False
59
+
60
+ def get_stale(self, key: Any) -> tuple[Any, bool]:
61
+ """Return ``(value, True)`` if a prior successful fetch exists for *key*, else ``(None, False)``."""
62
+ value = self._stale.get(key, _MISSING)
63
+ if value is _MISSING:
64
+ return None, False
65
+ return value, True
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from typing import ClassVar
6
+
7
+ import httpx
8
+ from tenacity import retry, retry_if_exception, stop_after_attempt
9
+
10
+ from unique_toolkit.app.unique_settings import AuthContextProtocol, UniqueSettings
11
+
12
+ from ._graphql_client import evaluate_flag
13
+ from ._ttl_cache import AsyncTTLCache
14
+ from .schemas import FlagEvaluation
15
+ from .settings import FeatureFlagSettings
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FeatureFlagClient:
21
+ """Feature flag client backed by configuration-backend's GraphQL API.
22
+
23
+ Mirrors the OpenFeature ``evaluate`` / ``isEnabled`` semantics used by the
24
+ Node.js ``@unique/feature-flags`` package without taking a dependency on
25
+ the OpenFeature Python SDK.
26
+
27
+ Evaluation order:
28
+ 1. Check the in-process TTL cache; return a cached value if present.
29
+ 2. Call configuration-backend's ``evaluateFlag`` GraphQL query (one
30
+ retry on transient 5xx errors).
31
+ 3. On failure, return the last successfully fetched value (stale cache)
32
+ if one exists, otherwise fall back to the env-var default.
33
+
34
+ The ``flag`` argument must be the upper-snake env-var-style key, e.g.
35
+ ``FEATURE_FLAG_ENABLE_MY_FEATURE``. This matches both the
36
+ configuration-backend registry key convention and the existing env-var
37
+ names, so env-var fallback requires no transformation.
38
+
39
+ Example (with UniqueSettings)::
40
+
41
+ client = FeatureFlagClient.from_settings()
42
+ bound = client.bind_settings(settings)
43
+ if await bound.is_enabled("FEATURE_FLAG_ENABLE_MY_FEATURE"):
44
+ ...
45
+
46
+ Example (explicit IDs)::
47
+
48
+ result = await client.evaluate(
49
+ "FEATURE_FLAG_ENABLE_MY_FEATURE",
50
+ company_id="acme",
51
+ user_id="user-123",
52
+ )
53
+ """
54
+
55
+ _instance: ClassVar[FeatureFlagClient | None] = None
56
+
57
+ def __init__(self, *, url: str, service_id: str, ttl_ms: int = 5_000) -> None:
58
+ self._url = url
59
+ self._service_id = service_id
60
+ self._cache = AsyncTTLCache(ttl_ms=ttl_ms)
61
+ self._http = httpx.AsyncClient(timeout=httpx.Timeout(2.0, connect=1.0))
62
+
63
+ @classmethod
64
+ def from_settings(cls) -> FeatureFlagClient:
65
+ """Construct (or return the cached) singleton from environment variables.
66
+
67
+ Reads :class:`.FeatureFlagSettings` (``CONFIGURATION_BACKEND_URL``,
68
+ ``FEATURE_FLAG_SERVICE_ID``, ``FEATURE_FLAG_CACHE_TTL_MS``).
69
+
70
+ Raises:
71
+ ValueError: If ``CONFIGURATION_BACKEND_URL`` or
72
+ ``FEATURE_FLAG_SERVICE_ID`` are not set.
73
+ """
74
+ if cls._instance is not None:
75
+ return cls._instance
76
+ s = FeatureFlagSettings()
77
+ if not s.configuration_backend_url:
78
+ raise ValueError(
79
+ "CONFIGURATION_BACKEND_URL is required to use FeatureFlagClient"
80
+ )
81
+ if not s.feature_flag_service_id:
82
+ raise ValueError(
83
+ "FEATURE_FLAG_SERVICE_ID is required to use FeatureFlagClient"
84
+ )
85
+ cls._instance = cls(
86
+ url=s.configuration_backend_url,
87
+ service_id=s.feature_flag_service_id,
88
+ ttl_ms=s.feature_flag_cache_ttl_ms,
89
+ )
90
+ return cls._instance
91
+
92
+ def bind_settings(self, settings: UniqueSettings) -> BoundFeatureFlagClient:
93
+ """Return a :class:`BoundFeatureFlagClient` for the request-level auth context."""
94
+ return BoundFeatureFlagClient(self, settings.authcontext)
95
+
96
+ async def evaluate(
97
+ self,
98
+ flag: str,
99
+ *,
100
+ company_id: str,
101
+ user_id: str | None = None,
102
+ ) -> FlagEvaluation:
103
+ """Evaluate *flag* for the given context."""
104
+ company_id = company_id.strip()
105
+ if not company_id:
106
+ raise ValueError("company_id must be a non-empty string")
107
+
108
+ cache_key = (flag, company_id, user_id)
109
+ try:
110
+ value, from_cache = await self._cache.get_or_fetch(
111
+ cache_key,
112
+ lambda: self._fetch(flag=flag, company_id=company_id, user_id=user_id),
113
+ )
114
+ return FlagEvaluation(
115
+ value=value, reason="cached" if from_cache else "remote"
116
+ )
117
+ except Exception:
118
+ logger.warning(
119
+ "FeatureFlagClient: fetch failed for '%s' (company=%s) — trying stale cache",
120
+ flag,
121
+ company_id,
122
+ exc_info=True,
123
+ )
124
+ stale_value, stale_hit = self._cache.get_stale(cache_key)
125
+ if stale_hit:
126
+ logger.warning(
127
+ "FeatureFlagClient: stale cache hit for '%s' (company=%s, value=%s)",
128
+ flag,
129
+ company_id,
130
+ stale_value,
131
+ )
132
+ return FlagEvaluation(value=stale_value, reason="stale")
133
+ logger.warning(
134
+ "FeatureFlagClient: no stale value for '%s' (company=%s) — falling back to env var",
135
+ flag,
136
+ company_id,
137
+ )
138
+ return FlagEvaluation(
139
+ value=self._env_fallback(flag, company_id), reason="fallback"
140
+ )
141
+
142
+ async def is_enabled(
143
+ self,
144
+ flag: str,
145
+ *,
146
+ company_id: str,
147
+ user_id: str | None = None,
148
+ ) -> bool:
149
+ """Return ``True`` if *flag* is enabled for the given context."""
150
+ return (await self.evaluate(flag, company_id=company_id, user_id=user_id)).value
151
+
152
+ @retry(
153
+ retry=retry_if_exception(
154
+ lambda exc: (
155
+ isinstance(exc, httpx.HTTPStatusError)
156
+ and exc.response.status_code >= 500
157
+ )
158
+ ),
159
+ stop=stop_after_attempt(2),
160
+ reraise=True,
161
+ )
162
+ async def _fetch(
163
+ self,
164
+ *,
165
+ flag: str,
166
+ company_id: str,
167
+ user_id: str | None,
168
+ ) -> bool:
169
+ return await evaluate_flag(
170
+ http=self._http,
171
+ url=self._url,
172
+ flag=flag,
173
+ service_id=self._service_id,
174
+ company_id=company_id,
175
+ user_id=user_id,
176
+ )
177
+
178
+ @staticmethod
179
+ def _env_fallback(flag: str, company_id: str | None = None) -> bool:
180
+ """Read *flag* from env vars using the same semantics as ``FeatureFlag``.
181
+
182
+ Supports both plain booleans (``"true"`` / ``"false"``) and
183
+ comma-separated company-ID allowlists (``"company1,company2"``),
184
+ consistent with ``unique_toolkit.agentic.feature_flags.FeatureFlags``.
185
+ """
186
+ raw = os.getenv(flag, "false").strip()
187
+ raw_lower = raw.lower()
188
+ if raw_lower in ("true", "1", "yes"):
189
+ return True
190
+ if raw_lower in ("false", "0", "no", ""):
191
+ return False
192
+ # Comma-separated company-ID allowlist
193
+ allowed = {part.strip() for part in raw.split(",") if part.strip()}
194
+ return company_id in allowed if company_id else False
195
+
196
+
197
+ class BoundFeatureFlagClient:
198
+ """A :class:`FeatureFlagClient` bound to a request-level auth context.
199
+
200
+ Obtain via :meth:`FeatureFlagClient.bind_settings` — do not instantiate directly.
201
+ """
202
+
203
+ def __init__(self, client: FeatureFlagClient, auth: AuthContextProtocol) -> None:
204
+ self._client = client
205
+ self._auth = auth
206
+
207
+ async def evaluate(self, flag: str) -> FlagEvaluation:
208
+ return await self._client.evaluate(
209
+ flag,
210
+ company_id=self._auth.get_confidential_company_id(),
211
+ user_id=self._auth.get_confidential_user_id(),
212
+ )
213
+
214
+ async def is_enabled(self, flag: str) -> bool:
215
+ return (await self.evaluate(flag)).value
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class FlagEvaluation:
7
+ """Result of a feature flag evaluation.
8
+
9
+ ``reason`` indicates how the value was determined:
10
+ - ``"remote"`` — freshly fetched from configuration-backend.
11
+ - ``"cached"`` — served from the in-process TTL cache.
12
+ - ``"stale"`` — transport error; last-known-good value for this flag+company used.
13
+ - ``"fallback"`` — transport error and no prior value; env-var default used.
14
+ """
15
+
16
+ value: bool
17
+ reason: Literal["remote", "cached", "stale", "fallback"]
@@ -0,0 +1,33 @@
1
+ from pydantic import field_validator
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+ from unique_toolkit.app.find_env_file import find_env_file
5
+
6
+
7
+ class FeatureFlagSettings(BaseSettings):
8
+ """Env-var settings for :class:`FeatureFlagClient`.
9
+
10
+ ``CONFIGURATION_BACKEND_URL`` and ``FEATURE_FLAG_SERVICE_ID`` are required;
11
+ :meth:`FeatureFlagClient.from_settings` raises ``ValueError`` if either is absent.
12
+ """
13
+
14
+ configuration_backend_url: str | None = None
15
+ feature_flag_service_id: str | None = None
16
+ feature_flag_cache_ttl_ms: int = 5_000
17
+
18
+ @field_validator(
19
+ "configuration_backend_url", "feature_flag_service_id", mode="before"
20
+ )
21
+ @classmethod
22
+ def _strip_strings(cls, v: object) -> object:
23
+ if isinstance(v, str):
24
+ stripped = v.strip()
25
+ return stripped if stripped else None
26
+ return v
27
+
28
+ model_config = SettingsConfigDict(
29
+ extra="ignore",
30
+ case_sensitive=False,
31
+ env_file=find_env_file(".env", required=False),
32
+ env_file_encoding="utf-8",
33
+ )
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def child_exit(server, worker) -> None:
7
+ """Gunicorn child_exit hook: clean up dead worker's per-PID metric files.
8
+
9
+ Wire this up in gunicorn.conf.py:
10
+
11
+ from unique_toolkit.monitoring.gunicorn import child_exit # noqa: F401
12
+
13
+ Gunicorn calls any function named child_exit(server, worker) in the
14
+ config file whenever a worker process exits. Without this, dead workers'
15
+ .db files accumulate in PROMETHEUS_MULTIPROC_DIR and inflate aggregated
16
+ metric values on every future scrape.
17
+
18
+ No-ops when PROMETHEUS_MULTIPROC_DIR is not set (single-process mode).
19
+ """
20
+ if not os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
21
+ return
22
+ from prometheus_client import multiprocess # pyright: ignore[reportMissingImports]
23
+
24
+ multiprocess.mark_process_dead(worker.pid)
@@ -11,22 +11,23 @@ from prometheus_client import ( # pyright: ignore[reportMissingImports]
11
11
  from .registry import REGISTRY
12
12
 
13
13
  _http_requests_total = Counter(
14
- "http_requests_total",
14
+ "python_http_requests_total",
15
15
  "Total HTTP requests",
16
16
  ["method", "path", "status_code"],
17
17
  registry=REGISTRY,
18
18
  )
19
19
  _http_request_duration_seconds = Histogram(
20
- "http_request_duration_seconds",
20
+ "python_http_request_duration_seconds",
21
21
  "HTTP request duration",
22
22
  ["method", "path"],
23
23
  registry=REGISTRY,
24
24
  )
25
25
  _http_requests_in_progress = Gauge(
26
- "http_requests_in_progress",
26
+ "python_http_requests_in_progress",
27
27
  "In-flight HTTP requests",
28
28
  ["method"],
29
29
  registry=REGISTRY,
30
+ multiprocess_mode="livesum",
30
31
  )
31
32
 
32
33
  _DEFAULT_EXCLUDED_PATHS = frozenset({"/health", "/metrics", "/"})
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import functools
5
+ import os
5
6
  import time
7
+ import warnings
6
8
  from contextlib import contextmanager
7
- from typing import Any, Callable
9
+ from typing import Any, Callable, Literal
8
10
 
9
11
  from prometheus_client import ( # pyright: ignore[reportMissingImports]
10
12
  CollectorRegistry,
@@ -18,7 +20,36 @@ REGISTRY = CollectorRegistry()
18
20
 
19
21
 
20
22
  def get_metrics() -> bytes:
21
- """Returns all registered metrics in Prometheus text format."""
23
+ """Returns all registered metrics in Prometheus text format.
24
+
25
+ In multiprocess mode (PROMETHEUS_MULTIPROC_DIR is set), aggregates
26
+ per-PID .db files from all Gunicorn workers via MultiProcessCollector.
27
+ In single-process mode, returns metrics from the in-process REGISTRY.
28
+
29
+ NOTE: PROMETHEUS_MULTIPROC_DIR must be set in the shell/entrypoint
30
+ *before* Python starts. Setting it from Python after prometheus_client
31
+ has been imported has no effect — metrics will silently bypass per-PID
32
+ files and this function will emit a warning.
33
+ """
34
+ if os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
35
+ from prometheus_client import ( # pyright: ignore[reportMissingImports]
36
+ multiprocess,
37
+ values,
38
+ )
39
+
40
+ if not getattr(values.ValueClass, "_multiprocess", False):
41
+ warnings.warn(
42
+ "PROMETHEUS_MULTIPROC_DIR is set but prometheus_client was imported "
43
+ "before the env var — metrics will silently bypass per-PID files. "
44
+ "Set PROMETHEUS_MULTIPROC_DIR in the shell/entrypoint before launching Python.",
45
+ RuntimeWarning,
46
+ stacklevel=2,
47
+ )
48
+
49
+ registry = CollectorRegistry()
50
+ multiprocess.MultiProcessCollector(registry)
51
+ return generate_latest(registry)
52
+
22
53
  return generate_latest(REGISTRY)
23
54
 
24
55
 
@@ -43,8 +74,31 @@ class MetricNamespace:
43
74
  def counter(self, name: str, description: str, labels: list[str]) -> Counter:
44
75
  return Counter(f"{self._prefix}_{name}", description, labels, registry=REGISTRY)
45
76
 
46
- def gauge(self, name: str, description: str, labels: list[str]) -> Gauge:
47
- return Gauge(f"{self._prefix}_{name}", description, labels, registry=REGISTRY)
77
+ def gauge(
78
+ self,
79
+ name: str,
80
+ description: str,
81
+ labels: list[str],
82
+ multiprocess_mode: Literal[
83
+ "all",
84
+ "liveall",
85
+ "min",
86
+ "livemin",
87
+ "max",
88
+ "livemax",
89
+ "sum",
90
+ "livesum",
91
+ "mostrecent",
92
+ "livemostrecent",
93
+ ] = "all",
94
+ ) -> Gauge:
95
+ return Gauge(
96
+ f"{self._prefix}_{name}",
97
+ description,
98
+ labels,
99
+ registry=REGISTRY,
100
+ multiprocess_mode=multiprocess_mode,
101
+ )
48
102
 
49
103
 
50
104
  @contextmanager
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-toolkit
3
- Version: 2026.24.0.dev6
3
+ Version: 2026.24.0.dev8
4
4
  Summary:
5
5
  Author: Cedric Klinkert, Martin Fadler, Sadique Sheik, Fabian Schläpfer, Pascal Hauri, Ejiroghene Oki-Peter, Rami Azouz, Ricardo Visini, Sanzio Monti
6
6
  Author-email: Cedric Klinkert <cedric.klinkert@unique.ch>, Martin Fadler <martin.fadler@unique.ch>, Sadique Sheik <sadique@unique.ch>, Fabian Schläpfer <fabian@unique.ch>, Pascal Hauri <pascal@unique.ch>, Ejiroghene Oki-Peter <oki-peter.ejiroghene@unique.ai>, Rami Azouz <rami.ext@unique.ch>, Ricardo Visini <ricardo.visini@unique.ch>, Sanzio Monti <sanzio.monti@unique.ai>
@@ -16,7 +16,7 @@ Requires-Dist: sseclient>=0.0.27,<0.0.28
16
16
  Requires-Dist: openai>=1.105.0,<3
17
17
  Requires-Dist: platformdirs>=4.0.0,<5
18
18
  Requires-Dist: pillow>=12.1.1
19
- Requires-Dist: unique-sdk>=2026.24.0.dev2,<2026.24.0rc0
19
+ Requires-Dist: unique-sdk>=2026.24.0.dev3,<2026.24.0rc0
20
20
  Requires-Dist: jambo>=0.1.2,<0.2
21
21
  Requires-Dist: jinja2>=3.1.6,<4
22
22
  Requires-Dist: docxtpl>=0.20.1,<0.21
@@ -31,6 +31,7 @@ Requires-Dist: deepdiff>=8.6.1,<9
31
31
  Requires-Dist: pypandoc>=1.16.2,<2
32
32
  Requires-Dist: tenacity>=9.1.4
33
33
  Requires-Dist: pydantic-core>=2.41.5
34
+ Requires-Dist: cachetools>=5.0,<6
34
35
  Requires-Dist: fastapi>=0.121.0,<0.122 ; extra == 'fastapi'
35
36
  Requires-Dist: starlette>=0.49.1 ; extra == 'fastapi'
36
37
  Requires-Dist: uvicorn>=0.37.0,<0.38 ; extra == 'fastapi'
@@ -276,7 +276,7 @@ unique_toolkit/embedding/functions.py,sha256=OWzY-iPNBjwXgSNNXDmu89Lzu-PAMG3Q7ig
276
276
  unique_toolkit/embedding/schemas.py,sha256=1GvKCaSk4jixzVQ2PKq8yDqwGEVY_hWclYtoAr6CC2g,96
277
277
  unique_toolkit/embedding/service.py,sha256=SSBIsn7ZyQ_5JtoCMqnP1bDoZzp0S3Lt-ao9YBUt1Bc,5232
278
278
  unique_toolkit/embedding/utils.py,sha256=v86lo__bCJbxZBQ3OcLu5SuwT6NbFfWlcq8iyk6BuzQ,279
279
- unique_toolkit/experimental/README.md,sha256=i8IrrfPqqQfhcBCBZ6_VhK7CY3vQt3g5KzVeHOWMEnI,11105
279
+ unique_toolkit/experimental/README.md,sha256=52uUCehy0ZRoIrTrsvk8k6O_NxSUh9-3dATOzZ7wWjU,11350
280
280
  unique_toolkit/experimental/__init__.py,sha256=fhSv-QhH0yxnnGqSwbFhX7Rp3TCG_zBphLHNC5GLBgA,393
281
281
  unique_toolkit/experimental/_internal/__init__.py,sha256=wClblbvHTnOCjymmXVoAde1is3AF18iQ46lK6wKObr4,995
282
282
  unique_toolkit/experimental/_internal/streaming/__init__.py,sha256=YExuxaOHB96xT1k0FsWiZEo6Ngiyv54GumhCAKW_3aA,942
@@ -331,7 +331,7 @@ unique_toolkit/experimental/integrations/openai/streaming/event_routing/response
331
331
  unique_toolkit/experimental/integrations/openai/streaming/event_routing/subscribers/__init__.py,sha256=4wpUt9kzfPzncO9tlKcvOQUzQG5e6kVp5AKZI5grxOk,276
332
332
  unique_toolkit/experimental/integrations/openai/streaming/event_routing/subscribers/message_persister.py,sha256=nHh2vQWF6YWurdkijof6UXhTMFzllj_4eSdr8nm9aDI,8016
333
333
  unique_toolkit/experimental/integrations/openai/streaming/event_routing/subscribers/progress_log_persister.py,sha256=VCYGZY3WSpI7mvVs2gO89-M0JenakW01BGfBfFkPDrw,3499
334
- unique_toolkit/experimental/resources/__init__.py,sha256=f0l77ZKwyMuG1JfWxb8r8PyRJVn6paWu4tobGcHlqlI,3167
334
+ unique_toolkit/experimental/resources/__init__.py,sha256=KdMPaaneY0twZ-JBfwImRjXZaWT68NVuOIeqdNXFh_Y,3377
335
335
  unique_toolkit/experimental/resources/content_folder/__init__.py,sha256=c5MUj1xIP10L7tcM4-hG2m1_yUMVfKr_jHAncUXVNvo,2492
336
336
  unique_toolkit/experimental/resources/content_folder/functions.py,sha256=nJle9Qnh-x1SLNetBBhTbQPu-zEBfQaHFnMq8N9RGEQ,16692
337
337
  unique_toolkit/experimental/resources/content_folder/schemas.py,sha256=LYWRpMLFVg_iKT9dw2AyCNfTPVmKeB8erMSsgbIkZ48,5345
@@ -339,6 +339,13 @@ unique_toolkit/experimental/resources/content_folder/service.py,sha256=tSNVJ681c
339
339
  unique_toolkit/experimental/resources/facades/__init__.py,sha256=imKXMz2nsQcg5zmG-Yq5aQJTMAgpJ3-cXW3L1WfP280,1421
340
340
  unique_toolkit/experimental/resources/facades/identity/__init__.py,sha256=7r-1_gr81Idc6AJ1IRO6GCHx7psmC81BtD15BWxrlYs,1640
341
341
  unique_toolkit/experimental/resources/facades/identity/service.py,sha256=A5I7QkNy-Su9FmOkrl8z1iwWhqzoo6KgxEgcdvnWN2M,4287
342
+ unique_toolkit/experimental/resources/feature_flags/README.md,sha256=ChPgYn4-SzY1UyF3OQgBxHzMk6JH_hVKfzHsTcKhcXo,6122
343
+ unique_toolkit/experimental/resources/feature_flags/__init__.py,sha256=eGU17swqiJdIqGC-HIGHjGk8rZFpc84IeTFFYkbZxpA,1510
344
+ unique_toolkit/experimental/resources/feature_flags/_graphql_client.py,sha256=a0KxF0EJk7baYChVKFy8bllIJShHKELyQReFd_B9bXs,1743
345
+ unique_toolkit/experimental/resources/feature_flags/_ttl_cache.py,sha256=lPEdWX1JkP2985M1AqQ0uYyWGlagXB0LvOtQPltbgJo,2520
346
+ unique_toolkit/experimental/resources/feature_flags/client.py,sha256=DicPHPpdckzQsHh2Xe5UMUWqdkqM0zZBKR1grB0_bHg,7600
347
+ unique_toolkit/experimental/resources/feature_flags/schemas.py,sha256=chLsBNkIL4BOvGrwrGeixy7BmNSi2BagXD6Jr2fZcqs,602
348
+ unique_toolkit/experimental/resources/feature_flags/settings.py,sha256=3L_c-0zxj4cpuHd3NL3mZ2kN35V6npgh6_hSwu6Gk00,1071
342
349
  unique_toolkit/experimental/resources/group/__init__.py,sha256=qPi2DWgWLZw1ku0zi-UsPdornfl8pczW6QSYT5P8CzQ,2098
343
350
  unique_toolkit/experimental/resources/group/functions.py,sha256=DrrafmR_6nfGZ1ThnqDxK7PP0zyAAo0uXKgp_mcIf-Q,7990
344
351
  unique_toolkit/experimental/resources/group/schemas.py,sha256=t6lXLETGDBGzC9ROKDngQ6mg0KRf5XDTKj0j2uj2b60,3179
@@ -376,8 +383,9 @@ unique_toolkit/language_model/settings.py,sha256=xkoMv3ATb2ft5V3MA8JvqW-EO8AnZ5t
376
383
  unique_toolkit/language_model/stream_transform.py,sha256=iNNGpLY_7G6Bpp9Y4KtbIkQm3WB4yZt75BXcqx0DVkI,2775
377
384
  unique_toolkit/language_model/utils.py,sha256=bPQ4l6_YO71w-zaIPanUUmtbXC1_hCvLK0tAFc3VCRc,1902
378
385
  unique_toolkit/monitoring/__init__.py,sha256=z1gkOCLxGcg936MFuLSeWFa0tREcBFSUbV09NxilBw4,1065
379
- unique_toolkit/monitoring/middleware.py,sha256=QSOcRwaByN4t6gWY5-IUgW2JXoN7Yl-0JR5kSmOjxM8,2877
380
- unique_toolkit/monitoring/registry.py,sha256=8VG1NkfzrDiMfPr7KsX7uWEJBK3rNSRWOB8HNQ5B9jY,3333
386
+ unique_toolkit/monitoring/gunicorn.py,sha256=urTFFS5zYHKoxb1TEQHcLqb2byi-aYgFRHGwgYRzYYI,844
387
+ unique_toolkit/monitoring/middleware.py,sha256=JNh5v_INAkexuLtXhCG9n5n2t6Pnf8ZdPTnimAVS0_s,2931
388
+ unique_toolkit/monitoring/registry.py,sha256=8r9jqaQoRHZveGRZe_GQvBzvQpmUV4cpw8Ej1ssDIdE,5056
381
389
  unique_toolkit/protocols/support.py,sha256=x5edzbBmKAUuvm3oLFh52gkgQsqR5LU7M0t8RHK0DZk,5161
382
390
  unique_toolkit/services/__init__.py,sha256=b9Wl7Wvz1JmZDbDexIZzBoXZ0wscXuczGAH8LoL-Qak,572
383
391
  unique_toolkit/services/chat_service.py,sha256=FPY-GTFAkD0C9pYfPfkfAvyIKnL9DX9iDk75gDeghCc,95209
@@ -391,6 +399,6 @@ unique_toolkit/short_term_memory/service.py,sha256=Ncw0_0CNM6kmzJSLVJGG5GcKpBduI
391
399
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
392
400
  unique_toolkit/smart_rules/compile.py,sha256=vRe0aMJWuVIK-VsqomgxQImzke7MDmwgSxxJfYCa7zw,1386
393
401
  unique_toolkit/test_utilities/events.py,sha256=FLrhMh0A_mQutAUjoD0FdMcVQ4Gq31r35OVGzRjvLro,6760
394
- unique_toolkit-2026.24.0.dev6.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
395
- unique_toolkit-2026.24.0.dev6.dist-info/METADATA,sha256=9iqeteqQZ9CeoDz6t3gI0L0YdK6AnpC376d7cqfdps4,7392
396
- unique_toolkit-2026.24.0.dev6.dist-info/RECORD,,
402
+ unique_toolkit-2026.24.0.dev8.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
403
+ unique_toolkit-2026.24.0.dev8.dist-info/METADATA,sha256=JE05F3xARkbw1R2yqL_Yc-YtzJhIXXjifPc7LLCQz6Q,7426
404
+ unique_toolkit-2026.24.0.dev8.dist-info/RECORD,,