Hello Software 3.0

Your first LLM API calls: text, streaming, voice, and images

Learning objectives

By the end of this lesson, you will be able to:

  1. Make a basic chat completion API call and understand its parameters
  2. Stream responses token-by-token for better user experience
  3. Build a voice pipeline using Whisper (STT) and TTS APIs
  4. Generate images with DALL-E and understand prompt revision
  5. Understand why testing AI systems is fundamentally different from testing traditional software

What is "Software 3.0"?

The term comes from Andrej Karpathy, who described an evolution in how we write software:

In Software 3.0, the "code" is the prompt. You're about to write your first lines.

Why start here?

Before you can build agents, orchestrate tools, or design evaluation frameworks, you need to understand the primitive: the API call. Everything else is layers on top of this.

Setup

If you haven't already, set up the repository:

# Clone and enter the repo
git clone https://github.com/reliableai/ai-design-2026-pub.git
cd ai-design-2026-pub

# Install uv (Python package manager) if needed
curl -LsSf https://astral.sh/uv/install.sh | sh

# Install dependencies (this creates a .venv and installs everything)
uv sync

# macOS only: portaudio is needed for the voice demo (script 3)
brew install portaudio

# Set up your API keys (one-time)
cp .env.example .env
# Edit .env and add your OPENAI_API_KEY

A shared API key will be provided during the lab. Add it to your .env file. All scripts automatically load keys from .env via python-dotenv.

Cost awareness

API calls cost real money. Even though you're using a shared key, it's good practice to understand what things cost. See OpenAI pricing for current rates.

Alternative: OpenRouter

For text-based examples (chat, streaming), you can use OpenRouter instead of OpenAI. OpenRouter provides a unified API to access models from OpenAI, Anthropic, Meta, Google, and others—often at lower prices.

from openai import OpenAI  # Same SDK!

client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ["OPENROUTER_API_KEY"],
)

response = client.chat.completions.create(
    model="openai/gpt-4.1-mini",       # Or "anthropic/claude-sonnet-4"
    messages=[...],                     # Or "meta-llama/llama-4-scout"
)

The OpenAI Python SDK works with OpenRouter—just change the base_url and use an OpenRouter API key. This lets you easily try models from different providers. Get yours at openrouter.ai/keys.

When to use which

  • OpenAI direct — required for voice (Whisper, TTS) and images (DALL-E)
  • OpenRouter — great for text generation, lets you compare models easily, often cheaper
01 / CHAT COMPLETION

Text in, text out

The Chat Completions API is the foundation. Send messages, get a response.

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # Load OPENAI_API_KEY from .env
client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4.1-mini",
    messages=[
        {"role": "user", "content": "Explain Software 3.0 in one sentence."}
    ],
    temperature=0.7,
)

print(response.choices[0].message.content)

Run it:

uv run python labs/01_hello_world/1_chat.py

Key parameters

  • model — which LLM to use. We use gpt-4.1-mini: a small, fast, and inexpensive model well-suited for learning. As of early 2026, OpenAI's model families include:
    • GPT-5 series (current flagship) — gpt-5.2 (best quality), gpt-5-mini ($0.25/M input tokens), gpt-5-nano (cheapest at ~$0.05/M input tokens)
    • GPT-4.1 series (previous generation, still available) — gpt-4.1-mini ($0.40/M input tokens) is what we use here
    Models evolve fast — see available models and pricing for the latest.
  • messages — the conversation history, as a list of message objects. Each has a role and content:
    • system — sets the model's behavior and persona (e.g., "You are a helpful tutor"). Processed first, shapes all subsequent responses.
    • user — the human's input
    • assistant — the model's previous responses (include these to give the model memory of the conversation)
    The API is stateless: the model has no memory between calls. To have a multi-turn conversation, you must send the full history each time.
  • temperature — controls randomness in token selection (range 0–2):
    • 0 — nearly deterministic; picks the most likely token every time. Good for factual Q&A and structured output.
    • 0.7 (our default) — balanced creativity and coherence. Good starting point for most tasks.
    • 1.0 (OpenAI's default) — more varied outputs.
    • 1.5+ — high randomness; useful for brainstorming but may produce incoherent text.
    Related: top_p (nucleus sampling) is an alternative way to control diversity. OpenAI recommends using one or the other, not both.

The response object

The API returns a structured response:

{
  "id": "chatcmpl-abc123",
  "object": "chat.completion",
  "created": 1234567890,
  "model": "gpt-4.1-mini",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Software 3.0 refers to..."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 15,
    "completion_tokens": 32,
    "total_tokens": 47
  }
}

The usage field tells you how many tokens were consumed—this determines cost.

02 / STREAMING

Watch tokens arrive

With streaming, you receive tokens as they're generated instead of waiting for the full response. This dramatically improves perceived latency.

stream = client.chat.completions.create(
    model="gpt-4.1-mini",
    messages=[{"role": "user", "content": "Write a haiku about APIs."}],
    stream=True,  # Enable streaming
)

for chunk in stream:
    content = chunk.choices[0].delta.content
    if content:
        print(content, end="", flush=True)

Run it:

uv run python labs/01_hello_world/2_streaming.py

Why streaming matters

  • Time to first token (TTFT) — the key UX metric. Users see output immediately instead of staring at a spinner.
  • Perceived speed — a 5-second streamed response feels faster than a 3-second blocked response.
  • Early cancellation — users can stop generation if it's going in the wrong direction.

Streaming vs non-streaming

Non-streaming: [====== waiting ======] → Full response appears Streaming: [=] H [=] e [=] l [=] l [=] o → Each token appears immediately

The total time may be similar, but the user experience is vastly different.

03 / VOICE

Speak and be spoken to

Voice interaction combines three APIs into a pipeline:

┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Your │───▶│ Whisper │───▶│ GPT │───▶│ TTS │───▶│ Speaker │ │ Voice │ │ (STT) │ │ │ │ │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ Audio Text Text Audio Audio

Each step is a separate API call. The LLM doesn't "hear" you—it reads your transcribed words.

# 1. Record audio from microphone
audio = sd.rec(int(5 * 16000), samplerate=16000, channels=1)
sd.wait()

# 2. Transcribe with Whisper (Speech-to-Text)
transcript = client.audio.transcriptions.create(
    model="whisper-1",
    file=audio_file,
)

# 3. Get LLM response
response = client.chat.completions.create(
    model="gpt-4.1-mini",
    messages=[{"role": "user", "content": transcript.text}],
)

# 4. Convert to speech (Text-to-Speech)
speech = client.audio.speech.create(
    model="tts-1",
    voice="nova",  # Options: alloy, echo, fable, onyx, nova, shimmer
    input=response.choices[0].message.content,
)

Run it:

uv run python labs/01_hello_world/3_voice.py

You'll have 5 seconds to speak. The script records, transcribes, thinks, and speaks back.

API references

Note

This script requires microphone access. On macOS, you may see a permission prompt on first run. Grant terminal access to the microphone in System Preferences → Privacy & Security → Microphone.

04 / IMAGE GENERATION

Describe, create

The Images API with DALL-E 3 generates images from text descriptions.

response = client.images.generate(
    model="dall-e-3",
    prompt="A robot teaching a classroom of humans, digital art style",
    size="1024x1024",      # Also: 1024x1792, 1792x1024
    quality="standard",    # Also: "hd" (more detail, higher cost)
    n=1,                   # DALL-E 3 only supports n=1
)

image_url = response.data[0].url
revised_prompt = response.data[0].revised_prompt

Please don't run this one — image generation is expensive (~$0.04 per call). The instructor will demo it in class.

Prompt revision

DALL-E 3 automatically rewrites your prompt for better results. Your input "A cat" might become "A photorealistic orange tabby cat sitting on a windowsill, warm afternoon light streaming through the glass, soft focus background..."

Check response.data[0].revised_prompt to see what DALL-E actually generated from. This is both a feature (better images) and a limitation (less control).

Note

The returned URL is temporary (~1 hour). Download the image if you want to keep it.

05 / TESTING AI SYSTEMS

How do you test this?

In traditional software, testing is straightforward: sort([3,1,2]) must return [1,2,3]. There is exactly one correct answer. Same input, same output, every time.

Now try testing the prompt you just ran in Section 1:

def test_explanation():
    response = ask_llm("Explain Software 3.0 in one sentence.")
    assert response == ???  # What goes here?

Run it three times. You might get:

All three are correct. All three are different. No assert catches this — and unlike a formatting issue you could parse around, these are genuinely different valid answers. The problem isn't surface-level variation. It's that for any non-trivial question, "correct" is a spectrum, not a point.

What we CAN test

Test type What it checks Example
Structural Response has expected shape Not empty, reasonable length, valid format
Property Properties hold within bounds Latency < 30s, response < 1000 chars
LLM-as-judge Another LLM evaluates quality "Is this response relevant to the question?"

LLM-as-judge: testing AI with AI

For the voice pipeline, we can't easily verify correctness. Instead, we ask another LLM:

def test_voice_response_is_sensible(user_input, assistant_response):
    judgment = client.chat.completions.create(
        model="gpt-4.1-mini",
        messages=[{
            "role": "user",
            "content": f"""User said: "{user_input}"
Assistant replied: "{assistant_response}"

Is this a sensible, relevant response? Answer YES or NO."""
        }],
        temperature=0,
    )

    assert "YES" in judgment.choices[0].message.content.upper()

This is circular (using the thing we're testing to evaluate itself), but it's often the best we can do. We'll explore this deeply in Phase 2: Evaluation.

Running the tests

This lesson includes a test suite demonstrating these approaches:

# Run fast tests (skip expensive image generation)
uv run pytest labs/tests/test_lesson_01.py -v -m "not slow"

# Run all tests including image generation (~$0.08)
uv run pytest labs/tests/test_lesson_01.py -v
Key insight

Testing AI systems requires different thinking. You can't test for "correct"—you test for "acceptable", "not broken", and "sensible". This is a fundamental shift from traditional software testing.

HOMEWORK

Run an LLM on your laptop

In class you used cloud APIs (OpenAI, OpenRouter). For homework, you'll run a model locally on your own machine using Ollama. No API key needed, no cost, no internet required once the model is downloaded.

Why this matters

Understanding local vs cloud inference is essential. Local models give you privacy, zero cost, and offline access — but with trade-offs in speed and capability. As a practitioner, you need to know both options.

Step 1: Install Ollama

Download and install from ollama.com/download. It's available for macOS, Linux, and Windows.

# macOS / Linux — or just download from the website
curl -fsSL https://ollama.com/install.sh | sh

Verify it's working:

ollama --version

Step 2: Pull a model and chat

Download a small model that runs well on most laptops (requires ~2 GB of disk and RAM):

# Download the model (~2 GB)
ollama pull llama3.2

# Start chatting in the terminal
ollama run llama3.2

Try asking it the same question from class: "Explain Software 3.0 in one sentence."

Type /bye to exit the chat.

Other models to try

Browse the full catalog at ollama.com/library. Some suggestions:

  • llama3.2 (3B, ~2 GB) — good default, fast on most machines
  • llama3.2:1b (1B, ~1.3 GB) — lighter, works on older hardware
  • gemma3:4b (4B, ~3 GB) — Google's small model, good quality
  • qwen3:4b (4B, ~2.6 GB) — another strong small model

If your machine has 16+ GB RAM, you can try larger models like llama3.3 (70B) or gemma3:27b.

Step 3: Use the OpenAI SDK with Ollama

Here's the key insight: Ollama exposes an OpenAI-compatible API. The same Python code you wrote in class works with local models — just change the base_url:

from openai import OpenAI

# Point the SDK at your local Ollama server
client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama",  # Required by the SDK, but Ollama ignores it
)

response = client.chat.completions.create(
    model="llama3.2",
    messages=[
        {"role": "user", "content": "Explain Software 3.0 in one sentence."}
    ],
    temperature=0.7,
)

print(response.choices[0].message.content)

The only differences from the cloud version: base_url points to localhost, and the model name matches what you pulled with Ollama.

Step 4: Complete the notebook

Open the homework notebook and work through the exercises:

uv run jupyter lab labs/01_hello_world/homework_01_ollama.ipynb

The notebook walks you through:

  1. Connecting to Ollama with the OpenAI SDK
  2. Comparing cloud vs local responses (quality, speed)
  3. Streaming from a local model
  4. Trying different models and system prompts
One SDK, three providers

You've now used the openai Python SDK with three different backends: OpenAI (cloud), OpenRouter (cloud, multi-provider), and Ollama (local). Same code, different base_url. This pattern — one interface, many backends — will keep coming back throughout the course.

WHAT'S NEXT

From calls to conversations

You've made four types of API calls. Each was stateless—the model has no memory of previous interactions. Every call starts fresh.

In the next lesson, we'll build agents: programs that maintain conversation history, make decisions, and use tools. The single API call becomes a building block in a larger loop.

Exercises

  1. Temperature experiment: Modify 1_chat.py to use temperature 0, then 1.5. Run each 3 times. How do the responses differ in consistency?
  2. Measure TTFT: In 2_streaming.py, measure time-to-first-token vs total time. Print both. What's the ratio?
  3. Voice variations: Change the TTS voice in 3_voice.py. Options: alloy, echo, fable, onyx, nova, shimmer. Which sounds most natural?
  4. Prompt revision: Generate images with "A cat" vs a 50-word detailed prompt. Compare DALL-E's revised prompts. When does it add more vs less?
  5. Test the tests: Run test_exact_match_fails 10 times. Does it always fail? Always pass? Sometimes both? What does this tell you?
REFERENCES

API documentation & resources

OpenAI API reference

Python SDK & tools

Background reading

Code for this lesson