Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

Ship a Local AI Fallback

~22 min · ops, production, fallback

Level 0Downloader
0 XP0/41 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

The shape of this final lesson

Tie the quest together by shipping one real thing: a small backend service that answers user prompts using your cloud provider of choice, with Ollama as a transparent fallback when the cloud is unreachable. This is the smallest production-shape proof of the patterns from every prior track.

Production check-list

  1. Adapter pattern. Cloud and Ollama both implement stream().
  2. Health checks. Both have health_check() with a fast timeout.
  3. Orchestrator. Tries cloud first; falls back to Ollama on failure or degraded health. Priority is an argument, not a default — the adapters track builds the same class local-first, so a service that means cloud-first has to say so.
  4. Streaming. Frontend sees one continuous SSE stream regardless of which vessel answered.
  5. Pinned model tags. Both Ollama and cloud model versions are explicit.
  6. Observability. Log what answered each request — the skeleton below records the model tag, which is what the chunks carry; if you need the vessel key, have the orchestrator yield it — and read that log once before you trust it — a name field that is empty rather than absent will happily record nothing forever. You'll want this when you wonder later why some answers feel different.
  7. Mini mode. If your local model is small, the system prompt is stripped down.
  8. Warm-up. A startup task pre-loads the local model with keep_alive: -1 so first-fallback latency is acceptable — reading the tag off the adapter, so the warmed model and the called model cannot drift apart.

What you've built when this works

A service that's privacy-aware, cost-aware, and outage-aware. Cloud goes down → users keep getting answers. Cloud rate-limits you → users keep getting answers. Cloud gets expensive → bulk batch jobs route local. The orchestrator is dumb and the adapters are narrow; that's why it's robust.

Code

End-to-end skeleton (FastAPI + orchestrator)·python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json

# Cloud first, local as the outage net. The default priority in the
# adapters track is the other way round, so say which one you mean here.
ORCHESTRATOR = AIOrchestrator(priority=["claude", "codex", "gemini", "ollama"])

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Pre-load the local fallback so the first cold fallback isn't a 30s wait.
    # Read the tag off the adapter instead of repeating it: a warm-up that
    # names its own model warms the wrong one the moment the adapter changes.
    local = ORCHESTRATOR.vessels["ollama"]
    try:
        await local.client.post(
            f"{local.base_url}/api/generate",
            json={"model": local.model, "keep_alive": -1},   # the number, not "-1"
            timeout=120.0,
        )
    except Exception:
        pass  # ok if local isn't installed
    yield

app = FastAPI(lifespan=lifespan)

@app.post("/chat/stream")
async def chat_stream(req: dict):
    """Stream answers to the user, with cloud→local fallback."""
    answered_by = {"model": None}

    async def gen():
        async for chunk in ORCHESTRATOR.stream(req["messages"]):
            # Record the first model tag we actually see. Guarding on
            # `is None` alone would latch the empty-string default from the
            # first chunk and log an empty value forever. Note what this
            # gives you: the model tag, not the vessel key — if you need the
            # key, have the orchestrator yield it.
            if not answered_by["model"] and chunk.model:
                answered_by["model"] = chunk.model
            payload = {"content": chunk.content, "done": chunk.done}
            yield f"data: {json.dumps(payload)}\n\n"
        # log which vessel answered (for ops)
        print(f"[/chat/stream] model={answered_by['model']}")

    return StreamingResponse(gen(), media_type="text/event-stream")

# Run with: uvicorn main:app --host 0.0.0.0 --port 9000

External links

Exercise

Build the FastAPI service. Run it. Test 4 scenarios: (1) cloud reachable, (2) cloud blocked at firewall, (3) cloud reachable but slow, (4) Ollama daemon stopped. Confirm the user-visible behavior is graceful in all four. Write down what 'graceful' means in the failure modes — that's your real ops contract.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.