Skip to content

Commit 554ec53

Browse files
committed
refactor to reorganise codebase
1 parent 653605d commit 554ec53

File tree

16 files changed

+1244
-1195
lines changed

16 files changed

+1244
-1195
lines changed

build.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def make_dmg(app_path: Path, dmg_path: Path, volume_name: str) -> None:
158158
def main() -> None:
159159
parser = argparse.ArgumentParser()
160160
parser.add_argument("--name", default="ChatMock")
161-
parser.add_argument("--entry", default="app_qt.py")
161+
parser.add_argument("--entry", default="gui.py")
162162
parser.add_argument("--icon", default="icon.png")
163163
parser.add_argument("--radius", type=float, default=0.22)
164164
parser.add_argument("--square", action="store_true")

chatmock.py

Lines changed: 2 additions & 1175 deletions
Large diffs are not rendered by default.

chatmock/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from __future__ import annotations
2+
3+
from .app import create_app
4+
from .cli import main
5+

chatmock/app.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from flask import Flask, jsonify
4+
5+
from .config import BASE_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+
reasoning_effort: str = "medium",
14+
reasoning_summary: str = "auto",
15+
reasoning_compat: str = "think-tags",
16+
debug_model: str | None = None,
17+
) -> Flask:
18+
app = Flask(__name__)
19+
20+
app.config.update(
21+
VERBOSE=bool(verbose),
22+
REASONING_EFFORT=reasoning_effort,
23+
REASONING_SUMMARY=reasoning_summary,
24+
REASONING_COMPAT=reasoning_compat,
25+
DEBUG_MODEL=debug_model,
26+
BASE_INSTRUCTIONS=BASE_INSTRUCTIONS,
27+
)
28+
29+
@app.get("/")
30+
@app.get("/health")
31+
def health():
32+
return jsonify({"status": "ok"})
33+
34+
@app.after_request
35+
def _cors(resp):
36+
for k, v in build_cors_headers().items():
37+
resp.headers.setdefault(k, v)
38+
return resp
39+
40+
app.register_blueprint(openai_bp)
41+
app.register_blueprint(ollama_bp)
42+
43+
return app
44+

chatmock/cli.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import errno
6+
import os
7+
import sys
8+
import webbrowser
9+
10+
from .app import create_app
11+
from .config import CLIENT_ID_DEFAULT
12+
from .oauth import OAuthHTTPServer, OAuthHandler, REQUIRED_PORT, URL_BASE
13+
from .utils import eprint, get_home_dir, load_chatgpt_tokens, parse_jwt_claims, read_auth_file
14+
15+
16+
def cmd_login(no_browser: bool, verbose: bool) -> int:
17+
home_dir = get_home_dir()
18+
client_id = CLIENT_ID_DEFAULT
19+
if not client_id:
20+
eprint("ERROR: No OAuth client id configured. Set CHATGPT_LOCAL_CLIENT_ID.")
21+
return 1
22+
23+
try:
24+
httpd = OAuthHTTPServer(("127.0.0.1", REQUIRED_PORT), OAuthHandler, home_dir=home_dir, client_id=client_id, verbose=verbose)
25+
except OSError as e:
26+
eprint(f"ERROR: {e}")
27+
if e.errno == errno.EADDRINUSE:
28+
return 13
29+
return 1
30+
31+
auth_url = httpd.auth_url()
32+
with httpd:
33+
eprint(f"Starting local login server on {URL_BASE}")
34+
if not no_browser:
35+
try:
36+
webbrowser.open(auth_url, new=1, autoraise=True)
37+
except Exception as e:
38+
eprint(f"Failed to open browser: {e}")
39+
eprint(f"If your browser did not open, navigate to:\n{auth_url}")
40+
try:
41+
httpd.serve_forever()
42+
except KeyboardInterrupt:
43+
eprint("\nKeyboard interrupt received, exiting.")
44+
return httpd.exit_code
45+
46+
47+
def cmd_serve(
48+
host: str,
49+
port: int,
50+
verbose: bool,
51+
reasoning_effort: str,
52+
reasoning_summary: str,
53+
reasoning_compat: str,
54+
debug_model: str | None,
55+
) -> int:
56+
app = create_app(
57+
verbose=verbose,
58+
reasoning_effort=reasoning_effort,
59+
reasoning_summary=reasoning_summary,
60+
reasoning_compat=reasoning_compat,
61+
debug_model=debug_model,
62+
)
63+
64+
app.run(host=host, debug=False, use_reloader=False, port=port, threaded=True)
65+
return 0
66+
67+
68+
def main() -> None:
69+
parser = argparse.ArgumentParser(description="ChatGPT Local: login & OpenAI-compatible proxy")
70+
sub = parser.add_subparsers(dest="command", required=True)
71+
72+
p_login = sub.add_parser("login", help="Authorize with ChatGPT and store tokens")
73+
p_login.add_argument("--no-browser", action="store_true", help="Do not open the browser automatically")
74+
p_login.add_argument("--verbose", action="store_true", help="Enable verbose logging")
75+
76+
p_serve = sub.add_parser("serve", help="Run local OpenAI-compatible server")
77+
p_serve.add_argument("--host", default="127.0.0.1")
78+
p_serve.add_argument("--port", type=int, default=8000)
79+
p_serve.add_argument("--verbose", action="store_true", help="Enable verbose logging")
80+
p_serve.add_argument(
81+
"--debug-model",
82+
dest="debug_model",
83+
default=os.getenv("CHATGPT_LOCAL_DEBUG_MODEL"),
84+
help="Forcibly override requested 'model' with this value",
85+
)
86+
p_serve.add_argument(
87+
"--reasoning-effort",
88+
choices=["low", "medium", "high", "none"],
89+
default=os.getenv("CHATGPT_LOCAL_REASONING_EFFORT", "medium").lower(),
90+
help="Reasoning effort level for Responses API (default: medium)",
91+
)
92+
p_serve.add_argument(
93+
"--reasoning-summary",
94+
choices=["auto", "concise", "detailed", "none"],
95+
default=os.getenv("CHATGPT_LOCAL_REASONING_SUMMARY", "auto").lower(),
96+
help="Reasoning summary verbosity (default: auto)",
97+
)
98+
p_serve.add_argument(
99+
"--reasoning-compat",
100+
choices=["legacy", "o3", "think-tags", "current"],
101+
default=os.getenv("CHATGPT_LOCAL_REASONING_COMPAT", "think-tags").lower(),
102+
help=(
103+
"Compatibility mode for exposing reasoning to clients (legacy|o3|think-tags). "
104+
"'current' is accepted as an alias for 'legacy'"
105+
),
106+
)
107+
108+
p_info = sub.add_parser("info", help="Print current stored tokens and derived account id")
109+
p_info.add_argument("--json", action="store_true", help="Output raw auth.json contents")
110+
111+
args = parser.parse_args()
112+
113+
if args.command == "login":
114+
sys.exit(cmd_login(no_browser=args.no_browser, verbose=args.verbose))
115+
elif args.command == "serve":
116+
sys.exit(
117+
cmd_serve(
118+
host=args.host,
119+
port=args.port,
120+
verbose=args.verbose,
121+
reasoning_effort=args.reasoning_effort,
122+
reasoning_summary=args.reasoning_summary,
123+
reasoning_compat=args.reasoning_compat,
124+
debug_model=args.debug_model,
125+
)
126+
)
127+
elif args.command == "info":
128+
auth = read_auth_file()
129+
if getattr(args, "json", False):
130+
print(json.dumps(auth or {}, indent=2))
131+
sys.exit(0)
132+
access_token, account_id, id_token = load_chatgpt_tokens()
133+
if not access_token or not id_token:
134+
print("👤 Account")
135+
print(" • Not signed in")
136+
print(" • Run: python3 chatmock.py login")
137+
sys.exit(0)
138+
139+
id_claims = parse_jwt_claims(id_token) or {}
140+
access_claims = parse_jwt_claims(access_token) or {}
141+
142+
email = id_claims.get("email") or id_claims.get("preferred_username") or "<unknown>"
143+
plan_raw = (access_claims.get("https://api.openai.com/auth") or {}).get("chatgpt_plan_type") or "unknown"
144+
plan_map = {
145+
"plus": "Plus",
146+
"pro": "Pro",
147+
"free": "Free",
148+
"team": "Team",
149+
"enterprise": "Enterprise",
150+
}
151+
plan = plan_map.get(str(plan_raw).lower(), str(plan_raw).title() if isinstance(plan_raw, str) else "Unknown")
152+
153+
print("👤 Account")
154+
print(" • Signed in with ChatGPT")
155+
print(f" • Login: {email}")
156+
print(f" • Plan: {plan}")
157+
if account_id:
158+
print(f" • Account ID: {account_id}")
159+
sys.exit(0)
160+
else:
161+
parser.error("Unknown command")
162+
163+
164+
if __name__ == "__main__":
165+
main()

chatmock/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
10+
CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
11+
12+
13+
def read_base_instructions() -> str:
14+
candidates = [
15+
Path(__file__).parent.parent / "prompt.md",
16+
Path(__file__).parent / "prompt.md",
17+
Path(getattr(sys, "_MEIPASS", "")) / "prompt.md" if getattr(sys, "_MEIPASS", None) else None,
18+
Path.cwd() / "prompt.md",
19+
]
20+
for p in candidates:
21+
if not p:
22+
continue
23+
try:
24+
if p.exists():
25+
content = p.read_text(encoding="utf-8")
26+
if isinstance(content, str) and content.strip():
27+
return content
28+
except Exception:
29+
continue
30+
raise FileNotFoundError(
31+
"Failed to read prompt.md; expected adjacent to package or CWD."
32+
)
33+
34+
35+
BASE_INSTRUCTIONS = read_base_instructions()

chatmock/http.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+

models.py renamed to chatmock/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass
24
from typing import Optional
35

oauth.py renamed to chatmock/oauth.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
import urllib.request
1111
from typing import Any, Dict, Tuple
1212

13-
from models import AuthBundle, PkceCodes, TokenData
14-
from utils import eprint, generate_pkce, parse_jwt_claims, write_auth_file
13+
from .models import AuthBundle, PkceCodes, TokenData
14+
from .utils import eprint, generate_pkce, parse_jwt_claims, write_auth_file
1515

1616

1717
REQUIRED_PORT = 1455
@@ -31,7 +31,7 @@
3131
<p>You can now close this window and return to the terminal and run <code>python3 chatmock.py serve</code> to start the server.</p>
3232
</div>
3333
</body>
34-
</html>
34+
</html>
3535
"""
3636

3737

@@ -258,3 +258,4 @@ def _maybe_obtain_api_key(
258258
}
259259
success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
260260
return exchanged_access_token, success_url
261+

chatmock/reasoning.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Dict
4+
5+
6+
def build_reasoning_param(
7+
base_effort: str = "medium", base_summary: str = "auto", overrides: Dict[str, Any] | None = None
8+
) -> Dict[str, Any]:
9+
effort = (base_effort or "").strip().lower()
10+
summary = (base_summary or "").strip().lower()
11+
12+
valid_efforts = {"low", "medium", "high", "none"}
13+
valid_summaries = {"auto", "concise", "detailed", "none"}
14+
15+
if isinstance(overrides, dict):
16+
o_eff = str(overrides.get("effort", "")).strip().lower()
17+
o_sum = str(overrides.get("summary", "")).strip().lower()
18+
if o_eff in valid_efforts and o_eff:
19+
effort = o_eff
20+
if o_sum in valid_summaries and o_sum:
21+
summary = o_sum
22+
if effort not in valid_efforts:
23+
effort = "medium"
24+
if summary not in valid_summaries:
25+
summary = "auto"
26+
27+
reasoning: Dict[str, Any] = {"effort": effort}
28+
if summary != "none":
29+
reasoning["summary"] = summary
30+
return reasoning
31+
32+
33+
def apply_reasoning_to_message(
34+
message: Dict[str, Any],
35+
reasoning_summary_text: str,
36+
reasoning_full_text: str,
37+
compat: str,
38+
) -> Dict[str, Any]:
39+
try:
40+
compat = (compat or "think-tags").strip().lower()
41+
except Exception:
42+
compat = "think-tags"
43+
44+
if compat == "o3":
45+
rtxt_parts: list[str] = []
46+
if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
47+
rtxt_parts.append(reasoning_summary_text)
48+
if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
49+
rtxt_parts.append(reasoning_full_text)
50+
rtxt = "\n\n".join([p for p in rtxt_parts if p])
51+
if rtxt:
52+
message["reasoning"] = {"content": [{"type": "text", "text": rtxt}]}
53+
return message
54+
55+
if compat in ("legacy", "current"):
56+
if reasoning_summary_text:
57+
message["reasoning_summary"] = reasoning_summary_text
58+
if reasoning_full_text:
59+
message["reasoning"] = reasoning_full_text
60+
return message
61+
62+
rtxt_parts: list[str] = []
63+
if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
64+
rtxt_parts.append(reasoning_summary_text)
65+
if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
66+
rtxt_parts.append(reasoning_full_text)
67+
rtxt = "\n\n".join([p for p in rtxt_parts if p])
68+
if rtxt:
69+
think_block = f"<think>{rtxt}</think>"
70+
content_text = message.get("content") or ""
71+
if isinstance(content_text, str):
72+
message["content"] = think_block + (content_text or "")
73+
return message
74+

0 commit comments

Comments
 (0)