Domain adapters¶
Concrete Port implementations: LLM providers (Claude, OpenAI,
DeepSeek, OpenRouter), vector / store / checkpoint back-ends,
observer + replay tooling, and the OTel bridge.
Most adapters are behind an opt-in extra (http, postgres, redis,
observability) so the zero-dep core stays clean.
There is no separate adapter aggregate package. Each implementation lives with its model, state or integration domain. Reference its owning package directly:
LLMPort adapters — CallableLLM (inject any provider).
Model fallover is no longer an adapter — it's the fallback middleware. These adapters just wrap a
single provider seam. CallableLLM adapts an injected fn/chat_fn (e.g. the OpenRouter
ModelSDKTools). The raw↔LLMResult translation helpers live in _mapping. The public surface
is agentkit.models.
For the offline, deterministic FakeLLM/Turn test double, import from agentkit.core.testing —
real adapters live in adapters/, test doubles in testing/.
model_registry is the from-configuration layer sitting ABOVE the explicit provider factories: it maps a
model name to a provider, reads the credential from the environment, and declares per-model
capabilities so a mismatch is refused at bind time. Importing it costs nothing — every provider
factory is loaded lazily by dotted path, so the registry stays usable on a zero-dependency install.
CallableLLM
¶
CallableLLM(
fn: Callable[..., Any],
*,
to_result: Callable[[Any], LLMResult] | None = None,
chat_fn: Callable[..., Any] | None = None,
cost_fn: Callable[[Any, int, int], float | None]
| None = None,
)
Adapt fn(*, system, user, model, …) (single call) and optionally
chat_fn(*, messages, model, tools, …) (tool-aware) into an LLMPort. Sync fns run off the loop
via asyncio.to_thread; coroutines are awaited. Provider tool-calls are normalized to ToolCall.
Capability
¶
Bases: StrEnum
Tri-state, because two-state is what causes the bug.
A bool forces every unrecognised model into either "has it" (silent
wrong answers) or "doesn't" (refuses everything custom). A bool | None
is worse than either, because if caps.vision: silently reads None
as absent and the type system won't stop you. A three-valued enum makes
the third case impossible to ignore at the read site.
CapabilityMismatch
¶
Bases: RegistryError
A model was bound to a role needing a capability it does not have.
Raised at construction / first bind, never on the call — the entire value is catching it before money is spent.
MissingProviderExtra
¶
Bases: RegistryError
The provider's optional dependency isn't installed.
Deliberately fatal rather than a downgrade: "your transport is missing" is a wiring bug the operator must see, and quietly serving a fake instead is precisely the silent failure this module exists to prevent.
ModelCapabilities
dataclass
¶
ModelCapabilities(
tools: Capability = UNKNOWN,
structured_output: Capability = UNKNOWN,
native_json_schema: Capability = UNKNOWN,
streaming: Capability = UNKNOWN,
vision: Capability = UNKNOWN,
context_window: int | None = None,
)
What one model can do. Every field defaults to UNKNOWN so a
partially-filled entry never over-claims.
vision is declared but not yet checkable end-to-end: Message.content
is a str and both provider adapters map it to text only, so there is
no multimodal input path to mismatch against today. It is here so the
declaration is ready when that path lands, and so an application that
routes images outside the framework can still consult it.
tools
class-attribute
instance-attribute
¶
tools: Capability = Capability.UNKNOWN
Provider-native tool/function calling (what ReActCognition needs).
structured_output
class-attribute
instance-attribute
¶
structured_output: Capability = Capability.UNKNOWN
Can be steered to emit parseable JSON at all — via native mode OR the
prompt-injection fallback. Practically every instruct model: NO means
a completion-only model that cannot follow the schema block.
native_json_schema
class-attribute
instance-attribute
¶
native_json_schema: Capability = Capability.UNKNOWN
Accepts a provider-native response_format={"type": "json_schema"}.
Distinct from structured_output: this is the strict server-side mode,
not the prompt fallback. Read by _infer_response_format.
streaming
class-attribute
instance-attribute
¶
streaming: Capability = Capability.UNKNOWN
Server-sent incremental deltas (as opposed to one-shot completion).
vision
class-attribute
instance-attribute
¶
vision: Capability = Capability.UNKNOWN
Accepts image input. See the class docstring on why this is forward-looking.
context_window
class-attribute
instance-attribute
¶
Total context in tokens, or None when unknown. Lets compaction size
itself against a real number instead of a guess.
all_unknown
property
¶
True when nothing at all is declared — i.e. this model is not in the registry and the object is a placeholder.
get
¶
get(name: str) -> Capability
Look one capability up by name. An unrecognised NAME is a
programming error (a typo in requires=) and raises immediately,
rather than degrading into UNKNOWN and silently never
checking anything.
ModelEntry
dataclass
¶
ModelEntry(
name: str,
provider: str,
capabilities: ModelCapabilities = ModelCapabilities(),
)
One model: who serves it, and what it can do.
ModelRegistry
¶
Model/provider knowledge, and the two operations that read it:
:meth:resolve (name → wired LLMPort) and :meth:check (name +
requirements → refuse or pass).
Instantiable so tests get an isolated table; the process-wide default is
:func:registry.
register_provider
¶
register_provider(entry: ProviderEntry) -> ModelRegistry
Add or replace a provider. Returns self so registration chains.
register_model
¶
register_model(entry: ModelEntry) -> ModelRegistry
Add or replace a model row.
The provider is NOT validated here: registration order shouldn't
matter, and an application may legitimately declare capabilities for a
model whose provider it registers later (or never, if it only ever
calls :meth:check).
register_rule
¶
register_rule(rule: Rule) -> ModelRegistry
Add a name→provider rule, consulted when no ModelEntry matches.
The most-recently-added rule wins: the built-in rules act as the
fallback, and an application's rule (registered after them) takes
precedence. A rule returns None to abstain.
reg.register_rule(lambda n: "openai" if n.startswith("acme-") else None)
entry_for
¶
entry_for(model: str) -> ModelEntry | None
The registered row for a model, trying each normalised candidate.
provider_for
¶
Which provider serves this model, or None if nothing claims it.
A registered ModelEntry wins over any rule — an explicit row is
always more authoritative than a pattern.
Rules are the OUTER loop, most-recently-registered first: each rule
sees EVERY normalised candidate before the next rule is consulted.
Iterating candidates outermost meant the first candidate decided the
answer, so the coarsest rule that matched the raw name won and the
later, more specific rules never saw the bare name at all — measured:
provider_for('anthropic/claude-sonnet-9') returned openrouter
because by_openrouter_prefix matched the slash before
by_family was offered claude-sonnet-9, contradicting both
register_rule's promise and _builtin_rules' own comment.
Reversing the rule order is the short-circuiting form of "run every
rule, last non-None wins", which is what :meth:register_rule
documents.
provider_entry
¶
provider_entry(name: str) -> ProviderEntry | None
The registered row for a provider, or None. Lets a caller read a
provider's default_model / env_vars without reaching into the
registry's internals.
capabilities
¶
capabilities(model: str) -> ModelCapabilities
What this model can do. An unregistered model yields an
all-UNKNOWN placeholder — never a guess in either direction.
describe
¶
A human-readable inventory, used in error messages so a refusal tells the operator what they COULD have asked for.
check
¶
check(
model: str,
requires: tuple[str, ...] = (),
*,
derived: tuple[str, ...] = (),
min_context_window: int | None = None,
on_unknown: str = "warn",
subject: str = "",
) -> None
Refuse a capability mismatch, or return silently.
requires names capabilities from :data:CAPABILITY_NAMES that the
caller DECLARED. derived names ones the framework INFERRED from
wiring (a tool-holding cognition implies tools). Both raise on a
declared :attr:Capability.NO — that is an unambiguous mismatch and
no policy makes it acceptable — but only requires participates in
the UNKNOWN policy below.
That asymmetry is the point. A derived requirement is the framework's
own inference, so nagging about it for every unregistered model name
would put a warning on essentially every development wiring and teach
people to filter the category out — at which point the real
requires= warnings go unread too. Derived requirements therefore
stay silent on UNKNOWN and speak only when a model has been
explicitly declared incapable.
:attr:Capability.UNKNOWN on a DECLARED requirement is governed by
on_unknown:
"warn"(default) — emit aUserWarningand continue. This is the default because refusing on unknown would break every self-hosted, fine-tuned, or newly-released model name the moment this module shipped, and the briefs require existing wiring to keep working."refuse"— raise. The right setting for a production service that pins its models; makes "we don't know" a deployment-time stop."allow"— say nothing. For a caller that has verified out of band.
subject names the thing being bound (an agent name), so the
message points at the wiring rather than at the framework.
resolve
¶
resolve(
model: str | None = None,
*,
provider: str | None = None,
api_key: str | None = None,
fallback: str | None = None,
env: Mapping[str, str] | None = None,
) -> Any
Build an LLMPort for model (or an explicit provider),
reading credentials from the environment.
llm = resolve("claude-sonnet-4-6") # reads ANTHROPIC_API_KEY
llm = resolve(provider="openai") # reads OPENAI_API_KEY
llm = resolve("gpt-4o", fallback="fake") # degrade, loudly, if unkeyed
fallback is OPT-IN and defaults to None (raise). This
deliberately differs from "quietly return the fake and warn": in a
server process a UserWarning goes to a log nobody reads, and the
result is fabricated completions served as real ones. Making the
caller type fallback="fake" keeps the escape hatch one keystroke
away for a notebook while refusing to make it the accidental default
for a deployment. When it IS requested, the downgrade warns exactly
once per registry.
env is injectable so tests never mutate os.environ.
A missing optional extra raises :class:MissingProviderExtra and is
NEVER absorbed into the fallback — a broken install must not
masquerade as a missing credential.
ProviderEntry
dataclass
¶
ProviderEntry(
name: str,
env_vars: tuple[str, ...],
factory: str,
extra: str = "http",
default_model: str | None = None,
accepts_model_kwarg: bool = True,
)
One provider: how to authenticate it and how to build its client.
factory is a dotted module:attr string rather than a callable so
that registering a provider costs NO import. agentkit.models.providers
raises ImportError at module load when the http extra is absent —
importing it eagerly here would make the whole registry unimportable on a
zero-dependency install, which is the opposite of what it is for.
ProviderNotConfigured
¶
Bases: RegistryError
A provider was selected but no credential was found in the environment.
The message names the environment variables that were CHECKED — never their values, and never a partial/masked value either. A masked credential is still a credential leak's first half.
RegistryError
¶
Bases: AgentkitError
Base for every failure this module raises — one except clause covers the whole bootstrap.
UnknownModel
¶
Bases: RegistryError
No registered provider claims this model name.
Raised instead of a KeyError so the message can name what IS
registered; a bare KeyError('gpt-5') tells the caller nothing about
how to fix their config.
CostSource
¶
Bases: StrEnum
Provenance for known monetary measurements; unknown cost is never zero.
Credential
dataclass
¶
Credential(
env: Mapping[str, str] = dict(),
home: Mapping[str, bytes] = dict(),
on_changed: Callable[
[Mapping[str, bytes]], Awaitable[None]
]
| None = None,
)
Resolved host credentials. Home files are installed only by allowlisted CLI adapters.
CredentialRef
dataclass
¶
Host-owned sync or async credential resolver; secrets are excluded from repr.
Workspace
dataclass
¶
Workspace(
working_dir: Path | str | None = None,
add_dirs: tuple[Path | str, ...] = (),
permission_mode: ClaudePermissionMode | None = None,
native_tools: tuple[str, ...] = (),
hooks: Mapping[str, Any] | None = None,
sandbox: CodexSandboxMode | None = None,
)
Per-call CLI filesystem and native-tool authority.
default_registry
¶
default_registry() -> ModelRegistry
A freshly-populated registry with the built-in table. Each call returns an INDEPENDENT instance — tests take one of these instead of mutating the process-wide default.
model_capabilities
¶
model_capabilities(model: str) -> ModelCapabilities
:meth:ModelRegistry.capabilities against the process-wide registry.
normalize_model_name
¶
Candidate lookup keys for a model name, most specific first.
("anthropic/claude-sonnet-4-6-20250101") →
("anthropic/claude-sonnet-4-6-20250101", "claude-sonnet-4-6-20250101",
"anthropic/claude-sonnet-4-6", "claude-sonnet-4-6")
Deliberately mirrors the candidate ORDER in
adapters/llm/providers/pricing.py::_lookup — exact, then strip an
OpenRouter-style provider/ prefix, then strip the trailing date, then
both. It is a separate function rather than a shared import because the
pricing table is owned elsewhere and stays untouched; the cost of the
duplication is one drift risk, which
tests/source/unit/models/adapters/test_model_registry.py::test_normalisation_agrees_with_pricing_lookup
pins by asserting both resolve the same names.
Returned as a de-duplicated tuple so callers can just iterate.
register_model
¶
register_model(entry: ModelEntry) -> None
Declare (or override) a model row on the process-wide registry.
register_provider
¶
register_provider(entry: ProviderEntry) -> None
Declare (or override) a provider on the process-wide registry.
register_rule
¶
Add a name→provider rule to the process-wide registry.
registry
¶
registry() -> ModelRegistry
The process-wide registry. Built on first use, then shared — so an
application's register_model / register_rule calls at startup are
visible to every later Agent construction.
require_capabilities
¶
require_capabilities(
model: str,
requires: tuple[str, ...] = (),
*,
derived: tuple[str, ...] = (),
min_context_window: int | None = None,
on_unknown: str = "warn",
subject: str = "",
) -> None
:meth:ModelRegistry.check against the process-wide registry.
resolve_llm
¶
resolve_llm(
model: str | None = None,
*,
provider: str | None = None,
api_key: str | None = None,
fallback: str | None = None,
env: Mapping[str, str] | None = None,
) -> Any
:meth:ModelRegistry.resolve against the process-wide registry.
compile_schema
¶
compile_schema(
schema: dict[str, Any], mode: Mode, model: str = ""
) -> dict[str, Any]
Compile a canonical 2020-12 schema to a transport dialect.
Model-specific schema extensions are deliberately conservative until a
live probe establishes support. model is retained for future profiles.
validate_output
¶
Clean and validate a response against the canonical schema.
validate_schema
¶
Check the canonical schema before dispatch; validation dependencies are mandatory.
Model registry¶
The from-configuration layer above the explicit provider factories:
model name → provider → wired LLMPort (credential from the
environment), plus per-model capability declaration so a mismatch is
refused at bind time rather than surfacing as a plausible empty
answer. See the
recipe for the mental model.
Model registry — resolve a model name to a wired LLMPort, and refuse a
capability mismatch before any spend.
Two problems, one object. They are the same problem wearing different hats:
The last mile (Brief 3). agentkit.models.client.chat ships claude() /
openai() / deepseek() / openrouter(), and every one of them
requires an explicit api_key=. The package read no provider environment
variable anywhere. So every application wrote the same bootstrap — read the
key, pick the provider, handle the optional-extra ImportError, fall back
without failing silently. In one production codebase that was ~710 lines and
the only domain-free code in its engine.
The silent capability failure (Brief 5). A model is a string.
claude(model="claude-sonnet-4-6") says nothing about what it can do, so
nothing catches a role bound to a model that lacks a needed capability. That
failure is silent AND well-formed: an agent asked to read images, bound to a
model that cannot see, returns a structurally valid answer citing evidence it
never read.
Both are answered by knowing things about a model name. So this module keeps
ONE table: a :class:ModelEntry carries the provider that serves it and the
capabilities it declares, and a :class:ProviderEntry carries the environment
variable and the (lazily imported) factory. Resolution and capability checking
read the same rows.
Design commitments, each of which is load-bearing:
- Nothing is guessed. An unregistered model reports every capability as
:attr:
Capability.UNKNOWN, neverFalseand neverTrue. A framework that guessesTruereintroduces the exact silent failure it exists to catch; one that guessesFalserefuses every self-hosted model on day one.UNKNOWNis the honest third answer, and what to DO about it is the caller's policy (on_unknown=). - A missing extra degrades loudly. If
httpxisn't installed we raise :class:MissingProviderExtranaming the extra. We never silently fall through to the fake — a fake serving production traffic is the worst outcome in this module's problem space. - Fallback is explicit.
resolve()with no credentials raises unless the caller passedfallback=. See :meth:ModelRegistry.resolvefor why this deliberately differs from "warn and downgrade". - A credential never appears in a message. Not in an error, not in a
warning, not in
repr. Pinned by test. - Rules over a prefix table.
register_rulelets an application map its own naming convention onto a provider without forking a hardcoded prefix list.
This layer sits ABOVE the explicit factories and does not replace them.
providers.claude(api_key=...) keeps working untouched; this is the
from-configuration path for callers who don't want to name a provider in code.
RegistryError
¶
Bases: AgentkitError
Base for every failure this module raises — one except clause covers the whole bootstrap.
UnknownModel
¶
Bases: RegistryError
No registered provider claims this model name.
Raised instead of a KeyError so the message can name what IS
registered; a bare KeyError('gpt-5') tells the caller nothing about
how to fix their config.
ProviderNotConfigured
¶
Bases: RegistryError
A provider was selected but no credential was found in the environment.
The message names the environment variables that were CHECKED — never their values, and never a partial/masked value either. A masked credential is still a credential leak's first half.
MissingProviderExtra
¶
Bases: RegistryError
The provider's optional dependency isn't installed.
Deliberately fatal rather than a downgrade: "your transport is missing" is a wiring bug the operator must see, and quietly serving a fake instead is precisely the silent failure this module exists to prevent.
CapabilityMismatch
¶
Bases: RegistryError
A model was bound to a role needing a capability it does not have.
Raised at construction / first bind, never on the call — the entire value is catching it before money is spent.
Capability
¶
Bases: StrEnum
Tri-state, because two-state is what causes the bug.
A bool forces every unrecognised model into either "has it" (silent
wrong answers) or "doesn't" (refuses everything custom). A bool | None
is worse than either, because if caps.vision: silently reads None
as absent and the type system won't stop you. A three-valued enum makes
the third case impossible to ignore at the read site.
ModelCapabilities
dataclass
¶
ModelCapabilities(
tools: Capability = UNKNOWN,
structured_output: Capability = UNKNOWN,
native_json_schema: Capability = UNKNOWN,
streaming: Capability = UNKNOWN,
vision: Capability = UNKNOWN,
context_window: int | None = None,
)
What one model can do. Every field defaults to UNKNOWN so a
partially-filled entry never over-claims.
vision is declared but not yet checkable end-to-end: Message.content
is a str and both provider adapters map it to text only, so there is
no multimodal input path to mismatch against today. It is here so the
declaration is ready when that path lands, and so an application that
routes images outside the framework can still consult it.
tools
class-attribute
instance-attribute
¶
tools: Capability = Capability.UNKNOWN
Provider-native tool/function calling (what ReActCognition needs).
structured_output
class-attribute
instance-attribute
¶
structured_output: Capability = Capability.UNKNOWN
Can be steered to emit parseable JSON at all — via native mode OR the
prompt-injection fallback. Practically every instruct model: NO means
a completion-only model that cannot follow the schema block.
native_json_schema
class-attribute
instance-attribute
¶
native_json_schema: Capability = Capability.UNKNOWN
Accepts a provider-native response_format={"type": "json_schema"}.
Distinct from structured_output: this is the strict server-side mode,
not the prompt fallback. Read by _infer_response_format.
streaming
class-attribute
instance-attribute
¶
streaming: Capability = Capability.UNKNOWN
Server-sent incremental deltas (as opposed to one-shot completion).
vision
class-attribute
instance-attribute
¶
vision: Capability = Capability.UNKNOWN
Accepts image input. See the class docstring on why this is forward-looking.
context_window
class-attribute
instance-attribute
¶
Total context in tokens, or None when unknown. Lets compaction size
itself against a real number instead of a guess.
all_unknown
property
¶
True when nothing at all is declared — i.e. this model is not in the registry and the object is a placeholder.
get
¶
get(name: str) -> Capability
Look one capability up by name. An unrecognised NAME is a
programming error (a typo in requires=) and raises immediately,
rather than degrading into UNKNOWN and silently never
checking anything.
ModelEntry
dataclass
¶
ModelEntry(
name: str,
provider: str,
capabilities: ModelCapabilities = ModelCapabilities(),
)
One model: who serves it, and what it can do.
ProviderEntry
dataclass
¶
ProviderEntry(
name: str,
env_vars: tuple[str, ...],
factory: str,
extra: str = "http",
default_model: str | None = None,
accepts_model_kwarg: bool = True,
)
One provider: how to authenticate it and how to build its client.
factory is a dotted module:attr string rather than a callable so
that registering a provider costs NO import. agentkit.models.providers
raises ImportError at module load when the http extra is absent —
importing it eagerly here would make the whole registry unimportable on a
zero-dependency install, which is the opposite of what it is for.
ModelRegistry
¶
Model/provider knowledge, and the two operations that read it:
:meth:resolve (name → wired LLMPort) and :meth:check (name +
requirements → refuse or pass).
Instantiable so tests get an isolated table; the process-wide default is
:func:registry.
register_provider
¶
register_provider(entry: ProviderEntry) -> ModelRegistry
Add or replace a provider. Returns self so registration chains.
register_model
¶
register_model(entry: ModelEntry) -> ModelRegistry
Add or replace a model row.
The provider is NOT validated here: registration order shouldn't
matter, and an application may legitimately declare capabilities for a
model whose provider it registers later (or never, if it only ever
calls :meth:check).
register_rule
¶
register_rule(rule: Rule) -> ModelRegistry
Add a name→provider rule, consulted when no ModelEntry matches.
The most-recently-added rule wins: the built-in rules act as the
fallback, and an application's rule (registered after them) takes
precedence. A rule returns None to abstain.
reg.register_rule(lambda n: "openai" if n.startswith("acme-") else None)
entry_for
¶
entry_for(model: str) -> ModelEntry | None
The registered row for a model, trying each normalised candidate.
provider_for
¶
Which provider serves this model, or None if nothing claims it.
A registered ModelEntry wins over any rule — an explicit row is
always more authoritative than a pattern.
Rules are the OUTER loop, most-recently-registered first: each rule
sees EVERY normalised candidate before the next rule is consulted.
Iterating candidates outermost meant the first candidate decided the
answer, so the coarsest rule that matched the raw name won and the
later, more specific rules never saw the bare name at all — measured:
provider_for('anthropic/claude-sonnet-9') returned openrouter
because by_openrouter_prefix matched the slash before
by_family was offered claude-sonnet-9, contradicting both
register_rule's promise and _builtin_rules' own comment.
Reversing the rule order is the short-circuiting form of "run every
rule, last non-None wins", which is what :meth:register_rule
documents.
provider_entry
¶
provider_entry(name: str) -> ProviderEntry | None
The registered row for a provider, or None. Lets a caller read a
provider's default_model / env_vars without reaching into the
registry's internals.
capabilities
¶
capabilities(model: str) -> ModelCapabilities
What this model can do. An unregistered model yields an
all-UNKNOWN placeholder — never a guess in either direction.
describe
¶
A human-readable inventory, used in error messages so a refusal tells the operator what they COULD have asked for.
check
¶
check(
model: str,
requires: tuple[str, ...] = (),
*,
derived: tuple[str, ...] = (),
min_context_window: int | None = None,
on_unknown: str = "warn",
subject: str = "",
) -> None
Refuse a capability mismatch, or return silently.
requires names capabilities from :data:CAPABILITY_NAMES that the
caller DECLARED. derived names ones the framework INFERRED from
wiring (a tool-holding cognition implies tools). Both raise on a
declared :attr:Capability.NO — that is an unambiguous mismatch and
no policy makes it acceptable — but only requires participates in
the UNKNOWN policy below.
That asymmetry is the point. A derived requirement is the framework's
own inference, so nagging about it for every unregistered model name
would put a warning on essentially every development wiring and teach
people to filter the category out — at which point the real
requires= warnings go unread too. Derived requirements therefore
stay silent on UNKNOWN and speak only when a model has been
explicitly declared incapable.
:attr:Capability.UNKNOWN on a DECLARED requirement is governed by
on_unknown:
"warn"(default) — emit aUserWarningand continue. This is the default because refusing on unknown would break every self-hosted, fine-tuned, or newly-released model name the moment this module shipped, and the briefs require existing wiring to keep working."refuse"— raise. The right setting for a production service that pins its models; makes "we don't know" a deployment-time stop."allow"— say nothing. For a caller that has verified out of band.
subject names the thing being bound (an agent name), so the
message points at the wiring rather than at the framework.
resolve
¶
resolve(
model: str | None = None,
*,
provider: str | None = None,
api_key: str | None = None,
fallback: str | None = None,
env: Mapping[str, str] | None = None,
) -> Any
Build an LLMPort for model (or an explicit provider),
reading credentials from the environment.
llm = resolve("claude-sonnet-4-6") # reads ANTHROPIC_API_KEY
llm = resolve(provider="openai") # reads OPENAI_API_KEY
llm = resolve("gpt-4o", fallback="fake") # degrade, loudly, if unkeyed
fallback is OPT-IN and defaults to None (raise). This
deliberately differs from "quietly return the fake and warn": in a
server process a UserWarning goes to a log nobody reads, and the
result is fabricated completions served as real ones. Making the
caller type fallback="fake" keeps the escape hatch one keystroke
away for a notebook while refusing to make it the accidental default
for a deployment. When it IS requested, the downgrade warns exactly
once per registry.
env is injectable so tests never mutate os.environ.
A missing optional extra raises :class:MissingProviderExtra and is
NEVER absorbed into the fallback — a broken install must not
masquerade as a missing credential.
normalize_model_name
¶
Candidate lookup keys for a model name, most specific first.
("anthropic/claude-sonnet-4-6-20250101") →
("anthropic/claude-sonnet-4-6-20250101", "claude-sonnet-4-6-20250101",
"anthropic/claude-sonnet-4-6", "claude-sonnet-4-6")
Deliberately mirrors the candidate ORDER in
adapters/llm/providers/pricing.py::_lookup — exact, then strip an
OpenRouter-style provider/ prefix, then strip the trailing date, then
both. It is a separate function rather than a shared import because the
pricing table is owned elsewhere and stays untouched; the cost of the
duplication is one drift risk, which
tests/source/unit/models/adapters/test_model_registry.py::test_normalisation_agrees_with_pricing_lookup
pins by asserting both resolve the same names.
Returned as a de-duplicated tuple so callers can just iterate.
default_registry
¶
default_registry() -> ModelRegistry
A freshly-populated registry with the built-in table. Each call returns an INDEPENDENT instance — tests take one of these instead of mutating the process-wide default.
registry
¶
registry() -> ModelRegistry
The process-wide registry. Built on first use, then shared — so an
application's register_model / register_rule calls at startup are
visible to every later Agent construction.
resolve_llm
¶
resolve_llm(
model: str | None = None,
*,
provider: str | None = None,
api_key: str | None = None,
fallback: str | None = None,
env: Mapping[str, str] | None = None,
) -> Any
:meth:ModelRegistry.resolve against the process-wide registry.
model_capabilities
¶
model_capabilities(model: str) -> ModelCapabilities
:meth:ModelRegistry.capabilities against the process-wide registry.
require_capabilities
¶
require_capabilities(
model: str,
requires: tuple[str, ...] = (),
*,
derived: tuple[str, ...] = (),
min_context_window: int | None = None,
on_unknown: str = "warn",
subject: str = "",
) -> None
:meth:ModelRegistry.check against the process-wide registry.
register_model
¶
register_model(entry: ModelEntry) -> None
Declare (or override) a model row on the process-wide registry.
register_provider
¶
register_provider(entry: ProviderEntry) -> None
Declare (or override) a provider on the process-wide registry.
register_rule
¶
Add a name→provider rule to the process-wide registry.
StorePort adapters — ONE durable-KV seam backing checkpoints, idempotency, audit, and cache.
InMemoryStore is the offline reference + contract (single-flight get_or_set, never-negative on a
raised producer, append-only logs). FileStore is the zero-dependency durable adapter (survives a
restart). RedisStore/PostgresStore (behind extras) are the same shape over real infra. One KV seam
serves checkpoints, idempotency, the audit log, and the result cache.
One backend per module (single responsibility); the public surface is agentkit.state.stores.
FileStore
¶
Durable StorePort backed by JSON files under base_dir — survives a process restart, so a
human-gate suspend or a crashed run resumes from disk. Zero-dependency (stdlib json/pathlib) and
async-first: the blocking file I/O is bridged via asyncio.to_thread so it never stalls the loop.
The reference durable adapter; a Postgres/Redis StorePort is the same shape behind an extra. Values
must be JSON-serializable (which the loop/workflow checkpoints are). Single-flight is in-process (an
asyncio.Lock per key); cross-process single-flight needs the real DB's transaction — documented.
ttl is accepted but not enforced (no expiry sweeper); use a TTL-native backend for that.
get_or_set
async
¶
Single-flight, keyed on PRESENCE rather than on the value.
This tested existing is not None, which conflates "nothing stored"
with "None stored" — so a producer that legitimately returns
None re-ran on every call and single-flight silently did not hold.
InMemoryStore, the declared reference contract, keys on in, so
the two backends disagreed on the same input: 1 call versus 3.
_exists restores the contract without needing the value at all.
compare_and_set
async
¶
Atomic within the process, via the SAME lock table get_or_set uses.
Sharing the table is the load-bearing part: two lock tables keyed on the same string give mutual exclusion between compare-and-sets and between single-flights, and none at all BETWEEN them — a producer writing its result while a CAS reads would be exactly the lost update the primitive exists to prevent.
Across processes this is NOT atomic, and no file-based store can make
it so without a lock file protocol; the class docstring already says
cross-process single-flight needs the real DB's transaction, and this
inherits that caveat. PostgresStore and RedisStore do it properly.
increment
async
¶
Read-add-write under the shared per-key lock — see compare_and_set.
Presence, not get() is None: a key holding a stored null is a
value the durable backends refuse to add to, so it raises here too
rather than restarting the counter at by.
scan
async
¶
List the directory ONCE, in a thread, then yield from that list.
Two reasons it is not lazy. A directory handle held open across an
await would see entries appear and vanish mid-iteration — the same
"must not raise while writers land" promise the in-memory backend needs
a snapshot for. And iterdir is blocking: streaming it would put a
stat syscall per key on the event loop, which is what
asyncio.to_thread exists here to avoid.
Filtering on the .json suffix is what excludes both namespaces that
must not appear: .log files (list reads those) and the
<name><random>.tmp scratch files _write_atomic leaves in flight,
which would otherwise surface a half-written key as a real one.
InMemoryStore
¶
purge_expired
¶
Drop every key whose deadline has passed; returns how many went.
Purge-on-read reclaimed a key only if something read it again, so
TTL'd keys nobody revisits were retained forever — a store used purely
for idempotency grew without bound while reporting the right answers.
Public because a long-lived process may want to sweep on its own
cadence; set also calls it every _SWEEP_EVERY writes, which keeps
the table bounded by live data rather than lifetime traffic.
get_or_set
async
¶
Single-flight: a hit returns the stored value; a miss runs fn once and stores it. A raised
fn propagates and is NOT stored, so a transient error retries clean (failures are never cached).
ttl is applied on the stored result, matching the other backends.
compare_and_set
async
¶
Deliberately NOT under self._locks.
There is no await between the read and the write below, and asyncio
is cooperative, so no other task can observe or interleave with the
intermediate state — the sequence is already atomic and a lock would
add nothing. Taking get_or_set's lock instead would be actively
worse: a producer that itself compare-and-sets the key it is producing
would deadlock against its own single-flight entry.
== rather than is: the durable backends compare JSON-decoded
values, and an identity check here would make the reference store the
only one where CAS on a dict could ever succeed.
increment
async
¶
Same no-await-in-between argument as compare_and_set: read, add, write.
ttl is applied only when the key has no deadline yet, which is
Redis's EXPIRE NX and keeps a hot counter from sliding its own
window forever.
The deadline is captured and restored around the write because set
CLEARS any expiry when handed ttl=None (it mirrors Redis's SET,
where a plain SET drops the TTL). Without that, the second increment of
every windowed counter would silently make it permanent — the counter
would keep counting and the window would never close.
Keyed on PRESENCE, not on get() is None. A key holding a stored
null is a VALUE, and the durable backends cannot add to it — Redis
INCRBY on the four bytes null is an error, and so is Postgres's
::bigint cast. Treating it as zero here would make the offline
reference the one backend that accepted it, which is the same drift
get_or_set was already caught in.
scan
async
¶
Snapshot the key list BEFORE yielding any of it.
yield is a suspension point, so a writer runs between two
iterations of this loop; iterating self._kv live raised
RuntimeError: dictionary changed size during iteration the first
time an audit reader ran against a live run. The snapshot costs one
list of keys — which the caller is about to hold anyway — and buys the
only concurrency promise the port makes.
PostgresStore
¶
Durable StorePort over Postgres (extra: arc-agentkit[postgres], via asyncpg). One KV table +
one append-log table, JSON-text values. Call await init() once to create the tables. Single-flight is
in-process; durability/atomicity is Postgres's. TTL is not enforced (no sweeper — use RedisStore for TTL).
Inject a pool for tests; else an asyncpg pool is created from dsn.
compare_and_set
async
¶
ONE statement, so the compare and the set cannot be pulled apart.
A SELECT followed by an UPDATE is two statements and, at the
default READ COMMITTED isolation, another transaction commits between
them — the lost update this primitive exists to prevent. The predicate
rides inside the write instead, and the row count is the answer.
value::jsonb = $3::jsonb rather than text equality: the column is
TEXT holding json.dumps output, and Python dicts with the same
contents in a different insertion order serialise to different text.
Comparing as JSON makes the equality structural, which is what the port
promises and what every other backend does.
expected=None takes the INSERT branch because absent must compare
equal to None — and the ON CONFLICT guard keeps it honest: if the
row turns out to exist it is only overwritten when it holds JSON null.
Without that guard the upsert would clobber any value at all, turning
the CAS into an unconditional set.
increment
async
¶
One upsert that adds in place, guarded so the cast can never raise.
kv.value ~ '^-?[0-9]+$' is not defensive noise: without it, a key
holding {"a": 1} makes value::bigint raise
InvalidTextRepresentationError — an asyncpg type escaping through a
port whose whole purpose is that callers never see backend types. With
it, the conflicting row simply fails the ON CONFLICT ... WHERE, no
row is returned, and the uniform StoreValueError is raised from here.
(ON CONFLICT ... WHERE filters rows before the SET expression is
evaluated, so the cast never runs on a non-numeric row.)
The regex is exactly the set of strings json.dumps produces for an
int, which is why a counter and an ordinary stored value can share
the KV table: 1.5, "7", true and null all fail it, and
all four are values Redis's INCRBY refuses too.
scan
async
¶
A prefix query over the KV table only — the log table has its own reader.
starts_with(key, $1) rather than key LIKE $1 || '%' because the
prefix is caller data: LIKE would read _ as "any character" and
% as "anything", so a scan for run_1: would also return
run-1:'s keys. Escaping for LIKE is possible but it is a
correctness trap that has to be got right in every caller's head;
starts_with has no pattern syntax to escape. The same class of bug
is escaped explicitly on the Redis backend, where SCAN gives no
alternative.
The whole result is fetched, then yielded. asyncpg cursors are
transaction-scoped, and holding a transaction open for as long as a
caller takes to consume an audit scan is a far worse failure mode than
materialising a list of keys — the caller is about to hold them anyway.
RedisStore
¶
Durable StorePort over Redis (extra: arc-agentkit[redis]). KV via GET/SET(EX), append-logs via
RPUSH/LRANGE, values JSON-encoded. Single-flight is in-process (an asyncio.Lock per key); TTL is honored
natively (SETEX). Inject a client (e.g. a fake) for tests; else built from url.
compare_and_set
async
¶
WATCH / read / MULTI / SET — Redis's optimistic-locking idiom.
WATCH arms the key BEFORE the read, so if anything writes it between
the read and the EXEC, the EXEC is discarded and redis-py raises
WatchError. Without the WATCH this would be a plain
read-then-write across two round trips, which is the lost update the
primitive exists to prevent — and it would pass every single-threaded
test.
A WatchError is translated to False, not re-raised: losing the
race IS the answer the caller asked for. Any other exception
propagates, because "the store is down" is not a lost race.
increment
async
¶
INCRBY and EXPIRE ... NX inside ONE transaction.
The two commands must be one unit. Issued separately, a process that dies between them leaves a counter with no window — a rate limit that is permanently exhausted for that key, with no expiry to ever clear it. MULTI/EXEC makes the pair atomic.
NX is what makes the window fixed rather than sliding: it sets an
expiry only when the key has none. A plain EXPIRE would push the
deadline out on every hit, so a caller sending traffic faster than the
window is long would keep the counter alive forever and the limit would
never reset — the failure appears only under exactly the load the limit
exists for. (EXPIRE ... NX needs Redis 7.0+.)
The JSON encoding lines up for free: json.dumps(5) == "5" is
precisely the string Redis stores an integer as, so a counter written
by INCRBY reads back through get as an int and a counter written
by set can be INCRBY'd. Nothing else in this adapter would work if
that were not true.
The error translation matches on Redis's MESSAGE rather than importing
ResponseError — same reason as _is_watch_conflict: the extra may
not be installed at all on the path that constructs this with a client.
scan
async
¶
SCAN with an escaped MATCH, looping on the cursor and de-duplicating.
Three properties of SCAN that a naive translation gets wrong:
- MATCH is a GLOB. The prefix is caller data, so it is escaped — see
_escape_globfor the cross-tenant read that follows if it is not. - A non-zero cursor means "more to come" even when this call returned nothing. Stopping on an empty page silently truncates the scan.
- The same key may be returned more than once (a rehash during the
scan), so keys are de-duplicated. The
seenset is bounded by the number of MATCHING keys, which is what the caller is about to hold anyway — the alternative is a caller that double-processes an audit record and cannot tell why.
Keys come back as bytes: redis-py only decodes when the client was
built with decode_responses=True, and this one is not. get
survives that because json.loads accepts bytes; the string surgery
here does not.
CheckpointPort adapters — InMemoryCheckpointStore is the offline reference + contract
every durable backend matches; PostgresCheckpointStore is the production durable backing
(extra: arc-agentkit[postgres]). Public surface is agentkit.state.checkpoints.adapters.
InMemoryCheckpointStore
¶
A CheckpointPort over an in-process dict. One list of Checkpoints per run_id.
PostgresCheckpointStore
¶
A CheckpointPort over Postgres via asyncpg. Pool is application-owned (matches the
explicit-lifecycle convention PostgresStore follows — see store/postgres.py). Call
await ensure_schema() once at startup; the per-call methods then go through pool.acquire()
like every other asyncpg adapter in this package.
ensure_schema
async
¶
Idempotent DDL — CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Safe to
call multiple times. Apps call this once at startup before any save/load (mirrors
PostgresStore.init()).
Concrete observer adapters for the observation channel.
CollectingObserver/QueueObserver— terminal sinks (list capture; bounded async-consumable queue).PolicyObserver/RollupObserver— emission-cadence wrappers around an inner observer.Hooks— lifecycle subscription (on(stage, handler)), sugar over the stream.
In-process transports. A cross-process transport (e.g. Redis pub/sub → SSE) is a future adapter on the
same ObserverPort. The public surface is agentkit.integrations.observers.
PolicyObserver
¶
The emission-cadence knob: forwards only selected kinds to an inner observer.
result/error/interrupt always pass (never silence a result or a human gate); other kinds pass
only if allow is None (everything) or contains the kind. Compose it around any ObserverPort
(e.g. a QueueObserver); the consumer still streams from the inner observer. Emit-only by design —
an ObserverPort needs only emit.
close
async
¶
Forward shutdown to inner. ObserverPort declares close(),
so without it isinstance(PolicyObserver(...), ObserverPort) was
False (measured) — a cadence wrapper did not satisfy the very
Protocol it exists to compose. Worse, a wrapper that swallows
close strands a buffering inner: wrapping a RollupObserver
dropped its trailing summary entirely.
everything
classmethod
¶
everything(inner: Any) -> PolicyObserver
Forward every observation (the streaming/step cadence).
summaries
classmethod
¶
summaries(inner: Any) -> PolicyObserver
Forward rolled-up summary/progress (+ always-forwarded), drop finer events.
result_only
classmethod
¶
result_only(inner: Any) -> PolicyObserver
Send only on a complete result/error (+ interrupt) — the quiet-pipeline cadence.
RollupObserver
¶
RollupObserver(
inner: Any,
*,
every: int = 8,
summarize: Callable[
[Sequence[Observation]], str | Awaitable[str]
]
| None = None,
kind: ObservationKind = "summary",
)
The 'rolled-up summary' cadence: buffer non-critical observations and emit one
summary every every of them — flushing the buffer first whenever a critical kind
(result/error/interrupt) passes through, and again on close().
summarize(buffer) -> str builds the roll-up text; it may be sync or async (async is the hook for
an LLM judge or a Compactor-backed summariser). Critical observations are always forwarded
immediately (after the flush), so a result is never delayed behind buffered progress.
Hooks
¶
Lifecycle subscription: a thin ObserverPort that dispatches each observation to handlers
registered with on(stage, handler), where stage is the observation kind (e.g. run_start,
summary, interrupt, error, result) or "*" for every observation.
Lifecycle is not a new mechanism — it's the observation stream, named. Handlers are sync or
async and never break the run: an exception in a handler is swallowed.
Optionally forwards to an inner observer, so Hooks chains in front of a QueueObserver.
close
async
¶
Forward shutdown to inner (no-op when unwired).
ObserverPort declares close(); without it
isinstance(Hooks(), ObserverPort) was False (measured) and,
because Hooks chains in FRONT of another observer, the whole chain
below it lost its shutdown hook — a Hooks -> PolicyObserver ->
RollupObserver stack silently dropped the rollup's trailing summary
at process exit. Like emit, a handler must never destabilise the
run, so a failing inner close is not swallowed here: close is
an explicit shutdown call, and hiding a flush failure would lose the
buffered tail without a trace.
CollectingObserver
¶
Records every observation into .items (no backpressure). For tests and small in-proc consumers.
QueueObserver
¶
Non-blocking, bounded async observer with the never-drop-results rule.
emit never blocks and never raises into the run. Non-critical observations (progress/summary) are
bounded to maxsize; when over, the oldest non-critical is dropped (coalesced). result/error
are never dropped and never count against the bound. stream() async-iterates in insertion order
until close().
OpenTelemetry bridge. Distinct from adapters.observer (in-process
observer-pattern impls). Requires the observability extra.
otel_exporter_otlp_http
¶
One-call setup of the OTel SDK with an OTLP-HTTP exporter.
Builds a TracerProvider with a BatchSpanProcessor wrapping
an OTLP-HTTP exporter pointed at endpoint (or the
OTEL_EXPORTER_OTLP_ENDPOINT env var). Call once at process
startup BEFORE otel_tracer() is consumed — sets the global
tracer provider.
The convenience version: operators who want a "give me OTel
with sensible defaults" path call this. Operators with their
own setup (custom processor, multiple exporters, etc.) skip
this and configure opentelemetry.trace.set_tracer_provider
themselves.
otel_meter
¶
Construct a MetricsPort backed by the OpenTelemetry SDK.
Production: pass nothing — pulls the global meter (configured
via env vars OTEL_EXPORTER_OTLP_ENDPOINT /
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT). Tests: pass an explicit
meter_provider so each test gets isolated state.
Raises ImportError when opentelemetry-sdk isn't installed
(i.e., the [observability] extra wasn't installed); the
error message names the extra so the operator knows what to do.
otel_metrics_exporter_otlp_http
¶
One-call OTel metrics SDK setup with an OTLP-HTTP exporter.
Sister to otel_exporter_otlp_http for traces. Call once at
startup BEFORE otel_meter() is consumed — sets the global
meter provider with a PeriodicExportingMetricReader pushing
to the OTLP-HTTP endpoint every interval_ms (default 60s, the
OTel default).
The convenience version: operators who want a "give me OTel metrics
with sensible defaults" path call this. Operators with their own
setup (custom readers, multiple exporters, etc.) skip this and
configure opentelemetry.metrics.set_meter_provider themselves.
otel_sampler
¶
Convenience: build a SamplerPort from agentkit's pure-Python
TraceIdRatioSampler. ratio=1.0 = always-on; ratio=0.1 = 10%.
The sampler is a kernel-level seam (no OTel dependency) so this
factory just wraps the in-tree impl — it lives next to the OTel
metrics/tracer factories so operators have ONE module to import
from when wiring observability. For richer policies (parent-based,
rule-based), construct your own SamplerPort impl and inject it
directly into Services(sampler=...).
otel_tracer
¶
Construct a TracePort backed by the OpenTelemetry SDK.
Production: pass nothing — pulls the global tracer (configured via env vars OTEL_SERVICE_NAME / OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_HEADERS / ... per the standard OTel SDK convention).
Tests: pass an explicit tracer_provider (e.g. a
TracerProvider wired to InMemorySpanExporter) so each
test gets its own isolated provider. OTel forbids overriding
the global set_tracer_provider once it's been set, so
sharing a global across tests gives stale-provider failures.
Raises ImportError when opentelemetry-sdk isn't installed
(i.e., the [observability] extra wasn't installed); the
error message names the extra so the operator knows what to do.
Replay store¶
FileReplayStore keeps the full LLM request/response for a span on
disk, keyed by span id, so the trace stays the index and the payloads
live outside the span's attribute budget.
Configuration, in precedence order:
- An explicit
FileReplayStore(root=...). AGENTKIT_REPLAY_DIR— the env varFileReplayStore.from_env()reads. An empty value counts as unset.FileReplayStore.default()—$XDG_DATA_HOME/agentkit/replays, or~/.local/share/agentkit/replayswhenXDG_DATA_HOMEis unset.
That list is exhaustive. AGENTKIT_REPLAY_DIR is the only env var
read, and default() probes nothing on disk — it returns the same
path whatever directories happen to exist, so where the store writes
is always predictable from the environment alone.
RIO_REPLAY_DIR no longer does anything
RIO_REPLAY_DIR and the ~/.rio/replays default date from before
this package was agentkit, and neither is read any more. If you
still set the old variable, replay is simply off —
from_env() returns None and the caller falls back to
NoopReplayStore, with nothing logged. Set AGENTKIT_REPLAY_DIR
instead.
Replays under an old directory are not migrated for you and are
not found by default(). Move them yourself
(mv ~/.rio/replays ~/.local/share/agentkit/replays) or point the
store at them with AGENTKIT_REPLAY_DIR.
Writes are best-effort by contract — ReplayStore.put must never
raise into a run — but never silent: a dropped write logs (once per
failure class, to keep a full disk from flooding the log) and
increments FileReplayStore.dropped_writes. Read failures that are
not ordinary cache misses increment failed_reads. Alert on those
counters if replay data matters to you; a miss on its own is normal
and is neither logged above DEBUG nor counted.
ReplayStore adapters.
FileReplayStore
¶
ReplayStore that writes each record to <root>/<span_id>.json.
Constructed with an explicit root directory (created on first
write if missing). Use FileReplayStore.from_env() to read
the AGENTKIT_REPLAY_DIR env var, or FileReplayStore.default()
for the conventional location.
Best-effort, but never silent: a failed write logs and returns
None (the contract of ReplayStore.put says writes must not
raise into the run) while incrementing dropped_writes. A
failed read returns None and increments failed_reads. A
genuine miss also returns None but is NOT a failure — it logs
at DEBUG and touches no counter, so "the record isn't there" stays
distinguishable from "the store is broken".
dropped_writes
property
¶
Records put() accepted but could not persist.
Non-zero means replay data is missing for this run. Exposed as a
number rather than only a log line because the log is warned-once
(see _warn_once) — the counter is what stays accurate under a
sustained failure such as a full disk.
failed_reads
property
¶
Reads that failed for a reason OTHER than a genuine miss.
A missing file is a cache miss and is deliberately excluded: mixing misses into this counter would make it useless, since a miss is the expected outcome for any span that was never recorded.
from_env
classmethod
¶
from_env() -> FileReplayStore | None
Read AGENTKIT_REPLAY_DIR and construct; return None when
the env var isn't set so callers can fall back to
NoopReplayStore.
An empty value counts as unset. FOO= in a shell profile or a
compose file is how people spell "not configured", and Path("")
resolves to the process CWD — so treating it as a path would scatter
replay JSON through whatever directory the app happened to start in,
while looking configured.
default
classmethod
¶
default() -> FileReplayStore
Conventional location: $XDG_DATA_HOME/agentkit/replays or
~/.local/share/agentkit/replays.
No directory probing and no fallbacks: the path is a pure function of
XDG_DATA_HOME, so what this returns is predictable from the
environment alone. An explicit root= or AGENTKIT_REPLAY_DIR
is how you point the store anywhere else.