codex-chat-bot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ # Python bytecode and caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .tox/
9
+ .nox/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ env/
15
+ ENV/
16
+
17
+ # Build artifacts
18
+ build/
19
+ dist/
20
+ *.egg-info/
21
+
22
+ # Local secrets and environment files
23
+ .env
24
+ .env.*
25
+ !.env.example
26
+
27
+ # IDE and OS files
28
+ .idea/
29
+ .vscode/
30
+ .DS_Store
31
+ Thumbs.db
32
+
33
+ # Logs
34
+ *.log
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-chat-bot
3
+ Version: 0.1.0
4
+ Summary: A small single-session chat client for OpenAI-compatible Responses API endpoints.
5
+ Author-email: GGN_2015 <neko@jlulug.org>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: openai<3,>=1.99.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # codex-chat-bot
13
+
14
+ A small Python chat project with:
15
+
16
+ - a Python programming interface
17
+ - a command-line interface
18
+ - single-session memory
19
+ - configurable API key, base URL, model, and system rules
20
+
21
+ The project uses OpenAI SDK-compatible Responses API endpoints.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ python -m pip install codex-chat-bot
27
+ ```
28
+
29
+ ## Environment Variables
30
+
31
+ Only `CODEX_CHAT_*` environment variables are read:
32
+
33
+ ```bash
34
+ export CODEX_CHAT_API_KEY="your API key"
35
+ export CODEX_CHAT_BASE_URL="https://api.openai.com/v1"
36
+ export CODEX_CHAT_MODEL="gpt-5.5" # optional
37
+ ```
38
+
39
+ ## Command-Line Usage
40
+
41
+ Run a single chat turn:
42
+
43
+ ```bash
44
+ codex-chat "Write a Python hello world example."
45
+ ```
46
+
47
+ Start interactive chat:
48
+
49
+ ```bash
50
+ codex-chat
51
+ ```
52
+
53
+ If `codex-chat` is not on your PATH, run the module directly:
54
+
55
+ ```bash
56
+ python -m codex_chat_bot.cli
57
+ python -m codex_chat_bot.cli "Hello"
58
+ ```
59
+
60
+ Interactive commands:
61
+
62
+ - `/reset` clears the current session memory.
63
+ - `/import PATH` loads chat history from a JSON file.
64
+ - `/export PATH` saves chat history to a JSON file.
65
+ - `/exit` or `/quit` exits the chat.
66
+
67
+ Common options:
68
+
69
+ ```bash
70
+ codex-chat --api-key "your API key" --base-url "https://api.openai.com/v1" "Hello"
71
+ codex-chat --model gpt-5.5 --system "You are a concise programming assistant."
72
+ codex-chat --system-rule "Answer in English." --system-rule "Keep answers short." "Explain pytest."
73
+ codex-chat --system-rules-file ./rules.txt "Review this idea."
74
+ codex-chat --bind-history ./history.json "Continue our chat."
75
+ codex-chat --base-url "https://api.openai.com/v1" "Explain pytest."
76
+ ```
77
+
78
+ If `CODEX_CHAT_API_KEY` or `CODEX_CHAT_BASE_URL` is not set and the value was
79
+ not passed on the command line, interactive CLI startup prompts for the missing
80
+ value.
81
+
82
+ `--bind-history` loads the JSON file if it already exists, creates it if it does
83
+ not, and writes every new message to that file as the chat changes.
84
+
85
+ ## Python API
86
+
87
+ ```python
88
+ from codex_chat_bot import ChatConfig, ChatSession
89
+
90
+ session = ChatSession(ChatConfig.from_env())
91
+
92
+ print(session.ask("Remember that my project is named codex-chat-bot."))
93
+ print(session.ask("What is my project called?"))
94
+
95
+ session.bind_history("history.json")
96
+ session.ask("This message is saved automatically.")
97
+ ```
98
+
99
+ You can also pass configuration explicitly:
100
+
101
+ ```python
102
+ from codex_chat_bot import ChatConfig, ChatSession
103
+
104
+ config = ChatConfig(
105
+ api_key="your API key",
106
+ base_url="https://api.openai.com/v1",
107
+ model="gpt-5.5",
108
+ system_rules=(
109
+ "You are a patient Python programming assistant.",
110
+ "Answer in English.",
111
+ "Keep code examples runnable.",
112
+ ),
113
+ )
114
+
115
+ session = ChatSession(config)
116
+ answer = session.ask("Write a function that reads a JSON file.")
117
+ print(answer)
118
+ ```
119
+
120
+ `system_rules` are added to the session's initial system message and stay active
121
+ until the session is reset.
122
+
123
+ Chat history JSON uses a top-level `messages` array:
124
+
125
+ ```json
126
+ {
127
+ "messages": [
128
+ { "role": "system", "content": "You are a helpful assistant." },
129
+ { "role": "user", "content": "Hello" },
130
+ { "role": "assistant", "content": "Hi!" }
131
+ ]
132
+ }
133
+ ```
134
+
135
+ ## Tests
136
+
137
+ ```bash
138
+ python -m pytest
139
+ ```
@@ -0,0 +1,128 @@
1
+ # codex-chat-bot
2
+
3
+ A small Python chat project with:
4
+
5
+ - a Python programming interface
6
+ - a command-line interface
7
+ - single-session memory
8
+ - configurable API key, base URL, model, and system rules
9
+
10
+ The project uses OpenAI SDK-compatible Responses API endpoints.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ python -m pip install codex-chat-bot
16
+ ```
17
+
18
+ ## Environment Variables
19
+
20
+ Only `CODEX_CHAT_*` environment variables are read:
21
+
22
+ ```bash
23
+ export CODEX_CHAT_API_KEY="your API key"
24
+ export CODEX_CHAT_BASE_URL="https://api.openai.com/v1"
25
+ export CODEX_CHAT_MODEL="gpt-5.5" # optional
26
+ ```
27
+
28
+ ## Command-Line Usage
29
+
30
+ Run a single chat turn:
31
+
32
+ ```bash
33
+ codex-chat "Write a Python hello world example."
34
+ ```
35
+
36
+ Start interactive chat:
37
+
38
+ ```bash
39
+ codex-chat
40
+ ```
41
+
42
+ If `codex-chat` is not on your PATH, run the module directly:
43
+
44
+ ```bash
45
+ python -m codex_chat_bot.cli
46
+ python -m codex_chat_bot.cli "Hello"
47
+ ```
48
+
49
+ Interactive commands:
50
+
51
+ - `/reset` clears the current session memory.
52
+ - `/import PATH` loads chat history from a JSON file.
53
+ - `/export PATH` saves chat history to a JSON file.
54
+ - `/exit` or `/quit` exits the chat.
55
+
56
+ Common options:
57
+
58
+ ```bash
59
+ codex-chat --api-key "your API key" --base-url "https://api.openai.com/v1" "Hello"
60
+ codex-chat --model gpt-5.5 --system "You are a concise programming assistant."
61
+ codex-chat --system-rule "Answer in English." --system-rule "Keep answers short." "Explain pytest."
62
+ codex-chat --system-rules-file ./rules.txt "Review this idea."
63
+ codex-chat --bind-history ./history.json "Continue our chat."
64
+ codex-chat --base-url "https://api.openai.com/v1" "Explain pytest."
65
+ ```
66
+
67
+ If `CODEX_CHAT_API_KEY` or `CODEX_CHAT_BASE_URL` is not set and the value was
68
+ not passed on the command line, interactive CLI startup prompts for the missing
69
+ value.
70
+
71
+ `--bind-history` loads the JSON file if it already exists, creates it if it does
72
+ not, and writes every new message to that file as the chat changes.
73
+
74
+ ## Python API
75
+
76
+ ```python
77
+ from codex_chat_bot import ChatConfig, ChatSession
78
+
79
+ session = ChatSession(ChatConfig.from_env())
80
+
81
+ print(session.ask("Remember that my project is named codex-chat-bot."))
82
+ print(session.ask("What is my project called?"))
83
+
84
+ session.bind_history("history.json")
85
+ session.ask("This message is saved automatically.")
86
+ ```
87
+
88
+ You can also pass configuration explicitly:
89
+
90
+ ```python
91
+ from codex_chat_bot import ChatConfig, ChatSession
92
+
93
+ config = ChatConfig(
94
+ api_key="your API key",
95
+ base_url="https://api.openai.com/v1",
96
+ model="gpt-5.5",
97
+ system_rules=(
98
+ "You are a patient Python programming assistant.",
99
+ "Answer in English.",
100
+ "Keep code examples runnable.",
101
+ ),
102
+ )
103
+
104
+ session = ChatSession(config)
105
+ answer = session.ask("Write a function that reads a JSON file.")
106
+ print(answer)
107
+ ```
108
+
109
+ `system_rules` are added to the session's initial system message and stay active
110
+ until the session is reset.
111
+
112
+ Chat history JSON uses a top-level `messages` array:
113
+
114
+ ```json
115
+ {
116
+ "messages": [
117
+ { "role": "system", "content": "You are a helpful assistant." },
118
+ { "role": "user", "content": "Hello" },
119
+ { "role": "assistant", "content": "Hi!" }
120
+ ]
121
+ }
122
+ ```
123
+
124
+ ## Tests
125
+
126
+ ```bash
127
+ python -m pytest
128
+ ```
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codex-chat-bot"
7
+ version = "0.1.0"
8
+ description = "A small single-session chat client for OpenAI-compatible Responses API endpoints."
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "GGN_2015", email = "neko@jlulug.org" },
12
+ ]
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "openai>=1.99.0,<3",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ ]
22
+
23
+ [project.scripts]
24
+ codex-chat = "codex_chat_bot.cli:main"
25
+
26
+ [tool.pytest.ini_options]
27
+ addopts = "-q"
28
+ pythonpath = ["src"]
@@ -0,0 +1,19 @@
1
+ """Single-session chat helpers for OpenAI-compatible Responses API clients."""
2
+
3
+ from .config import ChatConfig
4
+ from .errors import ChatBotError, MissingAPIKeyError, MissingBaseURLError, ResponseTextError
5
+ from .session import ChatResponse, ChatSession, Message
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "ChatBotError",
11
+ "ChatConfig",
12
+ "ChatResponse",
13
+ "ChatSession",
14
+ "Message",
15
+ "MissingAPIKeyError",
16
+ "MissingBaseURLError",
17
+ "ResponseTextError",
18
+ "__version__",
19
+ ]
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .config import ChatConfig
12
+ from .errors import ChatBotError
13
+ from .session import ChatSession
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="codex-chat",
19
+ description="A simple single-session chat CLI for OpenAI-compatible endpoints.",
20
+ )
21
+ parser.add_argument(
22
+ "message",
23
+ nargs="*",
24
+ help="Run one chat turn and print the answer. Omit to start interactive mode.",
25
+ )
26
+ parser.add_argument("--api-key", help="API key. Defaults to CODEX_CHAT_API_KEY.")
27
+ parser.add_argument("--base-url", help="API base URL. Defaults to CODEX_CHAT_BASE_URL.")
28
+ parser.add_argument("--model", help="Model name. Defaults to CODEX_CHAT_MODEL or gpt-5.5.")
29
+ parser.add_argument(
30
+ "--system",
31
+ dest="system_rules",
32
+ action="append",
33
+ help="System instruction for this session. Repeat the option to add multiple instructions.",
34
+ )
35
+ parser.add_argument(
36
+ "--system-rule",
37
+ action="append",
38
+ dest="system_rules",
39
+ help="System rule for this session. Repeat the option to add multiple rules.",
40
+ )
41
+ parser.add_argument(
42
+ "--system-rules-file",
43
+ help="Read system rules from a UTF-8 text file, one non-empty line per rule.",
44
+ )
45
+ parser.add_argument("--temperature", type=float, help="Optional model temperature.")
46
+ parser.add_argument("--max-output-tokens", type=int, help="Optional response token limit.")
47
+ parser.add_argument(
48
+ "--max-history-messages",
49
+ type=int,
50
+ help="Keep only the latest N non-system messages in memory.",
51
+ )
52
+ parser.add_argument("--import-history", help="Load chat history from a JSON file before chatting.")
53
+ parser.add_argument("--bind-history", help="Bind chat history to a JSON file and save every update.")
54
+ parser.add_argument(
55
+ "--export-history",
56
+ nargs="?",
57
+ help=argparse.SUPPRESS,
58
+ )
59
+ parser.add_argument("--version", action="store_true", help="Print package version and exit.")
60
+ return parser
61
+
62
+
63
+ def main(argv: Sequence[str] | None = None) -> int:
64
+ parser = build_parser()
65
+ args = parser.parse_args(argv)
66
+
67
+ if args.version:
68
+ print(__version__)
69
+ return 0
70
+ if args.export_history:
71
+ parser.error("--export-history was renamed to --bind-history")
72
+
73
+ try:
74
+ system_rules = _collect_system_rules(args.system_rules, args.system_rules_file)
75
+ config = ChatConfig.from_env(
76
+ api_key=args.api_key,
77
+ base_url=args.base_url,
78
+ model=args.model,
79
+ system_rules=system_rules,
80
+ temperature=args.temperature,
81
+ max_output_tokens=args.max_output_tokens,
82
+ max_history_messages=args.max_history_messages,
83
+ )
84
+ config = _prompt_for_missing_required_config(config)
85
+ session = ChatSession(config)
86
+ if args.bind_history:
87
+ session.bind_history(args.bind_history)
88
+ elif args.import_history:
89
+ session.load_history(args.import_history)
90
+ except (ChatBotError, OSError, ValueError) as exc:
91
+ print(f"error: {exc}", file=sys.stderr)
92
+ return 1
93
+
94
+ return _run_session(session, args.message)
95
+
96
+
97
+ def _run_session(session: ChatSession, message_parts: list[str]) -> int:
98
+ try:
99
+ if message_parts:
100
+ return _run_one_shot(session, " ".join(message_parts))
101
+ return _run_interactive(session)
102
+ except KeyboardInterrupt:
103
+ print()
104
+ return 130
105
+
106
+
107
+ def _run_one_shot(session: ChatSession, message: str) -> int:
108
+ try:
109
+ print(session.ask(message))
110
+ return 0
111
+ except ChatBotError as exc:
112
+ print(f"error: {exc}", file=sys.stderr)
113
+ return 1
114
+
115
+
116
+ def _run_interactive(session: ChatSession) -> int:
117
+ print("Codex Chat Bot")
118
+ print("Commands: /reset clears this session, /import PATH loads history, /export PATH saves history, /exit quits.")
119
+
120
+ while True:
121
+ try:
122
+ user_text = input("you> ").strip()
123
+ except (EOFError, KeyboardInterrupt):
124
+ print()
125
+ return 0
126
+
127
+ if not user_text:
128
+ continue
129
+ if user_text in {"/exit", "/quit"}:
130
+ return 0
131
+ if user_text == "/reset":
132
+ session.reset()
133
+ print("session reset")
134
+ continue
135
+ if user_text.startswith("/import "):
136
+ _run_import_command(session, user_text.removeprefix("/import ").strip())
137
+ continue
138
+ if user_text.startswith("/export "):
139
+ _run_export_command(session, user_text.removeprefix("/export ").strip())
140
+ continue
141
+
142
+ try:
143
+ answer = session.ask(user_text)
144
+ except ChatBotError as exc:
145
+ print(f"error: {exc}", file=sys.stderr)
146
+ continue
147
+
148
+ print(f"assistant> {answer}")
149
+
150
+
151
+ def _run_import_command(session: ChatSession, path: str) -> None:
152
+ if not path:
153
+ print("usage: /import PATH")
154
+ return
155
+
156
+ try:
157
+ session.load_history(path)
158
+ except (OSError, ValueError) as exc:
159
+ print(f"error: {exc}", file=sys.stderr)
160
+ return
161
+
162
+ print(f"history imported from {path}")
163
+
164
+
165
+ def _run_export_command(session: ChatSession, path: str) -> None:
166
+ if not path:
167
+ print("usage: /export PATH")
168
+ return
169
+
170
+ try:
171
+ session.save_history(path)
172
+ except OSError as exc:
173
+ print(f"error: {exc}", file=sys.stderr)
174
+ return
175
+
176
+ print(f"history exported to {path}")
177
+
178
+
179
+ def _collect_system_rules(rules: list[str] | None, rules_file: str | None) -> list[str] | None:
180
+ collected: list[str] = []
181
+ if rules_file:
182
+ collected.extend(line.strip() for line in Path(rules_file).read_text(encoding="utf-8").splitlines())
183
+ if rules:
184
+ collected.extend(rules)
185
+
186
+ clean_rules = [rule.strip() for rule in collected if rule.strip()]
187
+ return clean_rules or None
188
+
189
+
190
+ def _prompt_for_missing_required_config(config: ChatConfig) -> ChatConfig:
191
+ api_key = _clean_optional_text(config.api_key)
192
+ base_url = _clean_optional_text(config.base_url)
193
+ if api_key and base_url:
194
+ return replace(config, api_key=api_key, base_url=base_url)
195
+
196
+ if not _is_interactive_input():
197
+ return replace(config, api_key=api_key, base_url=base_url)
198
+
199
+ prompted_api_key = api_key
200
+ prompted_base_url = base_url
201
+
202
+ try:
203
+ if not prompted_api_key:
204
+ print("No API key found in --api-key or CODEX_CHAT_API_KEY.", file=sys.stderr)
205
+ prompted_api_key = getpass.getpass("API key: ")
206
+ if not prompted_base_url:
207
+ print("No API base URL found in --base-url or CODEX_CHAT_BASE_URL.", file=sys.stderr)
208
+ prompted_base_url = input("Base URL: ")
209
+ except (EOFError, KeyboardInterrupt):
210
+ print(file=sys.stderr)
211
+ return replace(config, api_key=_clean_optional_text(prompted_api_key), base_url=_clean_optional_text(prompted_base_url))
212
+
213
+ return replace(
214
+ config,
215
+ api_key=_clean_optional_text(prompted_api_key),
216
+ base_url=_clean_optional_text(prompted_base_url),
217
+ )
218
+
219
+
220
+ def _clean_optional_text(value: str | None) -> str | None:
221
+ if value is None:
222
+ return None
223
+ value = value.strip()
224
+ return value or None
225
+
226
+
227
+ def _is_interactive_input() -> bool:
228
+ return sys.stdin.isatty()
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+
7
+
8
+ DEFAULT_MODEL = "gpt-5.5"
9
+ DEFAULT_SYSTEM_RULES = ("You are a helpful assistant.",)
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ChatConfig:
14
+ """Runtime configuration for a chat session."""
15
+
16
+ api_key: str | None = None
17
+ base_url: str | None = None
18
+ model: str = DEFAULT_MODEL
19
+ system_rules: tuple[str, ...] = DEFAULT_SYSTEM_RULES
20
+ temperature: float | None = None
21
+ max_output_tokens: int | None = None
22
+ max_history_messages: int | None = None
23
+ timeout: float | None = None
24
+ organization: str | None = None
25
+ project: str | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ object.__setattr__(self, "system_rules", _normalize_system_rules(self.system_rules))
29
+
30
+ @classmethod
31
+ def from_env(cls, **overrides: object) -> "ChatConfig":
32
+ values: dict[str, object] = {
33
+ "api_key": _first_env("CODEX_CHAT_API_KEY"),
34
+ "base_url": _first_env("CODEX_CHAT_BASE_URL"),
35
+ "model": _first_env("CODEX_CHAT_MODEL") or DEFAULT_MODEL,
36
+ "temperature": _optional_float(_first_env("CODEX_CHAT_TEMPERATURE")),
37
+ "max_output_tokens": _optional_int(_first_env("CODEX_CHAT_MAX_OUTPUT_TOKENS")),
38
+ "max_history_messages": _optional_int(_first_env("CODEX_CHAT_MAX_HISTORY_MESSAGES")),
39
+ "timeout": _optional_float(_first_env("CODEX_CHAT_TIMEOUT")),
40
+ "organization": _first_env("CODEX_CHAT_ORG_ID"),
41
+ "project": _first_env("CODEX_CHAT_PROJECT_ID"),
42
+ }
43
+
44
+ for key, value in overrides.items():
45
+ if value is not None:
46
+ values[key] = value
47
+
48
+ return cls(**values)
49
+
50
+
51
+ def _first_env(*names: str) -> str | None:
52
+ for name in names:
53
+ value = os.getenv(name)
54
+ if value:
55
+ return value
56
+ return None
57
+
58
+
59
+ def _optional_int(value: str | None) -> int | None:
60
+ if value is None:
61
+ return None
62
+ return int(value)
63
+
64
+
65
+ def _optional_float(value: str | None) -> float | None:
66
+ if value is None:
67
+ return None
68
+ return float(value)
69
+
70
+
71
+ def _normalize_system_rules(value: object) -> tuple[str, ...]:
72
+ if value is None:
73
+ return ()
74
+ if isinstance(value, str):
75
+ return tuple(line.strip() for line in value.splitlines() if line.strip())
76
+ if isinstance(value, Sequence):
77
+ return tuple(str(item).strip() for item in value if str(item).strip())
78
+ raise TypeError("system_rules must be a string, a sequence of strings, or None.")
@@ -0,0 +1,14 @@
1
+ class ChatBotError(Exception):
2
+ """Base exception for codex-chat-bot."""
3
+
4
+
5
+ class MissingAPIKeyError(ChatBotError):
6
+ """Raised when no API key is available."""
7
+
8
+
9
+ class MissingBaseURLError(ChatBotError):
10
+ """Raised when no API base URL is available."""
11
+
12
+
13
+ class ResponseTextError(ChatBotError):
14
+ """Raised when response text cannot be extracted from an API response."""
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from .config import ChatConfig
10
+ from .errors import MissingAPIKeyError, MissingBaseURLError, ResponseTextError
11
+
12
+
13
+ Role = Literal["system", "developer", "user", "assistant"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Message:
18
+ role: Role
19
+ content: str
20
+
21
+ def to_api(self) -> dict[str, str]:
22
+ return {"role": self.role, "content": self.content}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ChatResponse:
27
+ text: str
28
+ raw: Any
29
+ messages: tuple[Message, ...]
30
+
31
+
32
+ class ChatSession:
33
+ """Single-session chat state kept entirely in local memory."""
34
+
35
+ def __init__(self, config: ChatConfig | None = None, client: Any | None = None) -> None:
36
+ self.config = config or ChatConfig.from_env()
37
+ self._client = client or self._build_client()
38
+ self._messages: list[Message] = []
39
+ self._history_path: Path | None = None
40
+ self.reset()
41
+
42
+ @property
43
+ def messages(self) -> tuple[Message, ...]:
44
+ return tuple(self._messages)
45
+
46
+ def export_history_json(self) -> str:
47
+ payload = {"messages": [message.to_api() for message in self._messages]}
48
+ return json.dumps(payload, ensure_ascii=False, indent=4)
49
+
50
+ def save_history(self, path: str | Path) -> None:
51
+ Path(path).write_text(self.export_history_json() + "\n", encoding="utf-8")
52
+
53
+ def bind_history(self, path: str | Path) -> None:
54
+ history_path = Path(path)
55
+ previous_history_path = self._history_path
56
+ self._history_path = None
57
+ if history_path.exists():
58
+ try:
59
+ self.load_history(history_path)
60
+ except Exception:
61
+ self._history_path = previous_history_path
62
+ raise
63
+
64
+ self._history_path = history_path
65
+ self._save_bound_history()
66
+
67
+ def load_history_json(self, data: str) -> None:
68
+ try:
69
+ payload = json.loads(data)
70
+ except json.JSONDecodeError as exc:
71
+ raise ValueError("chat history must be valid JSON") from exc
72
+
73
+ messages = _messages_from_history_payload(payload)
74
+ self._messages = list(messages)
75
+ self._trim_history()
76
+ self._save_bound_history()
77
+
78
+ def load_history(self, path: str | Path) -> None:
79
+ self.load_history_json(Path(path).read_text(encoding="utf-8"))
80
+
81
+ def reset(self, system_rules: Sequence[str] | None = None) -> None:
82
+ self._messages.clear()
83
+ system_content = self._build_system_content(system_rules)
84
+ if system_content:
85
+ self._messages.append(Message(role="system", content=system_content))
86
+ self._save_bound_history()
87
+
88
+ def ask(self, message: str, **extra_request_args: Any) -> str:
89
+ return self.send(message, **extra_request_args).text
90
+
91
+ def send(self, message: str, **extra_request_args: Any) -> ChatResponse:
92
+ message = message.strip()
93
+ if not message:
94
+ raise ValueError("message cannot be empty")
95
+
96
+ self._messages.append(Message(role="user", content=message))
97
+ try:
98
+ response = self._client.responses.create(**self._request_payload(extra_request_args))
99
+ except Exception:
100
+ self._messages.pop()
101
+ self._save_bound_history()
102
+ raise
103
+
104
+ text = _extract_response_text(response)
105
+ self._messages.append(Message(role="assistant", content=text))
106
+ self._trim_history()
107
+ self._save_bound_history()
108
+ return ChatResponse(text=text, raw=response, messages=self.messages)
109
+
110
+ def _request_payload(self, extra_request_args: Mapping[str, Any]) -> dict[str, Any]:
111
+ payload: dict[str, Any] = {
112
+ "model": self.config.model,
113
+ "input": [message.to_api() for message in self._messages],
114
+ }
115
+ if self.config.temperature is not None:
116
+ payload["temperature"] = self.config.temperature
117
+ if self.config.max_output_tokens is not None:
118
+ payload["max_output_tokens"] = self.config.max_output_tokens
119
+
120
+ payload.update(extra_request_args)
121
+ return payload
122
+
123
+ def _build_system_content(self, system_rules: Sequence[str] | None) -> str:
124
+ rules = self.config.system_rules if system_rules is None else system_rules
125
+
126
+ clean_rules = [str(rule).strip() for rule in rules if str(rule).strip()]
127
+ return "\n".join(clean_rules)
128
+
129
+ def _trim_history(self) -> None:
130
+ max_messages = self.config.max_history_messages
131
+ if max_messages is None or max_messages <= 0:
132
+ return
133
+
134
+ prefix: list[Message] = []
135
+ rest = self._messages
136
+ while rest and rest[0].role in {"system", "developer"}:
137
+ prefix.append(rest[0])
138
+ rest = rest[1:]
139
+
140
+ if len(rest) > max_messages:
141
+ rest = rest[-max_messages:]
142
+ self._messages = prefix + rest
143
+
144
+ def _save_bound_history(self) -> None:
145
+ if self._history_path is not None:
146
+ self.save_history(self._history_path)
147
+
148
+ def _build_client(self) -> Any:
149
+ if not self.config.api_key:
150
+ raise MissingAPIKeyError(
151
+ "missing API key; set CODEX_CHAT_API_KEY, pass --api-key, "
152
+ "or pass ChatConfig(api_key=...)."
153
+ )
154
+ if not self.config.base_url:
155
+ raise MissingBaseURLError(
156
+ "missing API base URL; set CODEX_CHAT_BASE_URL, pass --base-url, "
157
+ "or pass ChatConfig(base_url=...)."
158
+ )
159
+
160
+ try:
161
+ from openai import OpenAI
162
+ except ImportError as exc:
163
+ raise RuntimeError("openai package is required; run `pip install -e .`.") from exc
164
+
165
+ kwargs: dict[str, Any] = {"api_key": self.config.api_key, "base_url": self.config.base_url}
166
+ for name in ("timeout", "organization", "project"):
167
+ value = getattr(self.config, name)
168
+ if value is not None:
169
+ kwargs[name] = value
170
+
171
+ return OpenAI(**kwargs)
172
+
173
+
174
+ def _extract_response_text(response: Any) -> str:
175
+ output_text = _get_value(response, "output_text")
176
+ if isinstance(output_text, str):
177
+ return output_text
178
+
179
+ chunks: list[str] = []
180
+ for item in _get_value(response, "output", default=[]) or []:
181
+ for part in _get_value(item, "content", default=[]) or []:
182
+ text = _get_value(part, "text")
183
+ if isinstance(text, str):
184
+ chunks.append(text)
185
+
186
+ if chunks:
187
+ return "".join(chunks)
188
+
189
+ raise ResponseTextError("could not extract text from the model response")
190
+
191
+
192
+ def _messages_from_history_payload(payload: Any) -> tuple[Message, ...]:
193
+ if isinstance(payload, list):
194
+ raw_messages = payload
195
+ elif isinstance(payload, Mapping) and isinstance(payload.get("messages"), list):
196
+ raw_messages = payload["messages"]
197
+ else:
198
+ raise ValueError("chat history JSON must be an object with a messages array")
199
+
200
+ messages: list[Message] = []
201
+ for index, item in enumerate(raw_messages):
202
+ if not isinstance(item, Mapping):
203
+ raise ValueError(f"chat history message {index} must be an object")
204
+
205
+ role = item.get("role")
206
+ content = item.get("content")
207
+ if role not in {"system", "developer", "user", "assistant"}:
208
+ raise ValueError(f"chat history message {index} has an invalid role")
209
+ if not isinstance(content, str):
210
+ raise ValueError(f"chat history message {index} content must be a string")
211
+
212
+ messages.append(Message(role=role, content=content))
213
+
214
+ return tuple(messages)
215
+
216
+
217
+ def _get_value(obj: Any, key: str, default: Any = None) -> Any:
218
+ if isinstance(obj, Mapping):
219
+ return obj.get(key, default)
220
+ return getattr(obj, key, default)
@@ -0,0 +1,169 @@
1
+ import pytest
2
+
3
+ from codex_chat_bot import ChatConfig
4
+ from codex_chat_bot.cli import build_parser, main
5
+ from codex_chat_bot.cli import _collect_system_rules
6
+ from codex_chat_bot.cli import _prompt_for_missing_required_config
7
+
8
+
9
+ class FakeSession:
10
+ instances = []
11
+
12
+ def __init__(self, config):
13
+ self.config = config
14
+ self.bound_paths = []
15
+ self.imported_paths = []
16
+ self.exported_paths = []
17
+ self.asked_messages = []
18
+ FakeSession.instances.append(self)
19
+
20
+ def ask(self, message):
21
+ self.asked_messages.append(message)
22
+ return f"answer: {message}"
23
+
24
+ def bind_history(self, path):
25
+ self.bound_paths.append(path)
26
+
27
+ def load_history(self, path):
28
+ self.imported_paths.append(path)
29
+
30
+ def save_history(self, path):
31
+ self.exported_paths.append(path)
32
+
33
+
34
+ class InterruptingSession(FakeSession):
35
+ def ask(self, message):
36
+ self.asked_messages.append(message)
37
+ raise KeyboardInterrupt
38
+
39
+
40
+ def test_cli_reports_missing_api_key_without_traceback(monkeypatch, capsys):
41
+ monkeypatch.delenv("CODEX_CHAT_API_KEY", raising=False)
42
+ monkeypatch.delenv("CODEX_CHAT_BASE_URL", raising=False)
43
+ monkeypatch.setattr("sys.stdin.isatty", lambda: False)
44
+
45
+ status = main(["--base-url", "https://api.example/v1", "hello"])
46
+
47
+ captured = capsys.readouterr()
48
+ assert status == 1
49
+ assert "missing API key" in captured.err
50
+ assert "Traceback" not in captured.err
51
+
52
+
53
+ def test_cli_reports_missing_base_url_without_traceback(monkeypatch, capsys):
54
+ monkeypatch.delenv("CODEX_CHAT_API_KEY", raising=False)
55
+ monkeypatch.delenv("CODEX_CHAT_BASE_URL", raising=False)
56
+ monkeypatch.setattr("sys.stdin.isatty", lambda: False)
57
+
58
+ status = main(["--api-key", "test-key", "hello"])
59
+
60
+ captured = capsys.readouterr()
61
+ assert status == 1
62
+ assert "missing API base URL" in captured.err
63
+ assert "Traceback" not in captured.err
64
+
65
+
66
+ def test_prompt_for_missing_required_config(monkeypatch, capsys):
67
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
68
+ monkeypatch.setattr("getpass.getpass", lambda prompt: " prompted-key ")
69
+ monkeypatch.setattr("builtins.input", lambda prompt: " https://api.example/v1 ")
70
+
71
+ config = _prompt_for_missing_required_config(ChatConfig())
72
+
73
+ captured = capsys.readouterr()
74
+ assert config.api_key == "prompted-key"
75
+ assert config.base_url == "https://api.example/v1"
76
+ assert "No API key found" in captured.err
77
+ assert "No API base URL found" in captured.err
78
+
79
+
80
+ def test_collect_system_rules_from_file_and_flags(tmp_path):
81
+ rules_file = tmp_path / "rules.txt"
82
+ rules_file.write_text("Answer in English.\n\nKeep answers short.\n", encoding="utf-8")
83
+
84
+ rules = _collect_system_rules(["Use Markdown."], str(rules_file))
85
+
86
+ assert rules == ["Answer in English.", "Keep answers short.", "Use Markdown."]
87
+
88
+
89
+ def test_system_and_system_rule_share_system_rules_argument():
90
+ args = build_parser().parse_args(
91
+ [
92
+ "--system",
93
+ "You are a concise programming assistant.",
94
+ "--system-rule",
95
+ "Answer in English.",
96
+ "hello",
97
+ ]
98
+ )
99
+
100
+ assert args.system_rules == ["You are a concise programming assistant.", "Answer in English."]
101
+
102
+
103
+ def test_history_options_are_parsed():
104
+ args = build_parser().parse_args(
105
+ [
106
+ "--import-history",
107
+ "in.json",
108
+ "--bind-history",
109
+ "bound.json",
110
+ "hello",
111
+ ]
112
+ )
113
+
114
+ assert args.import_history == "in.json"
115
+ assert args.bind_history == "bound.json"
116
+
117
+
118
+ def test_export_history_option_reports_rename(capsys):
119
+ with pytest.raises(SystemExit) as exc_info:
120
+ main(["--export-history", "out.json"])
121
+
122
+ captured = capsys.readouterr()
123
+ assert exc_info.value.code == 2
124
+ assert "--export-history was renamed to --bind-history" in captured.err
125
+
126
+
127
+ def test_cli_binds_history(monkeypatch, capsys):
128
+ FakeSession.instances.clear()
129
+ monkeypatch.setenv("CODEX_CHAT_API_KEY", "test-key")
130
+ monkeypatch.setenv("CODEX_CHAT_BASE_URL", "https://api.example/v1")
131
+ monkeypatch.setattr("codex_chat_bot.cli.ChatSession", FakeSession)
132
+
133
+ status = main(["--bind-history", "history.json", "hello"])
134
+
135
+ captured = capsys.readouterr()
136
+ session = FakeSession.instances[0]
137
+ assert status == 0
138
+ assert session.bound_paths == ["history.json"]
139
+ assert session.imported_paths == []
140
+ assert session.exported_paths == []
141
+ assert session.asked_messages == ["hello"]
142
+ assert captured.out == "answer: hello\n"
143
+
144
+
145
+ def test_cli_does_not_skip_bound_history_on_keyboard_interrupt_in_one_shot(monkeypatch, capsys):
146
+ InterruptingSession.instances.clear()
147
+ monkeypatch.setenv("CODEX_CHAT_API_KEY", "test-key")
148
+ monkeypatch.setenv("CODEX_CHAT_BASE_URL", "https://api.example/v1")
149
+ monkeypatch.setattr("codex_chat_bot.cli.ChatSession", InterruptingSession)
150
+
151
+ status = main(["--bind-history", "history.json", "hello"])
152
+
153
+ session = InterruptingSession.instances[0]
154
+ assert status == 130
155
+ assert session.bound_paths == ["history.json"]
156
+
157
+
158
+ def test_cli_binds_history_before_keyboard_interrupt_in_interactive(monkeypatch, capsys):
159
+ FakeSession.instances.clear()
160
+ monkeypatch.setenv("CODEX_CHAT_API_KEY", "test-key")
161
+ monkeypatch.setenv("CODEX_CHAT_BASE_URL", "https://api.example/v1")
162
+ monkeypatch.setattr("codex_chat_bot.cli.ChatSession", FakeSession)
163
+ monkeypatch.setattr("builtins.input", lambda prompt: (_ for _ in ()).throw(KeyboardInterrupt))
164
+
165
+ status = main(["--bind-history", "history.json"])
166
+
167
+ session = FakeSession.instances[0]
168
+ assert status == 0
169
+ assert session.bound_paths == ["history.json"]
@@ -0,0 +1,56 @@
1
+ from codex_chat_bot import ChatConfig
2
+ from codex_chat_bot.config import DEFAULT_MODEL, DEFAULT_SYSTEM_RULES
3
+
4
+
5
+ def test_config_reads_codex_chat_environment(monkeypatch):
6
+ monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
7
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://ignored.example/v1")
8
+ monkeypatch.setenv("OPENAI_MODEL", "ignored-model")
9
+ monkeypatch.setenv("CODEX_CHAT_API_KEY", "codex-key")
10
+ monkeypatch.setenv("CODEX_CHAT_BASE_URL", "https://api.example/v1")
11
+ monkeypatch.setenv("CODEX_CHAT_MODEL", "codex-model")
12
+
13
+ config = ChatConfig.from_env()
14
+
15
+ assert config.api_key == "codex-key"
16
+ assert config.base_url == "https://api.example/v1"
17
+ assert config.model == "codex-model"
18
+
19
+
20
+ def test_config_ignores_openai_environment(monkeypatch):
21
+ monkeypatch.delenv("CODEX_CHAT_API_KEY", raising=False)
22
+ monkeypatch.delenv("CODEX_CHAT_BASE_URL", raising=False)
23
+ monkeypatch.delenv("CODEX_CHAT_MODEL", raising=False)
24
+ monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
25
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://ignored.example/v1")
26
+ monkeypatch.setenv("OPENAI_MODEL", "ignored-model")
27
+
28
+ config = ChatConfig.from_env()
29
+
30
+ assert config.api_key is None
31
+ assert config.base_url is None
32
+ assert config.model == DEFAULT_MODEL
33
+
34
+
35
+ def test_config_uses_default_model(monkeypatch):
36
+ monkeypatch.delenv("CODEX_CHAT_MODEL", raising=False)
37
+
38
+ config = ChatConfig.from_env(api_key="key")
39
+
40
+ assert config.model == DEFAULT_MODEL
41
+
42
+
43
+ def test_config_uses_default_system_rules(monkeypatch):
44
+ monkeypatch.setenv("CODEX_CHAT_SYSTEM_RULES", "Answer in English.\nKeep answers short.")
45
+
46
+ config = ChatConfig.from_env(api_key="key")
47
+
48
+ assert config.system_rules == DEFAULT_SYSTEM_RULES
49
+
50
+
51
+ def test_config_accepts_system_rules_as_function_parameter(monkeypatch):
52
+ monkeypatch.setenv("CODEX_CHAT_SYSTEM_RULES", "ignored")
53
+
54
+ config = ChatConfig.from_env(api_key="key", system_rules=("Answer in English.", "Keep answers short."))
55
+
56
+ assert config.system_rules == ("Answer in English.", "Keep answers short.")
@@ -0,0 +1,211 @@
1
+ from types import SimpleNamespace
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from codex_chat_bot import ChatConfig, ChatSession, Message
7
+ from codex_chat_bot.session import _extract_response_text
8
+
9
+
10
+ class FakeResponses:
11
+ def __init__(self):
12
+ self.calls = []
13
+
14
+ def create(self, **kwargs):
15
+ self.calls.append(kwargs)
16
+ user_message = kwargs["input"][-1]["content"]
17
+ return SimpleNamespace(output_text=f"answer: {user_message}")
18
+
19
+
20
+ class FakeClient:
21
+ def __init__(self):
22
+ self.responses = FakeResponses()
23
+
24
+
25
+ def make_session(**config_overrides):
26
+ config = ChatConfig(api_key="test-key", base_url="https://api.example/v1", model="test-model", **config_overrides)
27
+ client = FakeClient()
28
+ return ChatSession(config=config, client=client), client
29
+
30
+
31
+ def test_session_sends_full_single_session_history():
32
+ session, client = make_session(system_rules=("Follow the test.",))
33
+
34
+ assert session.ask("hello") == "answer: hello"
35
+ assert session.ask("what did I say?") == "answer: what did I say?"
36
+
37
+ second_input = client.responses.calls[1]["input"]
38
+ assert second_input == [
39
+ {"role": "system", "content": "Follow the test."},
40
+ {"role": "user", "content": "hello"},
41
+ {"role": "assistant", "content": "answer: hello"},
42
+ {"role": "user", "content": "what did I say?"},
43
+ ]
44
+
45
+
46
+ def test_session_adds_system_rules_to_system_message():
47
+ session, client = make_session(
48
+ system_rules=("Follow the test.", "Answer in English.", "Keep answers short."),
49
+ )
50
+
51
+ session.ask("hello")
52
+
53
+ assert client.responses.calls[0]["input"][0] == {
54
+ "role": "system",
55
+ "content": "Follow the test.\nAnswer in English.\nKeep answers short.",
56
+ }
57
+
58
+
59
+ def test_reset_clears_conversation_but_keeps_system_rules():
60
+ session, _ = make_session(system_rules=("Stay brief.",))
61
+
62
+ session.ask("hello")
63
+ session.reset()
64
+
65
+ assert session.messages == (Message(role="system", content="Stay brief."),)
66
+
67
+
68
+ def test_reset_can_override_system_rules():
69
+ session, _ = make_session(system_rules=("Stay brief.", "Use Markdown."))
70
+
71
+ session.reset(system_rules=("No Markdown.",))
72
+
73
+ assert session.messages == (
74
+ Message(role="system", content="No Markdown."),
75
+ )
76
+
77
+
78
+ def test_history_limit_keeps_latest_non_system_messages():
79
+ session, _ = make_session(system_rules=("sys",), max_history_messages=2)
80
+
81
+ session.ask("one")
82
+ session.ask("two")
83
+
84
+ assert session.messages == (
85
+ Message(role="system", content="sys"),
86
+ Message(role="user", content="two"),
87
+ Message(role="assistant", content="answer: two"),
88
+ )
89
+
90
+
91
+ def test_session_exports_and_imports_history_json():
92
+ session, _ = make_session(system_rules=("Follow the test.",))
93
+
94
+ session.ask("hello")
95
+ exported = session.export_history_json()
96
+
97
+ assert '\n "messages": [' in exported
98
+
99
+ payload = json.loads(exported)
100
+ assert payload == {
101
+ "messages": [
102
+ {"role": "system", "content": "Follow the test."},
103
+ {"role": "user", "content": "hello"},
104
+ {"role": "assistant", "content": "answer: hello"},
105
+ ]
106
+ }
107
+
108
+ restored, client = make_session(system_rules=("ignored",))
109
+ restored.load_history_json(exported)
110
+ restored.ask("continue")
111
+
112
+ assert client.responses.calls[0]["input"] == [
113
+ {"role": "system", "content": "Follow the test."},
114
+ {"role": "user", "content": "hello"},
115
+ {"role": "assistant", "content": "answer: hello"},
116
+ {"role": "user", "content": "continue"},
117
+ ]
118
+
119
+
120
+ def test_session_saves_and_loads_history_file(tmp_path):
121
+ history_file = tmp_path / "history.json"
122
+ session, _ = make_session(system_rules=("Follow the test.",))
123
+ session.ask("hello")
124
+
125
+ session.save_history(history_file)
126
+
127
+ restored, _ = make_session(system_rules=("ignored",))
128
+ restored.load_history(history_file)
129
+
130
+ assert restored.messages == session.messages
131
+
132
+
133
+ def test_session_bind_history_loads_existing_file_and_saves_updates(tmp_path):
134
+ history_file = tmp_path / "history.json"
135
+ history_file.write_text(
136
+ """
137
+ {
138
+ "messages": [
139
+ {
140
+ "role": "system",
141
+ "content": "Persisted system."
142
+ },
143
+ {
144
+ "role": "user",
145
+ "content": "old"
146
+ },
147
+ {
148
+ "role": "assistant",
149
+ "content": "answer: old"
150
+ }
151
+ ]
152
+ }
153
+ """.strip(),
154
+ encoding="utf-8",
155
+ )
156
+ session, client = make_session(system_rules=("ignored",))
157
+
158
+ session.bind_history(history_file)
159
+ session.ask("new")
160
+
161
+ assert client.responses.calls[0]["input"] == [
162
+ {"role": "system", "content": "Persisted system."},
163
+ {"role": "user", "content": "old"},
164
+ {"role": "assistant", "content": "answer: old"},
165
+ {"role": "user", "content": "new"},
166
+ ]
167
+ assert json.loads(history_file.read_text(encoding="utf-8")) == {
168
+ "messages": [
169
+ {"role": "system", "content": "Persisted system."},
170
+ {"role": "user", "content": "old"},
171
+ {"role": "assistant", "content": "answer: old"},
172
+ {"role": "user", "content": "new"},
173
+ {"role": "assistant", "content": "answer: new"},
174
+ ]
175
+ }
176
+
177
+
178
+ def test_session_bind_history_creates_file_and_tracks_reset(tmp_path):
179
+ history_file = tmp_path / "history.json"
180
+ session, _ = make_session(system_rules=("Follow the test.",))
181
+
182
+ session.bind_history(history_file)
183
+ session.reset(system_rules=("After reset.",))
184
+
185
+ assert json.loads(history_file.read_text(encoding="utf-8")) == {
186
+ "messages": [{"role": "system", "content": "After reset."}]
187
+ }
188
+
189
+
190
+ def test_session_rejects_invalid_history_json():
191
+ session, _ = make_session()
192
+
193
+ with pytest.raises(ValueError, match="invalid role"):
194
+ session.load_history_json('{"messages": [{"role": "tool", "content": "nope"}]}')
195
+
196
+
197
+ def test_extract_response_text_from_output_parts():
198
+ response = {
199
+ "output": [
200
+ {"content": [{"type": "output_text", "text": "hello"}, {"type": "output_text", "text": " world"}]}
201
+ ]
202
+ }
203
+
204
+ assert _extract_response_text(response) == "hello world"
205
+
206
+
207
+ def test_empty_message_is_rejected():
208
+ session, _ = make_session()
209
+
210
+ with pytest.raises(ValueError):
211
+ session.ask(" ")