n4nAI

Build a GPT-5 command-line client in Python with Click

Step-by-step tutorial to build gpt-5 cli python tool with Click and the OpenAI SDK, including streaming, config, and error handling for engineers.

n4n Team3 min read606 words

Audio narration

Coming soon — every post will get a voice note here.

To build gpt-5 cli python tool that holds up outside a notebook, you need a real CLI framework, not a script with argparse bolted on. This tutorial walks through a small but production-minded client using Click and the OpenAI SDK, with streaming, env-based config, and clean exit codes.

Prerequisites

  • Python 3.10 or newer
  • pip and a virtual environment tool
  • An API key from OpenAI or any OpenAI-compatible provider
  • Basic familiarity with terminal and environment variables

If you plan to route through a gateway, set OPENAI_API_KEY and OPENAI_BASE_URL accordingly before running the examples.

Project setup

Create a directory and a venv:

mkdir gpt5cli && cd gpt5cli
python -m venv .venv
source .venv/bin/activate
pip install click openai

Create cli.py. We’ll build it incrementally.

A minimal Click command

Start with a single command that accepts a prompt and prints a response. Click makes argument parsing declarative.

import click

@click.command()
@click.argument("prompt")
def chat(prompt):
    """Send PROMPT to GPT-5 and print the reply."""
    click.echo(f"You said: {prompt}")

if __name__ == "__main__":
    chat()

Run it:

python cli.py "What is the capital of Estonia?"

Expected output:

You said: What is the capital of Estonia?

That confirms the CLI skeleton works. Now wire the model.

Wiring up the OpenAI client

We’ll use the official openai package. It speaks the OpenAI-compatible HTTP protocol, so any endpoint that mirrors /v1/chat/completions works.

import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ.get("OPENAI_API_KEY"),
    base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)

Add a model option defaulting to gpt-5. (If your provider uses a different identifier, override with --model.)

@click.command()
@click.argument("prompt")
@click.option("--model", default="gpt-5", help="Model ID to use.")
def chat(prompt, model):
    resp = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
    )
    click.echo(resp.choices[0].message.content)

Run:

export OPENAI_API_KEY=sk-...
python cli.py "Explain CAP theorem in one sentence."

Expected output (truncated):

The CAP theorem states that a distributed data store cannot simultaneously guarantee consistency, availability, and partition tolerance.

Adding options: system prompt, temperature, streaming

A real client needs a system prompt and temperature control. Streaming is non-negotiable for UX—nobody wants to wait silently for a 2k-token reply.

Extend the command:

@click.command()
@click.argument("prompt")
@click.option("--model", default="gpt-5")
@click.option("--system", default="You are a concise assistant.", help="System prompt.")
@click.option("--temperature", default=0.7, type=float)
@click.option("--stream/--no-stream", default=True)
def chat(prompt, model, system, temperature, stream):
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt},
    ]
    if stream:
        chunks = client.chat.completions.create(
            model=model, messages=messages, temperature=temperature, stream=True
        )
        for chunk in chunks:
            delta = chunk.choices[0].delta.content
            if delta:
                click.echo(delta, nl=False)
        click.echo()
    else:
        resp = client.chat.completions.create(
            model=model, messages=messages, temperature=temperature
        )
        click.echo(resp.choices[0].message.content)

Now you can run:

python cli.py "List three Rust web frameworks." --temperature 0.2 --no-stream

Expected output (non-streaming):

1. Actix Web
2. Rocket
3. Axum

With streaming (default), tokens appear incrementally as they arrive.

Handling errors and exit codes

Network failures, rate limits, and invalid keys happen. Click should exit non-zero and print a clean message.

Wrap the API call:

from openai import APIError, RateLimitError

@click.command()
# ... options as above ...
def chat(prompt, model, system, temperature, stream):
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt},
    ]
    try:
        if stream:
            chunks = client.chat.completions.create(
                model=model, messages=messages, temperature=temperature, stream=True
            )
            for chunk in chunks:
                delta = chunk.choices[0].delta.content
                if delta:
                    click.echo(delta, nl=False)
            click.echo()
        else:
            resp = client.chat.completions.create(
                model=model, messages=messages, temperature=temperature
            )
            click.echo(resp.choices[0].message.content)
    except RateLimitError:
        raise click.ClickException("Rate limited. Check your plan or use a gateway with fallback.")
    except APIError as e:
        raise click.ClickException(f"API error: {e.status_code} {e.message}")

click.ClickException prints Error: <msg> and exits with code 1.

Test error path by unsetting the key:

unset OPENAI_API_KEY
python cli.py "test"

Expected output:

Error: API error: 401 Incorrect API key provided.

Making it configurable via env and a small helper

Hardcoding defaults is fine, but reading from env makes the tool portable. We already use OPENAI_API_KEY and OPENAI_BASE_URL. Add a GPT5_MODEL env override:

import os

DEFAULT_MODEL = os.environ.get("GPT5_MODEL", "gpt-5")

@click.command()
@click.argument("prompt")
@click.option("--model", default=DEFAULT_MODEL)
# ... rest ...

You can also persist config in a .env file using python-dotenv, but for a CLI, env vars are enough.

Using a gateway for resilience

If you point OPENAI_BASE_URL at an OpenAI-compatible inference gateway, you get provider redundancy without code changes. For example, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and automatically falls back when a provider is rate-limited or degraded, while still honoring your --model routing directive and forwarding cache-control hints. That turns a single-point CLI into a resilient piece of infrastructure.

To use it:

export OPENAI_BASE_URL=https://api.n4n.ai/v1
export OPENAI_API_KEY=your-gateway-key
python cli.py "Summarize the Zen of Python." --model gpt-5

The same build gpt-5 cli python code now runs against multiple upstreams.

Supporting JSON output for scripting

Sometimes you want the raw response, including token usage, for metering. Add --json:

@click.option("--json", "as_json", is_flag=True)
# in non-streaming branch:
if as_json:
    click.echo(resp.model_dump_json())
else:
    click.echo(resp.choices[0].message.content)

Example:

python cli.py "ping" --no-stream --json

Outputs:

{"id":"chatcmpl-...","object":"chat.completion","model":"gpt-5","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}

This makes the CLI embeddable in larger pipelines.

Why Click instead of argparse

Argparse is in the stdlib, but its error messages and option composition are clunky. Click gives you declarative options, automatic --help, and easy exit code control. For a tool you’ll maintain, that saves real time. The group/command pattern also lets you add subcommands (chat, repl) without rewriting parsing logic.

Adding a REPL for multi-turn sessions

A single-shot CLI is useful, but engineers often want a quick interactive loop. Click doesn’t ship a REPL, but a simple while loop with click.prompt is enough:

@click.command()
@click.option("--model", default=DEFAULT_MODEL)
def repl(model):
    """Start an interactive session."""
    messages = [{"role": "system", "content": "You are a concise assistant."}]
    while True:
        try:
            user_input = click.prompt("you")
        except (EOFError, KeyboardInterrupt):
            break
        messages.append({"role": "user", "content": user_input})
        resp = client.chat.completions.create(model=model, messages=messages)
        reply = resp.choices[0].message.content
        click.echo(f"bot: {reply}")
        messages.append({"role": "assistant", "content": reply})

Wrap commands in a group:

@click.group()
def cli():
    pass

@cli.command()
def repl():
    ...

@cli.command()
@click.argument("prompt")
# ... chat options ...
def chat(prompt, ...):
    ...

if __name__ == "__main__":
    cli()

Exit codes and piping

Click returns 0 on success, 1 on ClickException. If you pipe to jq, check $? in shell scripts. That’s the contract your ops team will rely on. Non-zero on rate limit means a wrapper can retry or alert.

Final checks

Install your tool editable for system-wide use:

pip install -e .

Add a pyproject.toml with console script:

[project.scripts]
gpt5 = "cli:cli"

Then gpt5 chat "Hello" works from anywhere. The complete cli.py is about 90 lines. It streams, handles errors, respects env config, and can target any OpenAI-compatible endpoint. That’s a solid foundation to build gpt-5 cli python utilities for your own workflows.

Tagspythongpt-5cliclick

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building cli tools for llm apis posts →