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 ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .app import create_app
4
+ from .cli import main
5
+ from .version import __version__
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
+