%pip install -q openaiChapter 1 - Notebook
Code-along notebook. Fill in the TODOs as we go.
Setup
- Go to openrouter.ai and sign up (free).
- Create an API key.
import os
from openai import OpenAI
api_key = "YOUR_OPENROUTER_API_KEY_HERE" # Replace with your actual key
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=api_key,
)
def ask(model: str, prompt: str, temperature: float = 0.7, max_tokens: int = 300) -> None:
"""Stream a single-turn prompt. Prints tokens as they arrive"""
stream = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
max_tokens=max_tokens,
extra_body={
"reasoning": {
"effort": "low",
"exclude": False
}
},
stream=True,
)
thinking_started = False
for event in stream:
choice = event.choices[0]
reasoning = getattr(choice.delta, "reasoning_content", None) or getattr(choice.delta, "reasoning", None) or ""
if reasoning:
if not thinking_started:
print("💭 Thinking: ", end="", flush=True)
thinking_started = True
print(reasoning, end="", flush=True)
delta = choice.delta.content or ""
if delta:
if thinking_started:
print("\n\n📝 Answer: ", end="", flush=True)
thinking_started = False
print(delta, end="", flush=True)
print()
# Some free models on OpenRouter (check the site for the current list):
FREE_MODELS = [
"nvidia/nemotron-3-super-120b-a12b:free",
"minimax/minimax-m2.5:free",
"stepfun/step-3.5-flash:free"
]
PAID_MODELS = [
"openai/gpt-oss-20b",
"openai/gpt-4o-mini", # supports temperature, will be sunset by openai soon
"openai/gpt-5.4", # doesn't support temperature
"openai/gpt-5.4-mini", # doesn't support temperature
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
"qwen/qwen3-235b-a22b-2507",
]
print("Client ready.")Exercise 1: Hello, LLM
Call a model. Print the response. Notice the latency.
# TODO: call `ask` with any free model and a prompt of your choice
model = FREE_MODELS[0]
print("Model is ", model)
ask(model, "Explain what a language model is in two sentences.")# TODO: call `ask` with any paid model and a prompt of your choice
model = PAID_MODELS[0]
print("Model is ", model)
ask(model, "Explain what a language model is in two sentences.")Exercise 2: Same Prompt, Different Models
Predict first: which model will give the clearest answer? The most verbose? The most wrong?
Then run and compare.
prompt = " احكيلي قصة قصيرة عن مصر لا تزيد عن خمس جملات"
# TODO: loop over PAID_MODELS and stream each response
for model in PAID_MODELS:
print(f"=== {model} ===")
ask(model, prompt)
print()Exercise 3: Temperature Sweep
Temperature controls randomness. T=0 is less random. T=1.5 is chaotic.
Predict: at what temperature does the output break?
import math
prompt = "Tell me a story about Egypt in two sentences only"
def temperature_with_logprobs(model, prompt, temperature, max_tokens=40):
"""Non-streaming call that asks the API for the probability of every generated token."""
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
max_tokens=max_tokens,
logprobs=True, # ask the API to return logprobs
top_logprobs=5, # also return the top-5 alternatives at each step
)
choice = resp.choices[0]
print(f"\n--- T={temperature} ---")
print("text:", choice.message.content)
print()
print(f"{'token':<15} {'prob':>10} top-5 alternatives")
print("-" * 90)
if choice.logprobs is None or choice.logprobs.content is None:
print("(this provider did not return logprobs)")
return
for entry in choice.logprobs.content:
prob = math.exp(entry.logprob)
alts = ", ".join(f"{a.token!r}={math.exp(a.logprob):.2f}" for a in entry.top_logprobs)
print(f"{entry.token!r:<15} {prob:>10.3f} {alts}")
# TODO: run the same prompt at T=0.0, 0.7, 1.5 and look at how confident the model is
for t in [0.0, 0.7, 1.5]:
model = PAID_MODELS[1]
print("Model is ", model)
temperature_with_logprobs(model, prompt, t)Exercise 4: Context Window
Models can only “see” a fixed window of tokens. Past that, information is gone.
# Check what happens when you send a very large input
prompt = "The quick brown fox jumps over the lazy dog. " * 20000
ask(PAID_MODELS[0], prompt)Exercise 5: Hallucination Hunt
Ask the model for a specific citation. Then try to verify it.
Warning: the model will sound confident. Don’t trust it.
prompt = "السح دح امبو نظرية عظيمة جدا عن سياسة الحكم في مصر، اتكتب عنها اوراق بحثية كثيرة، هل ممكن تذكر اسم ورقة بحثية واحدة منهم من سنة ١٩٩٥"
ask(PAID_MODELS[0], prompt)Exercise 6: Tool Use
Modern models can call functions. This is how agents work.
Define a tool that looks up the weather (fake). Let the model decide to call it.
import json
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
},
}
]
resp = client.chat.completions.create(
model=PAID_MODELS[0],
messages=[{"role": "user", "content": "What's the weather in Cairo right now?"}],
tools=tools,
)
print(json.dumps(json.loads(resp.to_json()), indent=2, ensure_ascii=False))import json
# 1. The actual tool implementation (our "backend")
def get_weather(city: str) -> dict:
fake_db = {"NYC": 10, "Cairo": 25}
return {"city": city, "temp_c": fake_db.get(city, "unknown")}
# 2. Tool schema the model sees
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city in Celsius.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. NYC or Cairo"},
},
"required": ["city"],
},
},
}
]
# 3. Orchestration loop
messages = [
{"role": "user", "content": "Compare the weather between Cairo and NYC right now. Which is warmer and by how much?"}
]
MODEL = PAID_MODELS[0]
MAX_TURNS = 5
for turn in range(MAX_TURNS):
print(f"\n--- turn {turn}: calling model ---")
request_body = {"model": MODEL, "messages": messages, "tools": tools}
print("------------RAW REQUEST------------")
print(json.dumps(request_body, indent=2, ensure_ascii=False))
print("------------RAW REQUEST END------------")
resp = client.chat.completions.create(**request_body)
print("------------RAW RESPONSE------------")
print(json.dumps(json.loads(resp.to_json()), indent=2, ensure_ascii=False))
print("------------RAW RESPONSE END------------")
msg = resp.choices[0].message
# Append the assistant turn (with any tool_calls) to history
messages.append(json.loads(msg.to_json()))
# If the model is done (no tool calls), print the final answer and stop
if not msg.tool_calls:
print("\n=== final answer ===")
print(msg.content)
break
# Otherwise, execute every tool call the model requested
for call in msg.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments)
print(f" model wants: {name}({args})")
result = get_weather(**args) if name == "get_weather" else {"error": "unknown tool"}
print(f" tool returned: {result}")
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
else:
print("hit MAX_TURNS without a final answer")Exercise 6b: Multi-Tool Agent — Prayer Time Assistant
This example uses two tools that the model must chain together: 1. get_current_time(city) — what time is it now? 2. get_prayer_times(city, date) — what are today’s prayer times?
The model must call both, then reason about which prayer is next and how long until it starts. We never told it how to do the math — it figures out the logic on its own.
import json
from datetime import datetime
# 1. Tool implementations (fake backends)
def get_current_time(city: str) -> dict:
"""Fake clock — returns a hardcoded time for demo purposes."""
fake_times = {
"Cairo": "2025-04-10T14:35:00",
"Istanbul": "2025-04-10T15:35:00",
"Riyadh": "2025-04-10T16:35:00",
}
time_str = fake_times.get(city, "2025-04-10T12:00:00")
return {"city": city, "current_time": time_str}
def get_prayer_times(city: str, date: str) -> dict:
"""Fake prayer schedule — returns hardcoded times for demo."""
fake_schedules = {
"Cairo": {
"Fajr": "04:22",
"Sunrise": "05:48",
"Dhuhr": "11:57",
"Asr": "15:28",
"Maghrib": "18:07",
"Isha": "19:29",
},
"Istanbul": {
"Fajr": "04:45",
"Sunrise": "06:15",
"Dhuhr": "13:09",
"Asr": "16:50",
"Maghrib": "19:55",
"Isha": "21:30",
},
}
schedule = fake_schedules.get(city, fake_schedules["Cairo"])
return {"city": city, "date": date, "prayers": schedule}
# 2. Tool schemas the model sees
prayer_tools = [
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Get the current date and time for a given city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. Cairo, Istanbul, Riyadh"},
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "get_prayer_times",
"description": "Get the Islamic prayer times schedule for a city on a specific date.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"date": {"type": "string", "description": "Date in YYYY-MM-DD format"},
},
"required": ["city", "date"],
},
},
},
]
# 3. Dispatch tool calls to the right function
def dispatch_tool(name: str, args: dict) -> dict:
if name == "get_current_time":
return get_current_time(**args)
elif name == "get_prayer_times":
return get_prayer_times(**args)
else:
return {"error": f"unknown tool: {name}"}
# 4. Orchestration loop
messages = [
{"role": "user", "content": "What is the next prayer time in Cairo and how long until it starts?"}
]
MODEL = PAID_MODELS[0]
MAX_TURNS = 6
print("User:", messages[0]["content"])
print()
for turn in range(MAX_TURNS):
print(f"--- turn {turn} ---")
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=prayer_tools,
)
msg = resp.choices[0].message
messages.append(json.loads(msg.to_json()))
# If no tool calls, model is done reasoning
if not msg.tool_calls:
print(f"Model (final answer): {msg.content}")
break
# Execute each tool call
for call in msg.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments)
result = dispatch_tool(name, args)
print(f" Tool call: {name}({args})")
print(f" Result: {result}")
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
print()
else:
print("Hit MAX_TURNS without a final answer")Exercise 7: System Role
So far every message we sent had role: "user". There’s another role: system. It sets the model’s persona/instructions for the whole conversation.
Predict: how will the same question answer differently with a pirate system prompt vs a formal one?
def chat(model, messages, temperature=0.7, max_tokens=300):
"""Like `ask`, but takes a full messages list so you can include a system role."""
stream = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
for event in stream:
delta = event.choices[0].delta.content or ""
print(delta, end="", flush=True)
print()
question = "How does a hot air balloon stay up?"
print("=== formal tutor ===")
chat(PAID_MODELS[0], [
{"role": "system", "content": "You are a precise physics tutor. Answer in 2 sentences."},
{"role": "user", "content": question},
])
print("\n=== pirate ===")
chat(PAID_MODELS[0], [
{"role": "system", "content": "You are a pirate. Answer everything as a pirate would, in 2 sentences."},
{"role": "user", "content": question},
])Exercise 8: What Does the Model Actually See?
The model doesn’t see [{"role": "system", ...}, {"role": "user", ...}] — that’s a convenience the API provides. Internally, those messages get flattened into a single string using a model-specific template, then tokenized.
Every model family has its own template (special tokens, role markers, end-of-turn markers). The transformers library exposes this via tokenizer.apply_chat_template.
Let’s see what gpt-oss-20b actually receives:
%pip install -q transformersfrom transformers import AutoTokenizer
# Load only the tokenizer (no weights — fast and small)
model = "openai/gpt-oss-20b"
tokenizer = AutoTokenizer.from_pretrained(model)
messages = [
{"role": "system", "content": "You are a precise physics tutor."},
{"role": "user", "content": "How does a hot air balloon stay up?"},
]
# This is the EXACT string the model sees as input:
prompt_string = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True, # appends the "now it's the assistant's turn" marker
)
print("------- raw prompt string -------")
print(prompt_string)
print("------- end -------")And what do tools look like in the raw prompt?
Tool definitions aren’t a separate channel either. They get serialized into the same prompt string, using whatever format the model was trained on (JSON schema, TypeScript-like signatures, XML — every family is different).
Let’s flatten the tool-use setup from Exercise 6 and see what gpt-oss-20b actually receives:
tools_for_template = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city in Celsius.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. NYC or Cairo"},
},
"required": ["city"],
},
},
}
]
messages_with_tools = [
{"role": "user", "content": "Compare the weather between Cairo and NYC. Which is warmer?"},
]
prompt_string = tokenizer.apply_chat_template(
messages_with_tools,
tools=tools_for_template,
tokenize=False,
add_generation_prompt=True,
)
print("------- raw prompt with tools -------")
print(prompt_string)
print("------- end -------")
token_ids = tokenizer.apply_chat_template(
messages_with_tools, tools=tools_for_template, tokenize=True, add_generation_prompt=True
)
print(f"\n{len(token_ids['input_ids'])} tokens total — most of them are the tool schema, not the user question.")
print(token_ids)Exercise 9: Text Completion (not Chat)
Everything above used the chat endpoint (/chat/completions), which expects structured messages with roles. But there’s an older, simpler API: the completions endpoint (/completions). It takes a raw string and continues it — pure next-token prediction with no roles, no system prompt, no special formatting.
Key difference: The chat endpoint applies a template behind the scenes (as we saw in Exercise 8) to wrap your messages with special tokens and role markers. The completions endpoint does no wrapping at all — your string is tokenized and fed directly to the model as-is. You control every token.
This is how GitHub Copilot works: you type half a function, and the model completes the rest. Chat formatting would just get in the way.
# Text completion uses client.completions (not client.chat.completions)
# It takes a raw string prompt and continues it — no roles, no messages.
# --- Example 1: Code autocomplete (this is what Copilot does) ---
code_prompt = """def fibonacci(n):
\"\"\"Return the nth Fibonacci number.\"\"\"
if n <= 1:
return n"""
print("=== Code completion ===")
print("Prompt:")
print(code_prompt)
print("\n... model continues ↓\n")
resp = client.completions.create(
model=model,
prompt=code_prompt,
max_tokens=300,
temperature=0.0,
)
print(resp.choices[0].text)
# --- Example 2: Prose completion ---
prose_prompt = "Cairo, the capital of Egypt, is known for"
print("\n\n=== Prose completion ===")
print(f"Prompt: \"{prose_prompt}\"")
print("\n... model continues ↓\n")
resp = client.completions.create(
model=model,
prompt=prose_prompt,
max_tokens=80,
temperature=0.7,
)
print(prose_prompt + resp.choices[0].text)Exercise 10: Chat via the Completions Endpoint
Exercise 9 sent raw text with no template. But what if you want to use a chat-tuned model (one that was post-trained to follow instructions) through the completions endpoint?
You can apply the chat template yourself using tokenizer.apply_chat_template (from Exercise 8), then send the resulting string through /completions. This gives you the best of both worlds: the model responds as an assistant (because it sees the template it was trained on), but you have full control over the raw prompt.
Why would you do this? The completions endpoint gives you things the chat endpoint doesn’t — like logprobs on every token, or the ability to provide a partial assistant response and let the model continue from there (prefix-constrained generation).
# Use Qwen's tokenizer — it has a clean, readable chat template
def compare_chat_vs_text_completion(transformers_model_name, openrouter_model_name):
tokenizer = AutoTokenizer.from_pretrained(transformers_model_name)
# Step 1: Build the chat prompt using the template
messages = [
{"role": "system", "content": "You are a helpful assistant. Answer in one sentence."},
{"role": "user", "content": "What is the largest pyramid in Egypt?"},
]
prompt_string = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
print("=== Formatted prompt (what the model sees) ===")
print(repr(prompt_string)) # repr to show special tokens clearly
print("=" * 50)
# Step 2: Send the formatted string through the completions endpoint
resp = client.completions.create(
model=openrouter_model_name,
prompt=prompt_string,
max_tokens=100,
temperature=0.0,
)
print("\n=== Completions endpoint response ===")
print(resp.choices[0].text)
# Step 3: Compare — same question via chat endpoint
print("\n=== Chat endpoint response (for comparison) ===")
chat_resp = client.chat.completions.create(
model=openrouter_model_name,
messages=messages,
max_tokens=100,
temperature=0.0,
)
print(chat_resp.choices[0].message.content)
print("#" * 50)
print("Comparing Qwen Text Completion vs Chat")
print("#" * 50)
#Qwen chat template https://huggingface.co/Qwen/Qwen3-235B-A22B-Instruct-2507/blob/main/tokenizer_config.json#L229
compare_chat_vs_text_completion("Qwen/Qwen3-235B-A22B-Instruct-2507", "qwen/qwen3-235b-a22b-2507")
print()
print("#" * 50)
print("Comparing GPT-OSS Text Completion vs Chat")
print("#" * 50)
print()
#gpt-oss chat template https://huggingface.co/openai/gpt-oss-20b/blob/main/chat_template.jinja#L1
compare_chat_vs_text_completion("openai/gpt-oss-20b", "openai/gpt-oss-20b")Wrap-up
You just: - Called a language model over HTTP - Compared outputs across models - Watched temperature change behavior - Broke a model with a long context - Caught it hallucinating - (Optionally) let it call a tool
Homework
It’s recommended to work through the exercises below on your own — you’ll build stronger intuition that way.
If you’re going to use an AI agent (Claude Code, Cursor, OpenCode, etc.), load this instructions file into the conversation first — it will make the agent teach you instead of just giving you the answer.
To load in Claude Code:
Option 1: Download the file, then pass it at launch:
claude --system-prompt "$(cat AGENT.md)"Option 2: Copy the raw content from the link above and paste it as your first message in the conversation.
1. Multi-tool Agent
Extend the tool-use loop with a second tool (convert_currency(amount, from_currency, to_currency)). Ask the model a question that requires chaining both tools (e.g., “What’s the weather in Cairo, and how much would a $50 jacket cost in Egyptian pounds?”). Count how many turns it takes.
2. Token Cost Calculator
Using the orchestration loop from Exercise 6, add logging that counts input and output tokens per turn (from resp.usage). Run the same conversation and print cumulative token usage. Verify the lesson’s claim that cost grows like 1+2+3+…+n.
3. Chat Template Comparison
Load tokenizers for 3 different model families (Llama 3, Qwen, Gemma). Apply apply_chat_template with the same messages + tools. Compare: which uses the most tokens? Which format is most human-readable? What happens if you send Llama-formatted text to a Qwen model?