Optional-by-design adapters
Every major backend in Bigwave is an ABC (Python Protocol / abstract base class) with multiple in-tree adapters. The operator chooses which adapters run via ./install.sh flags or env vars — never via a refactor.
The vocabulary
| Axis | ABC | Adapters shipped today | Coming |
|---|---|---|---|
| Serving engine | ServingEngine | ollama, vllm, llama_cpp, sglang, tgi | — |
| Gateway | Gateway | firstparty (FastAPI in-process) | litellm |
| Storage | StorageBackend | sqlite, postgres | — |
| Vector store | VectorStore | lancedb, pgvector, qdrant, milvus | — |
| Training | TrainingBackend | unsloth, axolotl, llama_factory, raw_peft_trl | — |
| Eval scorer | EvalScorer | exact_match, trajectory_match, hallucination_probe, jailbreak_resistance, llm_judge | lm-eval-harness, per-tool-call scoring |
| Eval suite | EvalSuite | golden_set | lm-eval-harness |
| Job runner | JobRunner | noop, arq | ray |
| Auth | AuthAdapter | bearer, proxy_headers | oauth (SSO/SAML) |
| Channel | ChannelAdapter | web, telegram, slack | discord |
| Guardrail | GuardrailProvider | noop, dsl, llama_guard | — |
| Observability | ObservabilityBackend | stdout, phoenix | langfuse |
What "optional-by-design" means in practice
Three properties:
- The contract is the ABC, not any specific adapter. Code in
packages/gateway/that needs to retrieve chunks callsvector_store.search()— notlancedb.search(). Switching from LanceDB to pgvector is a config change, not an import change. - All adapters satisfy a conformance harness. Each ABC has an
assert_<axis>_conforms()test. New adapters must pass it before they ship. - The operator picks at install time.
./install.sh --vector=qdrantswaps the implementation. The product code is identical regardless.
What "optional-by-design" deliberately doesn't promise
Bigwave does not migrate users between adapters. Switching from SQLite to Postgres means re-installing on Postgres and starting fresh. There's no in-tree migration tooling. Cross-adapter data portability is operator-driven via standard formats (HuggingFace datasets, OpenAI message format, OTel traces) — not platform- driven via Bigwave migration scripts.
Why: maintaining bidirectional migration tooling for every adapter-pair multiplies the maintenance surface. Operators who upgrade a backend usually have a one-time migration script in their own infrastructure; Bigwave doesn't try to be that.
How to add an adapter
The pattern, using the ServingEngine ABC as a worked example:
1. Implement the ABC
# packages/serving/adapters/my_engine.py
from packages.serving.base import ServingEngine
from packages.serving.types import (
Capabilities, ChatChunk, ChatRequest, EmbedRequest, EmbedResponse,
HealthReport, ModelConfig, ReplicaHandle, ...
)
class MyEngine(ServingEngine):
name = "my_engine"
capabilities = Capabilities(streaming=True, embeddings=True)
async def load(self, config: ModelConfig) -> ReplicaHandle: ...
async def unload(self, handle: ReplicaHandle) -> None: ...
def chat(self, handle, request: ChatRequest): ... # AsyncIterator[ChatChunk]
async def embed(self, handle, request: EmbedRequest) -> EmbedResponse: ...
async def health(self, handle: ReplicaHandle) -> HealthReport: ...
async def attach_adapter(self, handle, adapter) -> None: ...
async def detach_adapter(self, handle, adapter) -> None: ...2. Pass the conformance harness
# tests/serving/test_my_engine_conforms.py
from packages.serving.conformance import assert_serving_engine_conforms
from packages.serving.adapters.my_engine import MyEngine
@pytest.mark.asyncio
async def test_my_engine_conforms():
await assert_serving_engine_conforms(MyEngine())The harness drives a synthetic load → chat round-trip → adapter attach → swap → unload sequence; if your adapter passes, it satisfies every contract the rest of the codebase relies on.
3. Wire into the lifespan
apps/api/lifespan.py::_build_engine dispatches on settings.serving_engine. Add your adapter:
def _build_engine(settings: Settings) -> ServingEngine:
if settings.serving_engine == "ollama":
return OllamaServingEngine(...)
if settings.serving_engine == "my_engine":
return MyEngine(...)
raise ValueError(f"Unknown engine: {settings.serving_engine}")4. Update the installer
./install.sh should accept --serving=my_engine. Add the flag to the installer's profile-resolution table. Document the new option in docs/deploy/env-vars.md.
5. Update licence-check (if your adapter pulls a new dep)
docs/licenses.md lists every runtime-path dependency and its licence classification. Add yours; the license-check CI step (scripts/license-check.py) will fail the build if your adapter adds an AGPL dep without an ADR exception.
Anti-patterns we deliberately avoid
- Soft routing across adapters. No "transparent fallback from Ollama to vLLM if Ollama is down." That's
ServingEngine's job through different deployments, not Bigwave's job through cross-engine routing. - Adapter capability negotiation that hides differences. If one adapter supports streaming and another doesn't, that's a property of the ABC contract — the gateway shouldn't paper over it with adapter-specific shims.
- One-way doors in adapter selection. Choosing an adapter at install time should always be reversible at the next install, even if it requires an export/import.
The license-check non-negotiable
A separate but related discipline: every runtime-path dependency goes through the GPL/AGPL gate. Permissive licences (MIT, Apache- 2.0, BSD, MPL-2.0) pass; LGPL needs an ADR with linkage analysis; GPL/AGPL fail.
The check runs in CI on every PR. New adapter PRs that introduce GPL/AGPL deps don't merge.
Reading order
- The loop — why the optional-by-design posture matters for the loop staying coherent across backends.
- Architecture — where each adapter slots into the request lifecycle.
docs/adr/in the repo — the architectural decisions per axis.