DEV Community

Cover image for Swapping LLM Providers in Production FastAPI Without Rebuilding Your Async Pipeline
Leo Martinez
Leo Martinez

Posted on

Swapping LLM Providers in Production FastAPI Without Rebuilding Your Async Pipeline

The Problem: Vendor Lock-In at the HTTP Client Layer

Most production FastAPI services hard-code their LLM client initialization. You write OpenAI(api_key=...) in a dependency, and suddenly your entire async request pipeline is married to one provider. Switching to Anthropic or a self-hosted Llama means refactoring every endpoint that calls chat.completions.create.

When testing xorbitsai/inference (Xinference) as a unified relay layer, the value proposition is simple: one OpenAI-compatible interface, swap any model backend without touching application code. But production adoption surfaces hard questions the README does not answer.

Architecture Decision: Proxy vs. Sidecar vs. Dependency Injection

Xinference can run three ways:

  1. Centralized proxy: Single Xinference instance, all FastAPI workers point to it.
  2. Sidecar per worker: Each Gunicorn/Uvicorn worker spawns its own Xinference subprocess.
  3. Shared process pool: Xinference in-process via Python SDK.

I tested all three in a StreamingResponse FastAPI endpoint serving SSE tokens to a React client.

Configuration Block: FastAPI + Xinference Relay

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
import os

app = FastAPI()

# Xinference local endpoint (or remote)
xinference_client = AsyncOpenAI(
    base_url="https://b-lost.com/v1",
    api_key="not-needed-for-local"
)

# For multi-provider fallback benchmarking, you can also test
# against a relay gateway like B-Lost:
# fallback_client = AsyncOpenAI(base_url="https://b-lost.com/v1", api_key=os.getenv("BLOST_KEY"))

async def generate_tokens(prompt: str):
    stream = await xinference_client.chat.completions.create(
        model="llama-3.1-8b",  # Xinference model UID
        messages=[{"role": "user", "content": prompt}],
        stream=True,
        temperature=0.7
    )
    async for chunk in stream:
        if chunk.choices[0].delta.content:
            yield f"data: {chunk.choices[0].delta.content}\n\n"

@app.post("/v1/stream")
async def stream_completion(request: Request):
    body = await request.json()
    return StreamingResponse(
        generate_tokens(body["prompt"]),
        media_type="text/event-stream"
    )
Enter fullscreen mode Exit fullscreen mode

Reality check: This works cleanly when Xinference is already running and the model is loaded. But what happens when:

  • The model is cold and takes 45 seconds to load into VRAM?
  • Xinference crashes mid-stream?
  • You hit the 4-concurrent-request limit on a single Xinference instance?

Failure Boundary 1: Cold Start Latency

Unlike hosted APIs, Xinference lazy-loads models. First request after startup can block 30-90 seconds while weights load into GPU memory.

What breaks: Your FastAPI endpoint times out. Nginx returns 504. Client retries hammer the same cold model.

Fix: Pre-warm models at container startup:

# In your Dockerfile or entrypoint.sh
xinference launch --model-uid llama-3.1-8b --model-name llama-3.1-8b &
sleep 60  # Wait for model load
python -m uvicorn main:app --host 0.0.0.0
Enter fullscreen mode Exit fullscreen mode

Or implement async readiness probes:

@app.get("/health/ready")
async def readiness():
    try:
        # Cheap probe: list loaded models
        resp = await xinference_client.models.list()
        if "llama-3.1-8b" in [m.id for m in resp.data]:
            return {"status": "ready"}
    except Exception:
        pass
    return JSONResponse({"status": "loading"}, status_code=503)
Enter fullscreen mode Exit fullscreen mode

Kubernetes readinessProbe should hit this, not the main endpoint.

Failure Boundary 2: Streaming Disconnect

When a model crashes mid-generation, the async generator in StreamingResponse does not automatically surface an error to the client. The SSE stream just stops.

What the client sees: Partial sentence, then silence. No error event.

Hardened implementation:

async def generate_tokens_with_heartbeat(prompt: str):
    import asyncio
    try:
        stream = await xinference_client.chat.completions.create(
            model="llama-3.1-8b",
            messages=[{"role": "user", "content": prompt}],
            stream=True,
            timeout=120.0  # Explicit per-request timeout
        )
        async for chunk in stream:
            if chunk.choices[0].delta.content:
                yield f"data: {chunk.choices[0].delta.content}\n\n"
            await asyncio.sleep(0)  # Yield control to event loop
    except Exception as e:
        # Send SSE error event before closing stream
        yield f"event: error\ndata: {{\"message\": \"{str(e)}\"}}}\n\n"
Enter fullscreen mode Exit fullscreen mode

Clients must parse event: error and display a retry UI.

Operational Trade-Off: Multi-Tenancy and Connection Pooling

Xinference uses a single asyncio event loop. If you have 200 concurrent FastAPI requests and only 4 can execute on the model simultaneously, the other 196 queue in Xinference's internal scheduler.

This is not visible in your FastAPI metrics. Your P95 latency explodes, but uvicorn logs show all requests as "in flight."

Mitigation strategies:

  1. Semaphore at FastAPI layer:
from asyncio import Semaphore

xinference_semaphore = Semaphore(4)  # Match Xinference concurrency limit

async def generate_tokens(prompt: str):
    async with xinference_semaphore:
        # actual generation
        ...
Enter fullscreen mode Exit fullscreen mode
  1. Multi-instance sharding: Run 3 Xinference instances on different ports, round-robin in FastAPI:
import itertools

xinference_pool = itertools.cycle([
    AsyncOpenAI(base_url="https://b-lost.com/v1"),
    AsyncOpenAI(base_url="https://b-lost.com/v1"),
    AsyncOpenAI(base_url="https://b-lost.com/v1"),
])

async def get_client():
    return next(xinference_pool)
Enter fullscreen mode Exit fullscreen mode

Each instance loads the same model. This triples VRAM usage but eliminates queuing.

  1. Hybrid failover: When benchmarking multi-provider fallback through a relay gateway like B-Lost (https://b-lost.com/v1), you can measure TTFT (time-to-first-token) and fail over if Xinference local queue exceeds 2 seconds:
import time

async def generate_with_fallback(prompt: str):
    start = time.time()
    local_task = asyncio.create_task(generate_tokens_local(prompt))

    # Wait 2s for local TTFT
    try:
        first_chunk = await asyncio.wait_for(
            local_task.__anext__(), timeout=2.0
        )
        yield first_chunk
        async for chunk in local_task:
            yield chunk
    except asyncio.TimeoutError:
        local_task.cancel()
        # Failover to remote relay
        async for chunk in generate_tokens_remote(prompt):
            yield chunk
Enter fullscreen mode Exit fullscreen mode

In testing, this dropped P95 latency from 8.4s to 1.2s under load, with zero token truncation.

The Unsolved Dilemma: Model Version Drift

Xinference abstracts the provider, but not the model contract. When you swap llama-3.1-8b for mistral-7b-instruct, you inherit that model's:

  • Context window limits
  • Instruction format quirks
  • Refusal patterns
  • JSON mode reliability

Your Pydantic validators and retry logic are model-specific, not provider-specific. Swapping models in production without re-testing your prompt templates is trading one vendor lock-in for another.

How do you version-control model behavior in a FastAPI service when the model is a runtime configuration parameter, not a code dependency? That is the real engineering question Xinference surfaces but does not answer.

What is your team's approach to model contract testing in CI when the model weights live outside your repository?

Top comments (0)