ADR 0010: Provider plugin architecture¶
Status¶
Accepted — 2026-09-19
Context¶
Image generation backends differ in capabilities (reference images, seeds, negative prompts, batch size, safe concurrency, output formats) and in lifecycle (local CLI vs. HTTP API vs. deterministic fake). The core services (hero, iterate, batch) must not know which provider is in use, tests must run without any real provider, and third parties should be able to add a provider without patching this repo.
Decision¶
The provider contract in providers/base.py is exactly:
class ProviderCapabilities(BaseModel, frozen=True):
supports_reference_image: bool
supports_seed: bool
supports_negative_prompt: bool
supports_aspect_ratio: bool
max_batch: int # images per call; 1 for Antigravity
max_concurrency: int # provider-side safe parallelism; 1 for Antigravity
output_formats: frozenset[str] # {"jpeg"} for Antigravity
class ImageProvider(Protocol):
key: ClassVar[str] # "antigravity", "fake"
capabilities: ProviderCapabilities
async def info(self) -> ProviderInfo # name, version string, auth state
async def healthcheck(self) -> HealthReport # binary found, auth ok, model list
async def generate(self, req: GenerationRequest, *, workdir: Path) -> GenerationResult
GenerationRequest and GenerationResult are the frozen Pydantic models in PLAN.md §4 (core/models.py).
- Registry (
providers/registry.py): builtin map{"fake": FakeProvider, "antigravity": AntigravityProvider}merged withimportlib.metadata.entry_points(group="thumbforge.providers"). The entry-point name is the provider key; the value is anImageProviderclass. Duplicate key →ProviderRegistryErrorat load time. Unknown key on the CLI →NotFoundError→ exit3. Core code never imports a concrete provider. - FakeProvider (
providers/fake.py) is a first-class builtin, deterministic and offline, with the failure markers[[FAIL_TRANSIENT]]/[[FAIL_PERMANENT]](PLAN.md§4.2). It is the default provider in settings. - Contract test suite
tests/contract/test_provider_contract.pyis parametrised over every registered provider; real providers are gated by-m integration. - Services consult
capabilitiesto clamp concurrency (asyncio.Semaphore(min(--concurrency, capabilities.max_concurrency))) and to reject unsupported requests early (e.g.--seedwithsupports_seed=Falseis a usage error, exit2). - A
provider_profilerow snapshotsprovider_key,provider_versionandparams_jsonfor every run; it never stores secrets (ADR 0014).
Consequences¶
- Phases 4–7 are developed and tested entirely against
FakeProvider; Antigravity (ADR 0013) can slip without blocking them. - Adding a provider = one class + one entry point + passing the contract suite.
- Providers are
async; the CLI layer bridges withasyncio.runonce per command.
Alternatives considered¶
- Abstract base class instead of Protocol — rejected: forces third-party plugins to inherit from this package; Protocol keeps structural typing and pyright checks it.
- Setuptools-style
pkg_resourcesdiscovery — rejected: deprecated;importlib.metadata.entry_pointsis stdlib. - Config-driven dotted-path loading (
provider.class = "pkg.mod:Cls") — rejected: entry points are discoverable byprovider listwithout editing config.