Phase 3 — Providers¶
Status: Implemented (P3.1–P3.5)
ROADMAP tasks: P3.1, P3.2, P3.3, P3.4, P3.5
ADRs: docs/adr/0010-provider-plugin-architecture.md, docs/adr/0013-antigravity-cli-adapter.md, docs/adr/0014-secrets-env-keyring.md
Scope¶
Define the ImageProvider protocol, capabilities, registry with entry-point discovery, the deterministic FakeProvider, the AntigravityProvider subprocess adapter, and the provider commands.
- P3.1
core/providers.py(Protocol and boundary models),core/json.py(JsonValue),providers/registry.py,[project.entry-points."thumbforge.providers"]. - P3.2
providers/fake.py+tests/contract/test_provider_contract.py. - P3.3 spikes S1–S8 executed; findings in
docs/spikes/antigravity.md. - P3.4
providers/antigravity.py(the wrapper prompt is a module constant in it, not a packaged.j2), marker-gated integration test — the contract suite'santigravitycase. - P3.5
cli/provider.py(provider list|check|models|set-key) andthumbforge/credentials.py(env → keyring lookup, the only writer of a secret).
Non-goals¶
- Overlay, fit, compliance — Phase 5. Providers return raw art only.
- Hero/batch orchestration — Phases 6–7. Phase 3 exposes
generatefor one request at a time. - Any provider beyond
fakeandantigravity; third parties use the entry-point group.
Interfaces¶
From PLAN.md §4:
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; 2 for Antigravity (spike S7)
output_formats: frozenset[str] # {"jpeg"} for Antigravity
class ImageProvider(Protocol):
key: ClassVar[str] # "antigravity", "fake"; matches the entry-point name
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
class GenerationRequest(BaseModel, frozen=True):
prompt: str
negative_prompt: str | None
width: int
height: int
reference_images: tuple[Path, ...]
seed: int | None
params: dict[str, JsonValue]
idempotency_key: str
class GenerationResult(BaseModel, frozen=True):
image_path: Path
provider_key: str
provider_version: str
model: str | None
seed_used: int | None
duration_ms: int
cost: Cost | None # Cost(tokens_in, tokens_out, credits: Decimal | None, currency: str | None)
raw_response: dict[str, JsonValue]
ProviderInfo(key, name, version, auth: Literal["ok", "missing", "unknown"]); HealthReport(checks, models) with ok derived as all(check.ok …) rather than supplied, so a report cannot claim health while carrying a failed check. auth="unknown" is deliberately distinct from "missing": a provider that cannot cheaply prove its credentials must not be reported as unauthenticated, or provider check would tell users to fix something that is not broken.
Placement. The Protocol and these models live in core/providers.py, not providers/base.py as an earlier draft of this spec said. core.services consumes them — HeroService (P6.1) builds a GenerationRequest and reads a GenerationResult — and core may not import providers. This is the same constraint that moved MetadataSource to core/sources.py in P2.3, and phase-1-skeleton.md pre-authorised it: "the contract is the rule, the file placement bends." JsonValue moved to core/json.py for the same reason: cli emits it, core.providers carries it in params/raw_response, and storage persists it, so it cannot live in cli.
supports_aspect_ratio means the requested ratio influences the result; it does not promise the exact width and height. Spike S3 measured Antigravity honouring "16:9 widescreen" while always returning 1376x768, so Phase 5 fits every result regardless of this flag.
Registry (PLAN.md §4.1)¶
- Builtin map
{"fake": FakeProvider, "antigravity": AntigravityProvider}is merged withimportlib.metadata.entry_points(group="thumbforge.providers"). The entry-point name is the provider key; the value is anImageProviderclass whose__init__takes its config mapping.BUILTINstarts empty and is populated by P3.2 and P3.4. - Duplicate key (builtin vs plugin, or two plugins) →
ProviderRegistryErrorat discovery. An error rather than a precedence rule: silently shadowing a provider would make--provider xmean different things depending on what else is installed, and that failure surfaces as wrong images rather than as a message. - A plugin that raises on import →
ProviderRegistryErrornaming the entry-point value, with the original error as__cause__. Skipping it is indistinguishable from "never installed", which is the harder failure to diagnose. - An entry point whose value is not callable →
ProviderRegistryError. - Unknown key on the CLI →
NotFoundError→ exit3, hinting the available keys. - Core code never imports a concrete provider; it asks the registry.
registry.get(key: str, config: Mapping[str, JsonValue] | None = None) -> ImageProvider; registry.keys() -> list[str].
A provider is constructed from its own config mapping, not from Settings as an earlier draft said. storage and sources likewise take plain values, so no adapter depends on the application's whole configuration tree — cli extracts settings.providers.<key> and passes it down. A mapping rather than a typed model because the registry cannot know which model a third-party provider wants, and provider_profile.params_json already stores provider config this way.
A provider shipped in this package is registered in providers.registry.BUILTIN and not
in the thumbforge.providers entry points. The map is merged with that group, so declaring
one in both places makes it collide with itself under the duplicate-key rule — the guard
caught exactly that during P3.2. The entry-point group is purely the third-party mechanism.
FakeProvider (PLAN.md §4.2)¶
- Renders a PNG at the requested
widthxheight, rather than the fixed 1376x768 an earlier draft specified. Honouring the request exactly is a property of this provider, not of the interface: it is what lets a Phase 5 or 6 test ask for a known-size source image. Request 1376x768 explicitly to exercise Phase 5's upscale path offline. supports_aspect_ratio=Truestill promises only influence, never exact dimensions, and Phase 5 fits every result regardless of the flag. Antigravity advertises the flag and cannot honour exact dimensions at all (spike S3), so the contract suite asserts the output ratio is within 5% of the request — which admits Antigravity's measured 1376x768 (0.8% off a 16:9 request) and rejects the 1024x1024 and 1264x848 that S3 measured when the ratio was omitted from the prompt. Exact sizing is asserted in the fake's own tests.- Background colour = first 3 bytes of
sha256(prompt + negative_prompt + seed + size); draws the digest prefix as text; when reference images are given, pastes 96-px thumbnails of them into the corners.negative_promptand the size are in the digest because both are advertised as supported, and a flag that changes nothing is worse than a missing one. - Deterministic to the byte for an identical request, so a caller can assert on the image rather than merely on its existence. Nothing varies between runs is embedded in the PNG.
- Sleeps
params.get("delay_ms", 0)ms (lets progress-bar and concurrency tests be observable). - Raises
ProviderTransientErrorwhen the prompt contains[[FAIL_TRANSIENT]]andProviderPermanentErroron[[FAIL_PERMANENT]]— used by contract, retry and resume tests. - Capabilities: reference=True, seed=True, negative=True, aspect=True, max_batch=8, max_concurrency=8, formats={"png"}.
AntigravityProvider (PLAN.md §4.3)¶
Designed around measured behaviour: spikes S1–S8 ran against agy 1.2.6 and are recorded in docs/spikes/antigravity.md. They confirmed the image tool exists (generate_image) and falsified four of the original design points, so ADR 0013 is now Accepted and the design below reflects the measurements, not the docs. Only the unauthenticated path (S6a) is still unverified.
The wrapper prompt is a module constant in providers/antigravity.py, not the
antigravity_wrapper.j2 file ADR 0013 named. Three reasons: it needs no template engine
(Jinja2 arrives with Phase 4's user-authored templates, and this text is provider-internal
rather than editable), a packaged data file would have to be declared for the wheel where a
constant cannot go missing, and it is only ever rendered with four substitutions. ADR 0013 is
Accepted, so it is left as written.
provider_version comes from agy --version, not the envelope: the envelope carries no
version field — its measured keys are conversation_id, status, response,
duration_seconds, num_turns, usage — so reading it there left every stored row saying
unknown. The value is cached per instance so a batch does not spawn agy --version per
image.
- Build the prompt from the
_WRAPPERmodule constant, which (a) instructs the agent to callgenerate_imageexactly once, (b) states the aspect ratio in words ("16:9 widescreen") — S3 measured this as the only lever on output size, (c) lists reference image absolute paths for the agent toview_file, (d) forbids running shell commands or other tools. It states no output path:generate_imageaccepts onlyImageNameandPrompt. - Run
[binary, "-p", prompt, "--output-format", "json", "--add-dir", str(workdir), *("--add-dir", d for d in reference_dirs), "--print-timeout", f"{timeout_s}s", *(["--dangerously-skip-permissions"] if skip_permissions else []), *(["--model", model] if model else []), *(["--effort", effort] if effort else [])]viaasyncio.create_subprocess_exec(..., cwd=workdir, stdout=PIPE, stderr=PIPE). - Locate the image by globbing
~/.gemini/antigravity-cli/brain/<conversation_id>/using the envelope'sconversation_id, then copy it into the asset store. Success ⇔ exit0andstatus == "SUCCESS"and exactly one image found and Pillow opens it. -
Error mapping. Two rows must precede the missing-output row, because
SUCCESStwice means something other than success. agy's print timeout presents asSUCCESSwith no file (S6c). And an upstream image-model refusal does too: measured live during P3.4, a 429RESOURCE_EXHAUSTEDongemini-3.1-flash-imagecame back asstatus: SUCCESS, one turn, real token usage, no image, with the error relayed only as prose inresponse. Both are retryable; classified as missing output they become permanent, so a long generation or a quota reset two hours away abandons its batch item and misdiagnoses the cause. The prose check is consulted only when no image was produced, so a prompt that merely mentions quotas cannot trigger it.Observation Exception Retryable SUCCESS, emptyresponse,total_tokens == 0, no output imageProviderTimeoutError(agy's print timeout)yes SUCCESS, no image,responserelays a 429/RESOURCE_EXHAUSTED/quota refusalProviderTransientError(the image model is rate limited)yes SUCCESSbut no image inbrain/<conversation_id>/ProviderOutputMissingError(message names the brain directory)no status ∈ {CANCELED, INTERRUPTED}ProviderTransientErroryes ERRORanderrorcontainsauthentication requiredProviderAuthErrorno ERRORotherwise /INVALID/WAITING/RUNNINGProviderPermanentErrorno subprocess exceeds timeout_s + 30ProviderTimeoutError(process killed)yes binary not found ProviderPermanentErrorwith hint "install Antigravity CLI"no -
usagetokens andduration_secondsare written toiteration.cost_json; the whole envelope toprovider_response_json; stdout/stderr to per-iteration log files. - Never uses
--continue/--conversation; every image is a fresh conversation. providers.antigravity.skip_permissionsdefaults tofalse— S5 superseded decision D4 by measuringgenerate_imagesucceeding with neither the flag nor apermissions.allowrule.- Capabilities, as measured: reference=False (S4: the agent describes a reference rather than conditioning on it), seed=False, negative=True (prompt-only), aspect=True but influence-only (S3), max_batch=1, max_concurrency=2 (S7), formats={"jpeg"}.
healthcheck()additionally reports~/.gemini/config/plugins/: every headless run inherits the developer's globally installed plugins (S4 observed asuperpowersskill file being opened unprompted), so output is not a pure function of thumbforge's inputs.
Settings consumed: [providers.antigravity] binary, model, effort, timeout_s, skip_permissions (see phase-1-skeleton.md). healthcheck() runs shutil.which(binary), agy --version, agy models, and agy -p "reply ok" --output-format json and reports auth from the envelope.
Retries (PLAN.md §7.2)¶
Only ProviderTransientError and ProviderTimeoutError are retried, via tenacity: exponential backoff base 2 s, factor 2, max 60 s, full jitter, max_attempts = 3 per provider call. Attempt count is recorded in iteration.provider_response_json.attempts. The retry wrapper lives in core/services/ (callers of generate), not inside providers, so contract tests see raw behaviour.
Commands¶
| Command | Key flags | Output | Exit |
|---|---|---|---|
thumbforge provider list |
table key, name, version, auth, capabilities |
0 | |
thumbforge provider check KEY |
runs healthcheck(), prints every check |
0, 3, 4 | |
thumbforge provider models KEY |
HealthReport.models (Antigravity: agy models) |
0, 3, 4 | |
thumbforge provider set-key KEY |
prompts for secret; stores in keyring | 0, 3, 4 |
Secrets (PLAN.md §8): lookup order environment variable THUMBFORGE_PROVIDERS__<KEY>__API_KEY, then keyring (service thumbforge, username <provider_key>). Implemented in thumbforge/credentials.py — named for the standard library's secrets module, which it must not shadow — and provider set-key is its only writer. A blank environment variable falls through to the keyring, because an exported-but-empty variable is how shells leave a value unset.
The settings loader drops secret-looking variables from its environment source (_SecretFreeEnvSource, reusing core.redaction's deny-list) instead of validating them. Measured in P3.5: env_nested_delimiter reads THUMBFORGE_PROVIDERS__ANTIGRAVITY__API_KEY as providers.antigravity.api_key, and extra="forbid" rejected it, so following ADR 0014's own convention made every command exit 2. Declaring the field would instead carry the secret inside the settings model, whose provider sections are dumped into provider config and snapshotted into provider_profile.params_json. Sections emptied by the removal are dropped as well: providers.fake = {} is still an extra input for a provider that has no settings section. ADR 0014 is Accepted and keeps its wording; the mechanism is recorded here and in PLAN.md §8.
ADR 0014 also says provider list shows auth as ok | missing | n/a; the implemented AuthState (P3.1) uses unknown rather than n/a, for the reason in §68 — "cannot cheaply prove" is a different statement from "not applicable". Antigravity needs no key: it authenticates through the CLI's own cached login.
Behaviour¶
generatewrites exactly one image intoworkdirand returns its path; the caller (Phase 6/7 services) moves it into theAssetStore. Providers never touch the DB.- A provider raising anything other than a
ProviderErrorsubclass is a bug; the contract test fails on it. -
provider check antigravityderives itsauthverdict fromagy models, not from a generation envelope:healthcheck()never generates, so it has no envelope to read. A clean non-zero exit from that call is the only cheap evidence the adapter can get that the CLI is not signed in, and it exits4withProviderAuthError. A call that times out is reported under a separatemodelscheck and is not an auth verdict — an unanswered call is no evidence either way. A missing binary fails thebinarycheck and exits4with the "install Antigravity CLI" hint.The
authentication requiredrow in the error table above belongs togenerate, which does have an envelope. Both end atProviderAuthError; they are simply reached by different commands. -
provider listshows plugins discovered via entry points alongside builtins; a duplicate key aborts withProviderRegistryError(exit4) naming both distributions. Corrected from1in P3.5:ProviderRegistryErroris aProviderError, and a key claimed twice is an expected, diagnosable installation state rather than the internal bug exit1denotes. Pinned bytest_duplicate_key_is_refused. provider set-key fakestores a value in keyring even though FakeProvider ignores it (used to test the path without a real provider).
Acceptance criteria¶
-
thumbforge provider listprints rows forfakeandantigravity, withauth=okfor fake,unknownfor an installed antigravity andmissingwhen its binary cannot be found.Corrected from "
ok|missing… depending onagylogin state". Proving the login state needs a network call (agy models, ~5 s), and §68 above already rules that "a provider that cannot cheaply prove its credentials must not be reported as unauthenticated".listis an inventory and stays cheap;checkis the diagnosis and resolves auth properly via itsauthcheck. Reportingmissingfromlistwould have told signed-in users to fix a login that works. -
thumbforge provider check fakeexits0;thumbforge provider check nopeexits3. thumbforge provider check antigravitynames the inherited plugin directory (ADR 0013 item 9) and resolvesauthfromagy models, the cheapest call that needs real credentials. Aagy modelsthat times out is reported under its ownmodelscheck, never asauth: an unanswered call is no evidence about credentials, and calling it an auth failure would tell a signed-in user to sign in. A failed check exits4after the full table is printed, so one failure never hides the others; a failed check namedauthis reported asprovider_authand any other asprovider_permanentbecause both exit4but call for different actions.thumbforge provider models antigravitylists slugs fromHealthReport.models;provider models fakeexits0saying the provider has no model selection. An unhealthy provider exits4rather than printing an empty list, because "no models" and "not signed in" are indistinguishable in output.thumbforge provider set-key KEYprompts without echo and writes only to the keyring; an unknown key exits3before prompting, a blank value exits4, and an unavailable keyring exits4naming the environment variable instead. Reading a key tolerates an unusable keyring; writing one does not.FakeProvider.generatewith the samepromptandseedtwice yields byte-identical PNGs; different seeds yield different background colours.- Prompt containing
[[FAIL_TRANSIENT]]raisesProviderTransientError;[[FAIL_PERMANENT]]raisesProviderPermanentError. - A test distribution registering
fake = tests.plugins:Dupunderthumbforge.providersmakesregistry.keys()raiseProviderRegistryError. - With an injected runner that returns
{"status":"SUCCESS", ...}but writes no file,AntigravityProvider.generateraisesProviderOutputMissingErrorwhose message names thebrain/<conversation_id>/directory it searched. S2 falsifiedscratch/: the image lands underbrain/<conversation_id>/. - With a stubbed
agythat sleeps pasttimeout_s + 30,generateraisesProviderTimeoutErrorand the child process is gone (psutil-free check:proc.returncode is not None). uv run pytest -m integration tests/contract/produces a JPEG inworkdirnamed<idempotency_key>.jpgfor theantigravitycase. There is no separate live test file:antigravityis a parametrised case of the contract suite markedintegration, which is stricter than a bespoke test because it holds the provider to the same contract asFakeProvider.
Test plan¶
- Unit: registry merging with monkeypatched
entry_points; FakeProvider determinism and failure markers; AntigravityProvider argv construction (exact list from step 2), envelope parsing for everystatus, error mapping table row by row using a stubagyexecutable written totmp_path. - Contract:
tests/contract/test_provider_contract.pyparametrised overregistry.keys(); per provider assertscapabilitiesis aProviderCapabilities,info()andhealthcheck()return the documented models,generatereturns aGenerationResultwhoseimage_pathexists and opens with Pillow at width/height consistent withcapabilities.supports_aspect_ratio, and that failures areProviderErrorsubclasses. Antigravity participates only under-m integration. - Integration (
-m integration): the contract suite itself is the live test.antigravityis a parametrised case markedintegration, souv run pytest -m integration tests/contract/drives real generations, and the fixture skips rather than fails whenagyis absent. A separate live test would duplicate it. Measured:5 passed, 3 skipped, 9 deselected in 63.12s— the 3 skips are the seed and reference cases the provider does not claim. - Golden: none.
Resolved spikes¶
S1–S8 ran on 2026-09-20 against agy 1.2.6 and are recorded with raw output in
docs/spikes/antigravity.md. P3.4 is unblocked. Outcomes that changed this spec:
- S1 — the tool is
generate_image(1 of 57 headless tools). ADR 0013 isAccepted; the "external-image-file" fallback provider is not needed. - S2 — output path is not controllable. The image lands in
~/.gemini/antigravity-cli/brain/<conversation_id>/, not--add-dirand notscratch/. The adapter globs that directory using the envelope'sconversation_id. - S3 — always JPEG;
"16:9 widescreen"reliably yields 1376×768 (5 of 5), and exact dimensions are never specifiable. Phase 5 therefore always upscales 1.40× to the 1920×1080 default and crops 1.792 → 1.778. - S4 — references are prose only: the agent
view_files the image and describes it, sosupports_reference_image = False. Palette transfer measured good, but there is no image-to-image conditioning. - S5 —
generate_imageneeds no permission grant, superseding decision D4:skip_permissionsdefaults tofalse. - S6 — a bad
--modelexits1with a cleanERRORenvelope, but--print-timeoutexpiry returns exit0andSUCCESSwith an empty response, which is why the timeout row precedes the missing-output row above. The unauthenticated path (S6a) remains unverified. - S7 — no throttling; two concurrent runs are safe and faster, so
max_concurrency = 2. Seven generations cost 1% of the five-hour quota. - S8 —
usageis tokens only, with no monetary field, soCostcarries tokens andduration_seconds.