
Building Neuravo: A Provider-Agnostic AI SDK on Top of AWS Bedrock's Converse API
A small Python toolkit that lets you write your AI chat code once, then switch between AWS Bedrock and OpenAI by changing a single word — no rewriting required.
Introducing Neuravo: One Python Interface for Every LLM Provider
If you've shipped more than one LLM-backed feature, you've probably hit this
wall: your Bedrock code and your OpenAI code don't look anything alike.
Different auth, different response shapes, different streaming mechanics,
different exceptions to catch. Swapping providers — or even just running
evals against two of them side by side — means touching application code
that has no business knowing which vendor answered the request.
Neuravo is a provider-agnostic Python SDK that fixes this by putting one
consistent interface — one
Client , one ChatResponse , one retry and
error-handling model — in front of whichever provider is actually doing the
work. This post walks through how it's built, what it gives you today, and
—unusually for an SDK's public docs— exactly what it doesn't do yet.The core idea: one contract, swappable backends
Everything starts from an abstract contract,
BaseProvider , that every
provider implementation has to satisfy:class BaseProvider(ABC):
@abstractmethod
async def initialize(self, config: Config) -> None: ...
@abstractmethod
async def chat(self, messages: List[Message]) -> ChatResponse: ...
@abstractmethod
async def stream_chat(self, messages: List[Message]) -> AsyncIterator[ChatResponse]: ...
@abstractmethod
async def health_check(self) -> HealthStatus: ...
@abstractmethod
async def close(self) -> None: ...Bedrock and OpenAI both implement this exact contract. The public-facing
Client doesn't know or care which one it's holding — it resolves a
provider by name through a ProviderRegistry and drives it through the
same five methods regardless of vendor:config = Config(provider="bedrock", region="us-east-1")
client = Client(config)
response = await client.chat("What is machine learning?")Switching vendors is a one-string change:
client = Client(Config(provider="openai", model="gpt-4o-mini"))Nothing else in the calling code changes — same
chat() , same
ChatResponse , same exceptions. That's the whole pitch of the library in
two lines of config.Providers register themselves — including yours
New providers don't get special-cased into the core.
neuravo/__init__.py
imports neuravo.providers.aws and neuravo.providers.openai purely for
their side effect: each module self-registers with ProviderRegistry on
import. providers.openai is a no-op if you haven't installed the
openai extra, so the dependency is genuinely optional:pip install neuravo # Bedrock only
pip install "neuravo[openai]" # + OpenAIThird-party providers plug into the exact same mechanism, but from outside
the package entirely —
neuravo.plugins discovers providers registered
under a neuravo.plugins entry point group in any installed package, no
fork of Neuravo required. Each plugin loads in isolation: one broken
third-party provider raising on import doesn't take the others down with
it. That's the kind of detail that only matters once someone else's badly
packaged plugin is sitting in your dependency tree — and it's already
handled.Retry logic lives in exactly one place
A subtle but important design choice: retry/backoff isn't duplicated per
provider.
Client.chat wraps every provider call in a shared
ExponentialBackoffRetry , configured straight from Config :retry = ExponentialBackoffRetry(
RetryConfig(
max_retries=self.config.max_retries,
backoff_factor=self.config.backoff_factor,
)
)
response = await retry.execute(self._provider.chat, self._history.get_all())Providers only need to implement the happy path plus honest failures —
they raise a normalized
ProviderError and let the client's retry policy
decide what to do about it. That's a meaningful chunk of complexity that
individual provider implementations, including any third-party plugin, get
for free.Config validation follows the same "fail fast, fail clearly" philosophy —
it's a frozen Pydantic model, so a
Config instance can't be mutated after
construction, and bad values ( max_retries outside 0-10 ,
backoff_factor outside 1.0-10.0 , an empty provider string) are rejected
at construction time with a clear ValueError , not three call stacks
deep during a live request.Streaming, the same way, everywhere
Streaming follows the identical pattern — an abstract
stream_chat on
BaseProvider , wrapped by Client.stream , which accumulates the full
response into conversation history as chunks arrive:async for chunk in client.stream("Tell me a story"):
print(chunk.content, end="", flush=True)One implementation detail worth calling out for anyone implementing a new
provider: the abstract
stream_chat / stream methods contain an
unreachable yield behind if False . It looks pointless until you
remember that Python decides whether a function is a generator at parse
time — without that yield, mypy infers a plain coroutine, and every real
subclass's AsyncIterator return type would mismatch the base class.
Small, easy to trip over, already solved.What building the Bedrock provider actually required
The clean five-method contract above hides three problems that only
surface once you try to satisfy it against a real vendor SDK — not
against a mock.
Wire formats don't agree. Bedrock's Converse API wants system
instructions pulled into their own
system field, separate from the
conversation turns:# ChatHandler.format_messages (Bedrock)
for message in messages:
if message.role == "system":
system_blocks.append({"text": message.content})
else:
turns.append({"role": message.role, "content": [{"text": message.content}]})
return turns, system_blocksOpenAI, by contrast, accepts
system as a normal message role inline —
its formatter's docstring says so directly: "Unlike Bedrock's Converse
API, OpenAI accepts system as a normal message role, so no splitting is
needed." Each provider owns exactly one function that knows its vendor's
wire format; nothing above that layer — Client , retry, conversation
history — ever branches on which one it's talking to.boto3 can't do async. The library Bedrock runs on is synchronous.
converse_stream 's event iterator performs a blocking network read on
every call — run it directly inside an async def and it stalls the
whole event loop, not just the current request, until AWS finishes
talking. StreamingHandler.handle_stream works around this by reading
events on a background thread and handing them to the async side through
a queue.Queue :def _consume() -> None:
for event in stream_response["stream"]: # blocking iterator
q.put(event)
q.put(_STREAM_DONE)
threading.Thread(target=_consume, daemon=True).start()
while True:
event = await loop.run_in_executor(None, q.get) # non-blocking wait
if event is _STREAM_DONE:
break
...The async generator that
Client.stream consumes never touches boto3
directly — only the queue.One Bedrock client isn't enough. The client used to send messages
(
bedrock-runtime ) has no method to list models or report service
health — list_foundation_models only exists on a second, separate
client ( bedrock ). Calling the wrong client for a health check fails in
a way that looks like a permissions problem rather than a missing method,
until you go looking. BedrockProvider.initialize creates both up front
rather than forcing one client to do both jobs:self.client = await asyncio.to_thread(boto3.client, "bedrock-runtime", **client_kwargs)
# Separate control-plane client (model listing, health checks) -
# "bedrock-runtime" only exposes converse/converse_stream/invoke_model.
self._control_client = await asyncio.to_thread(boto3.client, "bedrock", **client_kwargs)chat() / stream_chat() use self.client ; health_check() calls
self._control_client.list_foundation_models() . None of this is visible
from Client — it's entirely contained inside the one provider that has
to deal with it.Observability, cost, evaluation, and everything past the MVP
Past the core chat/streaming loop, Neuravo ships a full production
toolbox, all built on the same "one interface, works for any provider"
philosophy:
-
observability/— aTracerwith nested spans, aMetricsRegistry(counters, gauges, histograms), an in-processMonitor, and aCloudWatchExporterfor shipping metrics off-box.
-
cost/— a per-model pricing table plus aCostTrackerthat turns token usage into running-total USD cost, broken down per model.
-
evaluation/—PromptTemplate-drivenEvalCases run through anEvaluationRunnerand scored with pluggable scorers (exact_match,contains,regex_match).
-
prompts/— named, versioned prompt templates viaPromptRegistry.
-
benchmark/—run_benchmarkcomputes latency stats (mean, percentiles) over repeated calls against any client, any provider.
-
security/— aSecurityPolicycombining block-list prompt checks with output redaction; sensitive values (API keys, tokens, credentials) are also automatically stripped from log output.
-
workflows/— a sequentialStep/Workflowpipeline that threads a shared context through multi-step operations.
Every one of these is real, tested code on
main today, not a slide-deck
promise.The part most SDKs don't tell you: what's still a stub
This is the detail that actually made this codebase worth writing about.
Most project READMEs are aspirational — a feature list of what the
maintainers intend to ship, blurred together with what already works.
Neuravo's
docs/roadmap.md draws that line explicitly,
module by module:Module | Status |
|---|---|
cache/ | Cache.get / set / clear exist with the right signatures — every body is an unimplemented pass . No caching happens. |
retry/rate_limit.py | RateLimiter / RateLimitConfig have a token-bucket shape, but acquire() and _refill() are pass . Nothing is enforced. |
retry/timeout.py | TimeoutManager / TimeoutConfig exist; execute_with_timeout() is pass . (The retry/backoff that Client.chat actually uses is a separate, fully implemented path — this stub is specifically about enforced per-call timeouts.) |
agents/ , embeddings/ , memory/ | Empty packages — an __init__.py and nothing else. Reserved namespace, no committed design. |
If you're evaluating Neuravo for a use case that leans on caching, rate
limiting, enforced timeouts, agents, embeddings, or memory, the roadmap
tells you outright: assume none of it exists yet. That's a small thing for
a maintainer to write down and an easy thing to skip — most projects would
rather let an empty
agents/ folder imply momentum than admit it's just a
placeholder. Neuravo does the opposite, and it's a better signal of
engineering discipline than any feature list would be.Why the shape of this matters
The interesting engineering decision in Neuravo isn't any single module —
it's that the boundary between "core contract" and "everything else" is
drawn so cleanly that retry, streaming, observability, cost tracking, and
plugin loading all sit outside any individual provider. A new provider —
official or third-party — only has to implement five methods on
BaseProvider and it inherits retry policy, history management, and the
whole observability stack for free. That's the actual payoff of the
"provider-agnostic" pitch: not just that Bedrock and OpenAI look the same
from the outside, but that adding a third provider is cheap precisely
because so little logic lives inside providers to begin with.Try it
pip install neuravoimport asyncio
from neuravo import Client, Config
async def main():
client = Client(Config(provider="bedrock", region="us-east-1"))
try:
response = await client.chat("What is machine learning?")
print(response.content)
finally:
await client.close()
asyncio.run(main())More runnable examples live in
examples/ , and the
full module-by-module status lives in docs/roadmap.md .Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article