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.
- unique_toolkit/experimental/README.md +11 -9
- unique_toolkit/experimental/resources/__init__.py +3 -1
- unique_toolkit/experimental/resources/feature_flags/README.md +183 -0
- unique_toolkit/experimental/resources/feature_flags/__init__.py +43 -0
- unique_toolkit/experimental/resources/feature_flags/_graphql_client.py +57 -0
- unique_toolkit/experimental/resources/feature_flags/_ttl_cache.py +65 -0
- unique_toolkit/experimental/resources/feature_flags/client.py +215 -0
- unique_toolkit/experimental/resources/feature_flags/schemas.py +17 -0
- unique_toolkit/experimental/resources/feature_flags/settings.py +33 -0
- unique_toolkit/services/chat_service.py +26 -20
- unique_toolkit/short_term_memory/functions.py +20 -8
- unique_toolkit/short_term_memory/service.py +5 -5
- {unique_toolkit-2026.24.0.dev7.dist-info → unique_toolkit-2026.24.0.dev9.dist-info}/METADATA +3 -2
- {unique_toolkit-2026.24.0.dev7.dist-info → unique_toolkit-2026.24.0.dev9.dist-info}/RECORD +15 -8
- {unique_toolkit-2026.24.0.dev7.dist-info → unique_toolkit-2026.24.0.dev9.dist-info}/WHEEL +0 -0
|
@@ -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
|
|
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
|
|
78
|
+
### `resources/<name>/` — typed adapters over internal Unique AI backends
|
|
79
79
|
|
|
80
|
-
One subfolder per
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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{"
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
{unique_toolkit-2026.24.0.dev7.dist-info → unique_toolkit-2026.24.0.dev9.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-toolkit
|
|
3
|
-
Version: 2026.24.0.
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
396
|
-
unique_toolkit-2026.24.0.
|
|
397
|
-
unique_toolkit-2026.24.0.
|
|
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,,
|
|
File without changes
|