tai42-kit 0.1.0__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.
- tai42_kit/__init__.py +20 -0
- tai42_kit/clients/__init__.py +27 -0
- tai42_kit/clients/base.py +203 -0
- tai42_kit/clients/facade.py +44 -0
- tai42_kit/clients/impl/__init__.py +3 -0
- tai42_kit/clients/impl/curl.py +41 -0
- tai42_kit/clients/impl/http.py +26 -0
- tai42_kit/clients/impl/mcp.py +60 -0
- tai42_kit/clients/impl/postgres.py +58 -0
- tai42_kit/clients/impl/redis.py +120 -0
- tai42_kit/clients/settings.py +159 -0
- tai42_kit/llm/__init__.py +23 -0
- tai42_kit/llm/_resource_registry.py +150 -0
- tai42_kit/llm/_secret_kwargs.py +84 -0
- tai42_kit/llm/checkpoint/__init__.py +0 -0
- tai42_kit/llm/checkpoint/checkpoint.py +147 -0
- tai42_kit/llm/checkpoint/checkpoint_registry.py +39 -0
- tai42_kit/llm/embedding.py +51 -0
- tai42_kit/llm/middleware/__init__.py +0 -0
- tai42_kit/llm/middleware/context_overflow.py +130 -0
- tai42_kit/llm/middleware/trimming.py +64 -0
- tai42_kit/llm/models.py +61 -0
- tai42_kit/llm/runtime.py +123 -0
- tai42_kit/llm/settings.py +166 -0
- tai42_kit/llm/store/__init__.py +0 -0
- tai42_kit/llm/store/store.py +139 -0
- tai42_kit/llm/store/store_registry.py +59 -0
- tai42_kit/logging/__init__.py +10 -0
- tai42_kit/logging/logger.py +25 -0
- tai42_kit/logging/settings.py +35 -0
- tai42_kit/net/__init__.py +28 -0
- tai42_kit/net/fetch_url.py +153 -0
- tai42_kit/net/jwt.py +319 -0
- tai42_kit/net/url_guard.py +135 -0
- tai42_kit/plugins/__init__.py +233 -0
- tai42_kit/py.typed +0 -0
- tai42_kit/settings/__init__.py +25 -0
- tai42_kit/settings/base.py +48 -0
- tai42_kit/settings/cache_registry.py +37 -0
- tai42_kit/settings/registry.py +279 -0
- tai42_kit/transport/__init__.py +17 -0
- tai42_kit/transport/base_uds_transport.py +72 -0
- tai42_kit/transport/http_uds_transport.py +20 -0
- tai42_kit/transport/sse_uds_transport.py +20 -0
- tai42_kit/transport/utils.py +27 -0
- tai42_kit/utils/__init__.py +1 -0
- tai42_kit/utils/data/__init__.py +31 -0
- tai42_kit/utils/data/jq_util.py +47 -0
- tai42_kit/utils/data/json_schema_util.py +470 -0
- tai42_kit/utils/data/mcp_output_util.py +93 -0
- tai42_kit/utils/data/string_util.py +28 -0
- tai42_kit/utils/data/url_util.py +9 -0
- tai42_kit/utils/data/yaml_util.py +136 -0
- tai42_kit/utils/lc/__init__.py +54 -0
- tai42_kit/utils/lc/lc_util.py +57 -0
- tai42_kit/utils/lc/signature_util.py +54 -0
- tai42_kit/utils/runtime/__init__.py +12 -0
- tai42_kit/utils/runtime/files_util.py +122 -0
- tai42_kit/utils/runtime/schedule_util.py +112 -0
- tai42_kit/utils/runtime/uvicorn_util.py +140 -0
- tai42_kit-0.1.0.dist-info/METADATA +163 -0
- tai42_kit-0.1.0.dist-info/RECORD +66 -0
- tai42_kit-0.1.0.dist-info/WHEEL +5 -0
- tai42_kit-0.1.0.dist-info/licenses/LICENSE +202 -0
- tai42_kit-0.1.0.dist-info/licenses/NOTICE +5 -0
- tai42_kit-0.1.0.dist-info/top_level.txt +1 -0
tai42_kit/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""tai42-kit — generic leaf helpers, LLM factories, pooled clients, and settings
|
|
2
|
+
for the TAI ecosystem.
|
|
3
|
+
|
|
4
|
+
Utilities (data/runtime/langchain helpers), LLM provider + checkpoint/store
|
|
5
|
+
factories, pooled clients + MCP transports, and co-located pydantic-settings.
|
|
6
|
+
The only tai-* dependency is ``tai42-contract`` (the leaf rule): kit implements its
|
|
7
|
+
``BaseClient`` Protocol and consumes its manifest types; it imports no other
|
|
8
|
+
tai-* package.
|
|
9
|
+
|
|
10
|
+
Deliberately light: nothing is re-exported here, so ``import tai42_kit`` pulls no
|
|
11
|
+
vendor dependency (no langchain, no drivers). Import the subpackage you need —
|
|
12
|
+
each one (``tai42_kit.llm``, ``tai42_kit.clients``, ...) is the opt-in boundary for
|
|
13
|
+
its dependencies.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
|
|
18
|
+
__version__ = version("tai42-kit")
|
|
19
|
+
|
|
20
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Pooled clients + the generic client facade.
|
|
2
|
+
|
|
3
|
+
Each client subclasses ``PooledClient`` (per-loop pooling, satisfies the contract
|
|
4
|
+
``BaseClient`` Protocol) and is reached by class through ``client_ctx``. The
|
|
5
|
+
driver each impl needs is declared by an extra (``tai42-kit[curl]`` /
|
|
6
|
+
``[postgres]``); import a driver submodule only when its driver is installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from tai42_contract.errors import ClientDisconnectedError
|
|
10
|
+
|
|
11
|
+
from tai42_kit.clients.base import PooledClient, shutdown_all_clients
|
|
12
|
+
from tai42_kit.clients.facade import client_ctx
|
|
13
|
+
from tai42_kit.clients.settings import (
|
|
14
|
+
ClientSettings,
|
|
15
|
+
PostgresConnectionSettings,
|
|
16
|
+
RedisConnectionSettings,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ClientDisconnectedError",
|
|
21
|
+
"ClientSettings",
|
|
22
|
+
"PooledClient",
|
|
23
|
+
"PostgresConnectionSettings",
|
|
24
|
+
"RedisConnectionSettings",
|
|
25
|
+
"client_ctx",
|
|
26
|
+
"shutdown_all_clients",
|
|
27
|
+
]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from asyncio import AbstractEventLoop
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import cast
|
|
9
|
+
from weakref import WeakKeyDictionary
|
|
10
|
+
|
|
11
|
+
from tai42_contract.errors import ClientDisconnectedError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Clients are cached per event loop (async pools are bound to the loop that
|
|
16
|
+
# created them) and per PooledClient subclass + connection key. Keyed off the
|
|
17
|
+
# loop in a WeakKeyDictionary so a loop's caches drop when the loop is collected —
|
|
18
|
+
# no attribute is stashed on the loop object.
|
|
19
|
+
_loop_clients: "WeakKeyDictionary[AbstractEventLoop, dict[type, dict[str, object]]]" = WeakKeyDictionary()
|
|
20
|
+
_loop_locks: "WeakKeyDictionary[AbstractEventLoop, dict[tuple[type, str], asyncio.Lock]]" = WeakKeyDictionary()
|
|
21
|
+
|
|
22
|
+
# The two maps above are module-global and reached from many threads: a template
|
|
23
|
+
# render runs in a worker thread (asyncio.to_thread) whose {% include %} bridge
|
|
24
|
+
# spins its own loop (asyncio.run), so concurrent renders touch these maps at the
|
|
25
|
+
# same time. WeakKeyDictionary is not thread-safe, so every EXPLICIT access to the
|
|
26
|
+
# OUTER maps is serialized under this lock. The lock is held only around dict
|
|
27
|
+
# bookkeeping, never across an await (client creation/closing happens outside it).
|
|
28
|
+
# The weak-ref cleanup of a collected loop's entry still fires off-lock on an
|
|
29
|
+
# arbitrary thread, but that removal is a single C-level op (atomic under the
|
|
30
|
+
# GIL). Inner per-loop dicts stay owned by their single loop.
|
|
31
|
+
_registry_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
# RuntimeError messages asyncio emits when a loop-bound resource is used after
|
|
34
|
+
# its event loop closed or from a different loop. Driver stacks that surface a
|
|
35
|
+
# dead session as a plain RuntimeError (curl_cffi, anyio) produce one of these;
|
|
36
|
+
# any other RuntimeError comes from caller code and must propagate untouched.
|
|
37
|
+
_LOOP_BOUND_ERROR_MARKERS = (
|
|
38
|
+
"Event loop is closed",
|
|
39
|
+
"attached to a different loop",
|
|
40
|
+
"bound to a different event loop",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_loop_bound_runtime_error(exc: BaseException) -> bool:
|
|
45
|
+
"""Whether ``exc`` is a RuntimeError signalling a closed/mismatched event loop."""
|
|
46
|
+
return isinstance(exc, RuntimeError) and any(marker in str(exc) for marker in _LOOP_BOUND_ERROR_MARKERS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reject_unknown_connection_kwargs(client_label: str, kwargs: dict[str, object], allowed: frozenset[str]) -> None:
|
|
50
|
+
"""Reject connection kwargs a client does not understand, raising ``ValueError``.
|
|
51
|
+
|
|
52
|
+
A pooled client keys its per-loop pool on the full kwargs dict, so an
|
|
53
|
+
unrecognized kwarg (typically a typo) would otherwise split the pool key and
|
|
54
|
+
silently create a second, mis-configured client rather than the caller's
|
|
55
|
+
intended one. The shared validation convention across the concrete impls:
|
|
56
|
+
fail loudly, naming the offending keys and the allowed set.
|
|
57
|
+
"""
|
|
58
|
+
unknown = sorted(set(kwargs) - allowed)
|
|
59
|
+
if unknown:
|
|
60
|
+
raise ValueError(f"{client_label} received unknown connection kwarg(s) {unknown}; allowed: {sorted(allowed)}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PooledClient[T]:
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _key(**kwargs) -> str:
|
|
66
|
+
return json.dumps(kwargs, sort_keys=True)
|
|
67
|
+
|
|
68
|
+
def _clients_for_loop(self, loop: AbstractEventLoop) -> dict[str, T]:
|
|
69
|
+
with _registry_lock:
|
|
70
|
+
per_loop = _loop_clients.get(loop)
|
|
71
|
+
if per_loop is None:
|
|
72
|
+
per_loop = {}
|
|
73
|
+
_loop_clients[loop] = per_loop
|
|
74
|
+
# The registry is heterogeneous across subclasses (values typed
|
|
75
|
+
# ``object``); a single class's entries are all ``T``.
|
|
76
|
+
return cast("dict[str, T]", per_loop.setdefault(self.__class__, {}))
|
|
77
|
+
|
|
78
|
+
def _create_lock(self, loop: AbstractEventLoop, key: str) -> asyncio.Lock:
|
|
79
|
+
with _registry_lock:
|
|
80
|
+
per_loop = _loop_locks.get(loop)
|
|
81
|
+
if per_loop is None:
|
|
82
|
+
per_loop = {}
|
|
83
|
+
_loop_locks[loop] = per_loop
|
|
84
|
+
lock = per_loop.get((self.__class__, key))
|
|
85
|
+
if lock is None:
|
|
86
|
+
lock = asyncio.Lock()
|
|
87
|
+
per_loop[(self.__class__, key)] = lock
|
|
88
|
+
return lock
|
|
89
|
+
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def current(self, **kwargs) -> AsyncIterator[T]:
|
|
92
|
+
loop = asyncio.get_running_loop()
|
|
93
|
+
clients_for_loop = self._clients_for_loop(loop)
|
|
94
|
+
key = self._key(**kwargs)
|
|
95
|
+
# Double-checked creation under a per-(class, key) lock: without it two
|
|
96
|
+
# concurrent first-use callers both create a client and one is orphaned
|
|
97
|
+
# (never closed). The synchronous get/setdefault above never awaits, so
|
|
98
|
+
# the cache and lock lookups are themselves race-free on the single loop.
|
|
99
|
+
if key not in clients_for_loop:
|
|
100
|
+
async with self._create_lock(loop, key):
|
|
101
|
+
# Re-fetch the live inner dict now that the lock is held.
|
|
102
|
+
# ``shutdown_all_clients()`` may have popped this loop's pool out
|
|
103
|
+
# of ``_loop_clients`` — detaching and clearing the dict captured
|
|
104
|
+
# before the lock — between that capture and acquiring the lock.
|
|
105
|
+
# Both the re-check and the write below must see the LIVE dict: a
|
|
106
|
+
# second waiter re-checking the stale-cleared dict would find
|
|
107
|
+
# ``key`` absent, create a duplicate, and overwrite the first
|
|
108
|
+
# waiter's client in the re-registered dict — orphaning it (never
|
|
109
|
+
# closed). The re-fetch and re-check are synchronous, so on the
|
|
110
|
+
# single loop nothing interleaves between them.
|
|
111
|
+
clients_for_loop = self._clients_for_loop(loop)
|
|
112
|
+
if key not in clients_for_loop:
|
|
113
|
+
created = await self._create(**kwargs)
|
|
114
|
+
# ``_create`` awaited above, so a shutdown may have detached
|
|
115
|
+
# the pool again. Re-fetch once more so the write lands in the
|
|
116
|
+
# live (re-registered if it was swept) dict rather than a
|
|
117
|
+
# stale one, where a future shutdown/close can reach it. The
|
|
118
|
+
# re-fetch and write are synchronous — nothing interleaves.
|
|
119
|
+
clients_for_loop = self._clients_for_loop(loop)
|
|
120
|
+
clients_for_loop[key] = created
|
|
121
|
+
|
|
122
|
+
client = clients_for_loop[key]
|
|
123
|
+
try:
|
|
124
|
+
yield client
|
|
125
|
+
except BaseException as e:
|
|
126
|
+
# Only errors the predicate classifies as a disconnection evict the
|
|
127
|
+
# client; everything else (including cancellation) re-raises
|
|
128
|
+
# untouched.
|
|
129
|
+
if not self._is_disconnection_error(e):
|
|
130
|
+
raise
|
|
131
|
+
clients_for_loop.pop(key, None)
|
|
132
|
+
try:
|
|
133
|
+
await self._close(client)
|
|
134
|
+
except Exception:
|
|
135
|
+
logger.debug("Failed to close disconnected client %s", type(client).__name__, exc_info=True)
|
|
136
|
+
raise ClientDisconnectedError(
|
|
137
|
+
f"{type(client).__name__} disconnected and was removed from the cache. "
|
|
138
|
+
f"Retry the operation to create a new client. (Original error: {e})"
|
|
139
|
+
) from e
|
|
140
|
+
|
|
141
|
+
async def close(self, **kwargs) -> None:
|
|
142
|
+
loop = asyncio.get_running_loop()
|
|
143
|
+
clients_for_loop = self._clients_for_loop(loop)
|
|
144
|
+
|
|
145
|
+
key = self._key(**kwargs)
|
|
146
|
+
client = clients_for_loop.pop(key, None)
|
|
147
|
+
if client is not None:
|
|
148
|
+
await self._close(client)
|
|
149
|
+
|
|
150
|
+
async def _create(self, **kwargs) -> T:
|
|
151
|
+
raise NotImplementedError
|
|
152
|
+
|
|
153
|
+
async def _close(self, client: T) -> None:
|
|
154
|
+
raise NotImplementedError
|
|
155
|
+
|
|
156
|
+
def _disconnection_exceptions(self) -> tuple[type[BaseException], ...]:
|
|
157
|
+
# Cancellation is deliberately excluded: a cancelled ``current()`` body
|
|
158
|
+
# must re-raise ``CancelledError`` so the await point unwinds, never be
|
|
159
|
+
# converted into ``ClientDisconnectedError``. Concrete clients override
|
|
160
|
+
# this with their driver's real disconnection set.
|
|
161
|
+
return (ConnectionError,)
|
|
162
|
+
|
|
163
|
+
def _is_disconnection_error(self, exc: BaseException) -> bool:
|
|
164
|
+
# Predicate ``current()`` consults to decide whether an error from the
|
|
165
|
+
# body means the pooled client is dead (evict + close + wrap in
|
|
166
|
+
# ``ClientDisconnectedError``) or is an ordinary caller error
|
|
167
|
+
# (propagate untouched). Defaults to an isinstance check against
|
|
168
|
+
# ``_disconnection_exceptions()``; clients whose drivers signal
|
|
169
|
+
# disconnection through a broad exception type (e.g. a plain
|
|
170
|
+
# ``RuntimeError``) override this to also match by message.
|
|
171
|
+
return isinstance(exc, self._disconnection_exceptions())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def shutdown_all_clients() -> None:
|
|
175
|
+
"""Close every live client pool for the running event loop.
|
|
176
|
+
|
|
177
|
+
Sweeps the per-loop class-keyed cache and closes each pooled client. Closes
|
|
178
|
+
are best-effort — failures are collected and raised together, never silently
|
|
179
|
+
dropped.
|
|
180
|
+
"""
|
|
181
|
+
# Always called from within a running loop (it is async); get_running_loop
|
|
182
|
+
# raises loudly if that invariant is ever broken.
|
|
183
|
+
loop = asyncio.get_running_loop()
|
|
184
|
+
# Detach this loop's pool atomically, then close its clients without holding
|
|
185
|
+
# the registry lock across the awaits.
|
|
186
|
+
with _registry_lock:
|
|
187
|
+
per_loop = _loop_clients.pop(loop, None)
|
|
188
|
+
if not per_loop:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
errors = []
|
|
192
|
+
for cls, clients in list(per_loop.items()):
|
|
193
|
+
closer = cls()
|
|
194
|
+
for client in list(clients.values()):
|
|
195
|
+
try:
|
|
196
|
+
await closer._close(client)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.exception("Error closing client %s", type(client).__name__)
|
|
199
|
+
errors.append(e)
|
|
200
|
+
clients.clear()
|
|
201
|
+
|
|
202
|
+
if errors:
|
|
203
|
+
raise ExceptionGroup("Errors while shutting down clients", errors)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The generic client facade — one way to get any pooled client.
|
|
2
|
+
|
|
3
|
+
``client_ctx(SomeClient, settings)`` returns an async context manager yielding a
|
|
4
|
+
connected client, pooled and reused per event-loop + connection params (the
|
|
5
|
+
pooling lives in ``PooledClient``). A client is a plain ``PooledClient`` subclass,
|
|
6
|
+
and callers reach it by class — no container, no wiring.
|
|
7
|
+
|
|
8
|
+
- ``settings`` (a ``ClientSettings``) supplies connection kwargs via
|
|
9
|
+
``client_kwargs()``; or pass raw ``**kwargs`` for clients with none.
|
|
10
|
+
- ``fresh=True`` yields a one-shot client built outside the pool and closed on
|
|
11
|
+
exit (for a blocking op that must not hold a shared-pool connection).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from tai42_kit.clients.base import PooledClient
|
|
19
|
+
from tai42_kit.clients.settings import ClientSettings
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def client_ctx[T](
|
|
23
|
+
client_cls: type[PooledClient[T]],
|
|
24
|
+
settings: ClientSettings | None = None,
|
|
25
|
+
*,
|
|
26
|
+
fresh: bool = False,
|
|
27
|
+
**kwargs: Any,
|
|
28
|
+
) -> AbstractAsyncContextManager[T]:
|
|
29
|
+
if settings is not None and kwargs:
|
|
30
|
+
raise ValueError("client_ctx: pass either `settings` or **kwargs, not both")
|
|
31
|
+
kw = settings.client_kwargs() if settings is not None else kwargs
|
|
32
|
+
if fresh:
|
|
33
|
+
return _fresh_client(client_cls, kw)
|
|
34
|
+
return client_cls().current(**kw)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def _fresh_client[T](client_cls: type[PooledClient[T]], kw: dict) -> AsyncIterator[T]:
|
|
39
|
+
inst = client_cls()
|
|
40
|
+
client = await inst._create(**kw)
|
|
41
|
+
try:
|
|
42
|
+
yield client
|
|
43
|
+
finally:
|
|
44
|
+
await inst._close(client)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import anyio
|
|
2
|
+
from curl_cffi import requests
|
|
3
|
+
from curl_cffi.requests.exceptions import SessionClosed
|
|
4
|
+
|
|
5
|
+
from tai42_kit.clients.base import PooledClient, is_loop_bound_runtime_error, reject_unknown_connection_kwargs
|
|
6
|
+
|
|
7
|
+
_ALLOWED_KWARGS = frozenset({"session_params"})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CurlClient(PooledClient[requests.AsyncSession]):
|
|
11
|
+
"""Manages reusable curl_cffi sessions.
|
|
12
|
+
|
|
13
|
+
Every primitive in ``session_params`` maps onto the ``AsyncSession``, giving
|
|
14
|
+
full control over the network layer.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
async def _create(self, **kwargs) -> requests.AsyncSession:
|
|
18
|
+
reject_unknown_connection_kwargs("Curl client", kwargs, _ALLOWED_KWARGS)
|
|
19
|
+
# Copy before mutating — never alter the caller's dict.
|
|
20
|
+
session_params = {k: v for k, v in kwargs.get("session_params", {}).items() if k != "session_key"}
|
|
21
|
+
return requests.AsyncSession(**session_params)
|
|
22
|
+
|
|
23
|
+
async def _close(self, client: requests.AsyncSession):
|
|
24
|
+
if client:
|
|
25
|
+
await client.close()
|
|
26
|
+
|
|
27
|
+
def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
|
|
28
|
+
return (
|
|
29
|
+
# The async resource is dead (socket / loop closed)
|
|
30
|
+
anyio.ClosedResourceError,
|
|
31
|
+
anyio.BrokenResourceError,
|
|
32
|
+
# The curl_cffi session object is explicitly closed / invalid
|
|
33
|
+
SessionClosed,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _is_disconnection_error(self, exc: BaseException) -> bool:
|
|
37
|
+
# curl_cffi/anyio surface a dead loop-bound session as a plain
|
|
38
|
+
# RuntimeError ("Event loop is closed" / "... attached to a different
|
|
39
|
+
# loop"); match those by message so an unrelated RuntimeError raised by
|
|
40
|
+
# caller code never tears down the pool.
|
|
41
|
+
return super()._is_disconnection_error(exc) or is_loop_bound_runtime_error(exc)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from tai42_kit.clients.base import PooledClient, reject_unknown_connection_kwargs
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpxClient(PooledClient[httpx.AsyncClient]):
|
|
7
|
+
"""Pooled ``httpx.AsyncClient`` for app-owned outbound HTTP.
|
|
8
|
+
|
|
9
|
+
Connection params arrive as kwargs (``timeout``, ``max_connections``,
|
|
10
|
+
``max_keepalive_connections``, ``keepalive_expiry``); unspecified ones keep
|
|
11
|
+
httpx's defaults. ``trust_env=False`` ignores ambient proxy env vars.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
_LIMIT_KEYS = ("max_connections", "max_keepalive_connections", "keepalive_expiry")
|
|
15
|
+
_ALLOWED_KWARGS = frozenset({"timeout", *_LIMIT_KEYS})
|
|
16
|
+
|
|
17
|
+
async def _create(self, **kwargs) -> httpx.AsyncClient:
|
|
18
|
+
reject_unknown_connection_kwargs("Httpx client", kwargs, self._ALLOWED_KWARGS)
|
|
19
|
+
limits = httpx.Limits(**{k: kwargs[k] for k in self._LIMIT_KEYS if k in kwargs})
|
|
20
|
+
client_kwargs = {"trust_env": False, "limits": limits}
|
|
21
|
+
if "timeout" in kwargs:
|
|
22
|
+
client_kwargs["timeout"] = httpx.Timeout(kwargs["timeout"])
|
|
23
|
+
return httpx.AsyncClient(**client_kwargs)
|
|
24
|
+
|
|
25
|
+
async def _close(self, client: httpx.AsyncClient):
|
|
26
|
+
await client.aclose()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import anyio
|
|
5
|
+
from fastmcp import Client
|
|
6
|
+
from tai42_contract.manifest import TaiMCPConfig
|
|
7
|
+
|
|
8
|
+
from tai42_kit.clients.base import PooledClient, is_loop_bound_runtime_error, reject_unknown_connection_kwargs
|
|
9
|
+
from tai42_kit.clients.settings import mcp_client_settings
|
|
10
|
+
from tai42_kit.transport import get_mcp_transport
|
|
11
|
+
|
|
12
|
+
# Connection kwargs a FastMCP client accepts. Anything else is rejected so it
|
|
13
|
+
# can't silently split the pool key.
|
|
14
|
+
_ALLOWED_KWARGS = frozenset({"config", "uds_protocol"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FastMCPClient(PooledClient[Client]):
|
|
18
|
+
async def _create(self, **kwargs) -> Client:
|
|
19
|
+
if "config" not in kwargs:
|
|
20
|
+
raise KeyError("FastMCP client requires a `config` kwarg")
|
|
21
|
+
|
|
22
|
+
config = kwargs["config"]
|
|
23
|
+
if not config or not isinstance(config, dict):
|
|
24
|
+
raise TypeError("FastMCP client `config` must be a non-empty dict")
|
|
25
|
+
reject_unknown_connection_kwargs("FastMCP client", kwargs, _ALLOWED_KWARGS)
|
|
26
|
+
|
|
27
|
+
# The MCP wire protocol over a UDS socket; the caller (which owns the
|
|
28
|
+
# runtime/CLI) supplies it. Ignored for non-UDS servers.
|
|
29
|
+
uds_protocol: Literal["http", "sse"] = kwargs.get("uds_protocol", "http")
|
|
30
|
+
transport = get_mcp_transport(TaiMCPConfig(**config), uds_protocol=uds_protocol)
|
|
31
|
+
# No ambient-header forwarding: fastmcp transports default
|
|
32
|
+
# ``forward_incoming_headers=False``, so this server's own inbound headers
|
|
33
|
+
# (incl. auth) are never leaked to the downstream MCP — only the headers
|
|
34
|
+
# declared in ``config`` are sent. The conformance tests lock this so a
|
|
35
|
+
# future fastmcp default-change fails loudly instead of leaking silently.
|
|
36
|
+
#
|
|
37
|
+
# Bound the connect/init: this runs inside the pooled per-key creation
|
|
38
|
+
# lock, so an unbounded connect-stall would queue every same-config caller
|
|
39
|
+
# forever. wait_for cancels the inner enter on expiry, letting fastmcp
|
|
40
|
+
# unwind its partial transport state; nothing is registered in the pool.
|
|
41
|
+
timeout = mcp_client_settings().connect_timeout_seconds
|
|
42
|
+
try:
|
|
43
|
+
return await asyncio.wait_for(Client(transport).__aenter__(), timeout=timeout)
|
|
44
|
+
except TimeoutError as exc:
|
|
45
|
+
raise TimeoutError(
|
|
46
|
+
f"MCP client connect/init did not complete within {timeout}s (MCP_CLIENT_CONNECT_TIMEOUT_SECONDS)"
|
|
47
|
+
) from exc
|
|
48
|
+
|
|
49
|
+
async def _close(self, client: Client):
|
|
50
|
+
await client.__aexit__(None, None, None)
|
|
51
|
+
|
|
52
|
+
def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
|
|
53
|
+
return anyio.ClosedResourceError, anyio.BrokenResourceError
|
|
54
|
+
|
|
55
|
+
def _is_disconnection_error(self, exc: BaseException) -> bool:
|
|
56
|
+
# anyio/asyncio surface a dead loop-bound transport as a plain
|
|
57
|
+
# RuntimeError ("Event loop is closed" / "... attached to a different
|
|
58
|
+
# loop"); match those by message so an unrelated RuntimeError raised by
|
|
59
|
+
# caller code never tears down the pool.
|
|
60
|
+
return super()._is_disconnection_error(exc) or is_loop_bound_runtime_error(exc)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from psycopg import AsyncConnection
|
|
2
|
+
from psycopg.rows import TupleRow
|
|
3
|
+
from psycopg.types.json import Json
|
|
4
|
+
from psycopg_pool import AsyncConnectionPool
|
|
5
|
+
|
|
6
|
+
from tai42_kit.clients.base import PooledClient, reject_unknown_connection_kwargs
|
|
7
|
+
|
|
8
|
+
# ``Json`` is re-exported so callers wrap jsonb params through the pooled
|
|
9
|
+
# client's own module rather than importing psycopg directly — every pg
|
|
10
|
+
# primitive the app touches is reached through the kit client layer.
|
|
11
|
+
__all__ = ["Json", "PostgresClient"]
|
|
12
|
+
|
|
13
|
+
# The pool's default connection type. Naming it lets the pooled-client generic
|
|
14
|
+
# resolve concretely (the AsyncConnectionPool default hides ACT behind a cast).
|
|
15
|
+
_Pool = AsyncConnectionPool[AsyncConnection[TupleRow]]
|
|
16
|
+
|
|
17
|
+
# Connection kwargs a Postgres client accepts — the identity produced by
|
|
18
|
+
# ``PostgresConnectionSettings.client_kwargs``. Anything else is rejected so it
|
|
19
|
+
# can't silently split the pool key.
|
|
20
|
+
_ALLOWED_KWARGS = frozenset({"dsn", "min_size", "max_size"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PostgresClient(PooledClient[_Pool]):
|
|
24
|
+
"""Pooled ``psycopg_pool.AsyncConnectionPool``, one pool per (DSN, size) per
|
|
25
|
+
loop. Compose ``PostgresConnectionSettings`` for the connection kwargs."""
|
|
26
|
+
|
|
27
|
+
async def _create(self, **kwargs) -> _Pool:
|
|
28
|
+
if "dsn" not in kwargs:
|
|
29
|
+
raise KeyError("Postgres client requires a 'dsn' kwarg")
|
|
30
|
+
dsn = kwargs["dsn"]
|
|
31
|
+
if not dsn or not isinstance(dsn, str):
|
|
32
|
+
raise ValueError("Postgres client 'dsn' must be a non-empty string")
|
|
33
|
+
reject_unknown_connection_kwargs("Postgres client", kwargs, _ALLOWED_KWARGS)
|
|
34
|
+
|
|
35
|
+
pool = AsyncConnectionPool(
|
|
36
|
+
conninfo=dsn,
|
|
37
|
+
min_size=kwargs.get("min_size", 2),
|
|
38
|
+
max_size=kwargs.get("max_size", 10),
|
|
39
|
+
open=False,
|
|
40
|
+
connection_class=AsyncConnection,
|
|
41
|
+
# Validate each connection on checkout: a connection the server has
|
|
42
|
+
# since closed (idle drop, restart, a severed-then-restored link) is
|
|
43
|
+
# discarded and replaced instead of handed to the caller, who would
|
|
44
|
+
# otherwise get an OperationalError on the first query. This is what
|
|
45
|
+
# lets a pooled caller ride out a transient Postgres outage without a
|
|
46
|
+
# restart.
|
|
47
|
+
check=AsyncConnectionPool.check_connection,
|
|
48
|
+
)
|
|
49
|
+
await pool.open()
|
|
50
|
+
return pool
|
|
51
|
+
|
|
52
|
+
async def _close(self, client: _Pool):
|
|
53
|
+
await client.close()
|
|
54
|
+
|
|
55
|
+
def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
|
|
56
|
+
import psycopg
|
|
57
|
+
|
|
58
|
+
return (psycopg.OperationalError,)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterator, Awaitable, Mapping
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from redis import Redis as SyncRedis
|
|
6
|
+
from redis.asyncio import Redis as AsyncRedis
|
|
7
|
+
|
|
8
|
+
from tai42_kit.clients.base import PooledClient, reject_unknown_connection_kwargs
|
|
9
|
+
|
|
10
|
+
# Connection kwargs a Redis client accepts — the JSON-serializable identity
|
|
11
|
+
# produced by ``RedisConnectionSettings.client_kwargs``. Anything else is a typo
|
|
12
|
+
# or a stray value and is rejected so it can't silently split the pool key.
|
|
13
|
+
_ALLOWED_KWARGS = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"url",
|
|
16
|
+
"max_connections",
|
|
17
|
+
"decode_responses",
|
|
18
|
+
"socket_timeout",
|
|
19
|
+
"socket_connect_timeout",
|
|
20
|
+
"retry_on_timeout",
|
|
21
|
+
"retry_attempts",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validate_kwargs(kwargs: dict[str, Any]) -> None:
|
|
27
|
+
_validate_url(kwargs)
|
|
28
|
+
reject_unknown_connection_kwargs("Redis client", kwargs, _ALLOWED_KWARGS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate_url(kwargs: dict[str, Any]) -> None:
|
|
32
|
+
if "url" not in kwargs:
|
|
33
|
+
raise KeyError("Redis client requires a 'url' kwarg")
|
|
34
|
+
url = kwargs["url"]
|
|
35
|
+
if not url:
|
|
36
|
+
raise ValueError("Redis client 'url' cannot be empty")
|
|
37
|
+
if not isinstance(url, str):
|
|
38
|
+
raise TypeError("Redis client 'url' must be a string (e.g., 'redis://localhost:6379/0')")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RedisClient(PooledClient[AsyncRedis]):
|
|
42
|
+
async def _create(self, **kwargs) -> AsyncRedis:
|
|
43
|
+
_validate_kwargs(kwargs)
|
|
44
|
+
return await AsyncRedis.from_url(**_with_retry(kwargs)).initialize()
|
|
45
|
+
|
|
46
|
+
async def _close(self, client: AsyncRedis):
|
|
47
|
+
await client.aclose()
|
|
48
|
+
|
|
49
|
+
def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
|
|
50
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
51
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
52
|
+
|
|
53
|
+
return RedisConnectionError, RedisTimeoutError
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SyncRedisClient(PooledClient[SyncRedis]):
|
|
57
|
+
async def _create(self, **kwargs) -> SyncRedis:
|
|
58
|
+
_validate_kwargs(kwargs)
|
|
59
|
+
return SyncRedis.from_url(**_with_retry(kwargs, sync=True))
|
|
60
|
+
|
|
61
|
+
async def _close(self, client: SyncRedis):
|
|
62
|
+
# Sync close can do network I/O to release pool connections — keep it
|
|
63
|
+
# off the event loop.
|
|
64
|
+
await asyncio.to_thread(client.close)
|
|
65
|
+
|
|
66
|
+
def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
|
|
67
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
68
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
69
|
+
|
|
70
|
+
return RedisConnectionError, RedisTimeoutError
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _with_retry(kwargs: dict[str, Any], *, sync: bool = False) -> dict[str, Any]:
|
|
74
|
+
"""Turn the serializable ``retry_attempts`` int into a redis ``Retry`` object.
|
|
75
|
+
|
|
76
|
+
``retry_attempts`` is part of the pool key (a stable int) but is not a valid
|
|
77
|
+
``Redis.from_url`` argument, so it is consumed here and, when positive,
|
|
78
|
+
materialized into an exponential-backoff ``Retry`` on connection/timeout
|
|
79
|
+
errors — built per client because a ``Retry`` instance is not serializable.
|
|
80
|
+
The sync and async clients import ``Retry`` from their own subpackage.
|
|
81
|
+
"""
|
|
82
|
+
retry_attempts = kwargs.pop("retry_attempts", 0)
|
|
83
|
+
if retry_attempts > 0:
|
|
84
|
+
if sync:
|
|
85
|
+
from redis.retry import Retry
|
|
86
|
+
else:
|
|
87
|
+
from redis.asyncio.retry import Retry
|
|
88
|
+
from redis.backoff import ExponentialBackoff
|
|
89
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
90
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
91
|
+
|
|
92
|
+
kwargs["retry"] = Retry(ExponentialBackoff(), retry_attempts)
|
|
93
|
+
kwargs["retry_on_error"] = [RedisConnectionError, RedisTimeoutError]
|
|
94
|
+
return kwargs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Typed seams over redis-py 7.x async client stubs.
|
|
98
|
+
#
|
|
99
|
+
# redis-py 7.x annotates command methods with the shared sync/async signature
|
|
100
|
+
# (``Awaitable[T] | T``), so ``await client.hgetall(...)`` fails type checking
|
|
101
|
+
# even though the async client always returns an awaitable at runtime. These
|
|
102
|
+
# helpers pin the async half of the hash commands, confining the required casts
|
|
103
|
+
# to one place; they add no runtime behavior beyond a plain function call.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def hgetall(client: AsyncRedis, key: str) -> Awaitable[dict[str, str]]:
|
|
107
|
+
"""``client.hgetall(key)`` pinned to the async client's true awaitable return
|
|
108
|
+
(decoded-responses shape: a field/value string map, ``{}`` for a missing key)."""
|
|
109
|
+
return cast("Awaitable[dict[str, str]]", client.hgetall(key))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def hset_mapping(client: AsyncRedis, key: str, mapping: Mapping[str, str]) -> Awaitable[int]:
|
|
113
|
+
"""``client.hset(key, mapping=mapping)`` pinned to the async client's true
|
|
114
|
+
awaitable return (the count of newly added fields)."""
|
|
115
|
+
return cast("Awaitable[int]", client.hset(key, mapping=dict(mapping)))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def scan_iter(client: AsyncRedis, match: str) -> AsyncIterator[Any]:
|
|
119
|
+
"""``client.scan_iter(match=match)`` pinned to the async client's iterator."""
|
|
120
|
+
return cast("AsyncIterator[Any]", client.scan_iter(match=match))
|