Topic 2 of 15 · Foundations

LLM APIs fundamentals.

OpenAI, Gemini, and Claude look like three different products. Underneath, their APIs are the same shape: send a list of role-tagged messages over HTTPS, get text back, pay per token. This lesson builds that shared mental model, then shows you the three SDKs side by side — streaming, tokens, cost, and the retry logic every production caller needs.

The mental model

One request shape, three vendors.

Think of an LLM API the way you'd think of a Retrofit endpoint: a request DTO goes out, a response DTO comes back, and the interesting behavior lives inside a service you don't control. The request DTO here is always a list of messages with a role (system, user, or assistant) and content. The response is text, metered by tokens, billed accordingly.

Your app builds
a messages list
POST to the
provider's API
Model generates
tokens
Response text +
token usage back
Section 0

Setup & API Keys

Each provider issues an API key from its own console. Treat every one of these exactly like a database password — never hardcode it, never commit it, never log it.

ProviderGet a key atPython package
OpenAIplatform.openai.compip install openai
Google Geminiaistudio.google.compip install google-genai
Anthropic Claudeconsole.anthropic.compip install anthropic

Load keys from environment variables, not from source code — this is the Python equivalent of keeping secrets out of local.properties / BuildConfig in an Android project.

pip install openai anthropic google-genai python-dotenv
intent-ai/
.env # never committed — add to .gitignore
.gitignore
main.py
.env
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=AIza...
ANTHROPIC_API_KEY=sk-ant-...
main.py
from dotenv import load_dotenv
import os

load_dotenv()  # reads .env into the process environment
api_key = os.environ["OPENAI_API_KEY"]  # raises KeyError if missing
Checkpoint: create accounts and API keys for at least OpenAI and one of Gemini/Claude, store them in a local .env, and confirm .env is listed in .gitignore before writing any code below.
Section 1

Anatomy of a Chat Completion Request

OpenAI's chat.completions shape became the de facto pattern the rest of the industry converged on. Learn it first; everything else in this lesson is a variation on it.

from openai import OpenAI

client = OpenAI()  # reads OPENAI_API_KEY from the environment automatically

response = client.chat.completions.create(
    model="gpt-4.1-mini",
    messages=[
        {"role": "system", "content": "You are a concise Android engineering mentor."},
        {"role": "user", "content": "Explain recursion in one sentence."},
    ],
    max_tokens=100,
)

print(response.choices[0].message.content)
print(response.usage)  # prompt_tokens, completion_tokens, total_tokens
systemSets behavior and constraints for the whole conversation. Sent once, applies throughout — like a base class defining defaults.
userWhat the human (or your app, on their behalf) is asking right now.
assistantThe model's prior replies. You send these back on the next call — the API is stateless; there is no server-side conversation memory.
The stateless trap: unlike a chat UI that "remembers" the conversation, the API itself has no memory between calls. Every request resends the entire message history. Forgetting this is the most common reason a "context-aware" bot suddenly loses context — the caller stopped appending prior turns to the list.
Section 2

Three Providers, One Shape

Same prompt, same intent, three SDKs. The differences are mostly naming: where the system prompt goes, what the response field is called, and how token usage is reported.

OpenAI
from openai import OpenAI
client = OpenAI()

r = client.chat.completions.create(
    model="gpt-4.1-mini",
    messages=[
        {"role": "system",
         "content": "Be concise."},
        {"role": "user",
         "content": "Explain recursion "
                     "in one sentence."},
    ],
)
print(r.choices[0].message.content)
Gemini
from google import genai
client = genai.Client()

r = client.models.generate_content(
    model="gemini-2.5-flash",
    config={"system_instruction":
            "Be concise."},
    contents="Explain recursion "
             "in one sentence.",
)
print(r.text)
Claude
from anthropic import Anthropic
client = Anthropic()

r = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=100,
    system="Be concise.",
    messages=[
        {"role": "user",
         "content": "Explain recursion "
                     "in one sentence."},
    ],
)
print(r.content[0].text)
ConceptOpenAIGeminiClaude
System prompt{"role": "system", ...} in messagesconfig.system_instruction, separatesystem parameter, separate
User turnsmessages listcontents (string or list)messages list
max_tokensOptionalOptional (in config)Required
Response textr.choices[0].message.contentr.textr.content[0].text
Token usager.usage.prompt_tokens / completion_tokensr.usage_metadatar.usage.input_tokens / output_tokens
Model name strings (gpt-4.1-mini, gemini-2.5-flash, claude-sonnet-4-5) change as providers ship new versions — always check the current model list in each provider's docs before shipping. Don't copy these literally into production without verifying.
Section 3

Streaming Responses

A non-streamed call waits for the entire response before returning anything — fine for short answers, bad for a chat UI where the user should see text appear as it's generated. Streaming is conceptually the same problem Kotlin's Flow<String> solves: emit values as they become available instead of collecting them all first.

Kotlin (the shape you know)
fun streamAnswer(prompt: String): Flow<String> = flow {
    val chunks = fakeLlmStream(prompt)
    chunks.collect { chunk -> emit(chunk) }
}

// caller
streamAnswer("Explain recursion").collect { chunk ->
    print(chunk)
}
Python (OpenAI streaming)
stream = client.chat.completions.create(
    model="gpt-4.1-mini",
    messages=[{"role": "user", "content": "Explain recursion"}],
    stream=True,
)

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

Under the hood, this is Server-Sent Events (SSE): one HTTP response, kept open, with the server pushing small JSON chunks as the model generates tokens. The SDK turns that into a plain Python iterator so you never touch raw SSE parsing.

ProviderEnable streamingRead a chunk
OpenAIstream=Truechunk.choices[0].delta.content
Geminigenerate_content_stream(...)chunk.text
Claudeclient.messages.stream(...) as a context managerevent.text inside for event in stream.text_stream
Async version: Topic 7 (FastAPI) will stream these same chunks out over your own API. That needs the async client (AsyncOpenAI, async for chunk in stream) from Topic 1's Section 7 — sync streaming here is for scripts, async streaming is for services.
Section 4

Tokens & Context Windows

A token is not a word or a character — it's a chunk of text the model's tokenizer produces, roughly 4 characters of English on average. "Recursion" might be one token; "antidisestablishmentarianism" might be six. Every request's system + history + user message + expected response must fit inside the model's context window — its maximum token budget.

import tiktoken

enc = tiktoken.encoding_for_model("gpt-4.1-mini")
tokens = enc.encode("Explain recursion in one sentence.")
print(len(tokens))   # 8 — an approximate cost/size check before you call the API

A running conversation eats into that budget with every turn, since you resend the whole history each time (Section 1). Here's what a growing conversation looks like against a 128K-token window:

System prompt
~600
10-turn history
~15K
+ retrieved docs (RAG)
~45K
Remaining budget
~67K
Model (example)Approx. context window
gpt-4.1-mini128K tokens
gemini-2.5-flash1M tokens
claude-sonnet-4-5200K tokens
This is exactly why Topic 7 (RAG) exists: instead of stuffing an entire knowledge base into the context window every call, you retrieve only the few relevant chunks that fit the remaining budget.
Section 5

Cost Math

Providers bill separately for input tokens (what you send) and output tokens (what the model generates), usually priced per million tokens, with output priced higher than input. Always check each provider's live pricing page before estimating a real budget — the numbers below are illustrative, not current pricing.

def estimate_cost(prompt_tokens: int, completion_tokens: int,
                   price_per_1m_in: float, price_per_1m_out: float) -> float:
    input_cost = (prompt_tokens / 1_000_000) * price_per_1m_in
    output_cost = (completion_tokens / 1_000_000) * price_per_1m_out
    return round(input_cost + output_cost, 6)

# after a real call, using the SDK's reported usage:
cost = estimate_cost(
    prompt_tokens=response.usage.prompt_tokens,
    completion_tokens=response.usage.completion_tokens,
    price_per_1m_in=0.40,
    price_per_1m_out=1.60,
)
print(f"${cost:.6f} for this call")
Habit to build now: log usage on every call from day one, even in a throwaway script. A feature that "feels free" in dev can become a real line item at production traffic — the same instinct that makes you watch Firebase read/write counts on a database.
Section 6

Parameters That Matter

The biggest adjustment coming from deterministic APIs: the same input can produce a different output on every call. These parameters control how much.

ParameterWhat it doesTypical use
temperature0 = nearly deterministic, most-likely tokens only. Higher (up to ~2) = more randomness/creativity.0–0.3 for extraction/classification, 0.7+ for creative writing
max_tokensHard cap on response length. The call is truncated, not rejected, if the model hits it mid-thought.Always set one — it's your cost ceiling per call
top_pNucleus sampling: only consider tokens within the top P cumulative probability. Alternative to temperature; usually tune one, not both.Leave at default (1.0) unless you have a specific reason
stopSequence(s) that end generation immediately when produced.Stopping a model from generating past a delimiter you parse on
For anything you'll parse programmatically (JSON, classification labels, function arguments — Topic 4), set temperature=0. Non-determinism is a feature for chat, a bug for structured extraction.
Section 7

Errors & Retries

This is Topic 1 Section 5 (error handling) applied to a specific, very real failure mode: LLM APIs rate-limit you, time out under load, and occasionally return server errors that have nothing to do with your request. Catch the SDK's specific exception types, not a bare except:.

import time
from openai import OpenAI, RateLimitError, APIStatusError, APITimeoutError

client = OpenAI()

def ask_with_retry(prompt: str, retries: int = 3) -> str:
    for attempt in range(retries):
        try:
            r = client.chat.completions.create(
                model="gpt-4.1-mini",
                messages=[{"role": "user", "content": prompt}],
                timeout=30,
            )
            return r.choices[0].message.content
        except RateLimitError:
            wait = 2 ** attempt  # exponential backoff: 1s, 2s, 4s
            print(f"rate limited, retrying in {wait}s")
            time.sleep(wait)
        except APITimeoutError:
            print("timed out, retrying")
        except APIStatusError as e:
            if e.status_code >= 500:
                print(f"server error {e.status_code}, retrying")
            else:
                raise  # a 400 won't fix itself on retry — don't loop on it
    raise RuntimeError(f"failed after {retries} attempts")
Retrying a 4xxA malformed request or bad API key returns the same error every time. Retry only on rate limits (429), timeouts, and 5xx server errors — retrying a 400 just burns time.
No backoffRetrying immediately in a loop during a rate limit makes the rate limit worse. Exponential backoff (1s, 2s, 4s...) is not optional at any real traffic volume.
Production tip: the tenacity library turns the loop above into a decorator (@retry(...)) — worth adopting once you're past the learning stage, using the decorator pattern from Topic 1 Section 6.
Section 8 · Checkpoint

Capstone: A Provider-Agnostic Ask Function

Build a single function that streams a response from whichever provider you pass in, prints tokens as they arrive, and reports the estimated cost when it's done. This exact shape — one call site, swappable providers — is what Topic 14's capstone product builds on top of.

import os
from dataclasses import dataclass
from openai import OpenAI
from anthropic import Anthropic

# illustrative pricing per 1M tokens — verify against each provider's current pricing page
PRICING = {
    "openai": {"in": 0.40, "out": 1.60},
    "claude": {"in": 3.00, "out": 15.00},
}

@dataclass
class AskResult:
    text: str
    prompt_tokens: int
    completion_tokens: int

    def cost(self, provider: str) -> float:
        rate = PRICING[provider]
        return round(
            (self.prompt_tokens / 1_000_000) * rate["in"]
            + (self.completion_tokens / 1_000_000) * rate["out"],
            6,
        )


def ask_openai(prompt: str) -> AskResult:
    client = OpenAI()
    chunks: list[str] = []
    stream = client.chat.completions.create(
        model="gpt-4.1-mini",
        messages=[{"role": "user", "content": prompt}],
        stream=True,
        stream_options={"include_usage": True},
    )
    usage = None
    for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            delta = chunk.choices[0].delta.content
            print(delta, end="", flush=True)
            chunks.append(delta)
        if chunk.usage:
            usage = chunk.usage
    print()
    return AskResult("".join(chunks), usage.prompt_tokens, usage.completion_tokens)


def ask_claude(prompt: str) -> AskResult:
    client = Anthropic()
    chunks: list[str] = []
    with client.messages.stream(
        model="claude-sonnet-4-5",
        max_tokens=300,
        messages=[{"role": "user", "content": prompt}],
    ) as stream:
        for text in stream.text_stream:
            print(text, end="", flush=True)
            chunks.append(text)
        final = stream.get_final_message()
    print()
    return AskResult("".join(chunks), final.usage.input_tokens, final.usage.output_tokens)


def ask(provider: str, prompt: str) -> None:
    result = ask_openai(prompt) if provider == "openai" else ask_claude(prompt)
    print(f"\n[{provider}] tokens: {result.prompt_tokens} in / "
          f"{result.completion_tokens} out — est. ${result.cost(provider):.6f}")


if __name__ == "__main__":
    ask("openai", "Explain recursion in one sentence, then give a Kotlin example.")
Expected behavior: text prints token-by-token as it streams, then a final line reports input/output token counts and an estimated cost. Swap the last line to ask("claude", ...) and confirm it works against the second provider without changing anything else.
  • Both ask_openai and ask_claude run against real API keys from your .env
  • Response text visibly streams rather than appearing all at once
  • Cost estimate prints after each call, using real usage data from the response — not a guess
  • Wrapped at least one call in the retry logic from Section 7 and forced a failure (e.g. a bad model name) to see it raise instead of retry-looping forever
  • Can explain out loud why max_tokens is required for Claude but optional for OpenAI, and why that matters for cost control either way
  • Next up

    Topic 3: Prompt & Context Engineering

    System prompts, few-shot examples, context windows, and the failure modes that show up once your prompts leave the demo stage.