Skip to content

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 ​

AxisABCAdapters shipped todayComing
Serving engineServingEngineollama, vllm, llama_cpp, sglang, tgi—
GatewayGatewayfirstparty (FastAPI in-process)litellm
StorageStorageBackendsqlite, postgres—
Vector storeVectorStorelancedb, pgvector, qdrant, milvus—
TrainingTrainingBackendunsloth, axolotl, llama_factory, raw_peft_trl—
Eval scorerEvalScorerexact_match, trajectory_match, hallucination_probe, jailbreak_resistance, llm_judgelm-eval-harness, per-tool-call scoring
Eval suiteEvalSuitegolden_setlm-eval-harness
Job runnerJobRunnernoop, arqray
AuthAuthAdapterbearer, proxy_headersoauth (SSO/SAML)
ChannelChannelAdapterweb, telegram, slackdiscord
GuardrailGuardrailProvidernoop, dsl, llama_guard—
ObservabilityObservabilityBackendstdout, phoenixlangfuse

What "optional-by-design" means in practice ​

Three properties:

  1. The contract is the ABC, not any specific adapter. Code in packages/gateway/ that needs to retrieve chunks calls vector_store.search() — not lancedb.search(). Switching from LanceDB to pgvector is a config change, not an import change.
  2. All adapters satisfy a conformance harness. Each ABC has an assert_<axis>_conforms() test. New adapters must pass it before they ship.
  3. The operator picks at install time. ./install.sh --vector=qdrant swaps 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 ​

python
# 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 ​

python
# 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:

python
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.

Dual-licensed: MIT OR Apache-2.0