codex-chat-bot 0.1.0__py3-none-any.whl
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.
- codex_chat_bot/__init__.py +19 -0
- codex_chat_bot/cli.py +232 -0
- codex_chat_bot/config.py +78 -0
- codex_chat_bot/errors.py +14 -0
- codex_chat_bot/session.py +220 -0
- codex_chat_bot-0.1.0.dist-info/METADATA +139 -0
- codex_chat_bot-0.1.0.dist-info/RECORD +9 -0
- codex_chat_bot-0.1.0.dist-info/WHEEL +4 -0
- codex_chat_bot-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|
+
]
|
codex_chat_bot/cli.py
ADDED
|
@@ -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())
|
codex_chat_bot/config.py
ADDED
|
@@ -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.")
|
codex_chat_bot/errors.py
ADDED
|
@@ -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,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,9 @@
|
|
|
1
|
+
codex_chat_bot/__init__.py,sha256=WvIBwz0w0e3d7TYJzxF6RqFrVwaghlHGzj7JP1ZzIv8,487
|
|
2
|
+
codex_chat_bot/cli.py,sha256=XUOK_tgcaQfaTgTrMtKk8R3KMO49qi09kzs0iLpgJEo,7546
|
|
3
|
+
codex_chat_bot/config.py,sha256=D-IewKcpzG8l5fxRdI1-FiuB-_M_MOhAPy61q7ZGFQY,2548
|
|
4
|
+
codex_chat_bot/errors.py,sha256=G0PsXacnxetZM_3B8N0Xa-FSRIg1ipgxNvCwG8E0mz0,379
|
|
5
|
+
codex_chat_bot/session.py,sha256=4rMH40f5O5bmXX5ZZebLqkqz4GhUMmqMXHUycEmjQHs,7808
|
|
6
|
+
codex_chat_bot-0.1.0.dist-info/METADATA,sha256=0CPgFvNOzYo3NgTLAKoI62umnQNQGqPM6qMTY6hL-rE,3494
|
|
7
|
+
codex_chat_bot-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
codex_chat_bot-0.1.0.dist-info/entry_points.txt,sha256=dsb0UKu37uXDthxyU8SLnXqY-6y3a50QzLQRwCkz8SU,55
|
|
9
|
+
codex_chat_bot-0.1.0.dist-info/RECORD,,
|