From c0904647b1715b9ab6521d81431eea06d58e2b0b Mon Sep 17 00:00:00 2001 From: Roman Pavelka Date: Sat, 18 Apr 2026 13:06:33 +0200 Subject: [PATCH 1/3] Constants refactored to separate file --- cli.py | 18 +++++---- gui.py | 8 ++-- libopenai/__init__.py | 0 libopenai/constants.py | 27 ++++++++++++++ core.py => libopenai/core.py | 71 +++--------------------------------- libopenai/pricing.py | 29 +++++++++++++++ 6 files changed, 77 insertions(+), 76 deletions(-) create mode 100644 libopenai/__init__.py create mode 100644 libopenai/constants.py rename core.py => libopenai/core.py (89%) create mode 100644 libopenai/pricing.py diff --git a/cli.py b/cli.py index a6899fa..272249e 100755 --- a/cli.py +++ b/cli.py @@ -10,7 +10,9 @@ 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 console = Console() @@ -184,8 +186,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", @@ -265,7 +267,7 @@ def main(): if args.command == "files": core.load_key() - gpt = core.GptCore(None, None, None) + gpt = core.GptCore() if args.files_command == "list": files = gpt.list_files() if not files: @@ -295,7 +297,7 @@ def main(): if args.command == "vectors": core.load_key() - gpt = core.GptCore(None, None, None) + gpt = core.GptCore() if args.vectors_command == "list": stores = gpt.list_vector_stores() if not stores: @@ -353,7 +355,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 @@ -372,14 +374,14 @@ 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 diff --git a/gui.py b/gui.py index ad1058d..56f7dc9 100755 --- a/gui.py +++ b/gui.py @@ -23,15 +23,17 @@ from mistletoe import markdown from mistletoe.contrib.pygments_renderer import PygmentsRenderer -from core import ( +from libopenai.core import ( GptCore, - load_key, + load_key +) +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)" diff --git a/libopenai/__init__.py b/libopenai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libopenai/constants.py b/libopenai/constants.py new file mode 100644 index 0000000..3f827a3 --- /dev/null +++ b/libopenai/constants.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +DEFAULT_MODEL = "gpt-5.4" + +IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif") +USER_DATA_EXTENSIONS = ( + ".csv", + ".doc", + ".docx", + ".html", + ".json", + ".md", + ".odt", + ".pdf", + ".ppt", + ".pptx", + ".rtf", + ".txt", + ".xls", + ".xlsx", + ".xml", +) + +DATA_DIRECTORY = Path( + os.environ.get("CHATGPT_GUI_DATA_DIR", Path.home() / ".chatgpt-gui") +).expanduser() diff --git a/core.py b/libopenai/core.py similarity index 89% rename from core.py rename to libopenai/core.py index 26ca83d..63ba188 100644 --- a/core.py +++ b/libopenai/core.py @@ -10,72 +10,13 @@ import openai -DEFAULT_MODEL = "gpt-5.4" - -IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif") -USER_DATA_EXTENSIONS = ( - ".csv", - ".doc", - ".docx", - ".html", - ".json", - ".md", - ".odt", - ".pdf", - ".ppt", - ".pptx", - ".rtf", - ".txt", - ".xls", - ".xlsx", - ".xml", -) - -# Prices in USD, source: https://openai.com/api/pricing/ -USD_PER_INPUT_TOKEN = { - "o1": 15e-6, - "o3-pro": 20e-6, - "o3-mini": 1.1e-6, - "o4-mini": 1.1e-6, - "gpt-5": 1.25e-6, - "gpt-5.1": 1.25e-6, - "gpt-5.2": 1.75e-6, - "gpt-5.2-codex": 1.75e-6, - "gpt-5.3-codex": 1.75e-6, - "gpt-5.4-nano": 0.2e-6, - "gpt-5.4-mini": 0.75e-6, - "gpt-5.4": 2.5e-6, - "gpt-5.4-pro": 30e-6, -} -USD_PER_OUTPUT_TOKEN = { - "o1": 60e-6, - "o3-pro": 80e-6, - "o3-mini": 4.4e-6, - "o4-mini": 4.4e-6, - "gpt-5": 10e-6, - "gpt-5.1": 10e-6, - "gpt-5.2": 14e-6, - "gpt-5.2-codex": 14e-6, - "gpt-5.3-codex": 14e-6, - "gpt-5.4-nano": 1.25e-6, - "gpt-5.4-mini": 4.5e-6, - "gpt-5.4": 15e-6, - "gpt-5.4-pro": 180e-6, -} -assert set(USD_PER_INPUT_TOKEN.keys()) == set(USD_PER_OUTPUT_TOKEN.keys()) - -KNOWN_MODELS = sorted(USD_PER_INPUT_TOKEN.keys()) - -USD_PER_WEB_SEARCH_CALL = 0.01 - -DATA_DIRECTORY = Path( - os.environ.get("CHATGPT_GUI_DATA_DIR", Path.home() / ".chatgpt-gui") -).expanduser() +from .pricing import USD_PER_TOKEN, USD_PER_WEB_SEARCH_CALL +from .constants import DEFAULT_MODEL, DATA_DIRECTORY def load_key(): if "OPENAI_API_KEY" not in os.environ: - api_key_path = os.path.join(os.path.dirname(__file__), ".api_key") + api_key_path = os.path.join(os.path.dirname(__file__), "..", ".api_key") with open(api_key_path, "r") as f: os.environ["OPENAI_API_KEY"] = f.read().strip() @@ -150,10 +91,10 @@ def __init__( self.client = openai.OpenAI() def _compute_price(self, input_tokens, output_tokens, web_search_calls=0): - if self.model in USD_PER_INPUT_TOKEN and self.model in USD_PER_OUTPUT_TOKEN: + if self.model in USD_PER_TOKEN: return ( - USD_PER_INPUT_TOKEN[self.model] * input_tokens - + USD_PER_OUTPUT_TOKEN[self.model] * output_tokens + USD_PER_TOKEN[self.model].input * input_tokens + + USD_PER_TOKEN[self.model].output * output_tokens + USD_PER_WEB_SEARCH_CALL * web_search_calls ) return None diff --git a/libopenai/pricing.py b/libopenai/pricing.py new file mode 100644 index 0000000..5421006 --- /dev/null +++ b/libopenai/pricing.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Pricing: + input: float + output: float + + +# Prices in USD, source: https://openai.com/api/pricing/ +USD_PER_TOKEN = { + "o1": Pricing(15e-6, 60e-6), + "o3-pro": Pricing(20e-6, 80e-6), + "o3-mini": Pricing(1.1e-6, 4.4e-6), + "o4-mini": Pricing(1.1e-6, 4.4e-6), + "gpt-5": Pricing(1.25e-6, 10e-6), + "gpt-5.1": Pricing(1.25e-6, 10e-6), + "gpt-5.2": Pricing(1.75e-6, 14e-6), + "gpt-5.2-codex": Pricing(1.75e-6, 14e-6), + "gpt-5.3-codex": Pricing(1.75e-6, 14e-6), + "gpt-5.4-nano": Pricing(0.2e-6, 1.25e-6), + "gpt-5.4-mini": Pricing(0.75e-6, 4.5e-6), + "gpt-5.4": Pricing(2.5e-6, 15e-6), + "gpt-5.4-pro": Pricing(30e-6, 180e-6), +} + +KNOWN_MODELS = sorted(USD_PER_TOKEN.keys()) + +USD_PER_WEB_SEARCH_CALL = 0.01 From 0c450a00817bf19d4ed0b86929e3a0c24afa3812 Mon Sep 17 00:00:00 2001 From: Roman Pavelka Date: Sat, 18 Apr 2026 16:44:39 +0200 Subject: [PATCH 2/3] Refactor API key loading --- cli.py | 4 ---- dale.py | 4 ++-- example-basic-interaction.py | 6 +++--- example-reasoning-on-multiple-documents.py | 5 ++--- ...ple-vector-search-in-multiple-documents.py | 5 ++--- gui.py | 7 +------ libopenai/auth.py | 19 +++++++++++++++++++ libopenai/core.py | 9 ++------- whisper.py | 4 ++-- 9 files changed, 33 insertions(+), 30 deletions(-) create mode 100644 libopenai/auth.py diff --git a/cli.py b/cli.py index 272249e..7a3baec 100755 --- a/cli.py +++ b/cli.py @@ -266,7 +266,6 @@ def main(): args = parser.parse_args() if args.command == "files": - core.load_key() gpt = core.GptCore() if args.files_command == "list": files = gpt.list_files() @@ -296,7 +295,6 @@ def main(): return if args.command == "vectors": - core.load_key() gpt = core.GptCore() if args.vectors_command == "list": stores = gpt.list_vector_stores() @@ -378,8 +376,6 @@ def main(): print(m) return - core.load_key() - if args.list_all: all_models = core.GptCore().list_models() for m in all_models: diff --git a/dale.py b/dale.py index 517e9cf..5cde1c6 100755 --- a/dale.py +++ b/dale.py @@ -8,9 +8,9 @@ from openai import OpenAI -from core import load_key +from libopenai.auth import ensure_key -load_key() +ensure_key() client = OpenAI() diff --git a/example-basic-interaction.py b/example-basic-interaction.py index 8f480a7..228fbc1 100755 --- a/example-basic-interaction.py +++ b/example-basic-interaction.py @@ -2,11 +2,11 @@ import openai import sys -import core +from libopenai.auth import ensure_key -MODEL = "gpt-5.3-codex" +MODEL = "gpt-5.4" -core.load_key() +ensure_key() client = openai.OpenAI() prompt = " ".join(sys.argv[1:]) diff --git a/example-reasoning-on-multiple-documents.py b/example-reasoning-on-multiple-documents.py index 894bdff..ba1a9e9 100755 --- a/example-reasoning-on-multiple-documents.py +++ b/example-reasoning-on-multiple-documents.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 from openai import OpenAI -from core import load_key - -load_key() +from libopenai.auth import ensure_key +ensure_key() client = OpenAI() # Upload diff --git a/example-vector-search-in-multiple-documents.py b/example-vector-search-in-multiple-documents.py index 7a40a07..75fbd7a 100755 --- a/example-vector-search-in-multiple-documents.py +++ b/example-vector-search-in-multiple-documents.py @@ -13,10 +13,9 @@ from openai import OpenAI -from core import load_key +from libopenai.auth import ensure_key -# Basic client setup -load_key() +ensure_key() client = OpenAI() # Create vector store diff --git a/gui.py b/gui.py index 56f7dc9..424dc06 100755 --- a/gui.py +++ b/gui.py @@ -23,10 +23,7 @@ from mistletoe import markdown from mistletoe.contrib.pygments_renderer import PygmentsRenderer -from libopenai.core import ( - GptCore, - load_key -) +from libopenai.core import GptCore from libopenai.constants import ( DATA_DIRECTORY, DEFAULT_MODEL, @@ -37,8 +34,6 @@ TEMPORARY_VECTOR_STORE = "(temporary)" -load_key() - class JsonViewerApp(tk.Tk): def __init__(self): diff --git a/libopenai/auth.py b/libopenai/auth.py new file mode 100644 index 0000000..08f0f4a --- /dev/null +++ b/libopenai/auth.py @@ -0,0 +1,19 @@ +import os + +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.") diff --git a/libopenai/core.py b/libopenai/core.py index 63ba188..c2271f4 100644 --- a/libopenai/core.py +++ b/libopenai/core.py @@ -12,13 +12,7 @@ from .pricing import USD_PER_TOKEN, USD_PER_WEB_SEARCH_CALL from .constants import DEFAULT_MODEL, DATA_DIRECTORY - - -def load_key(): - if "OPENAI_API_KEY" not in os.environ: - api_key_path = os.path.join(os.path.dirname(__file__), "..", ".api_key") - with open(api_key_path, "r") as f: - os.environ["OPENAI_API_KEY"] = f.read().strip() +from .auth import ensure_key def _extract_sources(response): @@ -88,6 +82,7 @@ def __init__( self.save_callback = None + ensure_key() self.client = openai.OpenAI() def _compute_price(self, input_tokens, output_tokens, web_search_calls=0): diff --git a/whisper.py b/whisper.py index 3e070ee..bca2e08 100755 --- a/whisper.py +++ b/whisper.py @@ -3,9 +3,9 @@ from openai import OpenAI -from core import load_key +from libopenai.auth import ensure_key -load_key() +ensure_key() client = OpenAI() with open(sys.argv[1], "rb") as audio_file: From daef8dbf8b6f15c494c8f70ce81223222d968a53 Mon Sep 17 00:00:00 2001 From: Roman Pavelka Date: Sat, 18 Apr 2026 18:55:15 +0200 Subject: [PATCH 3/3] Refactoring into separate files --- AGENTS.md | 20 ++- README.md | 3 +- cli.py | 54 ++++---- dale.py | 17 +-- example-basic-interaction.py | 6 +- example-reasoning-on-multiple-documents.py | 7 +- ...ple-vector-search-in-multiple-documents.py | 7 +- gui.py | 12 +- libopenai/auth.py | 14 +- libopenai/core.py | 129 ++++-------------- libopenai/files.py | 41 ++++++ libopenai/vectors.py | 55 ++++++++ tests/conftest.py | 4 +- tests/e2e_test.py | 28 ++-- tests/test_concurrency.py | 21 +++ whisper.py | 8 +- 16 files changed, 235 insertions(+), 191 deletions(-) create mode 100644 libopenai/files.py create mode 100644 libopenai/vectors.py create mode 100644 tests/test_concurrency.py diff --git a/AGENTS.md b/AGENTS.md index 4784f6a..fcf9996 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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()` @@ -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 @@ -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. diff --git a/README.md b/README.md index 2e41f45..b5c312e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cli.py b/cli.py index 7a3baec..53b88cc 100755 --- a/cli.py +++ b/cli.py @@ -13,6 +13,8 @@ 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() @@ -266,9 +268,9 @@ def main(): args = parser.parse_args() if args.command == "files": - gpt = core.GptCore() + files_api = Files() if args.files_command == "list": - files = gpt.list_files() + files = files_api.list_files() if not files: return rows = [ @@ -282,22 +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": - gpt = core.GptCore() + vectors_api = Vectors() if args.vectors_command == "list": - stores = gpt.list_vector_stores() + stores = vectors_api.list_vector_stores() if not stores: return rows = [ @@ -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 = [ @@ -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: diff --git a/dale.py b/dale.py index 5cde1c6..16db0ae 100755 --- a/dale.py +++ b/dale.py @@ -6,15 +6,10 @@ import string import time -from openai import OpenAI +from libopenai.auth import initialize_client -from libopenai.auth import ensure_key -ensure_key() -client = OpenAI() - - -def generate(prompt): +def generate(client, prompt): response = client.images.generate( model="dall-e-3", prompt=prompt, @@ -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__": diff --git a/example-basic-interaction.py b/example-basic-interaction.py index 228fbc1..7a46395 100755 --- a/example-basic-interaction.py +++ b/example-basic-interaction.py @@ -1,13 +1,11 @@ #!/usr/bin/env python3 -import openai import sys -from libopenai.auth import ensure_key +from libopenai.auth import initialize_client MODEL = "gpt-5.4" -ensure_key() -client = openai.OpenAI() +client = initialize_client() prompt = " ".join(sys.argv[1:]) diff --git a/example-reasoning-on-multiple-documents.py b/example-reasoning-on-multiple-documents.py index ba1a9e9..be4f79f 100755 --- a/example-reasoning-on-multiple-documents.py +++ b/example-reasoning-on-multiple-documents.py @@ -1,10 +1,7 @@ #!/usr/bin/env python3 -from openai import OpenAI +from libopenai.auth import initialize_client -from libopenai.auth import ensure_key - -ensure_key() -client = OpenAI() +client = initialize_client() # Upload apple = client.files.create( diff --git a/example-vector-search-in-multiple-documents.py b/example-vector-search-in-multiple-documents.py index 75fbd7a..af63de9 100755 --- a/example-vector-search-in-multiple-documents.py +++ b/example-vector-search-in-multiple-documents.py @@ -11,12 +11,9 @@ import os import time -from openai import OpenAI +from libopenai.auth import initialize_client -from libopenai.auth import ensure_key - -ensure_key() -client = OpenAI() +client = initialize_client() # Create vector store diff --git a/gui.py b/gui.py index 424dc06..d19d299 100755 --- a/gui.py +++ b/gui.py @@ -23,7 +23,9 @@ from mistletoe import markdown from mistletoe.contrib.pygments_renderer import PygmentsRenderer +from libopenai.auth import initialize_client from libopenai.core import GptCore +from libopenai.vectors import Vectors from libopenai.constants import ( DATA_DIRECTORY, DEFAULT_MODEL, @@ -218,6 +220,8 @@ def __init__(self): # Bind Enter key to send message (Shift+Enter for newline) self.input_text.bind("", 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 @@ -371,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 ] @@ -381,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) @@ -665,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 @@ -690,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 = [] diff --git a/libopenai/auth.py b/libopenai/auth.py index 08f0f4a..3c680f6 100644 --- a/libopenai/auth.py +++ b/libopenai/auth.py @@ -1,15 +1,17 @@ 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") + 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): @@ -17,3 +19,11 @@ def ensure_key(): 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 diff --git a/libopenai/core.py b/libopenai/core.py index c2271f4..aa8e691 100644 --- a/libopenai/core.py +++ b/libopenai/core.py @@ -1,5 +1,4 @@ import os -import time import uuid from dataclasses import dataclass from pathlib import Path @@ -8,11 +7,11 @@ from datetime import datetime as dt from pprint import pprint -import openai - from .pricing import USD_PER_TOKEN, USD_PER_WEB_SEARCH_CALL from .constants import DEFAULT_MODEL, DATA_DIRECTORY -from .auth import ensure_key +from .auth import initialize_client +from .files import Files +from .vectors import Vectors def _extract_sources(response): @@ -61,6 +60,7 @@ def __init__( model=DEFAULT_MODEL, web_search=False, debug=False, + client=None, ): # noqa: A002 (input is a callback, not the builtin) self.input = input self.output = output @@ -82,8 +82,9 @@ def __init__( self.save_callback = None - ensure_key() - self.client = openai.OpenAI() + self.client = initialize_client(client) + self.files_api = Files(self.client) + self.vectors_api = Vectors(self.client) def _compute_price(self, input_tokens, output_tokens, web_search_calls=0): if self.model in USD_PER_TOKEN: @@ -94,58 +95,31 @@ def _compute_price(self, input_tokens, output_tokens, web_search_calls=0): ) return None - def upload_file(self, path, purpose): - """Upload a file and return the file ID.""" - assert purpose in ("vision", "user_data", "assistants") - print(f"Uploading {Path(path).name}...", end="", file=sys.stderr, flush=True) - with open(path, "rb") as f: - file = self.client.files.create(file=f, purpose=purpose) - print(" done.", file=sys.stderr) - if os.environ.get("CHATGPT_CLI_LOG_UPLOAD_IDS"): - print(f"uploaded:{file.id}", file=sys.stderr) - return file.id - - def delete_file(self, file_id): - """Delete a previously uploaded file.""" - print(f"Deleting {file_id}...", end="", file=sys.stderr, flush=True) - self.client.files.delete(file_id) - print(" done.", file=sys.stderr) - - def wait_for_vector_store(self, vs_id): - """Block until a vector store has finished indexing.""" - print("Processing...", end="", file=sys.stderr, flush=True) - while True: - vs = self.client.vector_stores.retrieve(vs_id) - if vs.status == "completed": - break - print(".", end="", file=sys.stderr, flush=True) - time.sleep(2) - print(" done.", file=sys.stderr) - def _setup_vector_store(self, file_paths): """Upload files to a new vector store and wait for processing to complete.""" - vector_store = self.client.vector_stores.create(name=self.conversation_id) - self._vector_store_id = vector_store.id + self._vector_store_id = self.vectors_api.create_vector_store( + name=self.conversation_id + ) self._vector_store_owned = True if os.environ.get("CHATGPT_CLI_LOG_UPLOAD_IDS"): - print(f"vector_store:{vector_store.id}", file=sys.stderr) + print(f"vector_store:{self._vector_store_id}", file=sys.stderr) for path in file_paths: - file_id = self.upload_file(path, "assistants") + file_id = self.files_api.upload_file(path, "assistants") self._vector_files.append((Path(path).name, file_id)) - self.add_vector_store_file(vector_store.id, file_id) - self.wait_for_vector_store(vector_store.id) + self.vectors_api.add_vector_store_file(self._vector_store_id, file_id) + self.vectors_api.wait_for_vector_store(self._vector_store_id) def _teardown_vector_store(self): """Delete the vector store and its files.""" if self._vector_store_id and self._vector_store_owned: - self.delete_vector_store(self._vector_store_id) + self.vectors_api.delete_vector_store(self._vector_store_id) for _, file_id in self._vector_files: - self.delete_file(file_id) + self.files_api.delete_file(file_id) - def _teardown(self): - """Delete all uploaded files and the vector store.""" + def _teardown_files(self): + """Delete all uploaded user files and the vector store.""" for _, file_id in self._images + self._files: - self.delete_file(file_id) + self.files_api.delete_file(file_id) self._teardown_vector_store() def _save(self): @@ -159,12 +133,12 @@ def send(self, prompt, image_path=None, file_paths=None): content = [] if image_path: name = Path(image_path).name - file_id = self.upload_file(image_path, "vision") + file_id = self.files_api.upload_file(image_path, "vision") self._images.append((name, file_id)) content.append({"type": "input_image", "file_id": file_id}) for path in file_paths or []: name = Path(path).name - file_id = self.upload_file(path, "user_data") + file_id = self.files_api.upload_file(path, "user_data") self._files.append((name, file_id)) content.append({"type": "input_file", "file_id": file_id}) content.append({"type": "input_text", "text": prompt}) @@ -228,15 +202,17 @@ def _init_session( self._next_vectorize_paths = vectorize_file_paths def _consume_attachments(self): - """Set up any pending vector store, then return and clear the per-message slots.""" + """Set up any pending vector store and files, then return and clear the per-message slots.""" if self._next_vectorize_paths: if not self._vector_store_id: self._setup_vector_store(self._next_vectorize_paths) else: for path in self._next_vectorize_paths: - file_id = self.upload_file(path, "assistants") - self.add_vector_store_file(self._vector_store_id, file_id) - self.wait_for_vector_store(self._vector_store_id) + file_id = self.files_api.upload_file(path, "assistants") + self.vectors_api.add_vector_store_file( + self._vector_store_id, file_id + ) + self.vectors_api.wait_for_vector_store(self._vector_store_id) self._next_vectorize_paths = None image_path, file_paths = self._next_image_path, self._next_file_paths self._next_image_path = None @@ -279,56 +255,7 @@ def main( if one_shot: break finally: - self._teardown() - - def list_files(self): - """Return list of (id, filename, bytes, purpose, created_at, expires_at) tuples.""" - return [ - ( - f.id, - f.filename, - f.bytes, - f.purpose, - f.created_at, - getattr(f, "expires_at", None), - ) - for f in self.client.files.list().data - ] - - def create_vector_store(self, name): - """Create a new vector store and return its ID.""" - vs = self.client.vector_stores.create(name=name) - return vs.id - - def delete_vector_store(self, vs_id): - """Delete a vector store by ID.""" - print(f"Deleting {vs_id}...", end="", file=sys.stderr, flush=True) - self.client.vector_stores.delete(vs_id) - print(" done.", file=sys.stderr) - - def list_vector_stores(self): - """Return list of (id, name, status, created_at) tuples for vector stores.""" - return [ - (vs.id, vs.name or "", vs.status, vs.created_at) - for vs in self.client.vector_stores.list().data - ] - - def list_vector_store_files(self, vs_id): - """Return list of (id, status, created_at) tuples for files in a vector store.""" - return [ - (f.id, f.status, f.created_at) - for f in self.client.vector_stores.files.list(vector_store_id=vs_id).data - ] - - def add_vector_store_file(self, vs_id, file_id): - """Add a file to a vector store.""" - self.client.vector_stores.files.create(vector_store_id=vs_id, file_id=file_id) - - def delete_vector_store_file(self, vs_id, file_id): - """Remove a file from a vector store.""" - print(f"Deleting {file_id}...", end="", file=sys.stderr, flush=True) - self.client.vector_stores.files.delete(vector_store_id=vs_id, file_id=file_id) - print(" done.", file=sys.stderr) + self._teardown_files() def list_models(self): return sorted([m["id"] for m in self.client.models.list().to_dict()["data"]]) # type: ignore diff --git a/libopenai/files.py b/libopenai/files.py new file mode 100644 index 0000000..f755b14 --- /dev/null +++ b/libopenai/files.py @@ -0,0 +1,41 @@ +import os +import sys +from pathlib import Path + +from .auth import initialize_client + + +class Files: + def __init__(self, client=None): + self.client = initialize_client(client) + + def list_files(self): + """Return list of (id, filename, bytes, purpose, created_at, expires_at) tuples.""" + return [ + ( + f.id, + f.filename, + f.bytes, + f.purpose, + f.created_at, + getattr(f, "expires_at", None), + ) + for f in self.client.files.list().data + ] + + def upload_file(self, path, purpose): + """Upload a file and return the file ID.""" + assert purpose in ("vision", "user_data", "assistants") + print(f"Uploading {Path(path).name}...", end="", file=sys.stderr, flush=True) + with open(path, "rb") as f: + file = self.client.files.create(file=f, purpose=purpose) + print(" done.", file=sys.stderr) + if os.environ.get("CHATGPT_CLI_LOG_UPLOAD_IDS"): + print(f"uploaded:{file.id}", file=sys.stderr) + return file.id + + def delete_file(self, file_id): + """Delete a previously uploaded file.""" + print(f"Deleting {file_id}...", end="", file=sys.stderr, flush=True) + self.client.files.delete(file_id) + print(" done.", file=sys.stderr) diff --git a/libopenai/vectors.py b/libopenai/vectors.py new file mode 100644 index 0000000..12ae9d2 --- /dev/null +++ b/libopenai/vectors.py @@ -0,0 +1,55 @@ +import sys +import time + +from .auth import initialize_client + + +class Vectors: + def __init__(self, client=None): + self.client = initialize_client(client) + + def create_vector_store(self, name): + """Create a new vector store and return its ID.""" + vs = self.client.vector_stores.create(name=name) + return vs.id + + def wait_for_vector_store(self, vs_id): + """Block until a vector store has finished indexing.""" + print("Processing...", end="", file=sys.stderr, flush=True) + while True: + vs = self.client.vector_stores.retrieve(vs_id) + if vs.status == "completed": + break + print(".", end="", file=sys.stderr, flush=True) + time.sleep(1) + print(" done.", file=sys.stderr) + + def delete_vector_store(self, vs_id): + """Delete a vector store by ID.""" + print(f"Deleting {vs_id}...", end="", file=sys.stderr, flush=True) + self.client.vector_stores.delete(vs_id) + print(" done.", file=sys.stderr) + + def list_vector_stores(self): + """Return list of (id, name, status, created_at) tuples for vector stores.""" + return [ + (vs.id, vs.name or "", vs.status, vs.created_at) + for vs in self.client.vector_stores.list().data + ] + + def list_vector_store_files(self, vs_id): + """Return list of (id, status, created_at) tuples for files in a vector store.""" + return [ + (f.id, f.status, f.created_at) + for f in self.client.vector_stores.files.list(vector_store_id=vs_id).data + ] + + def add_vector_store_file(self, vs_id, file_id): + """Add a file to a vector store.""" + self.client.vector_stores.files.create(vector_store_id=vs_id, file_id=file_id) + + def delete_vector_store_file(self, vs_id, file_id): + """Remove a file from a vector store.""" + print(f"Deleting {file_id}...", end="", file=sys.stderr, flush=True) + self.client.vector_stores.files.delete(vector_store_id=vs_id, file_id=file_id) + print(" done.", file=sys.stderr) diff --git a/tests/conftest.py b/tests/conftest.py index e6aded7..9645af7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -import core +import libopenai.constants @pytest.fixture(autouse=True) @@ -13,5 +13,5 @@ def isolated_data_dir(tmp_path, monkeypatch): Both are restored automatically after each test. """ monkeypatch.setenv("CHATGPT_GUI_DATA_DIR", str(tmp_path)) - monkeypatch.setattr(core, "DATA_DIRECTORY", tmp_path) + monkeypatch.setattr(libopenai.constants, "DATA_DIRECTORY", tmp_path) yield diff --git a/tests/e2e_test.py b/tests/e2e_test.py index 3b59306..5bb6759 100644 --- a/tests/e2e_test.py +++ b/tests/e2e_test.py @@ -13,7 +13,7 @@ import pytest -from core import KNOWN_MODELS +from libopenai.pricing import KNOWN_MODELS CLI = os.path.join(os.path.dirname(__file__), "..", "cli.py") TEST_MODEL = "gpt-5.4-mini" @@ -702,6 +702,10 @@ def test_list_create_delete(self): ) assert rc == 0 + # files.list is eventually consistent: the detached file can linger + # for ~1s after delete returns deleted=True. + time.sleep(2) + # Must no longer appear in listing stdout, _, rc = run_cli(None, extra_args=["vectors", "list"], model=None) assert rc == 0 @@ -752,6 +756,9 @@ def test_files_add_id_list_delete(self): ) assert rc == 0 + # files.list is eventually consistent: the detached file can linger + # for ~1s after delete returns deleted=True. + time.sleep(2) # File must no longer appear in listing stdout, _, rc = run_cli( None, extra_args=["vectors", "files", "list", vs_id], model=None @@ -807,25 +814,6 @@ def test_files_add_by_path(self): run_cli(None, extra_args=["files", "delete", fid], model=None) -class TestConcurrency: - """Tests verifying safe parallel execution.""" - - def test_concurrent_sessions_have_unique_export_files(self, tmp_path): - """Two GptCore instances created at the same second must not share a JSON export path.""" - from unittest.mock import patch - from datetime import datetime - import core as core_module - - # Freeze time so both instances see the exact same timestamp — exposes the collision. - fixed = datetime(2026, 4, 10, 12, 0, 0) - with patch("core.dt") as mock_dt, patch("openai.OpenAI"): - mock_dt.now.return_value = fixed - c1 = core_module.GptCore(lambda: None, lambda *a: None, "gpt-5.4-mini") - c2 = core_module.GptCore(lambda: None, lambda *a: None, "gpt-5.4-mini") - - assert c1.file != c2.file - - class TestAllModelsSmokeTest: """Smoke test: verify every in-code-priced model can handle a minimal prompt.""" diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..f16dc48 --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,21 @@ +from unittest.mock import patch +from datetime import datetime + +import libopenai.core as core_module + + +class TestConcurrency: + """Tests verifying safe parallel execution.""" + + def test_concurrent_sessions_have_unique_export_files(self): + """Two GptCore instances created at the same second must not share a JSON export path.""" + + # Freeze time so both instances see the exact same timestamp — exposes the collision. + fixed = datetime(2026, 4, 10, 12, 0, 0) + with patch("libopenai.core.dt") as mock_dt, patch("openai.OpenAI"): + mock_dt.now.return_value = fixed + c1 = core_module.GptCore() + c2 = core_module.GptCore() + + assert c1.file + assert c1.file != c2.file diff --git a/whisper.py b/whisper.py index bca2e08..8bb926a 100755 --- a/whisper.py +++ b/whisper.py @@ -1,12 +1,8 @@ #!/usr/bin/env python3 import sys +from libopenai.auth import initialize_client -from openai import OpenAI - -from libopenai.auth import ensure_key - -ensure_key() -client = OpenAI() +client = initialize_client() with open(sys.argv[1], "rb") as audio_file: transcription = client.audio.transcriptions.create(