- Status: Accepted
- Amended by: #949 (2026-10-05), closing the standalone blocking-client gap.
- Date: 2026-10-03
- Issue: #725 (part of #707), merged as #843. Recorded after the fact under the ADR process from #731.
Every governed surface ships in three forms that must behave the same
(ARCHITECTURE.md, "How the pieces connect"): donkey.<framework>.<factory>(),
connection_kwargs(), and a module-level factory such as
donkey_kit.integrations.langgraph.chat_model(). The module-level factories
were documented as equivalent to Donkey.from_env().<framework>.<factory>(),
but they weren't. The layered import contract (§1.1) forbids
integrations from importing the top package, so integrations/_base.py
built its own client for each adapter class. That client had no budget, no
data-plane token auth or 401 refresh, no OTLP export, and a connection pool
that was never closed (evidence in #725).
The fix had to give the module-level factories the same wiring Donkey gets
without breaking the layering.
core/runtime.pyRuntimeowns what a governed handle needs: the resolvedDonkeyConfig, the OTLP export bootstrap (configure_otlp_export), the control-plane auth provider, the data-plane credential (llm_auth, used in thejwtandbearermodes), oneBudget, one shared client per credential plane, the blocking client built on first use, and the close of all of them (aclose(),close()).Runtimeis the only place shared clients are built. Nobuild_http_client(call exists outsidecore/runtime.py(apart from its definition incore/transport.py).tests/unit/test_default_runtime.pytest_no_http_client_is_built_outside_the_runtimeholds this. The rule covers the shared clients only: an adapter orLLMClientused without a runtime's blocking-client accessor builds its own blocking client on demand (_own_sync_client, throughbuild_sync_http_client). Nothing closed that client when this ADR was accepted (tracked as #949).Donkeywraps oneRuntime.Donkey.__init__builds it, and the handle's config, auth, budget and clients are the runtime's.core.runtime.default()is the process-wide runtime behind the module-level factories. It is built lazily from the environment, exactly asDonkey.from_env()would be. It is guarded by a lock, so concurrent first calls get one instance. It is closed at interpreter exit (atexitrunsclose_default()).integrations/_base.pydefault_adapter()builds each module-level adapter on it and rebuilds when the default has been closed and replaced.- It lives in
coresointegrationscan reach it by importing downward. The layering contract is unchanged.
- Let
integrationsimportDonkey. Rejected: it breaks the layered contract, and every adapter would then depend on the top-level orchestrator. - Keep per-adapter clients and copy the wiring into
_base.py. Rejected: a second copy of the budget, auth and OTLP wiring would drift fromDonkey.__init__, which is how the original gap arose. - Drop the module-level factories. Rejected: they are one of the three documented forms, and removing them is a breaking change for a gap that can be closed.
- Every module-level factory shares one client with
Donkey.from_env()-equivalent budget, auth and OTLP behaviour, sends the same headers and returns the sameconnection_kwargs().tests/unit/test_default_runtime.pychecks this for every entry inADAPTERS, and checks that the runtime is closed at interpreter exit. - The process default is built from the environment only, with no
llm_authprovider. A caller who needs non-env configuration, lifecycle control or a data-plane token provider (jwtorbearermode) uses an explicitDonkey, as thedefault_adapter()docstring says. close_default()called from inside a running event loop can close only the blocking client; the caller awaitsdefault().aclose()in that case.- The default runtime is process-global state that the SDK creates on first
use of a module-level factory. It is SDK-owned state, created only when the
user calls a module-level factory, so it is not the kind of side effect ADR
0010 forbids. The OTLP bootstrap it runs, like every
Runtime, is, and ADR 0010 changes that bootstrap, not this ADR.
Standalone Adapter and LLMClient instances now expose close() and
aclose() for the blocking client they build on demand. Neither method closes
the shared async client or a Runtime-supplied blocking client. The ownership
decision above is unchanged; this closes the gap recorded in decision 2.