unique_toolkit 2026.24.0.dev7__py3-none-any.whl → 2026.24.0.dev9__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
+ )
@@ -2209,7 +2209,9 @@ class ChatService(ChatServiceDeprecated):
2209
2209
  message_id=message_id,
2210
2210
  )
2211
2211
 
2212
- def find_chat_memory_by_id(self, *, chat_id: str, key: str) -> ShortTermMemory:
2212
+ def find_chat_memory_by_id(
2213
+ self, *, chat_id: str, key: str
2214
+ ) -> ShortTermMemory | None:
2213
2215
  """Finds the latest short-term memory for a specific chat synchronously.
2214
2216
 
2215
2217
  Args:
@@ -2217,7 +2219,7 @@ class ChatService(ChatServiceDeprecated):
2217
2219
  key (str): The memory key
2218
2220
 
2219
2221
  Returns:
2220
- ShortTermMemory: The latest short-term memory
2222
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2221
2223
 
2222
2224
  Raises:
2223
2225
  Exception: If the retrieval fails
@@ -2231,7 +2233,7 @@ class ChatService(ChatServiceDeprecated):
2231
2233
 
2232
2234
  async def find_chat_memory_by_id_async(
2233
2235
  self, *, chat_id: str, key: str
2234
- ) -> ShortTermMemory:
2236
+ ) -> ShortTermMemory | None:
2235
2237
  """Finds the latest short-term memory for a specific chat asynchronously.
2236
2238
 
2237
2239
  Args:
@@ -2239,7 +2241,7 @@ class ChatService(ChatServiceDeprecated):
2239
2241
  key (str): The memory key
2240
2242
 
2241
2243
  Returns:
2242
- ShortTermMemory: The latest short-term memory
2244
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2243
2245
 
2244
2246
  Raises:
2245
2247
  Exception: If the retrieval fails
@@ -2253,7 +2255,7 @@ class ChatService(ChatServiceDeprecated):
2253
2255
 
2254
2256
  def find_message_memory_by_id(
2255
2257
  self, *, message_id: str, key: str
2256
- ) -> ShortTermMemory:
2258
+ ) -> ShortTermMemory | None:
2257
2259
  """Finds the latest short-term memory for a specific message synchronously.
2258
2260
 
2259
2261
  Args:
@@ -2261,7 +2263,7 @@ class ChatService(ChatServiceDeprecated):
2261
2263
  key (str): The memory key
2262
2264
 
2263
2265
  Returns:
2264
- ShortTermMemory: The latest short-term memory
2266
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2265
2267
 
2266
2268
  Raises:
2267
2269
  Exception: If the retrieval fails
@@ -2275,7 +2277,7 @@ class ChatService(ChatServiceDeprecated):
2275
2277
 
2276
2278
  async def find_message_memory_by_id_async(
2277
2279
  self, *, message_id: str, key: str
2278
- ) -> ShortTermMemory:
2280
+ ) -> ShortTermMemory | None:
2279
2281
  """Finds the latest short-term memory for a specific message asynchronously.
2280
2282
 
2281
2283
  Args:
@@ -2283,7 +2285,7 @@ class ChatService(ChatServiceDeprecated):
2283
2285
  key (str): The memory key
2284
2286
 
2285
2287
  Returns:
2286
- ShortTermMemory: The latest short-term memory
2288
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2287
2289
 
2288
2290
  Raises:
2289
2291
  Exception: If the retrieval fails
@@ -2416,14 +2418,14 @@ class ChatService(ChatServiceDeprecated):
2416
2418
  value=value,
2417
2419
  )
2418
2420
 
2419
- def find_chat_memory(self, *, key: str) -> ShortTermMemory:
2421
+ def find_chat_memory(self, *, key: str) -> ShortTermMemory | None:
2420
2422
  """Finds the latest short-term memory for the current chat synchronously.
2421
2423
 
2422
2424
  Args:
2423
2425
  key (str): The memory key
2424
2426
 
2425
2427
  Returns:
2426
- ShortTermMemory: The latest short-term memory
2428
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2427
2429
 
2428
2430
  Raises:
2429
2431
  Exception: If the retrieval fails
@@ -2433,14 +2435,14 @@ class ChatService(ChatServiceDeprecated):
2433
2435
  key=key,
2434
2436
  )
2435
2437
 
2436
- async def find_chat_memory_async(self, *, key: str) -> ShortTermMemory:
2438
+ async def find_chat_memory_async(self, *, key: str) -> ShortTermMemory | None:
2437
2439
  """Finds the latest short-term memory for the current chat asynchronously.
2438
2440
 
2439
2441
  Args:
2440
2442
  key (str): The memory key
2441
2443
 
2442
2444
  Returns:
2443
- ShortTermMemory: The latest short-term memory
2445
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2444
2446
 
2445
2447
  Raises:
2446
2448
  Exception: If the retrieval fails
@@ -2451,21 +2453,23 @@ class ChatService(ChatServiceDeprecated):
2451
2453
  )
2452
2454
 
2453
2455
  @overload
2454
- def find_message_memory(self, *, key: str) -> ShortTermMemory: ...
2456
+ def find_message_memory(self, *, key: str) -> ShortTermMemory | None: ...
2455
2457
 
2456
2458
  @overload
2457
- def find_message_memory(self, *, key: str, message_id: str) -> ShortTermMemory: ...
2459
+ def find_message_memory(
2460
+ self, *, key: str, message_id: str
2461
+ ) -> ShortTermMemory | None: ...
2458
2462
 
2459
2463
  def find_message_memory(
2460
2464
  self, *, key: str, message_id: str | None = None
2461
- ) -> ShortTermMemory:
2465
+ ) -> ShortTermMemory | None:
2462
2466
  """Finds the latest short-term memory for the current assistant message synchronously.
2463
2467
 
2464
2468
  Args:
2465
2469
  key (str): The memory key
2466
2470
 
2467
2471
  Returns:
2468
- ShortTermMemory: The latest short-term memory
2472
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2469
2473
 
2470
2474
  Raises:
2471
2475
  Exception: If the retrieval fails
@@ -2476,23 +2480,25 @@ class ChatService(ChatServiceDeprecated):
2476
2480
  )
2477
2481
 
2478
2482
  @overload
2479
- async def find_message_memory_async(self, *, key: str) -> ShortTermMemory: ...
2483
+ async def find_message_memory_async(
2484
+ self, *, key: str
2485
+ ) -> ShortTermMemory | None: ...
2480
2486
 
2481
2487
  @overload
2482
2488
  async def find_message_memory_async(
2483
2489
  self, *, key: str, message_id: str
2484
- ) -> ShortTermMemory: ...
2490
+ ) -> ShortTermMemory | None: ...
2485
2491
 
2486
2492
  async def find_message_memory_async(
2487
2493
  self, *, key: str, message_id: str | None = None
2488
- ) -> ShortTermMemory:
2494
+ ) -> ShortTermMemory | None:
2489
2495
  """Finds the latest short-term memory for the current assistant message asynchronously.
2490
2496
 
2491
2497
  Args:
2492
2498
  key (str): The memory key
2493
2499
 
2494
2500
  Returns:
2495
- ShortTermMemory: The latest short-term memory
2501
+ ShortTermMemory | None: The latest short-term memory, or None if not found
2496
2502
 
2497
2503
  Raises:
2498
2504
  Exception: If the retrieval fails
@@ -16,7 +16,7 @@ async def find_latest_memory_async(
16
16
  key: str,
17
17
  chat_id: str | None = None,
18
18
  message_id: str | None = None,
19
- ) -> ShortTermMemory:
19
+ ) -> ShortTermMemory | None:
20
20
  """
21
21
  Find the latest short term memory asynchronously.
22
22
 
@@ -28,7 +28,7 @@ async def find_latest_memory_async(
28
28
  message_id (str | None): The message ID.
29
29
 
30
30
  Returns:
31
- ShortTermMemory: The latest short term memory.
31
+ ShortTermMemory | None: The latest short term memory, or None if not found.
32
32
 
33
33
  Raises:
34
34
  Exception: If an error occurs.
@@ -42,6 +42,8 @@ async def find_latest_memory_async(
42
42
  chatId=chat_id,
43
43
  messageId=message_id,
44
44
  )
45
+ if not stm:
46
+ return None
45
47
  return ShortTermMemory.model_validate(stm, by_alias=True, by_name=True)
46
48
  except Exception as e:
47
49
  logger.error(f"Error finding latest short term memory: {e}")
@@ -54,7 +56,7 @@ def find_latest_memory(
54
56
  key: str,
55
57
  chat_id: str | None = None,
56
58
  message_id: str | None = None,
57
- ) -> ShortTermMemory:
59
+ ) -> ShortTermMemory | None:
58
60
  """
59
61
  Find the latest short term memory.
60
62
 
@@ -66,7 +68,7 @@ def find_latest_memory(
66
68
  message_id (str | None): The message ID.
67
69
 
68
70
  Returns:
69
- ShortTermMemory: The latest short term memory.
71
+ ShortTermMemory | None: The latest short term memory, or None if not found.
70
72
 
71
73
  Raises:
72
74
  Exception: If an error occurs.
@@ -80,6 +82,8 @@ def find_latest_memory(
80
82
  chatId=chat_id,
81
83
  messageId=message_id,
82
84
  )
85
+ if not stm:
86
+ return None
83
87
  return ShortTermMemory.model_validate(stm, by_alias=True, by_name=True)
84
88
  except Exception as e:
85
89
  logger.error(f"Error finding latest short term memory: {e}")
@@ -183,7 +187,7 @@ def find_last_chat_memory(
183
187
  company_id: str,
184
188
  key: str,
185
189
  chat_id: str,
186
- ) -> ShortTermMemory:
190
+ ) -> ShortTermMemory | None:
187
191
  """
188
192
  Find the last chat short term memory.
189
193
  """
@@ -193,6 +197,8 @@ def find_last_chat_memory(
193
197
  memoryName=key,
194
198
  chatId=chat_id,
195
199
  )
200
+ if not stm:
201
+ return None
196
202
  return ShortTermMemory.model_validate(stm, by_alias=True, by_name=True)
197
203
 
198
204
 
@@ -202,7 +208,7 @@ async def find_last_chat_memory_async(
202
208
  company_id: str,
203
209
  key: str,
204
210
  chat_id: str,
205
- ) -> ShortTermMemory:
211
+ ) -> ShortTermMemory | None:
206
212
  """
207
213
  Find the last chat short term memory.
208
214
  """
@@ -212,6 +218,8 @@ async def find_last_chat_memory_async(
212
218
  memoryName=key,
213
219
  chatId=chat_id,
214
220
  )
221
+ if not stm:
222
+ return None
215
223
  return ShortTermMemory.model_validate(stm, by_alias=True, by_name=True)
216
224
 
217
225
 
@@ -221,7 +229,7 @@ def find_last_message_memory(
221
229
  company_id: str,
222
230
  key: str,
223
231
  message_id: str,
224
- ) -> ShortTermMemory:
232
+ ) -> ShortTermMemory | None:
225
233
  """
226
234
  Find the last message short term memory.
227
235
  """
@@ -232,6 +240,8 @@ def find_last_message_memory(
232
240
  memoryName=key,
233
241
  messageId=message_id,
234
242
  )
243
+ if not stm:
244
+ return None
235
245
  return ShortTermMemory.model_validate(stm, by_alias=True, by_name=True)
236
246
 
237
247
 
@@ -241,7 +251,7 @@ async def find_last_message_memory_async(
241
251
  company_id: str,
242
252
  key: str,
243
253
  message_id: str,
244
- ) -> ShortTermMemory:
254
+ ) -> ShortTermMemory | None:
245
255
  """
246
256
  Find the last message short term memory.
247
257
  """
@@ -252,6 +262,8 @@ async def find_last_message_memory_async(
252
262
  memoryName=key,
253
263
  messageId=message_id,
254
264
  )
265
+ if not stm:
266
+ return None
255
267
  return ShortTermMemory.model_validate(stm, by_alias=True, by_name=True)
256
268
 
257
269
 
@@ -209,7 +209,7 @@ class ShortTermMemoryService:
209
209
  message_id=chat_event.payload.user_message.id,
210
210
  )
211
211
 
212
- async def find_latest_memory_async(self, key: str) -> ShortTermMemory:
212
+ async def find_latest_memory_async(self, key: str) -> ShortTermMemory | None:
213
213
  """
214
214
  Find the latest short term memory.
215
215
 
@@ -217,7 +217,7 @@ class ShortTermMemoryService:
217
217
  key (str): The key.
218
218
 
219
219
  Returns:
220
- ShortTermMemory: The latest short term memory.
220
+ ShortTermMemory | None: The latest short term memory, or None if not found.
221
221
 
222
222
  Raises:
223
223
  Exception: If an error occurs.
@@ -231,7 +231,7 @@ class ShortTermMemoryService:
231
231
  message_id=self._message_id,
232
232
  )
233
233
 
234
- def find_latest_memory(self, key: str) -> ShortTermMemory:
234
+ def find_latest_memory(self, key: str) -> ShortTermMemory | None:
235
235
  """
236
236
  Find the latest short term memory.
237
237
 
@@ -239,7 +239,7 @@ class ShortTermMemoryService:
239
239
  key (str): The key.
240
240
 
241
241
  Returns:
242
- ShortTermMemory: The latest short term memory.
242
+ ShortTermMemory | None: The latest short term memory, or None if not found.
243
243
 
244
244
  Raises:
245
245
  Exception: If an error occurs.
@@ -312,7 +312,7 @@ class ShortTermMemoryService:
312
312
  )
313
313
 
314
314
  @deprecated("Use find_latest_memory_async instead")
315
- async def get(self, key: str) -> ShortTermMemory:
315
+ async def get(self, key: str) -> ShortTermMemory | None:
316
316
  return await find_latest_memory_async(
317
317
  user_id=self._user_id,
318
318
  company_id=self._company_id,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-toolkit
3
- Version: 2026.24.0.dev7
3
+ Version: 2026.24.0.dev9
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.dev4,<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
@@ -381,17 +388,17 @@ unique_toolkit/monitoring/middleware.py,sha256=JNh5v_INAkexuLtXhCG9n5n2t6Pnf8ZdP
381
388
  unique_toolkit/monitoring/registry.py,sha256=8r9jqaQoRHZveGRZe_GQvBzvQpmUV4cpw8Ej1ssDIdE,5056
382
389
  unique_toolkit/protocols/support.py,sha256=x5edzbBmKAUuvm3oLFh52gkgQsqR5LU7M0t8RHK0DZk,5161
383
390
  unique_toolkit/services/__init__.py,sha256=b9Wl7Wvz1JmZDbDexIZzBoXZ0wscXuczGAH8LoL-Qak,572
384
- unique_toolkit/services/chat_service.py,sha256=FPY-GTFAkD0C9pYfPfkfAvyIKnL9DX9iDk75gDeghCc,95209
391
+ unique_toolkit/services/chat_service.py,sha256=mh9lZr_AiNR0JSdiEU4kh2vkh5YQG4tASlQIRpeeEQI,95567
385
392
  unique_toolkit/services/factory.py,sha256=tZo49glnXyW0wc76PXG_c95p9SE2DL9V1UwrcaVXFPo,9525
386
393
  unique_toolkit/services/knowledge_base.py,sha256=wuH0KYLHpvpyq-ZCYleAe19AobMSYgd5I60jLNq-mwo,43439
387
394
  unique_toolkit/short_term_memory/__init__.py,sha256=2mI3AUrffgH7Yt-xS57EGqnHf7jnn6xquoKEhJqk3Wg,185
388
395
  unique_toolkit/short_term_memory/constants.py,sha256=698CL6-wjup2MvU19RxSmQk3gX7aqW_OOpZB7sbz_Xg,34
389
- unique_toolkit/short_term_memory/functions.py,sha256=RQpdcpAIKQ8rRDhP8bHqekIKttQgaXIzNHw4D-_E9EI,7575
396
+ unique_toolkit/short_term_memory/functions.py,sha256=mbEeWkJ5Jr0cvg1_m6l9aS1L6nWE5ZhviNzpKlCs-a8,7907
390
397
  unique_toolkit/short_term_memory/schemas.py,sha256=C6gopsnuIgWJBG7XF87gxMwzLJtqbSGmsKXUf2h1sn4,1666
391
- unique_toolkit/short_term_memory/service.py,sha256=Ncw0_0CNM6kmzJSLVJGG5GcKpBduIBebYZlzZ7N4G8Q,8900
398
+ unique_toolkit/short_term_memory/service.py,sha256=Qo-c9yxzC1ZEEHJyIG_vnRs_ef0SKbjCAm-iH_JeJCk,8979
392
399
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
393
400
  unique_toolkit/smart_rules/compile.py,sha256=vRe0aMJWuVIK-VsqomgxQImzke7MDmwgSxxJfYCa7zw,1386
394
401
  unique_toolkit/test_utilities/events.py,sha256=FLrhMh0A_mQutAUjoD0FdMcVQ4Gq31r35OVGzRjvLro,6760
395
- unique_toolkit-2026.24.0.dev7.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
396
- unique_toolkit-2026.24.0.dev7.dist-info/METADATA,sha256=ThX3w1DhE99W9OR-nA3Kmd-hNVmqxdGU2xtsV9mUvqg,7392
397
- unique_toolkit-2026.24.0.dev7.dist-info/RECORD,,
402
+ unique_toolkit-2026.24.0.dev9.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
403
+ unique_toolkit-2026.24.0.dev9.dist-info/METADATA,sha256=NJIBtAnv-7_bn0TxQPwmfR9R6CTNc_iNrGd_MOqa0g8,7426
404
+ unique_toolkit-2026.24.0.dev9.dist-info/RECORD,,