Skip to content

Providers (pluggable backends)

Every external backend keryx uses — image, video, voice and music generation, and rendering — sits behind a narrow Go interface, with the concrete implementation chosen from user config at construction. Swapping a backend, or adding a new one, is a config change plus an additive adapter package — never a call-site change.

The seams

Capability Interface Config key Adapters registered today
Image generation ImageProvider.Generate providers.image gemini
Video generation VideoProvider.Generate providers.video none
Voice / TTS VoiceProvider.Synthesize providers.voice elevenlabs
Music MusicProvider.Compose providers.music elevenlabs
Rendering Renderer.Render providers.render ffmpeg (default), afmpeg

The seam is genuine, but it is not yet a choice. Four of the five capabilities have exactly one implementation, and providers.video has none — its declared default of gemini names a constructor nobody registered, so resolving the video capability always fails. Video generation is a deferred feature, not a configuration you are missing.

Interfaces and the provider-neutral request/response types live in pkg/provider; mocks are generated into mocks/pkg/provider for tests.

Render backends

Two Renderer adapters ship, selected with providers.render:

providers.render How it renders
ffmpeg (default) shells out to a system ffmpeg binary
afmpeg FFmpeg compiled to WebAssembly (the ffmpeg-wasi engine), driven via the afmpeg library — the whole reel renders in memory, no system ffmpeg

The afmpeg backend needs an ffmpeg-wasi module. By default it needs no configuration — it downloads a pinned published module (the gpl variant, by URL + SHA-256), caches it under the OS user-cache dir, and reuses it thereafter. So providers.render = afmpeg just works.

Overriding the wasm module, and why config cannot do it

Three keys exist to point afmpeg somewhere else — a locally-built .wasm, a different release, or an air-gapped mirror:

Key Value
providers.render.module a host path to a .wasm, or an https:// URL (a .gz URL is decompressed on download)
providers.render.module_sha256 hex SHA-256 to verify a URL module against (the decompressed .wasm) — strongly recommended, it is executable code
providers.render.cache_dir override where a downloaded module is cached

None of them can be combined with providers.render: afmpeg. providers.render is a scalar naming the adapter and providers.render.module is a key inside a map; YAML rejects a key that is both, and splitting them across config layers does not help either — the higher-precedence layer supplies the shape and the other form reads as empty. Select afmpeg and the module keys resolve to nothing; set the module keys and no adapter is selected, so you get the default ffmpeg.

The working route for an air-gapped or custom module is the KERYX_FFMPEG_WASI environment variable, which the renderer reads directly as the fallback for providers.render.module. It takes a host path or an https:// URL.

lgpl vs gpl. ffmpeg-wasi ships two variants: gpl carries libx264 and can encode H.264 (what reels need — keryx pins the gpl module by default); lgpl decodes H.264 but cannot encode it. Choosing the gpl module accepts GPL terms for that artefact.

The reel logic (still-loop, xfade-concat, audio mix) stays in keryx; afmpeg/ffmpeg-wasi remain generic tools.

Render works over any filesystem. The render core reads inputs from and writes the mp4 into an afero.Fs — so a project that lives in memory (the studio's in-memory/RAM-worktree remotes) renders too, not just an on-disk checkout. afmpeg renders any fs natively; the shell-out ffmpeg (its binary needs real files) transparently materialises a non-OS fs to a temp dir and copies the result back. The studio's former "render is local-only" gate is gone.

Provider-neutral requests

Requests carry keryx's intent, not vendor payloads — a prompt + aspect for an image, the narration text + clone settings for voice, a Timeline of segments + an audio mix for the renderer. Each adapter maps that intent to its own API and back, so call sites never mention a vendor. Provider-specific identifiers (an ElevenLabs voice id, a Gemini model name) live in the theme / provider config the active adapter understands.

Config-driven construction

A per-capability Factory[T] resolves providers.<capability> to a registered constructor and builds it from that provider's config block (endpoint, model, credentials via keychain/env — never committed). A blank or unset value falls back to the default adapter; an unknown value is an error listing what's available.

// call site — never names a vendor
voice, err := provider.VoiceFactory.Resolve(cfg)
audio, err := voice.Synthesize(ctx, provider.VoiceRequest{Text: line, VoiceID: id})

Adding an adapter is purely additive — implement the interface and register a constructor from the adapter package's init():

func init() {
    provider.VoiceFactory.Register("openai", newOpenAIVoice)
}

…then providers.voice: openai selects it. No other code changes.

Provider config blocks also carry adapter settings. For Gemini, providers.gemini.model forces a specific image model id (else the adapter tries its built-in Imagen→Gemini fallback chain); a per-run --model on cover/portrait overrides it. The model is routed by id prefix — imagen* uses the Imagen :predict endpoint, others use :generateContent.

Where API keys come from

Generation API keys are read from the environment, never from config, so they are never committed:

Provider Env var
Gemini (image / cards / chat draft) GEMINI_API_KEY
ElevenLabs (voice + music) ELEVENLABS_API_TOKEN
Anthropic (chat) ANTHROPIC_API_KEY
OpenAI (chat) OPENAI_API_KEY

When providers.chat.provider is unset, keryx infers the chat provider from exactly one of the three chat keys being present. Two or more, and it refuses rather than guessing — which is what you hit the moment you want images from one vendor and chat from another. Set providers.chat.provider explicitly and the combination works; image and chat selection are independent keys.

Environment variables do not override config keys

There is no environment layer over keryx's configuration. It sets no env prefix, and its config store is built from files and changed flags only. PROVIDERS_IMAGE, STUDIO_TAKE_COUNT, KERYX_PROVIDERS_CHAT_PROVIDER and every other name derived from a config key are ignored.

The environment variables keryx does read are a fixed list that a specific piece of code looks up by name — the API keys above, the platform secrets, KERYX_FFMPEG_WASI, and a few CI variables. They are all on the environment variables page.

Where the config file is read from

Non-secret config (themes, provider choice, defaults) is read from files and deep-merged, project over user:

Layer File Scope
User ~/.keryx/config.yaml per-user, written by keryx init
User ~/.keryx/themes.yaml the style library, written by keryx theme add
User ~/.keryx/accounts.yaml credentials, written by keryx auth
Project .keryx.yaml at the repo root committed with the owning project; overrides the user files

The project .keryx.yaml is discovered by walking up from the working directory, so keryx run from anywhere inside the repo picks it up. This is what lets config live in the owning project — the blog brings its own themes and provider choices. Full precedence, highest first: changed flags → project .keryx.yamlaccounts.yamlthemes.yamlconfig.yaml → built-in defaults.

Testability

Because every backend is an interface, the deterministic core is unit-tested with no network, no ffmpeg, and no API keys — fakes (or the generated mocks) stand in. This is what keeps the timing maths, wrapping, theme/provider resolution, and the posting ledger testable in isolation.

Status: the seams, the registry, and the Gemini image / ElevenLabs voice+music / ffmpeg-render adapters are implemented and drive the working pipeline. Video generation is deferred to a later phase.