Why Wrap It in an API
| Script (Topics 1-10) | Service (this topic) |
|---|---|
API keys live in your local .env | API keys live only on the server — never shipped inside an Android app or a web bundle |
| You run it manually | Runs continuously, handles concurrent requests from many users |
| Output printed to a terminal | Output is JSON or a stream a mobile/web client parses |
| No auth, no rate limiting | Auth, per-user rate limits, and cost controls sit in front of every LLM call |
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
/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.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,
)
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.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")
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.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.
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
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
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.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)):
...
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.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
| Status | Meaning here |
|---|---|
| 400 | Bad request from the client — retrying the same request won't help (Topic 2 Section 7's 4xx rule) |
| 502 / 503 | The upstream LLM provider failed or rate-limited — the client can retry with backoff |
| 422 | Pydantic validation failure — handled automatically by FastAPI before your code runs |
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)
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
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.uvicorn main:app --reload and opened /docs to see the auto-generated interface/extract-task with an empty text and confirmed a 400, not a 500 or a hang/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 onceget_client is wrapped in @lru_cache instead of constructing a new AsyncOpenAI() inside every request