Your First LLM Call in Python
9 min read · updated August 4, 2026
A first model call in Python is one HTTP POST with a JSON body, an Authorization header, and a response you read one field out of. Everything else in this page exists so that the second call, and the ten-thousandth, still work.
An empty directory to a working environment
- Make the project and a virtual environment. Python 3.10 or newer;
python3on macOS and Linux,py -3on Windows. - Activate it. The prompt changes to show the environment name, which is the only confirmation you get.
- Install one dependency.
httpxspeaks HTTP/1.1 and HTTP/2, has a synchronous and an asynchronous client with the same method names, and streams responses without extra machinery. - Freeze what you installed, so the next machine gets the same thing.
mkdir llm-first && cd llm-first python3 -m venv .venv # Windows: py -3 -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install httpx python-dotenv pip freeze > requirements.txt echo ".venv/" >> .gitignore echo ".env" >> .gitignore # do this BEFORE you create .env
The last two lines are the load-bearing ones and they are the two every first tutorial leaves out. Write the ignore rules before the secret exists, because a .env that has already been committed once is in the history for ever and the key has to be rotated, not deleted.
uv instead of pip, the equivalent is uv venv then uv pip install httpx python-dotenv. Nothing else on this page changes.The request, at the HTTP level
Almost every hosted model today is reachable through an OpenAI-compatible chat completions endpoint. Knowing its shape is worth more than knowing any one SDK, because the shape is what the SDKs are wrapping and it is what you will see in a proxy log when something is wrong.
POST {BASE_URL}/chat/completions
Authorization: Bearer {API_KEY}
Content-Type: application/json
{
"model": "openai/gpt-4o-mini",
"messages": [
{"role": "system", "content": "You answer in one sentence."},
{"role": "user", "content": "Why is the sky blue?"}
],
"temperature": 0.2,
"max_tokens": 200
}The response body you care about is three fields deep:
{
"id": "chatcmpl-...",
"model": "openai/gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Because ..."},
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 24, "completion_tokens": 31, "total_tokens": 55}
}| Field | Description |
|---|---|
| choices[0].message.content | The text. This is the only field most code reads, and reading it without checking finish_reason is the first bug on this page. |
| choices[0].finish_reason | Why generation stopped. stop means the model finished; length means it hit max_tokens and the answer is cut off mid-sentence. See truncated output. |
| usage | Token counts for the request. Log them from day one; this is the only place the cost of a call is knowable without re-tokenising the prompt yourself. |
Where the key goes instead of the file
The key belongs in the process environment, and the environment gets populated from a file that git has never seen. That is two rules, and the second one is why python-dotenv is installed above.
# .env — already in .gitignore, never committed LLM_BASE_URL=https://api.multigrid.ai/v1 LLM_API_KEY=sk-... LLM_MODEL=openai/gpt-4o-mini
Read it with os.environ["LLM_API_KEY"], not os.environ.get("LLM_API_KEY"). The subscript form raises KeyError at import time with the name of the missing variable in the message; the .get form returns None, sends Authorization: Bearer None, and gives you a 401 to debug instead of a one-line answer. Fail on the missing config, not on the consequence.
For anything beyond a scratch project, a .env file on disk is still a secret in plaintext. Secrets in a Python AI project covers the keyring and process-injection options, and API key security covers what an exposed key actually costs you.
The whole script
# main.py
import os
import sys
import httpx
from dotenv import load_dotenv
load_dotenv() # reads .env into os.environ; does not overwrite real env vars
BASE_URL = os.environ["LLM_BASE_URL"].rstrip("/")
API_KEY = os.environ["LLM_API_KEY"]
MODEL = os.environ.get("LLM_MODEL", "openai/gpt-4o-mini")
def ask(question: str, *, max_tokens: int = 400) -> str:
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": "Answer in at most three sentences."},
{"role": "user", "content": question},
],
"temperature": 0.2,
"max_tokens": max_tokens,
}
with httpx.Client(timeout=httpx.Timeout(connect=5.0, read=60.0,
write=10.0, pool=5.0)) as client:
response = client.post(
f"{BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json=payload,
)
response.raise_for_status()
body = response.json()
choice = body["choices"][0]
if choice.get("finish_reason") == "length":
print("warning: answer truncated at max_tokens", file=sys.stderr)
usage = body.get("usage", {})
print(
f"[in {usage.get('prompt_tokens')} / out {usage.get('completion_tokens')} tokens]",
file=sys.stderr,
)
return choice["message"]["content"]
if __name__ == "__main__":
print(ask(" ".join(sys.argv[1:]) or "Why is the sky blue?"))$ python main.py "What does an ORM actually do?" [in 29 / out 64 tokens] An ORM maps rows in a relational database to objects in your program ...
Two details in that function are not decoration. The timeout is four numbers rather than one, because connecting and reading fail for unrelated reasons and want unrelated budgets — five seconds to establish a connection is generous, five seconds to read a long answer is a guaranteed failure. And the diagnostics go to stderr so that python main.py ... > out.txt still writes only the answer.
The same call, streamed
Add "stream": true and the response stops being one JSON document and becomes a sequence of server-sent events: lines beginning data: , each carrying a fragment, terminated by the literal data: [DONE].
import json
def ask_streaming(question: str) -> str:
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": question}],
"stream": True,
}
parts: list[str] = []
with httpx.Client(timeout=httpx.Timeout(connect=5.0, read=60.0,
write=10.0, pool=5.0)) as client:
with client.stream(
"POST",
f"{BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json=payload,
) as response:
response.raise_for_status()
for line in response.iter_lines():
line = line.strip()
if not line or not line.startswith("data:"):
continue # comments and keep-alives
data = line[len("data:"):].strip()
if data == "[DONE]":
break
delta = json.loads(data)["choices"][0].get("delta", {})
chunk = delta.get("content")
if chunk:
print(chunk, end="", flush=True)
parts.append(chunk)
print()
return "".join(parts)flush=True is the whole point. Python buffers stdout when it is not a terminal, so without it a piped or redirected stream arrives in one lump at the end and the streaming was for nothing. Streaming a response without blocking your app takes this further — turning it into a generator, handling the delta fields other than content, and pushing the bytes to a browser.
The four things that go wrong first
- 401 with a key you can see is correct. Nearly always a stray character: a trailing newline from a copy-paste, or quotes around the value in
.envthat dotenv strips but a shellexportdoes not. Printrepr(API_KEY[-4:])— if it shows'...\n'you have found it. - 404 on the endpoint. The base URL already ends in
/v1and the code appends/v1/chat/completions, giving/v1/v1/.... The.rstrip("/")above fixes the other half of this, the double slash. - The answer stops mid-sentence. That is
finish_reason == "length", not a model fault. Raisemax_tokens— and note that it caps the output only, which is a different thing from the context window. - A 429, immediately, on the second script you write. Rate limits are per-key, so a loop over a hundred rows hits them in seconds. That needs a retry policy — retrying model calls with tenacity — and ideally a limiter on your own side, in rate limiting yourself before they do.
At this point you have a script that calls one model. The reason to keep it at the HTTP layer is that switching models is now editing one string, and switching providers is editing one URL — the code above has no vendor in it anywhere except an environment variable.
What this script is not ready for
Being explicit about the gap is more useful than a longer script. Forty lines is the right size for a first call and the wrong size for anything that runs unattended. Five things are missing, and each has a recipe of its own in this cluster.
| Missing | Description |
|---|---|
| Any retry at all | One 503 and the script dies. Roughly ten lines fixes it, but only if you decide which failures deserve a second attempt — retrying model calls. |
| A limit on your own rate | Wrap this in a loop over a CSV and you will be rate-limited within seconds. See rate limiting yourself. |
| A record of what it did | The token counts go to the terminal and vanish. Nothing can answer “what did this cost” a week later — logging every model call. |
| A client that is reused | httpx.Client is created and destroyed per call, so every request pays a fresh TCP and TLS handshake. Fine once; wasteful in a loop, and the fix is to build one client and pass it in. |
| Any validation of the answer | The content is returned as a string, whatever it is. The moment you want JSON out of it, parsing model output safely is the next page. |
Two things it deliberately does not need. There is no SDK, so there is no SDK version to track and no method name that can be renamed under you. And there is no vendor-specific logic, which means the same script points at a different provider by changing one environment variable — worth keeping true as the code grows, because it is the property that makes trying a second model a five-second experiment rather than a refactor.