chapter 03
Backend Fundamentals
HTTP, JSON and servers - the pipe every model call travels through.
1 / 8
When you "call LM Studio" or "call OpenAI," you are doing exactly one thing over and over: sending a request over a network to a backend, and getting a response back. This chapter demystifies that pipe, because Chapters 5–8 are just this pipe, repeated.
3.1 Client / Server - the whole model in one picture
CLIENT (your Python script) SERVER (LM Studio / OpenAI / anything) ┌───────────────────────┐ ┌───────────────────────────┐ │ │ HTTP request │ │ │ "please answer this │ ───────────────▶ │ runs the model / logic │ │ question for me" │ │ produces an answer │ │ │ ◀─────────────── │ │ │ receives the answer │ HTTP response │ │ └───────────────────────┘ └───────────────────────────┘
A "backend" is just: something listening on a network address, waiting for requests, doing work, sending back a response. LM Studio, when you turn on its local server in Chapter 5, IS a backend running on your own machine.
Who is the "client" and who is the "server," really?
These are roles, not fixed properties of a machine. Your laptop is the client when it asks OpenAI's servers for a completion. But that same laptop is the server in section 3.5 below, when you build a tiny FastAPI app on it and your own script (or a browser) sends it requests. "Client" means "the one who initiates the request." "Server" means "the one who is listening, waiting to be asked." Nothing more mystical than that.
Where does the address come from?
Every server needs an address to be reachable at: a combination of a host (which machine) and a port (which "door" on that machine, since one machine can run many servers at once).
http://localhost:1234/v1/chat/completions
└───┬────┘ └┬─┘ └──────┬──────────┘
host port path (which endpoint, on that server)localhost (or 127.0.0.1) is a special address meaning "this same computer" - traffic never
actually leaves your machine's network card. That's exactly why LM Studio's server, running
locally, never sends your prompts over the real internet: it's listening on localhost, and
your Python script is also running on that same machine, so the whole conversation stays local.
3.2 HTTP - the envelope every request travels in
Every HTTP request has: a method, a URL, headers, and (often) a body.
METHOD URL
POST http://localhost:1234/v1/chat/completions
┌─────────────────────────────────────────────┐
│ HEADERS │
│ Content-Type: application/json │
│ Authorization: Bearer sk-... │
├─────────────────────────────────────────────┤
│ BODY (the actual data, usually JSON) │
│ {"model": "...", "messages": [...]} │
└─────────────────────────────────────────────┘Think of an HTTP request like a physical letter: the URL is the address on the envelope, the method is a stamped instruction like "please reply" vs "please deliver this package," headers are metadata written on the outside of the envelope (what language it's in, who's allowed to open it), and the body is the letter itself, sealed inside.
The common methods you'll actually use:
| Method | Meaning | Example in this book |
|---|---|---|
GET | "give me data" | GET /v1/models - list available models |
POST | "here's data, do something with it" | POST /v1/chat/completions - send a prompt, get a reply |
PUT / PATCH | "update something that exists" | rare in this book, common in typical web APIs |
DELETE | "remove something" | rare in this book, common in typical web APIs |
A GET request typically has no body - everything it needs is in the URL itself (and maybe
headers), because it's just asking to read something, not send new data to be processed. A
POST almost always has a body, because you're handing the server a payload to act on.
Status codes tell you what happened:
| Code range | Meaning |
|---|---|
200–299 | Success |
400–499 | You made a mistake (bad request, missing auth) |
500–599 | The server broke |
A few specific codes worth recognizing on sight, because you will see them constantly while working with LLM APIs:
| Code | Meaning | When you'll see it in this book |
|---|---|---|
200 | OK | A normal successful completion |
401 | Unauthorized | Your API key is missing or wrong |
404 | Not found | Wrong URL - e.g. typo'd endpoint path |
429 | Too many requests | You hit a rate limit; back off and retry |
500 | Internal server error | The provider's server had a problem, not you |
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: treating every non-200 response as a Python exception.
requestsdoes not raise an error automatically just because the server said "400 Bad Request" - it hands you back a normalResponseobject either way. You have to checkresponse.status_codeyourself, or explicitly callresponse.raise_for_status()if you want it to throw. Beginners often write code that silently "succeeds" while actually receiving an error payload inresponse.json().
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
3.3 JSON - the shared language everything speaks
JSON (JavaScript Object Notation) is just text, formatted so that both a Python dict and a JavaScript object (and Java, and Rust, and everything else) can read/write it identically. It's the universal contract we described in Chapter 0.
Python dict JSON text (what actually travels Python dict
(in memory) over the network, as raw bytes) (in memory, elsewhere)
┌────────────────┐ ┌──────────────────────────┐ ┌────────────────┐
│ {"name":"Ada", │ json.dumps │ '{"name": "Ada", │ json.loads │ {"name":"Ada", │
│ "age": 36} │────────────▶│ "age": 36}' │──────────────▶│ "age": 36} │
└────────────────┘ └──────────────────────────┘ └────────────────┘
YOUR MACHINE THE WIRE / DISK THEIR MACHINEThe key insight: JSON is text. It doesn't know about Python dicts, or JavaScript objects, or
Java HashMaps - it's a lowest-common-denominator format that every language independently
knows how to translate into its own native data structure. That translation step has names in
every language:
| Language | "Dict/object → JSON text" | "JSON text → dict/object" |
|---|---|---|
| Python | json.dumps() | json.loads() |
| JavaScript | JSON.stringify() | JSON.parse() |
| Java | (library-dependent, e.g. Jackson's writeValueAsString) | readValue() |
JSON's data types, and how they map to Python
JSON only has six kinds of values, and every one of them maps onto something you already know in Python:
| JSON type | Python equivalent | Example |
|---|---|---|
| object | dict | {"name": "Ada"} → {"name": "Ada"} |
| array | list | [1, 2, 3] → [1, 2, 3] |
| string | str | "hello" → "hello" |
| number | int or float | 36 → 36, 3.14 → 3.14 |
| boolean | bool | true → True, false → False |
| null | None | null → None |
Notice JSON has no concept of a Python tuple, set, or custom class - if you try to
json.dumps() one of those directly, it'll either silently convert it (tuples become arrays) or
raise a TypeError (sets and custom objects, unless you teach it how). This matters the moment
you build your own agent's memory or tool-call structures in Chapter 6: stick to dicts, lists,
strings, numbers, booleans, and None for anything that needs to cross a JSON boundary.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
A slightly bigger example, showing nesting (objects inside arrays inside objects - exactly the shape you'll see in real LLM API responses):
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
This dumps/loads round trip is exactly what happens every time you send a prompt to an
LLM and get a reply back - your requests library and the openai SDK do this JSON
conversion for you automatically, but now you know what's happening underneath.
Common mistake: forgetting that
response.json()can itself throw if the server didn't actually send valid JSON back (for instance, an HTML error page from a proxy, or an empty body). If you ever seejson.decoder.JSONDecodeError, the fix is to firstprint(response.text)to see the raw text you actually got back, before assuming it was JSON at all.
3.4 Calling an API from Python
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
INPUT (Python dict) TRANSFORM OUTPUT (Python dict)
{"question": "..."} requests.post(url, json=...) response.json()
───────────────────────────▶ {"json": {"question": "..."}, ...}
(auto dict→JSON out,
auto JSON→dict back)This four-line pattern - build a dict, POST it, call .json() on the response - is the entire
skeleton of Chapter 5's first LM Studio call. Nothing new will actually be new; it'll be this,
with a different URL and a different-shaped dict.
The two ways to send data with requests.post, and why they're different
requests.post(url, json=...) is convenient shorthand. It's worth seeing what it's shorthand
for, because you'll eventually hit an API that wants form data instead of JSON:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
data= sends raw bytes/text as the body with no assumptions about format; json= is a
convenience wrapper that does the json.dumps plus header-setting for you. Every LLM HTTP API
you'll touch in this book (LM Studio, OpenAI, Anthropic) expects JSON bodies, so you'll almost
always use json=.
Sending headers, like an API key
Real APIs (unlike LM Studio's local, keyless server) require an Authorization header proving
who you are:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Bearer here just means "the token that follows proves I'm authorized" - it's a convention, not
magic. Chapter 5 shows you how to load that key from your .env file (from Chapter 2) instead
of ever typing it directly into your source code.
3.5 Building a tiny backend yourself (so "server" stops being a black box)
You don't need to build LM Studio's server - it's already built. But building one tiny one yourself for five minutes will make everything else in this book click.
pip install fastapi uvicorn
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
uvicorn server:app --reload --port 8000
Two separate things are happening in that command worth naming: FastAPI is the framework
that lets you describe endpoints (@app.post("/echo") and what function should run when that
URL is hit). Uvicorn is the actual program that opens a network port, listens for incoming
connections, and hands each request to FastAPI to figure out what to do with it. FastAPI without
Uvicorn (or something like it) is just Python code sitting there - Uvicorn is what makes it
reachable over a network at all.
Now, from another terminal (or your Python script):
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
You just ran a backend on your own machine, on localhost (meaning: "this same computer,
not the internet"), and talked to it over HTTP. This is precisely what LM Studio does -
except instead of @app.post("/echo") echoing your input, it has @app.post("/v1/chat/ completions") running an actual language model and returning its generated text. Same
pattern, bigger box.
YOUR TOY SERVER LM STUDIO'S SERVER (Chapter 5)
POST /echo POST /v1/chat/completions
{"hello": "world"} {"model": "...", "messages": [...]}
│ │
▼ ▼
returns input unchanged runs the LLM, returns generated textAdding a second endpoint
To really see this is "just a program that responds to different URLs," add another route to
server.py and restart:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
/health endpoints like this one are extremely common in real backends - they exist purely so
that monitoring tools (or you) can ask "are you alive?" without asking the server to do any real
work. LM Studio, OpenAI, and most production APIs all expose something similar.
Common mistake: forgetting
--reloadwhile iterating onserver.py, then editing the file and wondering why your changes don't take effect - Uvicorn is still running the old version of the code it loaded at startup.--reloadtells it to watch the file and restart automatically; without it you must stop (Ctrl+C) and rerun theuvicorncommand yourself after every edit.
3.6 Async - why LLM code always has async/await sprinkled in it
Calling a model can take several seconds. If your program just sits and blocks the whole time, it can't do anything else - no handling other users, no updating a progress bar, nothing.
SYNCHRONOUS (blocking) ASYNCHRONOUS (non-blocking) call model ──(waiting... 3 sec)──▶ done call model ──▶ (frees up thread to do other work) nothing else happens meanwhile ...3 sec later, result arrives, resumes
Picture a restaurant with one waiter. Synchronous service: the waiter takes your order, stands at the kitchen window doing nothing until your food is ready, then serves it, and only then goes to take the next table's order. Asynchronous service: the waiter takes your order, hands it to the kitchen, immediately goes to take other tables' orders while your food cooks, and comes back to deliver it the moment it's ready. Same waiter, same amount of total work, but the asynchronous waiter serves far more tables per hour because they never stand around idle.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
async def marks a function as one that can pause while waiting (at every await) instead
of blocking everything. await is the pause point itself - it means "give control back to
whoever's coordinating tasks until this particular thing finishes." You cannot use await
outside an async def function; and calling an async def function without await (or without
asyncio.run) doesn't actually run it - it just gives you back an unexecuted "coroutine"
object, which is one of the most common async bugs.
Why does this matter for a single script that only calls one model at a time?
It might not, if you truly only ever do one thing sequentially. Async pays off the moment you
need to do more than one slow thing concurrently - for example, calling three different models
at once and waiting for whichever answers first, or handling multiple users' requests to your
own backend simultaneously (exactly what uvicorn from 3.5 does under the hood). Here's the
concurrent case, which is where async actually earns its keep:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
You'll see this pattern again in Chapter 7 whenever an agent asks two different backends for their answers to compare, or fans a request out across multiple tools at once.
You'll see this heavily when we do streaming in Chapter 5 - where the model's reply arrives word-by-word instead of all at once, and your code needs to process each piece as it lands rather than waiting for the whole thing.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
yield (a generator) is Python's synchronous cousin of this idea - producing values one at
a time instead of building the whole list first. LLM streaming APIs use this exact shape.
Streaming and async solve two different problems that often show up together: streaming is
about "give me the output piece by piece as it's produced" (the shape of the data), while async
is about "let other work happen while I wait" (how time is spent). A real streaming LLM client
usually combines both: an async def function that yields each token as it arrives, often
written as async def stream_tokens(): ... yield token, called an async generator.
Common mistake: assuming
asyncmakes code faster by itself. It doesn't speed up any single slow operation - the network call to the model still takes exactly as long. What async buys you is the ability to do other things during that wait, not a shorter wait.
3.7 Recap - the pipe you'll use for the rest of the book
your Python dict → json.dumps (automatic) → HTTP POST → server does work
│
your Python dict ← json.loads (automatic) ← HTTP response ←────┘Every single API call you make in this book - to LM Studio, to OpenAI, to Anthropic, to your own FastAPI server - is a variation on this exact picture: build a dict, decide the method and URL, send it as JSON over HTTP, get JSON back, parse it into a dict, read the field you need out of it. The dict's shape changes chapter to chapter (Chapter 4 covers exactly what shape an LLM expects and returns), but the pipe itself never does.
Chapter 4 gives you the vocabulary for what's actually inside that dict when the server is an LLM. Chapter 5 puts it all together with a real, running local model.
3.8 Check yourself
- In the phrase "client/server," is the same machine always the client or always the server? Give an example from this chapter where the same laptop plays both roles at different moments.
- What's the difference between the host, the port, and the path in a URL like
http://localhost:8000/echo? - Your code calls
requests.post(...)and the server returns status code 401. Doesrequestsraise an exception on its own? What line of code would you add to make it raise one? - List the six JSON value types and their Python equivalents. Which common Python type has
no direct JSON equivalent, and what usually happens if you try to
json.dumps()it? - What's the actual job of Uvicorn versus the actual job of FastAPI, in the toy
/echoserver from 3.5? - Explain, using the restaurant-waiter analogy or your own, why
async/awaitdoesn't make a single slow network call finish any faster - and describe a scenario where it does reduce total wall-clock time. - What is the practical difference between a generator's
yieldand an LLM API's streaming response? Why do both show up together in real streaming client code?