Topic 11 of 15 · Backend & deployment

FastAPI for AI services.

Everything so far has run as a script on your machine, with your API keys, in your terminal. A real product needs this logic behind an HTTP endpoint an Android or web client can call safely — keys stay server-side, and the client just sends a request and gets a response (or a stream). This lesson wraps Topic 4's task extractor as a real service.

The mental model

The backend you'd build anyway, with an LLM call inside a handler.

Strip away the AI framing and this is exactly the backend work a Kotlin engineer building a Retrofit-consuming client already expects to exist somewhere: routes, request/response DTOs (Topic 4's Pydantic models again), dependency injection, and error handling. FastAPI is intentionally close to that mental model — path operations look like annotated interface methods, and Pydantic does the serialization Moshi or kotlinx.serialization would do on the Android side.

Section 0

Why Wrap It in an API

Script (Topics 1-10)Service (this topic)
API keys live in your local .envAPI keys live only on the server — never shipped inside an Android app or a web bundle
You run it manuallyRuns continuously, handles concurrent requests from many users
Output printed to a terminalOutput is JSON or a stream a mobile/web client parses
No auth, no rate limitingAuth, per-user rate limits, and cost controls sit in front of every LLM call
Never call an LLM provider directly from an Android app with an embedded API key — it's extractable from the APK and effectively a public key at that point. Every LLM call from a client app should go through a server you control.
Section 1

Minimal App Anatomy

pip install fastapi uvicorn[standard]
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health() -> dict:
    return {"status": "ok"}

# run with: uvicorn main:app --reload
# visit http://localhost:8000/docs for auto-generated interactive API docs
The /docs page is free — FastAPI generates interactive OpenAPI documentation from your route signatures and Pydantic models automatically. This is the closest thing Python has to Retrofit's compile-time-checked interface, just discovered at the boundary instead of compiled into the client.
Section 2

Request/Response Models

Exactly Topic 4's Pydantic models, now describing the HTTP contract instead of the LLM's output shape — often the same models, reused directly.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class ExtractRequest(BaseModel):
    text: str

class TaskResponse(BaseModel):
    title: str
    priority: str
    due_date: str | None
    confidence: float

@app.post("/extract-task", response_model=TaskResponse)
def extract(request: ExtractRequest) -> TaskResponse:
    task = extract_task(request.text)  # Topic 4's capstone function
    return TaskResponse(
        title=task.title, priority=task.priority.value,
        due_date=task.due_date, confidence=task.confidence,
    )
FastAPI validates the incoming request body against ExtractRequest automatically — a malformed request gets a 422 response before your function body ever runs, the same boundary-validation instinct as Topic 4's schema enforcement, now applied at the HTTP layer.
Section 3

Streaming Endpoints (SSE)

Topic 2 Section 3 streamed tokens into a terminal with print(). Streaming them to an HTTP client uses the same idea over a persistent response — a StreamingResponse that yields chunks as they arrive from the provider.

from fastapi.responses import StreamingResponse
from openai import OpenAI

client = OpenAI()

@app.post("/chat/stream")
def chat_stream(request: ExtractRequest):
    def token_generator():
        stream = client.chat.completions.create(
            model="gpt-4.1-mini",
            messages=[{"role": "user", "content": request.text}],
            stream=True,
        )
        for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                yield f"data: {delta}\n\n"  # the SSE wire format from Topic 2 Section 3

    return StreamingResponse(token_generator(), media_type="text/event-stream")
An Android client consumes this with an OkHttp EventSource listener or a Ktor SSE client — the same "read chunks as they arrive" pattern as Kotlin's Flow, just carried over HTTP instead of in-process.
Section 4

Async Endpoints

Topic 1 Section 7 flagged this: sync streaming is fine for a script, but a service handling concurrent requests needs the async client, or one slow LLM call blocks every other request on the same worker.

Blocks other requests
from openai import OpenAI
client = OpenAI()

@app.post("/extract-task")
def extract(request: ExtractRequest):
    task = extract_task(request.text)  # sync call — blocks this worker
    return task
Handles concurrency
from openai import AsyncOpenAI
client = AsyncOpenAI()

@app.post("/extract-task")
async def extract(request: ExtractRequest):
    task = await extract_task_async(request.text)  # frees the worker while waiting
    return task
A route defined with def instead of async def runs in FastAPI's thread pool, which still works but caps concurrency at the pool size. Use async def with the async client for anything that calls an LLM — it's the difference between a handful and thousands of concurrent in-flight requests on the same process.
Section 5

Dependency Injection

FastAPI's Depends() is conceptually close to Hilt/Dagger's constructor injection — declare what a route needs, and the framework provides it, rather than constructing it inline every time.

from fastapi import Depends
from functools import lru_cache

@lru_cache
def get_openai_client() -> AsyncOpenAI:
    return AsyncOpenAI()  # created once, reused across requests

@app.post("/extract-task")
async def extract(request: ExtractRequest,
                   client: AsyncOpenAI = Depends(get_openai_client)):
    ...
This matters beyond convenience: in tests, you override get_openai_client with a fake that returns canned responses, so your route tests don't make real API calls — the same seam Hilt gives you for swapping a fake repository in an Android test.
Section 6

Errors & Status Codes

Topic 2 Section 7's retry logic still lives inside the route — the new piece is translating internal failures into the right HTTP status, so the client can react correctly instead of treating every failure the same way.

from fastapi import HTTPException

@app.post("/extract-task", response_model=TaskResponse)
async def extract(request: ExtractRequest,
                   client: AsyncOpenAI = Depends(get_openai_client)):
    if not request.text.strip():
        raise HTTPException(status_code=400, detail="text must not be empty")
    try:
        task = await extract_task_async(request.text, client)
    except RateLimitError:
        raise HTTPException(status_code=503, detail="upstream provider is rate limiting us")
    except Exception:
        raise HTTPException(status_code=502, detail="extraction failed")
    return task
StatusMeaning here
400Bad request from the client — retrying the same request won't help (Topic 2 Section 7's 4xx rule)
502 / 503The upstream LLM provider failed or rate-limited — the client can retry with backoff
422Pydantic validation failure — handled automatically by FastAPI before your code runs
Section 7

CORS & Calling from Android

Android clients aren't affected by CORS — that's a browser-only restriction, so a Kotlin/Retrofit client just needs the URL and, typically, an auth header your server checks. A web client calling this same API from a browser does need CORS configured.

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourapp.com"],  # never "*" once real users hit this
    allow_methods=["POST"],
    allow_headers=["*"],
)
// Retrofit interface on the Android side — the client's view of Section 2's contract
interface AiService {
    @POST("extract-task")
    suspend fun extractTask(@Body request: ExtractRequest): TaskResponse
}

data class ExtractRequest(val text: String)
data class TaskResponse(val title: String, val priority: String,
                         val dueDate: String?, val confidence: Double)
Section 8 · Checkpoint

Capstone: A Real Task Extraction Service

Combine every section: async endpoint, DI, proper error handling, and a streaming variant, wrapping Topic 4's extractor.

from functools import lru_cache
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
from pydantic import BaseModel

app = FastAPI()

class ExtractRequest(BaseModel):
    text: str

class TaskResponse(BaseModel):
    title: str
    priority: str
    due_date: str | None
    confidence: float

@lru_cache
def get_client() -> AsyncOpenAI:
    return AsyncOpenAI()

@app.get("/health")
def health() -> dict:
    return {"status": "ok"}

@app.post("/extract-task", response_model=TaskResponse)
async def extract(request: ExtractRequest,
                   client: AsyncOpenAI = Depends(get_client)) -> TaskResponse:
    if not request.text.strip():
        raise HTTPException(status_code=400, detail="text must not be empty")
    try:
        completion = await client.beta.chat.completions.parse(
            model="gpt-4.1-mini",
            messages=[{"role": "user", "content": request.text}],
            response_format=Task,  # Topic 4's Pydantic model
            temperature=0,
        )
    except Exception:
        raise HTTPException(status_code=502, detail="extraction failed")

    task = completion.choices[0].message.parsed
    if task is None:
        raise HTTPException(status_code=502, detail="model returned no result")
    return TaskResponse(title=task.title, priority=task.priority.value,
                         due_date=task.due_date, confidence=task.confidence)

@app.post("/chat/stream")
async def chat_stream(request: ExtractRequest,
                       client: AsyncOpenAI = Depends(get_client)):
    async def token_generator():
        stream = await client.chat.completions.create(
            model="gpt-4.1-mini",
            messages=[{"role": "user", "content": request.text}],
            stream=True,
        )
        async for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                yield f"data: {delta}\n\n"

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

# run with: uvicorn main:app --reload
Expected behavior: GET /health returns {"status": "ok"}; POST /extract-task with {"text": "..."} returns a validated TaskResponse or a proper error status; POST /chat/stream streams text incrementally, visible with curl -N.
  • Ran uvicorn main:app --reload and opened /docs to see the auto-generated interface
  • Called /extract-task with an empty text and confirmed a 400, not a 500 or a hang
  • Called /chat/stream with curl -N http://localhost:8000/chat/stream -d '{"text":"count to 5"}' -H "Content-Type: application/json" and watched tokens arrive incrementally rather than all at once
  • Can explain out loud why get_client is wrapped in @lru_cache instead of constructing a new AsyncOpenAI() inside every request
  • Next up

    Topic 12: PostgreSQL + pgvector

    Give this service a real database — persist embeddings and run vector search at production scale.