Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ Read README.md.

Python CLI and GUI clients for OpenAI's ChatGPT. Key files:

- `core.py` — shared library: `GptCore` class, pricing dicts, `Info` dataclass
- `libopenai/` — shared library package:
- `core.py` — `GptCore` class, `Info` dataclass
- `auth.py` — API key loading and client initialization
- `constants.py` — `DEFAULT_MODEL`, file extensions, `DATA_DIRECTORY`
- `pricing.py` — `Pricing` dataclass, per-model pricing dict, `KNOWN_MODELS`
- `files.py` — `Files` class (upload, list, delete)
- `vectors.py` — `Vectors` class (vector store CRUD)
- `cli.py` — CLI client with interactive, batch, multiline, web-search, debug modes
- `gui.py` — Tkinter GUI client with conversation browser

Expand Down Expand Up @@ -42,12 +48,12 @@ Tests live in `tests/`. The test suite (`tests/e2e_test.py`) calls the **real Op

## Architecture principles

### `core.py` is client-agnostic
### `libopenai/` is client-agnostic

`core.py` is a reusable library. It must not reference or special-case any specific caller. New clients (web frontends, backend libraries, …) will be added over time.
`libopenai/` is a reusable library package. It must not reference or special-case any specific caller. New clients (web frontends, backend libraries, …) will be added over time.

Follow **SOLID**, especially:
- **Single Responsibility** — `core.py` handles AI interaction only. I/O, presentation, and session management belong to the caller.
- **Single Responsibility** — `libopenai/` handles AI interaction only. I/O, presentation, and session management belong to the caller.
- **Open/Closed** — extend behaviour through the defined callback and slot protocol; never add branches that check which kind of caller is in use.

### Caller protocol for `main()` and `one_shot()`
Expand All @@ -69,8 +75,8 @@ An event-driven caller sets them on the instance before signalling `input()` to

## Code conventions

- No `pyproject.toml` or `setup.py` — this is a flat script-based repo, not a package.
- Imports: stdlib first, then third-party (`openai`, `rich`, etc.), then local (`core`). Ruff enforces this.
- No `pyproject.toml` or `setup.py` — the shared library lives in `libopenai/`; callers are flat scripts.
- Imports: stdlib first, then third-party (`openai`, `rich`, etc.), then local (`libopenai`). Ruff enforces this.
- `GptCore` uses an input/output callback pattern — `input()` returns a string or `None`, `output(msg, info)` displays results.

## When adding a new CLI flag
Expand Down Expand Up @@ -100,7 +106,7 @@ You may be running in a git worktree (e.g. `.claude/worktrees/...`). Your cwd is
## Things to avoid

- Don't add type stubs, docstrings, or comments to code you didn't change.
- Don't restructure into a package — the flat layout is intentional.
- Keep `libopenai/` as the shared library package; callers remain flat scripts.
- Don't add dependencies without strong justification.
- Don't run `make test` for changes that don't touch core logic — it costs real money.
- Don't modify `tests/conftest.py` cleanup logic without understanding side effects.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ pip install openai tkinterweb mistletoe pygments
### API Key

Expects an `OPENAI_API_KEY` environment variable, or a `.api_key` file in the repo
directory as a fallback. The filename is in `.gitignore` already.
directory or data directory (`~/.chatgpt-gui/`) as a fallback. The filename is in
`.gitignore` already.

Conversations are automatically saved as JSON files in `~/.chatgpt-gui/`. Override the
location with the `CHATGPT_GUI_DATA_DIR` environment variable:
Expand Down
72 changes: 38 additions & 34 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from rich.console import Console
from rich.markdown import Markdown

import core
from libopenai import core
from libopenai.pricing import KNOWN_MODELS
from libopenai.constants import DEFAULT_MODEL
from libopenai.files import Files
from libopenai.vectors import Vectors

console = Console()

Expand Down Expand Up @@ -184,8 +188,8 @@ def main():
parser.add_argument(
"-M",
"--model",
default=core.DEFAULT_MODEL,
help=f"Use different model than {core.DEFAULT_MODEL}",
default=DEFAULT_MODEL,
help=f"Use different model than {DEFAULT_MODEL}",
)
parser.add_argument(
"-b",
Expand Down Expand Up @@ -264,10 +268,9 @@ def main():
args = parser.parse_args()

if args.command == "files":
core.load_key()
gpt = core.GptCore(None, None, None)
files_api = Files()
if args.files_command == "list":
files = gpt.list_files()
files = files_api.list_files()
if not files:
return
rows = [
Expand All @@ -281,23 +284,22 @@ def main():
)
elif args.files_command == "add":
for path in args.files:
file_id = gpt.upload_file(path, "user_data")
file_id = files_api.upload_file(path, "user_data")
print(file_id)
elif args.files_command == "delete":
for file_id in args.ids:
gpt.delete_file(file_id)
files_api.delete_file(file_id)
elif args.files_command == "purge":
for file_id, *_ in gpt.list_files():
gpt.delete_file(file_id)
for file_id, *_ in files_api.list_files():
files_api.delete_file(file_id)
else:
files_parser.print_help()
return

if args.command == "vectors":
core.load_key()
gpt = core.GptCore(None, None, None)
vectors_api = Vectors()
if args.vectors_command == "list":
stores = gpt.list_vector_stores()
stores = vectors_api.list_vector_stores()
if not stores:
return
rows = [
Expand All @@ -306,21 +308,23 @@ def main():
]
print_table(("ID", "NAME", "STATUS", "CREATED_AT"), rows)
elif args.vectors_command == "create":
vs_id = gpt.create_vector_store(args.name)
for path in args.files:
file_id = gpt.upload_file(path, "assistants")
gpt.add_vector_store_file(vs_id, file_id)
vs_id = vectors_api.create_vector_store(args.name)
if args.files:
files_api = Files(vectors_api.client)
for path in args.files:
file_id = files_api.upload_file(path, "assistants")
vectors_api.add_vector_store_file(vs_id, file_id)
if args.files and not args.no_wait:
gpt.wait_for_vector_store(vs_id)
vectors_api.wait_for_vector_store(vs_id)
print(vs_id)
elif args.vectors_command == "delete":
gpt.delete_vector_store(args.id)
vectors_api.delete_vector_store(args.id)
elif args.vectors_command == "purge":
for vsid, *_ in gpt.list_vector_stores():
gpt.delete_vector_store(vsid)
for vsid, *_ in vectors_api.list_vector_stores():
vectors_api.delete_vector_store(vsid)
elif args.vectors_command == "files":
if args.vectors_files_command == "list":
files = gpt.list_vector_store_files(args.id)
files = vectors_api.list_vector_store_files(args.id)
if not files:
return
rows = [
Expand All @@ -329,19 +333,21 @@ def main():
]
print_table(("ID", "STATUS", "CREATED_AT"), rows)
elif args.vectors_files_command == "add":
for path in args.files:
file_id = gpt.upload_file(path, "assistants")
gpt.add_vector_store_file(args.id, file_id)
if args.files:
files_api = Files(vectors_api.client)
for path in args.files:
file_id = files_api.upload_file(path, "assistants")
vectors_api.add_vector_store_file(args.id, file_id)
if not args.no_wait:
gpt.wait_for_vector_store(args.id)
vectors_api.wait_for_vector_store(args.id)
elif args.vectors_files_command == "add-id":
for file_id in args.file_ids:
gpt.add_vector_store_file(args.id, file_id)
vectors_api.add_vector_store_file(args.id, file_id)
if not args.no_wait:
gpt.wait_for_vector_store(args.id)
vectors_api.wait_for_vector_store(args.id)
elif args.vectors_files_command == "delete":
for file_id in args.file_ids:
gpt.delete_vector_store_file(args.id, file_id)
vectors_api.delete_vector_store_file(args.id, file_id)
else:
vectors_files_parser.print_help()
else:
Expand All @@ -353,7 +359,7 @@ def main():
any(list_opts)
and (
args.multiline
or args.model != core.DEFAULT_MODEL
or args.model != DEFAULT_MODEL
or args.batch_mode
or args.prepend
or args.prepend_file
Expand All @@ -372,14 +378,12 @@ def main():
)

if args.list_known:
for m in core.KNOWN_MODELS:
for m in KNOWN_MODELS:
print(m)
return

core.load_key()

if args.list_all:
all_models = core.GptCore(None, None, None).list_models()
all_models = core.GptCore().list_models()
for m in all_models:
print(m)
return
Expand Down
17 changes: 7 additions & 10 deletions dale.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,10 @@
import string
import time

from openai import OpenAI
from libopenai.auth import initialize_client

from core import load_key

load_key()
client = OpenAI()


def generate(prompt):
def generate(client, prompt):
response = client.images.generate(
model="dall-e-3",
prompt=prompt,
Expand Down Expand Up @@ -53,12 +48,14 @@ def main():
args = parser.parse_args()

prompts = [args.prompt] * args.amount
client = initialize_client()

if args.jobs > 1:
with multiprocessing.Pool(args.jobs) as pool:
pool.map(generate, prompts)
pool.map(lambda prompt: generate(client, prompt), prompts)
else:
for p in prompts:
generate(p)
for prompt in prompts:
generate(client, prompt)


if __name__ == "__main__":
Expand Down
8 changes: 3 additions & 5 deletions example-basic-interaction.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
#!/usr/bin/env python3
import openai
import sys

import core
from libopenai.auth import initialize_client

MODEL = "gpt-5.3-codex"
MODEL = "gpt-5.4"

core.load_key()
client = openai.OpenAI()
client = initialize_client()

prompt = " ".join(sys.argv[1:])

Expand Down
8 changes: 2 additions & 6 deletions example-reasoning-on-multiple-documents.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
#!/usr/bin/env python3
from openai import OpenAI
from libopenai.auth import initialize_client

from core import load_key

load_key()

client = OpenAI()
client = initialize_client()

# Upload
apple = client.files.create(
Expand Down
8 changes: 2 additions & 6 deletions example-vector-search-in-multiple-documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,9 @@
import os
import time

from openai import OpenAI
from libopenai.auth import initialize_client

from core import load_key

# Basic client setup
load_key()
client = OpenAI()
client = initialize_client()

# Create vector store

Expand Down
21 changes: 11 additions & 10 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,19 @@
from mistletoe import markdown
from mistletoe.contrib.pygments_renderer import PygmentsRenderer

from core import (
GptCore,
load_key,
from libopenai.auth import initialize_client
from libopenai.core import GptCore
from libopenai.vectors import Vectors
from libopenai.constants import (
DATA_DIRECTORY,
DEFAULT_MODEL,
KNOWN_MODELS,
IMAGE_EXTENSIONS,
USER_DATA_EXTENSIONS,
)
from libopenai.pricing import KNOWN_MODELS

TEMPORARY_VECTOR_STORE = "(temporary)"

load_key()


class JsonViewerApp(tk.Tk):
def __init__(self):
Expand Down Expand Up @@ -221,6 +220,8 @@ def __init__(self):
# Bind Enter key to send message (Shift+Enter for newline)
self.input_text.bind("<Return>", self.on_enter)

self.client = initialize_client()

# Core and conversation state:
# _cores — one live GptCore (with its thread) per file path; reused on
# switch-back so in-progress requests survive navigation
Expand Down Expand Up @@ -374,7 +375,7 @@ def initialize_gpt_core(self, existing_messages, file_path):
if key in self._cores:
self.gpt_core = self._cores[key]
return
core = GptCore()
core = GptCore(client=self.client)
core.messages = [
{"role": m["role"], "content": m["content"]} for m in existing_messages
]
Expand All @@ -384,7 +385,7 @@ def initialize_gpt_core(self, existing_messages, file_path):
def new_conversation(self):
"""Start a blank conversation — GptCore.__init__ sets messages=[] and a fresh file path."""
self._save_draft()
core = GptCore()
core = GptCore(client=self.client)
self._launch_core(core)
self._set_ui_idle()
self.input_text.delete("1.0", END)
Expand Down Expand Up @@ -668,7 +669,7 @@ def _fetch_all_models(self):

def do_fetch():
try:
models = GptCore().list_models()
models = GptCore(client=self.client).list_models()
except Exception:
models = KNOWN_MODELS

Expand All @@ -693,7 +694,7 @@ def _fetch_vector_stores(self):

def do_fetch():
try:
stores = GptCore().list_vector_stores()
stores = Vectors(self.client).list_vector_stores()
except Exception:
stores = []

Expand Down
Empty file added libopenai/__init__.py
Empty file.
29 changes: 29 additions & 0 deletions libopenai/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import os

import openai

from .constants import DATA_DIRECTORY


def ensure_key():
if "OPENAI_API_KEY" in os.environ:
return

paths_to_search = [
os.path.join(os.path.dirname(__file__), "..", ".api_key"),
os.path.join(DATA_DIRECTORY, ".api_key"),
]
for api_key_path in paths_to_search:
if os.path.isfile(api_key_path) or os.path.islink(api_key_path):
with open(api_key_path, "r") as f:
os.environ["OPENAI_API_KEY"] = f.read().strip()
return
raise RuntimeError("API key not found.")


def initialize_client(client=None):
if client is None:
ensure_key()
return openai.OpenAI()
else:
return client
Loading
Loading