chatmock 1.36__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.
- chatmock/__init__.py +5 -0
- chatmock/app.py +50 -0
- chatmock/cli.py +416 -0
- chatmock/config.py +48 -0
- chatmock/http.py +24 -0
- chatmock/limits.py +200 -0
- chatmock/model_registry.py +183 -0
- chatmock/models.py +26 -0
- chatmock/oauth.py +340 -0
- chatmock/prompt.md +326 -0
- chatmock/prompt_gpt5_codex.md +100 -0
- chatmock/reasoning.py +79 -0
- chatmock/routes_ollama.py +573 -0
- chatmock/routes_openai.py +541 -0
- chatmock/session.py +89 -0
- chatmock/transform.py +149 -0
- chatmock/upstream.py +115 -0
- chatmock/utils.py +874 -0
- chatmock/version.py +4 -0
- chatmock-1.36.dist-info/METADATA +194 -0
- chatmock-1.36.dist-info/RECORD +25 -0
- chatmock-1.36.dist-info/WHEEL +5 -0
- chatmock-1.36.dist-info/entry_points.txt +2 -0
- chatmock-1.36.dist-info/licenses/LICENSE +21 -0
- chatmock-1.36.dist-info/top_level.txt +1 -0
chatmock/__init__.py
ADDED
chatmock/app.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flask import Flask, jsonify
|
|
4
|
+
|
|
5
|
+
from .config import BASE_INSTRUCTIONS, GPT5_CODEX_INSTRUCTIONS
|
|
6
|
+
from .http import build_cors_headers
|
|
7
|
+
from .routes_openai import openai_bp
|
|
8
|
+
from .routes_ollama import ollama_bp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_app(
|
|
12
|
+
verbose: bool = False,
|
|
13
|
+
verbose_obfuscation: bool = False,
|
|
14
|
+
reasoning_effort: str = "medium",
|
|
15
|
+
reasoning_summary: str = "auto",
|
|
16
|
+
reasoning_compat: str = "think-tags",
|
|
17
|
+
debug_model: str | None = None,
|
|
18
|
+
expose_reasoning_models: bool = False,
|
|
19
|
+
default_web_search: bool = False,
|
|
20
|
+
) -> Flask:
|
|
21
|
+
app = Flask(__name__)
|
|
22
|
+
|
|
23
|
+
app.config.update(
|
|
24
|
+
VERBOSE=bool(verbose),
|
|
25
|
+
VERBOSE_OBFUSCATION=bool(verbose_obfuscation),
|
|
26
|
+
REASONING_EFFORT=reasoning_effort,
|
|
27
|
+
REASONING_SUMMARY=reasoning_summary,
|
|
28
|
+
REASONING_COMPAT=reasoning_compat,
|
|
29
|
+
DEBUG_MODEL=debug_model,
|
|
30
|
+
BASE_INSTRUCTIONS=BASE_INSTRUCTIONS,
|
|
31
|
+
GPT5_CODEX_INSTRUCTIONS=GPT5_CODEX_INSTRUCTIONS,
|
|
32
|
+
EXPOSE_REASONING_MODELS=bool(expose_reasoning_models),
|
|
33
|
+
DEFAULT_WEB_SEARCH=bool(default_web_search),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@app.get("/")
|
|
37
|
+
@app.get("/health")
|
|
38
|
+
def health():
|
|
39
|
+
return jsonify({"status": "ok"})
|
|
40
|
+
|
|
41
|
+
@app.after_request
|
|
42
|
+
def _cors(resp):
|
|
43
|
+
for k, v in build_cors_headers().items():
|
|
44
|
+
resp.headers.setdefault(k, v)
|
|
45
|
+
return resp
|
|
46
|
+
|
|
47
|
+
app.register_blueprint(openai_bp)
|
|
48
|
+
app.register_blueprint(ollama_bp)
|
|
49
|
+
|
|
50
|
+
return app
|
chatmock/cli.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import webbrowser
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from .app import create_app
|
|
12
|
+
from .config import CLIENT_ID_DEFAULT
|
|
13
|
+
from .limits import RateLimitWindow, compute_reset_at, load_rate_limit_snapshot
|
|
14
|
+
from .oauth import OAuthHTTPServer, OAuthHandler, REQUIRED_PORT, URL_BASE
|
|
15
|
+
from .utils import eprint, get_home_dir, load_chatgpt_tokens, parse_jwt_claims, read_auth_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_STATUS_LIMIT_BAR_SEGMENTS = 30
|
|
19
|
+
_STATUS_LIMIT_BAR_FILLED = "█"
|
|
20
|
+
_STATUS_LIMIT_BAR_EMPTY = "░"
|
|
21
|
+
_STATUS_LIMIT_BAR_PARTIAL = "▓"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _clamp_percent(value: float) -> float:
|
|
25
|
+
try:
|
|
26
|
+
percent = float(value)
|
|
27
|
+
except Exception:
|
|
28
|
+
return 0.0
|
|
29
|
+
if percent != percent:
|
|
30
|
+
return 0.0
|
|
31
|
+
if percent < 0.0:
|
|
32
|
+
return 0.0
|
|
33
|
+
if percent > 100.0:
|
|
34
|
+
return 100.0
|
|
35
|
+
return percent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _render_progress_bar(percent_used: float) -> str:
|
|
39
|
+
ratio = max(0.0, min(1.0, percent_used / 100.0))
|
|
40
|
+
filled_exact = ratio * _STATUS_LIMIT_BAR_SEGMENTS
|
|
41
|
+
filled = int(filled_exact)
|
|
42
|
+
partial = filled_exact - filled
|
|
43
|
+
|
|
44
|
+
has_partial = partial > 0.5
|
|
45
|
+
if has_partial:
|
|
46
|
+
filled += 1
|
|
47
|
+
|
|
48
|
+
filled = max(0, min(_STATUS_LIMIT_BAR_SEGMENTS, filled))
|
|
49
|
+
empty = _STATUS_LIMIT_BAR_SEGMENTS - filled
|
|
50
|
+
|
|
51
|
+
if has_partial and filled > 0:
|
|
52
|
+
bar = _STATUS_LIMIT_BAR_FILLED * (filled - 1) + _STATUS_LIMIT_BAR_PARTIAL + _STATUS_LIMIT_BAR_EMPTY * empty
|
|
53
|
+
else:
|
|
54
|
+
bar = _STATUS_LIMIT_BAR_FILLED * filled + _STATUS_LIMIT_BAR_EMPTY * empty
|
|
55
|
+
|
|
56
|
+
return f"[{bar}]"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_usage_color(percent_used: float) -> str:
|
|
60
|
+
if percent_used >= 90:
|
|
61
|
+
return "\033[91m"
|
|
62
|
+
elif percent_used >= 75:
|
|
63
|
+
return "\033[93m"
|
|
64
|
+
elif percent_used >= 50:
|
|
65
|
+
return "\033[94m"
|
|
66
|
+
else:
|
|
67
|
+
return "\033[92m"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reset_color() -> str:
|
|
71
|
+
"""ANSI reset color code"""
|
|
72
|
+
return "\033[0m"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_window_duration(minutes: int | None) -> str | None:
|
|
76
|
+
if minutes is None:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
total = int(minutes)
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
if total <= 0:
|
|
83
|
+
return None
|
|
84
|
+
minutes = total
|
|
85
|
+
weeks, remainder = divmod(minutes, 7 * 24 * 60)
|
|
86
|
+
days, remainder = divmod(remainder, 24 * 60)
|
|
87
|
+
hours, remainder = divmod(remainder, 60)
|
|
88
|
+
parts = []
|
|
89
|
+
if weeks:
|
|
90
|
+
parts.append(f"{weeks} week" + ("s" if weeks != 1 else ""))
|
|
91
|
+
if days:
|
|
92
|
+
parts.append(f"{days} day" + ("s" if days != 1 else ""))
|
|
93
|
+
if hours:
|
|
94
|
+
parts.append(f"{hours} hour" + ("s" if hours != 1 else ""))
|
|
95
|
+
if remainder:
|
|
96
|
+
parts.append(f"{remainder} minute" + ("s" if remainder != 1 else ""))
|
|
97
|
+
if not parts:
|
|
98
|
+
parts.append(f"{minutes} minute" + ("s" if minutes != 1 else ""))
|
|
99
|
+
return " ".join(parts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _format_reset_duration(seconds: int | None) -> str | None:
|
|
103
|
+
if seconds is None:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
value = int(seconds)
|
|
107
|
+
except Exception:
|
|
108
|
+
return None
|
|
109
|
+
if value < 0:
|
|
110
|
+
value = 0
|
|
111
|
+
days, remainder = divmod(value, 86400)
|
|
112
|
+
hours, remainder = divmod(remainder, 3600)
|
|
113
|
+
minutes, remainder = divmod(remainder, 60)
|
|
114
|
+
parts: list[str] = []
|
|
115
|
+
if days:
|
|
116
|
+
parts.append(f"{days}d")
|
|
117
|
+
if hours:
|
|
118
|
+
parts.append(f"{hours}h")
|
|
119
|
+
if minutes:
|
|
120
|
+
parts.append(f"{minutes}m")
|
|
121
|
+
if not parts and remainder:
|
|
122
|
+
parts.append("under 1m")
|
|
123
|
+
if not parts:
|
|
124
|
+
parts.append("0m")
|
|
125
|
+
return " ".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _format_local_datetime(dt: datetime) -> str:
|
|
129
|
+
local = dt.astimezone()
|
|
130
|
+
tz_name = local.tzname() or "local"
|
|
131
|
+
return f"{local.strftime('%b %d, %Y %H:%M')} {tz_name}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _print_usage_limits_block() -> None:
|
|
135
|
+
stored = load_rate_limit_snapshot()
|
|
136
|
+
|
|
137
|
+
print("📊 Usage Limits")
|
|
138
|
+
|
|
139
|
+
if stored is None:
|
|
140
|
+
print(" No usage data available yet. Send a request through ChatMock first.")
|
|
141
|
+
print()
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
update_time = _format_local_datetime(stored.captured_at)
|
|
145
|
+
print(f"Last updated: {update_time}")
|
|
146
|
+
print()
|
|
147
|
+
|
|
148
|
+
windows: list[tuple[str, str, RateLimitWindow]] = []
|
|
149
|
+
if stored.snapshot.primary is not None:
|
|
150
|
+
windows.append(("⚡", "5 hour limit", stored.snapshot.primary))
|
|
151
|
+
if stored.snapshot.secondary is not None:
|
|
152
|
+
windows.append(("📅", "Weekly limit", stored.snapshot.secondary))
|
|
153
|
+
|
|
154
|
+
if not windows:
|
|
155
|
+
print(" Usage data was captured but no limit windows were provided.")
|
|
156
|
+
print()
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
for i, (icon_label, desc, window) in enumerate(windows):
|
|
160
|
+
if i > 0:
|
|
161
|
+
print()
|
|
162
|
+
|
|
163
|
+
percent_used = _clamp_percent(window.used_percent)
|
|
164
|
+
remaining = max(0.0, 100.0 - percent_used)
|
|
165
|
+
color = _get_usage_color(percent_used)
|
|
166
|
+
reset = _reset_color()
|
|
167
|
+
|
|
168
|
+
progress = _render_progress_bar(percent_used)
|
|
169
|
+
usage_text = f"{percent_used:5.1f}% used"
|
|
170
|
+
remaining_text = f"{remaining:5.1f}% left"
|
|
171
|
+
|
|
172
|
+
print(f"{icon_label} {desc}")
|
|
173
|
+
print(f"{color}{progress}{reset} {color}{usage_text}{reset} | {remaining_text}")
|
|
174
|
+
|
|
175
|
+
reset_in = _format_reset_duration(window.resets_in_seconds)
|
|
176
|
+
reset_at = compute_reset_at(stored.captured_at, window)
|
|
177
|
+
|
|
178
|
+
if reset_in and reset_at:
|
|
179
|
+
reset_at_str = _format_local_datetime(reset_at)
|
|
180
|
+
print(f" ⏳ Resets in: {reset_in} at {reset_at_str}")
|
|
181
|
+
elif reset_in:
|
|
182
|
+
print(f" ⏳ Resets in: {reset_in}")
|
|
183
|
+
elif reset_at:
|
|
184
|
+
reset_at_str = _format_local_datetime(reset_at)
|
|
185
|
+
print(f" ⏳ Resets at: {reset_at_str}")
|
|
186
|
+
|
|
187
|
+
print()
|
|
188
|
+
|
|
189
|
+
def cmd_login(no_browser: bool, verbose: bool) -> int:
|
|
190
|
+
home_dir = get_home_dir()
|
|
191
|
+
client_id = CLIENT_ID_DEFAULT
|
|
192
|
+
if not client_id:
|
|
193
|
+
eprint("ERROR: No OAuth client id configured. Set CHATGPT_LOCAL_CLIENT_ID.")
|
|
194
|
+
return 1
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
bind_host = os.getenv("CHATGPT_LOCAL_LOGIN_BIND", "127.0.0.1")
|
|
198
|
+
httpd = OAuthHTTPServer((bind_host, REQUIRED_PORT), OAuthHandler, home_dir=home_dir, client_id=client_id, verbose=verbose)
|
|
199
|
+
except OSError as e:
|
|
200
|
+
eprint(f"ERROR: {e}")
|
|
201
|
+
if e.errno == errno.EADDRINUSE:
|
|
202
|
+
return 13
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
auth_url = httpd.auth_url()
|
|
206
|
+
with httpd:
|
|
207
|
+
eprint(f"Starting local login server on {URL_BASE}")
|
|
208
|
+
if not no_browser:
|
|
209
|
+
try:
|
|
210
|
+
webbrowser.open(auth_url, new=1, autoraise=True)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
eprint(f"Failed to open browser: {e}")
|
|
213
|
+
eprint(f"If your browser did not open, navigate to:\n{auth_url}")
|
|
214
|
+
|
|
215
|
+
def _stdin_paste_worker() -> None:
|
|
216
|
+
try:
|
|
217
|
+
eprint(
|
|
218
|
+
"If the browser can't reach this machine, paste the full redirect URL here and press Enter (or leave blank to keep waiting):"
|
|
219
|
+
)
|
|
220
|
+
line = sys.stdin.readline().strip()
|
|
221
|
+
if not line:
|
|
222
|
+
return
|
|
223
|
+
try:
|
|
224
|
+
from urllib.parse import urlparse, parse_qs
|
|
225
|
+
|
|
226
|
+
parsed = urlparse(line)
|
|
227
|
+
params = parse_qs(parsed.query)
|
|
228
|
+
code = (params.get("code") or [None])[0]
|
|
229
|
+
state = (params.get("state") or [None])[0]
|
|
230
|
+
if not code:
|
|
231
|
+
eprint("Input did not contain an auth code. Ignoring.")
|
|
232
|
+
return
|
|
233
|
+
if state and state != httpd.state:
|
|
234
|
+
eprint("State mismatch. Ignoring pasted URL for safety.")
|
|
235
|
+
return
|
|
236
|
+
eprint("Received redirect URL. Completing login without callback…")
|
|
237
|
+
bundle, _ = httpd.exchange_code(code)
|
|
238
|
+
if httpd.persist_auth(bundle):
|
|
239
|
+
httpd.exit_code = 0
|
|
240
|
+
eprint("Login successful. Tokens saved.")
|
|
241
|
+
else:
|
|
242
|
+
eprint("ERROR: Unable to persist auth file.")
|
|
243
|
+
httpd.shutdown()
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
eprint(f"Failed to process pasted redirect URL: {exc}")
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
import threading
|
|
251
|
+
|
|
252
|
+
threading.Thread(target=_stdin_paste_worker, daemon=True).start()
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
try:
|
|
256
|
+
httpd.serve_forever()
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
eprint("\nKeyboard interrupt received, exiting.")
|
|
259
|
+
return httpd.exit_code
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_serve(
|
|
263
|
+
host: str,
|
|
264
|
+
port: int,
|
|
265
|
+
verbose: bool,
|
|
266
|
+
verbose_obfuscation: bool,
|
|
267
|
+
reasoning_effort: str,
|
|
268
|
+
reasoning_summary: str,
|
|
269
|
+
reasoning_compat: str,
|
|
270
|
+
debug_model: str | None,
|
|
271
|
+
expose_reasoning_models: bool,
|
|
272
|
+
default_web_search: bool,
|
|
273
|
+
) -> int:
|
|
274
|
+
app = create_app(
|
|
275
|
+
verbose=verbose,
|
|
276
|
+
verbose_obfuscation=verbose_obfuscation,
|
|
277
|
+
reasoning_effort=reasoning_effort,
|
|
278
|
+
reasoning_summary=reasoning_summary,
|
|
279
|
+
reasoning_compat=reasoning_compat,
|
|
280
|
+
debug_model=debug_model,
|
|
281
|
+
expose_reasoning_models=expose_reasoning_models,
|
|
282
|
+
default_web_search=default_web_search,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
app.run(host=host, debug=False, use_reloader=False, port=port, threaded=True)
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main() -> None:
|
|
290
|
+
parser = argparse.ArgumentParser(description="ChatMock: login & OpenAI-compatible proxy")
|
|
291
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
292
|
+
|
|
293
|
+
p_login = sub.add_parser("login", help="Authorize with ChatGPT and store tokens")
|
|
294
|
+
p_login.add_argument("--no-browser", action="store_true", help="Do not open the browser automatically")
|
|
295
|
+
p_login.add_argument("--verbose", action="store_true", help="Enable verbose logging")
|
|
296
|
+
|
|
297
|
+
p_serve = sub.add_parser("serve", help="Run local OpenAI-compatible server")
|
|
298
|
+
p_serve.add_argument("--host", default="127.0.0.1")
|
|
299
|
+
p_serve.add_argument("--port", type=int, default=8000)
|
|
300
|
+
p_serve.add_argument("--verbose", action="store_true", help="Enable verbose logging")
|
|
301
|
+
p_serve.add_argument(
|
|
302
|
+
"--verbose-obfuscation",
|
|
303
|
+
action="store_true",
|
|
304
|
+
help="Also dump raw SSE/obfuscation events (in addition to --verbose request/response logs).",
|
|
305
|
+
)
|
|
306
|
+
p_serve.add_argument(
|
|
307
|
+
"--debug-model",
|
|
308
|
+
dest="debug_model",
|
|
309
|
+
default=os.getenv("CHATGPT_LOCAL_DEBUG_MODEL"),
|
|
310
|
+
help="Forcibly override requested 'model' with this value",
|
|
311
|
+
)
|
|
312
|
+
p_serve.add_argument(
|
|
313
|
+
"--reasoning-effort",
|
|
314
|
+
choices=["none", "minimal", "low", "medium", "high", "xhigh"],
|
|
315
|
+
default=os.getenv("CHATGPT_LOCAL_REASONING_EFFORT", "medium").lower(),
|
|
316
|
+
help="Reasoning effort level for Responses API (default: medium)",
|
|
317
|
+
)
|
|
318
|
+
p_serve.add_argument(
|
|
319
|
+
"--reasoning-summary",
|
|
320
|
+
choices=["auto", "concise", "detailed", "none"],
|
|
321
|
+
default=os.getenv("CHATGPT_LOCAL_REASONING_SUMMARY", "auto").lower(),
|
|
322
|
+
help="Reasoning summary verbosity (default: auto)",
|
|
323
|
+
)
|
|
324
|
+
p_serve.add_argument(
|
|
325
|
+
"--reasoning-compat",
|
|
326
|
+
choices=["legacy", "o3", "think-tags", "current"],
|
|
327
|
+
default=os.getenv("CHATGPT_LOCAL_REASONING_COMPAT", "think-tags").lower(),
|
|
328
|
+
help=(
|
|
329
|
+
"Compatibility mode for exposing reasoning to clients (legacy|o3|think-tags). "
|
|
330
|
+
"'current' is accepted as an alias for 'legacy'"
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
p_serve.add_argument(
|
|
334
|
+
"--expose-reasoning-models",
|
|
335
|
+
action="store_true",
|
|
336
|
+
default=(os.getenv("CHATGPT_LOCAL_EXPOSE_REASONING_MODELS") or "").strip().lower() in ("1", "true", "yes", "on"),
|
|
337
|
+
help=(
|
|
338
|
+
"Expose GPT-5 family reasoning effort variants (none|minimal|low|medium|high|xhigh where supported) "
|
|
339
|
+
"as separate models from /v1/models. This allows choosing effort via model selection in compatible UIs."
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
p_serve.add_argument(
|
|
343
|
+
"--enable-web-search",
|
|
344
|
+
action=argparse.BooleanOptionalAction,
|
|
345
|
+
default=(os.getenv("CHATGPT_LOCAL_ENABLE_WEB_SEARCH") or "").strip().lower() in ("1", "true", "yes", "on"),
|
|
346
|
+
help=(
|
|
347
|
+
"Enable default web_search tool when a request omits responses_tools (off by default). "
|
|
348
|
+
"Also configurable via CHATGPT_LOCAL_ENABLE_WEB_SEARCH."
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
p_info = sub.add_parser("info", help="Print current stored tokens and derived account id")
|
|
353
|
+
p_info.add_argument("--json", action="store_true", help="Output raw auth.json contents")
|
|
354
|
+
|
|
355
|
+
args = parser.parse_args()
|
|
356
|
+
|
|
357
|
+
if args.command == "login":
|
|
358
|
+
sys.exit(cmd_login(no_browser=args.no_browser, verbose=args.verbose))
|
|
359
|
+
elif args.command == "serve":
|
|
360
|
+
sys.exit(
|
|
361
|
+
cmd_serve(
|
|
362
|
+
host=args.host,
|
|
363
|
+
port=args.port,
|
|
364
|
+
verbose=args.verbose,
|
|
365
|
+
verbose_obfuscation=args.verbose_obfuscation,
|
|
366
|
+
reasoning_effort=args.reasoning_effort,
|
|
367
|
+
reasoning_summary=args.reasoning_summary,
|
|
368
|
+
reasoning_compat=args.reasoning_compat,
|
|
369
|
+
debug_model=args.debug_model,
|
|
370
|
+
expose_reasoning_models=args.expose_reasoning_models,
|
|
371
|
+
default_web_search=args.enable_web_search,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
elif args.command == "info":
|
|
375
|
+
auth = read_auth_file()
|
|
376
|
+
if getattr(args, "json", False):
|
|
377
|
+
print(json.dumps(auth or {}, indent=2))
|
|
378
|
+
sys.exit(0)
|
|
379
|
+
access_token, account_id, id_token = load_chatgpt_tokens()
|
|
380
|
+
if not access_token or not id_token:
|
|
381
|
+
print("👤 Account")
|
|
382
|
+
print(" • Not signed in")
|
|
383
|
+
print(" • Run: python3 chatmock.py login")
|
|
384
|
+
print("")
|
|
385
|
+
_print_usage_limits_block()
|
|
386
|
+
sys.exit(0)
|
|
387
|
+
|
|
388
|
+
id_claims = parse_jwt_claims(id_token) or {}
|
|
389
|
+
access_claims = parse_jwt_claims(access_token) or {}
|
|
390
|
+
|
|
391
|
+
email = id_claims.get("email") or id_claims.get("preferred_username") or "<unknown>"
|
|
392
|
+
plan_raw = (access_claims.get("https://api.openai.com/auth") or {}).get("chatgpt_plan_type") or "unknown"
|
|
393
|
+
plan_map = {
|
|
394
|
+
"plus": "Plus",
|
|
395
|
+
"pro": "Pro",
|
|
396
|
+
"free": "Free",
|
|
397
|
+
"team": "Team",
|
|
398
|
+
"enterprise": "Enterprise",
|
|
399
|
+
}
|
|
400
|
+
plan = plan_map.get(str(plan_raw).lower(), str(plan_raw).title() if isinstance(plan_raw, str) else "Unknown")
|
|
401
|
+
|
|
402
|
+
print("👤 Account")
|
|
403
|
+
print(" • Signed in with ChatGPT")
|
|
404
|
+
print(f" • Login: {email}")
|
|
405
|
+
print(f" • Plan: {plan}")
|
|
406
|
+
if account_id:
|
|
407
|
+
print(f" • Account ID: {account_id}")
|
|
408
|
+
print("")
|
|
409
|
+
_print_usage_limits_block()
|
|
410
|
+
sys.exit(0)
|
|
411
|
+
else:
|
|
412
|
+
parser.error("Unknown command")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
if __name__ == "__main__":
|
|
416
|
+
main()
|
chatmock/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CLIENT_ID_DEFAULT = os.getenv("CHATGPT_LOCAL_CLIENT_ID") or "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
9
|
+
OAUTH_ISSUER_DEFAULT = os.getenv("CHATGPT_LOCAL_ISSUER") or "https://auth.openai.com"
|
|
10
|
+
OAUTH_TOKEN_URL = f"{OAUTH_ISSUER_DEFAULT}/oauth/token"
|
|
11
|
+
|
|
12
|
+
CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_prompt_text(filename: str) -> str | None:
|
|
16
|
+
candidates = [
|
|
17
|
+
Path(__file__).parent.parent / filename,
|
|
18
|
+
Path(__file__).parent / filename,
|
|
19
|
+
Path(getattr(sys, "_MEIPASS", "")) / filename if getattr(sys, "_MEIPASS", None) else None,
|
|
20
|
+
Path.cwd() / filename,
|
|
21
|
+
]
|
|
22
|
+
for candidate in candidates:
|
|
23
|
+
if not candidate:
|
|
24
|
+
continue
|
|
25
|
+
try:
|
|
26
|
+
if candidate.exists():
|
|
27
|
+
content = candidate.read_text(encoding="utf-8")
|
|
28
|
+
if isinstance(content, str) and content.strip():
|
|
29
|
+
return content
|
|
30
|
+
except Exception:
|
|
31
|
+
continue
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_base_instructions() -> str:
|
|
36
|
+
content = _read_prompt_text("prompt.md")
|
|
37
|
+
if content is None:
|
|
38
|
+
raise FileNotFoundError("Failed to read prompt.md; expected adjacent to package or CWD.")
|
|
39
|
+
return content
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_gpt5_codex_instructions(fallback: str) -> str:
|
|
43
|
+
content = _read_prompt_text("prompt_gpt5_codex.md")
|
|
44
|
+
return content if isinstance(content, str) and content.strip() else fallback
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
BASE_INSTRUCTIONS = read_base_instructions()
|
|
48
|
+
GPT5_CODEX_INSTRUCTIONS = read_gpt5_codex_instructions(BASE_INSTRUCTIONS)
|
chatmock/http.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flask import Response, jsonify, request
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_cors_headers() -> dict:
|
|
7
|
+
origin = request.headers.get("Origin", "*")
|
|
8
|
+
req_headers = request.headers.get("Access-Control-Request-Headers")
|
|
9
|
+
allow_headers = req_headers if req_headers else "Authorization, Content-Type, Accept"
|
|
10
|
+
return {
|
|
11
|
+
"Access-Control-Allow-Origin": origin,
|
|
12
|
+
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
|
13
|
+
"Access-Control-Allow-Headers": allow_headers,
|
|
14
|
+
"Access-Control-Max-Age": "86400",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def json_error(message: str, status: int = 400) -> Response:
|
|
19
|
+
resp = jsonify({"error": {"message": message}})
|
|
20
|
+
response: Response = Response(response=resp.response, status=status, mimetype="application/json")
|
|
21
|
+
for k, v in build_cors_headers().items():
|
|
22
|
+
response.headers.setdefault(k, v)
|
|
23
|
+
return response
|
|
24
|
+
|