refactor to reorganise codebase

This commit is contained in:
Game_Time
2025-08-19 17:21:00 +05:00
parent 653605d939
commit 554ec53a25
16 changed files with 1244 additions and 1195 deletions

5
chatmock/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from .app import create_app
from .cli import main

44
chatmock/app.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from flask import Flask, jsonify
from .config import BASE_INSTRUCTIONS
from .http import build_cors_headers
from .routes_openai import openai_bp
from .routes_ollama import ollama_bp
def create_app(
verbose: bool = False,
reasoning_effort: str = "medium",
reasoning_summary: str = "auto",
reasoning_compat: str = "think-tags",
debug_model: str | None = None,
) -> Flask:
app = Flask(__name__)
app.config.update(
VERBOSE=bool(verbose),
REASONING_EFFORT=reasoning_effort,
REASONING_SUMMARY=reasoning_summary,
REASONING_COMPAT=reasoning_compat,
DEBUG_MODEL=debug_model,
BASE_INSTRUCTIONS=BASE_INSTRUCTIONS,
)
@app.get("/")
@app.get("/health")
def health():
return jsonify({"status": "ok"})
@app.after_request
def _cors(resp):
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
app.register_blueprint(openai_bp)
app.register_blueprint(ollama_bp)
return app

165
chatmock/cli.py Normal file
View File

@@ -0,0 +1,165 @@
from __future__ import annotations
import argparse
import json
import errno
import os
import sys
import webbrowser
from .app import create_app
from .config import CLIENT_ID_DEFAULT
from .oauth import OAuthHTTPServer, OAuthHandler, REQUIRED_PORT, URL_BASE
from .utils import eprint, get_home_dir, load_chatgpt_tokens, parse_jwt_claims, read_auth_file
def cmd_login(no_browser: bool, verbose: bool) -> int:
home_dir = get_home_dir()
client_id = CLIENT_ID_DEFAULT
if not client_id:
eprint("ERROR: No OAuth client id configured. Set CHATGPT_LOCAL_CLIENT_ID.")
return 1
try:
httpd = OAuthHTTPServer(("127.0.0.1", REQUIRED_PORT), OAuthHandler, home_dir=home_dir, client_id=client_id, verbose=verbose)
except OSError as e:
eprint(f"ERROR: {e}")
if e.errno == errno.EADDRINUSE:
return 13
return 1
auth_url = httpd.auth_url()
with httpd:
eprint(f"Starting local login server on {URL_BASE}")
if not no_browser:
try:
webbrowser.open(auth_url, new=1, autoraise=True)
except Exception as e:
eprint(f"Failed to open browser: {e}")
eprint(f"If your browser did not open, navigate to:\n{auth_url}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
eprint("\nKeyboard interrupt received, exiting.")
return httpd.exit_code
def cmd_serve(
host: str,
port: int,
verbose: bool,
reasoning_effort: str,
reasoning_summary: str,
reasoning_compat: str,
debug_model: str | None,
) -> int:
app = create_app(
verbose=verbose,
reasoning_effort=reasoning_effort,
reasoning_summary=reasoning_summary,
reasoning_compat=reasoning_compat,
debug_model=debug_model,
)
app.run(host=host, debug=False, use_reloader=False, port=port, threaded=True)
return 0
def main() -> None:
parser = argparse.ArgumentParser(description="ChatGPT Local: login & OpenAI-compatible proxy")
sub = parser.add_subparsers(dest="command", required=True)
p_login = sub.add_parser("login", help="Authorize with ChatGPT and store tokens")
p_login.add_argument("--no-browser", action="store_true", help="Do not open the browser automatically")
p_login.add_argument("--verbose", action="store_true", help="Enable verbose logging")
p_serve = sub.add_parser("serve", help="Run local OpenAI-compatible server")
p_serve.add_argument("--host", default="127.0.0.1")
p_serve.add_argument("--port", type=int, default=8000)
p_serve.add_argument("--verbose", action="store_true", help="Enable verbose logging")
p_serve.add_argument(
"--debug-model",
dest="debug_model",
default=os.getenv("CHATGPT_LOCAL_DEBUG_MODEL"),
help="Forcibly override requested 'model' with this value",
)
p_serve.add_argument(
"--reasoning-effort",
choices=["low", "medium", "high", "none"],
default=os.getenv("CHATGPT_LOCAL_REASONING_EFFORT", "medium").lower(),
help="Reasoning effort level for Responses API (default: medium)",
)
p_serve.add_argument(
"--reasoning-summary",
choices=["auto", "concise", "detailed", "none"],
default=os.getenv("CHATGPT_LOCAL_REASONING_SUMMARY", "auto").lower(),
help="Reasoning summary verbosity (default: auto)",
)
p_serve.add_argument(
"--reasoning-compat",
choices=["legacy", "o3", "think-tags", "current"],
default=os.getenv("CHATGPT_LOCAL_REASONING_COMPAT", "think-tags").lower(),
help=(
"Compatibility mode for exposing reasoning to clients (legacy|o3|think-tags). "
"'current' is accepted as an alias for 'legacy'"
),
)
p_info = sub.add_parser("info", help="Print current stored tokens and derived account id")
p_info.add_argument("--json", action="store_true", help="Output raw auth.json contents")
args = parser.parse_args()
if args.command == "login":
sys.exit(cmd_login(no_browser=args.no_browser, verbose=args.verbose))
elif args.command == "serve":
sys.exit(
cmd_serve(
host=args.host,
port=args.port,
verbose=args.verbose,
reasoning_effort=args.reasoning_effort,
reasoning_summary=args.reasoning_summary,
reasoning_compat=args.reasoning_compat,
debug_model=args.debug_model,
)
)
elif args.command == "info":
auth = read_auth_file()
if getattr(args, "json", False):
print(json.dumps(auth or {}, indent=2))
sys.exit(0)
access_token, account_id, id_token = load_chatgpt_tokens()
if not access_token or not id_token:
print("👤 Account")
print(" • Not signed in")
print(" • Run: python3 chatmock.py login")
sys.exit(0)
id_claims = parse_jwt_claims(id_token) or {}
access_claims = parse_jwt_claims(access_token) or {}
email = id_claims.get("email") or id_claims.get("preferred_username") or "<unknown>"
plan_raw = (access_claims.get("https://api.openai.com/auth") or {}).get("chatgpt_plan_type") or "unknown"
plan_map = {
"plus": "Plus",
"pro": "Pro",
"free": "Free",
"team": "Team",
"enterprise": "Enterprise",
}
plan = plan_map.get(str(plan_raw).lower(), str(plan_raw).title() if isinstance(plan_raw, str) else "Unknown")
print("👤 Account")
print(" • Signed in with ChatGPT")
print(f" • Login: {email}")
print(f" • Plan: {plan}")
if account_id:
print(f" • Account ID: {account_id}")
sys.exit(0)
else:
parser.error("Unknown command")
if __name__ == "__main__":
main()

35
chatmock/config.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
CLIENT_ID_DEFAULT = os.getenv("CHATGPT_LOCAL_CLIENT_ID") or "app_EMoamEEZ73f0CkXaXp7hrann"
CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
def read_base_instructions() -> str:
candidates = [
Path(__file__).parent.parent / "prompt.md",
Path(__file__).parent / "prompt.md",
Path(getattr(sys, "_MEIPASS", "")) / "prompt.md" if getattr(sys, "_MEIPASS", None) else None,
Path.cwd() / "prompt.md",
]
for p in candidates:
if not p:
continue
try:
if p.exists():
content = p.read_text(encoding="utf-8")
if isinstance(content, str) and content.strip():
return content
except Exception:
continue
raise FileNotFoundError(
"Failed to read prompt.md; expected adjacent to package or CWD."
)
BASE_INSTRUCTIONS = read_base_instructions()

24
chatmock/http.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from flask import Response, jsonify, request
def build_cors_headers() -> dict:
origin = request.headers.get("Origin", "*")
req_headers = request.headers.get("Access-Control-Request-Headers")
allow_headers = req_headers if req_headers else "Authorization, Content-Type, Accept"
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": allow_headers,
"Access-Control-Max-Age": "86400",
}
def json_error(message: str, status: int = 400) -> Response:
resp = jsonify({"error": {"message": message}})
response: Response = Response(response=resp.response, status=status, mimetype="application/json")
for k, v in build_cors_headers().items():
response.headers.setdefault(k, v)
return response

26
chatmock/models.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class TokenData:
id_token: str
access_token: str
refresh_token: str
account_id: str
@dataclass
class AuthBundle:
api_key: Optional[str]
token_data: TokenData
last_refresh: str
@dataclass
class PkceCodes:
code_verifier: str
code_challenge: str

261
chatmock/oauth.py Normal file
View File

@@ -0,0 +1,261 @@
from __future__ import annotations
import datetime
import http.server
import json
import secrets
import threading
import time
import urllib.parse
import urllib.request
from typing import Any, Dict, Tuple
from .models import AuthBundle, PkceCodes, TokenData
from .utils import eprint, generate_pkce, parse_jwt_claims, write_auth_file
REQUIRED_PORT = 1455
URL_BASE = f"http://localhost:{REQUIRED_PORT}"
DEFAULT_ISSUER = "https://auth.openai.com"
LOGIN_SUCCESS_HTML = """<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<title>Login successful</title>
</head>
<body>
<div style=\"max-width: 640px; margin: 80px auto; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;\">
<h1>Login successful</h1>
<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>
</div>
</body>
</html>
"""
class OAuthHTTPServer(http.server.HTTPServer):
def __init__(
self,
server_address: tuple[str, int],
request_handler_class: type[http.server.BaseHTTPRequestHandler],
*,
home_dir: str,
client_id: str,
verbose: bool = False,
) -> None:
super().__init__(server_address, request_handler_class, bind_and_activate=True)
self.exit_code = 1
self.home_dir = home_dir
self.verbose = verbose
self.issuer = DEFAULT_ISSUER
self.token_endpoint = f"{self.issuer}/oauth/token"
self.client_id = client_id
port = server_address[1]
self.redirect_uri = f"http://localhost:{port}/auth/callback"
self.pkce = generate_pkce()
self.state = secrets.token_hex(32)
def auth_url(self) -> str:
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": "openid profile email offline_access",
"code_challenge": self.pkce.code_challenge,
"code_challenge_method": "S256",
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"state": self.state,
}
return f"{self.issuer}/oauth/authorize?" + urllib.parse.urlencode(params)
class OAuthHandler(http.server.BaseHTTPRequestHandler):
server: "OAuthHTTPServer"
def do_GET(self) -> None:
path = urllib.parse.urlparse(self.path).path
if path == "/success":
self._send_html(LOGIN_SUCCESS_HTML)
try:
self.wfile.flush()
except Exception as e:
eprint(f"Failed to flush response: {e}")
self._shutdown_after_delay(2.0)
return
if path != "/auth/callback":
self.send_error(404, "Not Found")
self._shutdown()
return
query = urllib.parse.urlparse(self.path).query
params = urllib.parse.parse_qs(query)
code = params.get("code", [None])[0]
if not code:
self.send_error(400, "Missing auth code")
self._shutdown()
return
try:
auth_bundle, success_url = self._exchange_code(code)
except Exception as exc:
self.send_error(500, f"Token exchange failed: {exc}")
self._shutdown()
return
auth_json_contents = {
"OPENAI_API_KEY": auth_bundle.api_key,
"tokens": {
"id_token": auth_bundle.token_data.id_token,
"access_token": auth_bundle.token_data.access_token,
"refresh_token": auth_bundle.token_data.refresh_token,
"account_id": auth_bundle.token_data.account_id,
},
"last_refresh": auth_bundle.last_refresh,
}
if write_auth_file(auth_json_contents):
self.server.exit_code = 0
self._send_html(LOGIN_SUCCESS_HTML)
else:
self.send_error(500, "Unable to persist auth file")
self._shutdown_after_delay(2.0)
def do_POST(self) -> None:
self.send_error(404, "Not Found")
self._shutdown()
def log_message(self, fmt: str, *args):
if getattr(self.server, "verbose", False):
super().log_message(fmt, *args)
def _send_redirect(self, url: str) -> None:
self.send_response(302)
self.send_header("Location", url)
self.end_headers()
def _send_html(self, body: str) -> None:
encoded = body.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _shutdown(self) -> None:
threading.Thread(target=self.server.shutdown, daemon=True).start()
def _shutdown_after_delay(self, seconds: float = 2.0) -> None:
def _later():
try:
time.sleep(seconds)
finally:
self._shutdown()
threading.Thread(target=_later, daemon=True).start()
def _exchange_code(self, code: str) -> Tuple[AuthBundle, str]:
data = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.server.redirect_uri,
"client_id": self.server.client_id,
"code_verifier": self.server.pkce.code_verifier,
}
).encode()
with urllib.request.urlopen(
urllib.request.Request(
self.server.token_endpoint,
data=data,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
) as resp:
payload = json.loads(resp.read().decode())
id_token = payload.get("id_token", "")
access_token = payload.get("access_token", "")
refresh_token = payload.get("refresh_token", "")
id_token_claims = parse_jwt_claims(id_token)
access_token_claims = parse_jwt_claims(access_token)
auth_claims = (id_token_claims or {}).get("https://api.openai.com/auth", {})
chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
token_data = TokenData(
id_token=id_token,
access_token=access_token,
refresh_token=refresh_token,
account_id=chatgpt_account_id,
)
api_key, success_url = self._maybe_obtain_api_key(
id_token_claims or {}, access_token_claims or {}, token_data
)
last_refresh_str = (
datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
)
bundle = AuthBundle(api_key=api_key, token_data=token_data, last_refresh=last_refresh_str)
return bundle, success_url or f"{URL_BASE}/success"
def _maybe_obtain_api_key(
self,
token_claims: Dict[str, Any],
access_claims: Dict[str, Any],
token_data: TokenData,
) -> Tuple[str | None, str | None]:
org_id = token_claims.get("organization_id")
project_id = token_claims.get("project_id")
if not org_id or not project_id:
query = {
"id_token": token_data.id_token,
"needs_setup": "false",
"org_id": org_id or "",
"project_id": project_id or "",
"plan_type": access_claims.get("chatgpt_plan_type"),
"platform_url": "https://platform.openai.com",
}
return None, f"{URL_BASE}/success?{urllib.parse.urlencode(query)}"
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
exchange_data = urllib.parse.urlencode(
{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": self.server.client_id,
"requested_token": "openai-api-key",
"subject_token": token_data.id_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"name": f"ChatGPT Local [auto-generated] ({today})",
}
).encode()
with urllib.request.urlopen(
urllib.request.Request(
self.server.token_endpoint,
data=exchange_data,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
) as resp:
exchange_payload = json.loads(resp.read().decode())
exchanged_access_token = exchange_payload.get("access_token")
chatgpt_plan_type = access_claims.get("chatgpt_plan_type")
success_url_query = {
"id_token": token_data.id_token,
"needs_setup": "false",
"org_id": org_id,
"project_id": project_id,
"plan_type": chatgpt_plan_type,
"platform_url": "https://platform.openai.com",
}
success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
return exchanged_access_token, success_url

74
chatmock/reasoning.py Normal file
View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any, Dict
def build_reasoning_param(
base_effort: str = "medium", base_summary: str = "auto", overrides: Dict[str, Any] | None = None
) -> Dict[str, Any]:
effort = (base_effort or "").strip().lower()
summary = (base_summary or "").strip().lower()
valid_efforts = {"low", "medium", "high", "none"}
valid_summaries = {"auto", "concise", "detailed", "none"}
if isinstance(overrides, dict):
o_eff = str(overrides.get("effort", "")).strip().lower()
o_sum = str(overrides.get("summary", "")).strip().lower()
if o_eff in valid_efforts and o_eff:
effort = o_eff
if o_sum in valid_summaries and o_sum:
summary = o_sum
if effort not in valid_efforts:
effort = "medium"
if summary not in valid_summaries:
summary = "auto"
reasoning: Dict[str, Any] = {"effort": effort}
if summary != "none":
reasoning["summary"] = summary
return reasoning
def apply_reasoning_to_message(
message: Dict[str, Any],
reasoning_summary_text: str,
reasoning_full_text: str,
compat: str,
) -> Dict[str, Any]:
try:
compat = (compat or "think-tags").strip().lower()
except Exception:
compat = "think-tags"
if compat == "o3":
rtxt_parts: list[str] = []
if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
rtxt_parts.append(reasoning_summary_text)
if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
rtxt_parts.append(reasoning_full_text)
rtxt = "\n\n".join([p for p in rtxt_parts if p])
if rtxt:
message["reasoning"] = {"content": [{"type": "text", "text": rtxt}]}
return message
if compat in ("legacy", "current"):
if reasoning_summary_text:
message["reasoning_summary"] = reasoning_summary_text
if reasoning_full_text:
message["reasoning"] = reasoning_full_text
return message
rtxt_parts: list[str] = []
if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
rtxt_parts.append(reasoning_summary_text)
if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
rtxt_parts.append(reasoning_full_text)
rtxt = "\n\n".join([p for p in rtxt_parts if p])
if rtxt:
think_block = f"<think>{rtxt}</think>"
content_text = message.get("content") or ""
if isinstance(content_text, str):
message["content"] = think_block + (content_text or "")
return message

299
chatmock/routes_ollama.py Normal file
View File

@@ -0,0 +1,299 @@
from __future__ import annotations
import json
import time
from typing import Any, Dict, List
from flask import Blueprint, Response, current_app, jsonify, make_response, request
from .config import BASE_INSTRUCTIONS
from .http import build_cors_headers
from .reasoning import build_reasoning_param
from .transform import convert_ollama_messages, normalize_ollama_tools
from .upstream import normalize_model_name, start_upstream_request
from .utils import convert_chat_messages_to_responses_input, convert_tools_chat_to_responses
ollama_bp = Blueprint("ollama", __name__)
_OLLAMA_FAKE_EVAL = {
"total_duration": 8497226791,
"load_duration": 1747193958,
"prompt_eval_count": 24,
"prompt_eval_duration": 269219750,
"eval_count": 247,
"eval_duration": 6413802458,
}
@ollama_bp.route("/api/tags", methods=["GET"])
def ollama_tags() -> Response:
if bool(current_app.config.get("VERBOSE")):
print("IN GET /api/tags")
model_id = "gpt-5"
models = [
{
"name": model_id,
"model": model_id,
"modified_at": "2023-10-01T00:00:00Z",
"size": 815319791,
"digest": "8648f39daa8fbf5b18c7b4e6a8fb4990c692751d49917417b8842ca5758e7ffc",
"details": {
"parent_model": "",
"format": "gguf",
"family": "llama",
"families": ["llama"],
"parameter_size": "8.0B",
"quantization_level": "Q4_0",
},
}
]
resp = make_response(jsonify({"models": models}), 200)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
@ollama_bp.route("/api/show", methods=["POST"])
def ollama_show() -> Response:
verbose = bool(current_app.config.get("VERBOSE"))
try:
if verbose:
body_preview = (request.get_data(cache=True, as_text=True) or "")[:2000]
print("IN POST /api/show\n" + body_preview)
except Exception:
pass
try:
payload = request.get_json(silent=True) or {}
except Exception:
payload = {}
model = payload.get("model")
if not isinstance(model, str) or not model.strip():
return jsonify({"error": "Model not found"}), 400
v1_show_response = {
"modelfile": "# Modelfile generated by \"ollama show\"\n# To build a new Modelfile based on this one, replace the FROM line with:\n# FROM llava:latest\n\nFROM /models/blobs/sha256:placeholder\nTEMPLATE \"\"\"{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: \"\"\"\nPARAMETER num_ctx 100000\nPARAMETER stop \"</s>\"\nPARAMETER stop \"USER:\"\nPARAMETER stop \"ASSISTANT:\"",
"parameters": "num_keep 24\nstop \"<|start_header_id|>\"\nstop \"<|end_header_id|>\"\nstop \"<|eot_id|>\"",
"template": "{{ if .System }}<|start_header_id|>system<|end_header_id|>\n\n{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>\n\n{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>\n\n{{ .Response }}<|eot_id|>",
"details": {
"parent_model": "",
"format": "gguf",
"family": "llama",
"families": ["llama"],
"parameter_size": "8.0B",
"quantization_level": "Q4_0",
},
"model_info": {
"general.architecture": "llama",
"general.file_type": 2,
"llama.context_length": 2000000,
},
"capabilities": ["completion", "vision", "tools", "thinking"],
}
resp = make_response(jsonify(v1_show_response), 200)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
@ollama_bp.route("/api/chat", methods=["POST"])
def ollama_chat() -> Response:
verbose = bool(current_app.config.get("VERBOSE"))
reasoning_effort = current_app.config.get("REASONING_EFFORT", "medium")
reasoning_summary = current_app.config.get("REASONING_SUMMARY", "auto")
reasoning_compat = current_app.config.get("REASONING_COMPAT", "think-tags")
try:
raw = request.get_data(cache=True, as_text=True) or ""
if verbose:
print("IN POST /api/chat\n" + (raw[:2000] if isinstance(raw, str) else ""))
payload = json.loads(raw) if raw else {}
except Exception:
return jsonify({"error": "Invalid JSON body"}), 400
model = payload.get("model")
raw_messages = payload.get("messages")
messages = convert_ollama_messages(
raw_messages, payload.get("images") if isinstance(payload.get("images"), list) else None
)
if isinstance(messages, list):
sys_idx = next((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system"), None)
if isinstance(sys_idx, int):
sys_msg = messages.pop(sys_idx)
content = sys_msg.get("content") if isinstance(sys_msg, dict) else ""
messages.insert(0, {"role": "user", "content": content})
stream_req = payload.get("stream")
if stream_req is None:
stream_req = True
stream_req = bool(stream_req)
tools_req = payload.get("tools") if isinstance(payload.get("tools"), list) else []
tools_responses = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req))
tool_choice = payload.get("tool_choice", "auto")
parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))
if not isinstance(model, str) or not isinstance(messages, list) or not messages:
return jsonify({"error": "Invalid request format"}), 400
input_items = convert_chat_messages_to_responses_input(messages)
upstream, error_resp = start_upstream_request(
normalize_model_name(model),
input_items,
instructions=BASE_INSTRUCTIONS,
tools=tools_responses,
tool_choice=tool_choice,
parallel_tool_calls=parallel_tool_calls,
reasoning_param=build_reasoning_param(reasoning_effort, reasoning_summary, None),
)
if error_resp is not None:
return error_resp
if upstream.status_code >= 400:
try:
err_body = json.loads(upstream.content.decode("utf-8", errors="ignore")) if upstream.content else {"raw": upstream.text}
except Exception:
err_body = {"raw": upstream.text}
if verbose:
print("/api/chat upstream error status=", upstream.status_code, " body:", json.dumps(err_body)[:2000])
return (
jsonify({"error": (err_body.get("error", {}) or {}).get("message", "Upstream error")}),
upstream.status_code,
)
created_at = str(int(time.time() * 1000))
if stream_req:
def _gen():
compat = (current_app.config.get("REASONING_COMPAT", "think-tags") or "think-tags").strip().lower()
think_open = False
think_closed = False
saw_any_summary = False
pending_summary_paragraph = False
try:
for raw_line in upstream.iter_lines(decode_unicode=False):
if not raw_line:
continue
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, (bytes, bytearray)) else raw_line
if not line.startswith("data: "):
continue
data = line[len("data: "):].strip()
if not data:
continue
if data == "[DONE]":
break
try:
evt = json.loads(data)
except Exception:
continue
kind = evt.get("type")
if kind == "response.reasoning_summary_part.added":
if compat in ("think-tags", "o3"):
if saw_any_summary:
pending_summary_paragraph = True
else:
saw_any_summary = True
elif kind in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
delta_txt = evt.get("delta") or ""
if compat == "o3":
if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
yield json.dumps({"message": {"role": "assistant", "content": "\n"}}) + "\n"
pending_summary_paragraph = False
elif compat == "think-tags":
if not think_open and not think_closed:
yield json.dumps({"message": {"role": "assistant", "content": "<think>"}}) + "\n"
think_open = True
if think_open and not think_closed:
if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
yield json.dumps({"message": {"role": "assistant", "content": "\n"}}) + "\n"
pending_summary_paragraph = False
else:
pass
elif kind == "response.output_text.delta":
delta = evt.get("delta") or ""
if compat == "think-tags" and think_open and not think_closed:
yield json.dumps({"message": {"role": "assistant", "content": "</think>"}}) + "\n"
think_open = False
think_closed = True
yield json.dumps({"message": {"role": "assistant", "content": delta}}) + "\n"
elif kind == "response.completed":
break
finally:
upstream.close()
resp = current_app.response_class(
_gen(),
status=200,
mimetype="application/x-ndjson",
)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
full_text = ""
reasoning_summary_text = ""
reasoning_full_text = ""
tool_calls: List[Dict[str, Any]] = []
try:
for raw in upstream.iter_lines(decode_unicode=False):
if not raw:
continue
line = raw.decode("utf-8", errors="ignore") if isinstance(raw, (bytes, bytearray)) else raw
if not line.startswith("data: "):
continue
data = line[len("data: "):].strip()
if not data:
continue
if data == "[DONE]":
break
try:
evt = json.loads(data)
except Exception:
continue
kind = evt.get("type")
if kind == "response.output_text.delta":
full_text += evt.get("delta") or ""
elif kind == "response.reasoning_summary_text.delta":
reasoning_summary_text += evt.get("delta") or ""
elif kind == "response.reasoning_text.delta":
reasoning_full_text += evt.get("delta") or ""
elif kind == "response.output_item.done":
item = evt.get("item") or {}
if isinstance(item, dict) and item.get("type") == "function_call":
call_id = item.get("call_id") or item.get("id") or ""
name = item.get("name") or ""
args = item.get("arguments") or ""
if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
tool_calls.append(
{
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": args},
}
)
elif kind == "response.completed":
break
finally:
upstream.close()
if (current_app.config.get("REASONING_COMPAT", "think-tags") or "think-tags").strip().lower() == "think-tags":
rtxt_parts = []
if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
rtxt_parts.append(reasoning_summary_text)
if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
rtxt_parts.append(reasoning_full_text)
rtxt = "\n\n".join([p for p in rtxt_parts if p])
if rtxt:
full_text = f"<think>{rtxt}</think>" + (full_text or "")
out_json = {
"model": normalize_model_name(model),
"created_at": created_at,
"message": {"role": "assistant", "content": full_text, **({"tool_calls": tool_calls} if tool_calls else {})},
"done": True,
"done_reason": "stop",
}
out_json.update(_OLLAMA_FAKE_EVAL)
resp = make_response(jsonify(out_json), 200)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp

313
chatmock/routes_openai.py Normal file
View File

@@ -0,0 +1,313 @@
from __future__ import annotations
import json
import time
from typing import Any, Dict, List
from flask import Blueprint, Response, current_app, jsonify, make_response, request
from .config import BASE_INSTRUCTIONS
from .http import build_cors_headers
from .reasoning import apply_reasoning_to_message, build_reasoning_param
from .upstream import normalize_model_name, start_upstream_request
from .utils import (
convert_chat_messages_to_responses_input,
convert_tools_chat_to_responses,
sse_translate_chat,
sse_translate_text,
)
openai_bp = Blueprint("openai", __name__)
@openai_bp.route("/v1/chat/completions", methods=["POST"])
def chat_completions() -> Response:
verbose = bool(current_app.config.get("VERBOSE"))
reasoning_effort = current_app.config.get("REASONING_EFFORT", "medium")
reasoning_summary = current_app.config.get("REASONING_SUMMARY", "auto")
reasoning_compat = current_app.config.get("REASONING_COMPAT", "think-tags")
debug_model = current_app.config.get("DEBUG_MODEL")
if verbose:
try:
body_preview = (request.get_data(cache=True, as_text=True) or "")[:2000]
print("IN POST /v1/chat/completions\n" + body_preview)
except Exception:
pass
raw = request.get_data(cache=True, as_text=True) or ""
try:
payload = json.loads(raw) if raw else {}
except Exception:
try:
payload = json.loads(raw.replace("\r", "").replace("\n", ""))
except Exception:
return jsonify({"error": {"message": "Invalid JSON body"}}), 400
model = normalize_model_name(payload.get("model"), debug_model)
messages = payload.get("messages")
if messages is None and isinstance(payload.get("prompt"), str):
messages = [{"role": "user", "content": payload.get("prompt") or ""}]
if messages is None and isinstance(payload.get("input"), str):
messages = [{"role": "user", "content": payload.get("input") or ""}]
if messages is None:
messages = []
if not isinstance(messages, list):
return jsonify({"error": {"message": "Request must include messages: []"}}), 400
if isinstance(messages, list):
sys_idx = next((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system"), None)
if isinstance(sys_idx, int):
sys_msg = messages.pop(sys_idx)
content = sys_msg.get("content") if isinstance(sys_msg, dict) else ""
messages.insert(0, {"role": "user", "content": content})
is_stream = bool(payload.get("stream"))
tools_responses = convert_tools_chat_to_responses(payload.get("tools"))
tool_choice = payload.get("tool_choice", "auto")
parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))
input_items = convert_chat_messages_to_responses_input(messages)
if not input_items and isinstance(payload.get("prompt"), str) and payload.get("prompt").strip():
input_items = [
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": payload.get("prompt")}]}
]
reasoning_overrides = payload.get("reasoning") if isinstance(payload.get("reasoning"), dict) else None
reasoning_param = build_reasoning_param(reasoning_effort, reasoning_summary, reasoning_overrides)
upstream, error_resp = start_upstream_request(
model,
input_items,
instructions=BASE_INSTRUCTIONS,
tools=tools_responses,
tool_choice=tool_choice,
parallel_tool_calls=parallel_tool_calls,
reasoning_param=reasoning_param,
)
if error_resp is not None:
return error_resp
created = int(time.time())
if upstream.status_code >= 400:
try:
raw = upstream.content
err_body = json.loads(raw.decode("utf-8", errors="ignore")) if raw else {"raw": upstream.text}
except Exception:
err_body = {"raw": upstream.text}
if verbose:
print("Upstream error status=", upstream.status_code, " body:", json.dumps(err_body)[:2000])
return (
jsonify({"error": {"message": (err_body.get("error", {}) or {}).get("message", "Upstream error")}}),
upstream.status_code,
)
if is_stream:
resp = Response(
sse_translate_chat(
upstream,
model,
created,
verbose=verbose,
vlog=print if verbose else None,
reasoning_compat=reasoning_compat,
),
status=upstream.status_code,
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
full_text = ""
reasoning_summary_text = ""
reasoning_full_text = ""
response_id = "chatcmpl"
tool_calls: List[Dict[str, Any]] = []
error_message: str | None = None
try:
for raw in upstream.iter_lines(decode_unicode=False):
if not raw:
continue
line = raw.decode("utf-8", errors="ignore") if isinstance(raw, (bytes, bytearray)) else raw
if not line.startswith("data: "):
continue
data = line[len("data: "):].strip()
if not data:
continue
if data == "[DONE]":
break
try:
evt = json.loads(data)
except Exception:
continue
kind = evt.get("type")
if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
response_id = evt["response"].get("id") or response_id
if kind == "response.output_text.delta":
full_text += evt.get("delta") or ""
elif kind == "response.reasoning_summary_text.delta":
reasoning_summary_text += evt.get("delta") or ""
elif kind == "response.reasoning_text.delta":
reasoning_full_text += evt.get("delta") or ""
elif kind == "response.output_item.done":
item = evt.get("item") or {}
if isinstance(item, dict) and item.get("type") == "function_call":
call_id = item.get("call_id") or item.get("id") or ""
name = item.get("name") or ""
args = item.get("arguments") or ""
if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
tool_calls.append(
{
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": args},
}
)
elif kind == "response.failed":
error_message = evt.get("response", {}).get("error", {}).get("message", "response.failed")
elif kind == "response.completed":
break
finally:
upstream.close()
if error_message:
resp = make_response(jsonify({"error": {"message": error_message}}), 502)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
message: Dict[str, Any] = {"role": "assistant", "content": full_text if full_text else None}
if tool_calls:
message["tool_calls"] = tool_calls
message = apply_reasoning_to_message(message, reasoning_summary_text, reasoning_full_text, reasoning_compat)
completion = {
"id": response_id or "chatcmpl",
"object": "chat.completion",
"created": created,
"model": model,
"choices": [
{
"index": 0,
"message": message,
"finish_reason": "stop",
}
],
}
resp = make_response(jsonify(completion), upstream.status_code)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
@openai_bp.route("/v1/completions", methods=["POST"])
def completions() -> Response:
verbose = bool(current_app.config.get("VERBOSE"))
debug_model = current_app.config.get("DEBUG_MODEL")
reasoning_effort = current_app.config.get("REASONING_EFFORT", "medium")
reasoning_summary = current_app.config.get("REASONING_SUMMARY", "auto")
raw = request.get_data(cache=True, as_text=True) or ""
try:
payload = json.loads(raw) if raw else {}
except Exception:
return jsonify({"error": {"message": "Invalid JSON body"}}), 400
model = normalize_model_name(payload.get("model"), debug_model)
prompt = payload.get("prompt")
if isinstance(prompt, list):
prompt = "".join([p if isinstance(p, str) else "" for p in prompt])
if not isinstance(prompt, str):
prompt = payload.get("suffix") or ""
stream_req = bool(payload.get("stream", False))
messages = [{"role": "user", "content": prompt or ""}]
input_items = convert_chat_messages_to_responses_input(messages)
reasoning_overrides = payload.get("reasoning") if isinstance(payload.get("reasoning"), dict) else None
reasoning_param = build_reasoning_param(reasoning_effort, reasoning_summary, reasoning_overrides)
upstream, error_resp = start_upstream_request(
model,
input_items,
instructions=BASE_INSTRUCTIONS,
reasoning_param=reasoning_param,
)
if error_resp is not None:
return error_resp
created = int(time.time())
if upstream.status_code >= 400:
try:
err_body = json.loads(upstream.content.decode("utf-8", errors="ignore")) if upstream.content else {"raw": upstream.text}
except Exception:
err_body = {"raw": upstream.text}
return (
jsonify({"error": {"message": (err_body.get("error", {}) or {}).get("message", "Upstream error")}}),
upstream.status_code,
)
if stream_req:
resp = Response(
sse_translate_text(upstream, model, created, verbose=verbose, vlog=(print if verbose else None)),
status=upstream.status_code,
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
full_text = ""
response_id = "cmpl"
try:
for raw_line in upstream.iter_lines(decode_unicode=False):
if not raw_line:
continue
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, (bytes, bytearray)) else raw_line
if not line.startswith("data: "):
continue
data = line[len("data: "):].strip()
if not data or data == "[DONE]":
if data == "[DONE]":
break
continue
try:
evt = json.loads(data)
except Exception:
continue
if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
response_id = evt["response"].get("id") or response_id
kind = evt.get("type")
if kind == "response.output_text.delta":
full_text += evt.get("delta") or ""
elif kind == "response.completed":
break
finally:
upstream.close()
completion = {
"id": response_id or "cmpl",
"object": "text_completion",
"created": created,
"model": model,
"choices": [
{"index": 0, "text": full_text, "finish_reason": "stop", "logprobs": None}
],
}
resp = make_response(jsonify(completion), upstream.status_code)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp
@openai_bp.route("/v1/models", methods=["GET"])
def list_models() -> Response:
models = {"object": "list", "data": [{"id": "gpt-5", "object": "model", "owned_by": "owner"}]}
resp = make_response(jsonify(models), 200)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return resp

149
chatmock/transform.py Normal file
View File

@@ -0,0 +1,149 @@
from __future__ import annotations
import json
from typing import Any, Dict, List
def to_data_url(image_str: str) -> str:
if not isinstance(image_str, str) or not image_str:
return image_str
s = image_str.strip()
if s.startswith("data:image/"):
return s
if s.startswith("http://") or s.startswith("https://"):
return s
b64 = s.replace("\n", "").replace("\r", "")
kind = "image/png"
if b64.startswith("/9j/"):
kind = "image/jpeg"
elif b64.startswith("iVBORw0KGgo"):
kind = "image/png"
elif b64.startswith("R0lGOD"):
kind = "image/gif"
return f"data:{kind};base64,{b64}"
def convert_ollama_messages(
messages: List[Dict[str, Any]] | None, top_images: List[str] | None
) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
msgs = messages if isinstance(messages, list) else []
pending_call_ids: List[str] = []
call_counter = 0
for m in msgs:
if not isinstance(m, dict):
continue
role = m.get("role") or "user"
nm: Dict[str, Any] = {"role": role}
content = m.get("content")
images = m.get("images") if isinstance(m.get("images"), list) else []
parts: List[Dict[str, Any]] = []
if isinstance(content, list):
for p in content:
if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str):
parts.append({"type": "text", "text": p.get("text")})
elif isinstance(content, str):
parts.append({"type": "text", "text": content})
for img in images:
url = to_data_url(img)
if isinstance(url, str) and url:
parts.append({"type": "image_url", "image_url": {"url": url}})
if parts:
nm["content"] = parts
if role == "assistant" and isinstance(m.get("tool_calls"), list):
tcs = []
for tc in m.get("tool_calls"):
if not isinstance(tc, dict):
continue
fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
name = fn.get("name") if isinstance(fn.get("name"), str) else None
args = fn.get("arguments")
if name is None:
continue
call_id = tc.get("id") or tc.get("call_id")
if not isinstance(call_id, str) or not call_id:
call_counter += 1
call_id = f"ollama_call_{call_counter}"
pending_call_ids.append(call_id)
tcs.append(
{
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": args if isinstance(args, str) else (json.dumps(args) if isinstance(args, dict) else "{}"),
},
}
)
if tcs:
nm["tool_calls"] = tcs
if role == "tool":
tci = m.get("tool_call_id") or m.get("id")
if not isinstance(tci, str) or not tci:
if pending_call_ids:
tci = pending_call_ids.pop(0)
if isinstance(tci, str) and tci:
nm["tool_call_id"] = tci
if not parts and isinstance(content, str):
nm["content"] = content
out.append(nm)
if isinstance(top_images, list) and top_images:
attach_to = None
for i in range(len(out) - 1, -1, -1):
if out[i].get("role") == "user":
attach_to = out[i]
break
if attach_to is None:
attach_to = {"role": "user", "content": []}
out.append(attach_to)
attach_to.setdefault("content", [])
for img in top_images:
url = to_data_url(img)
if isinstance(url, str) and url:
attach_to["content"].append({"type": "image_url", "image_url": {"url": url}})
return out
def normalize_ollama_tools(tools: List[Dict[str, Any]] | None) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
if not isinstance(tools, list):
return out
for t in tools:
if not isinstance(t, dict):
continue
if isinstance(t.get("function"), dict):
fn = t.get("function")
name = fn.get("name") if isinstance(fn.get("name"), str) else None
if not name:
continue
out.append(
{
"type": "function",
"function": {
"name": name,
"description": fn.get("description") or "",
"parameters": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {"type": "object", "properties": {}},
},
}
)
continue
name = t.get("name") if isinstance(t.get("name"), str) else None
if name:
out.append(
{
"type": "function",
"function": {
"name": name,
"description": t.get("description") or "",
"parameters": {"type": "object", "properties": {}},
},
}
)
return out

99
chatmock/upstream.py Normal file
View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import json
import time
from typing import Any, Dict, List, Tuple
import requests
from flask import Response, jsonify, make_response
from .config import CHATGPT_RESPONSES_URL
from .http import build_cors_headers
from .utils import get_effective_chatgpt_auth
def normalize_model_name(name: str | None, debug_model: str | None = None) -> str:
if isinstance(debug_model, str) and debug_model.strip():
return debug_model.strip()
if not isinstance(name, str) or not name.strip():
return "gpt-5"
base = name.split(":", 1)[0].strip()
mapping = {
"gpt5": "gpt-5",
"gpt-5-latest": "gpt-5",
"gpt-5": "gpt-5",
"codex": "codex-mini-latest",
"codex-mini": "codex-mini-latest",
"codex-mini-latest": "codex-mini-latest",
}
return mapping.get(base, base)
def start_upstream_request(
model: str,
input_items: List[Dict[str, Any]],
*,
instructions: str | None = None,
tools: List[Dict[str, Any]] | None = None,
tool_choice: Any | None = None,
parallel_tool_calls: bool = False,
reasoning_param: Dict[str, Any] | None = None,
):
access_token, account_id = get_effective_chatgpt_auth()
if not access_token or not account_id:
resp = make_response(
jsonify(
{
"error": {
"message": "Missing ChatGPT credentials. Run 'python3 chatmock.py login' first.",
}
}
),
401,
)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return None, resp
include: List[str] = []
if isinstance(reasoning_param, dict) and reasoning_param.get("effort") != "none":
include.append("reasoning.encrypted_content")
responses_payload = {
"model": model,
"instructions": instructions if isinstance(instructions, str) and instructions.strip() else instructions,
"input": input_items,
"tools": tools or [],
"tool_choice": tool_choice if tool_choice in ("auto", "none") or isinstance(tool_choice, dict) else "auto",
"parallel_tool_calls": bool(parallel_tool_calls),
"store": False,
"stream": True,
"include": include,
}
if reasoning_param is not None:
responses_payload["reasoning"] = reasoning_param
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
}
try:
upstream = requests.post(
CHATGPT_RESPONSES_URL,
headers=headers,
json=responses_payload,
stream=True,
timeout=600,
)
except requests.RequestException as e:
resp = make_response(jsonify({"error": {"message": f"Upstream ChatGPT request failed: {e}"}}), 502)
for k, v in build_cors_headers().items():
resp.headers.setdefault(k, v)
return None, resp
return upstream, None

516
chatmock/utils.py Normal file
View File

@@ -0,0 +1,516 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import sys
from typing import Any, Dict, List
def eprint(*args, **kwargs) -> None:
print(*args, file=sys.stderr, **kwargs)
def get_home_dir() -> str:
home = os.getenv("CHATGPT_LOCAL_HOME") or os.getenv("CODEX_HOME")
if not home:
home = os.path.expanduser("~/.chatgpt-local")
return home
def read_auth_file() -> Dict[str, Any] | None:
for base in [
os.getenv("CHATGPT_LOCAL_HOME"),
os.getenv("CODEX_HOME"),
os.path.expanduser("~/.chatgpt-local"),
os.path.expanduser("~/.codex"),
]:
if not base:
continue
path = os.path.join(base, "auth.json")
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
continue
except Exception:
continue
return None
def write_auth_file(auth: Dict[str, Any]) -> bool:
home = get_home_dir()
try:
os.makedirs(home, exist_ok=True)
except Exception as exc:
eprint(f"ERROR: unable to create auth home directory {home}: {exc}")
return False
path = os.path.join(home, "auth.json")
try:
with open(path, "w", encoding="utf-8") as fp:
if hasattr(os, "fchmod"):
os.fchmod(fp.fileno(), 0o600)
json.dump(auth, fp, indent=2)
return True
except Exception as exc:
eprint(f"ERROR: unable to write auth file: {exc}")
return False
def parse_jwt_claims(token: str) -> Dict[str, Any] | None:
if not token or token.count(".") != 2:
return None
try:
_, payload, _ = token.split(".")
padded = payload + "=" * (-len(payload) % 4)
data = base64.urlsafe_b64decode(padded.encode())
return json.loads(data.decode())
except Exception:
return None
def generate_pkce() -> "PkceCodes":
from .models import PkceCodes
code_verifier = secrets.token_hex(64)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return PkceCodes(code_verifier=code_verifier, code_challenge=code_challenge)
def convert_chat_messages_to_responses_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def _normalize_image_data_url(url: str) -> str:
try:
if not isinstance(url, str):
return url
if not url.startswith("data:image/"):
return url
if ";base64," not in url:
return url
header, data = url.split(",", 1)
try:
from urllib.parse import unquote
data = unquote(data)
except Exception:
pass
data = data.strip().replace("\n", "").replace("\r", "")
data = data.replace("-", "+").replace("_", "/")
pad = (-len(data)) % 4
if pad:
data = data + ("=" * pad)
try:
base64.b64decode(data, validate=True)
except Exception:
return url
return f"{header},{data}"
except Exception:
return url
input_items: List[Dict[str, Any]] = []
for message in messages:
role = message.get("role")
if role == "system":
continue
if role == "tool":
call_id = message.get("tool_call_id") or message.get("id")
if isinstance(call_id, str) and call_id:
content = message.get("content", "")
if isinstance(content, list):
texts = []
for part in content:
if isinstance(part, dict):
t = part.get("text") or part.get("content")
if isinstance(t, str) and t:
texts.append(t)
content = "\n".join(texts)
if isinstance(content, str):
input_items.append(
{
"type": "function_call_output",
"call_id": call_id,
"output": content,
}
)
continue
if role == "assistant" and isinstance(message.get("tool_calls"), list):
for tc in message.get("tool_calls") or []:
if not isinstance(tc, dict):
continue
tc_type = tc.get("type", "function")
if tc_type != "function":
continue
call_id = tc.get("id") or tc.get("call_id")
fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
name = fn.get("name") if isinstance(fn, dict) else None
args = fn.get("arguments") if isinstance(fn, dict) else None
if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
input_items.append(
{
"type": "function_call",
"name": name,
"arguments": args,
"call_id": call_id,
}
)
content = message.get("content", "")
content_items: List[Dict[str, Any]] = []
if isinstance(content, list):
for part in content:
if not isinstance(part, dict):
continue
ptype = part.get("type")
if ptype == "text":
text = part.get("text") or part.get("content") or ""
if isinstance(text, str) and text:
kind = "output_text" if role == "assistant" else "input_text"
content_items.append({"type": kind, "text": text})
elif ptype == "image_url":
image = part.get("image_url")
url = image.get("url") if isinstance(image, dict) else image
if isinstance(url, str) and url:
content_items.append({"type": "input_image", "image_url": _normalize_image_data_url(url)})
elif isinstance(content, str) and content:
kind = "output_text" if role == "assistant" else "input_text"
content_items.append({"type": kind, "text": content})
if not content_items:
continue
role_out = "assistant" if role == "assistant" else "user"
input_items.append({"type": "message", "role": role_out, "content": content_items})
return input_items
def convert_tools_chat_to_responses(tools: Any) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
if not isinstance(tools, list):
return out
for t in tools:
if not isinstance(t, dict):
continue
if t.get("type") != "function":
continue
fn = t.get("function") if isinstance(t.get("function"), dict) else {}
name = fn.get("name") if isinstance(fn, dict) else None
if not isinstance(name, str) or not name:
continue
desc = fn.get("description") if isinstance(fn, dict) else None
params = fn.get("parameters") if isinstance(fn, dict) else None
if not isinstance(params, dict):
params = {"type": "object", "properties": {}}
out.append(
{
"type": "function",
"name": name,
"description": desc or "",
"strict": False,
"parameters": params,
}
)
return out
def load_chatgpt_tokens() -> tuple[str | None, str | None, str | None]:
auth = read_auth_file()
if not auth:
return None, None, None
tokens = auth.get("tokens", {}) if isinstance(auth, dict) else {}
return tokens.get("access_token"), tokens.get("account_id"), tokens.get("id_token")
def get_effective_chatgpt_auth() -> tuple[str | None, str | None]:
access_token, account_id, id_token = load_chatgpt_tokens()
if not account_id and id_token:
claims = parse_jwt_claims(id_token) or {}
auth_claims = claims.get("https://api.openai.com/auth", {}) or {}
if isinstance(auth_claims, dict):
account_id = auth_claims.get("chatgpt_account_id")
return access_token, account_id
def sse_translate_chat(
upstream,
model: str,
created: int,
verbose: bool = False,
vlog=None,
reasoning_compat: str = "think-tags",
):
response_id = "chatcmpl-stream"
compat = (reasoning_compat or "think-tags").strip().lower()
think_open = False
think_closed = False
saw_output = False
saw_any_summary = False
pending_summary_paragraph = False
try:
for raw in upstream.iter_lines(decode_unicode=False):
if not raw:
continue
line = raw.decode("utf-8", errors="ignore") if isinstance(raw, (bytes, bytearray)) else raw
if verbose and vlog:
vlog(line)
if not line.startswith("data: "):
continue
data = line[len("data: "):].strip()
if not data:
continue
if data == "[DONE]":
break
try:
evt = json.loads(data)
except Exception:
continue
kind = evt.get("type")
if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
response_id = evt["response"].get("id") or response_id
if kind == "response.output_text.delta":
delta = evt.get("delta") or ""
if compat == "think-tags" and think_open and not think_closed:
close_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {"content": "</think>"}, "finish_reason": None}],
}
yield f"data: {json.dumps(close_chunk)}\n\n".encode("utf-8")
think_open = False
think_closed = True
saw_output = True
chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
elif kind == "response.output_item.done":
item = evt.get("item") or {}
if isinstance(item, dict) and item.get("type") == "function_call":
call_id = item.get("call_id") or item.get("id") or ""
name = item.get("name") or ""
args = item.get("arguments") or ""
if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
delta_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": 0,
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": args},
}
]
},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(delta_chunk)}\n\n".encode("utf-8")
finish_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
}
yield f"data: {json.dumps(finish_chunk)}\n\n".encode("utf-8")
elif kind == "response.reasoning_summary_part.added":
if compat in ("think-tags", "o3"):
if saw_any_summary:
pending_summary_paragraph = True
else:
saw_any_summary = True
elif kind in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
delta_txt = evt.get("delta") or ""
if compat == "o3":
if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
nl_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [
{
"index": 0,
"delta": {"reasoning": {"content": [{"type": "text", "text": "\n"}]}},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(nl_chunk)}\n\n".encode("utf-8")
pending_summary_paragraph = False
chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [
{
"index": 0,
"delta": {"reasoning": {"content": [{"type": "text", "text": delta_txt}]}},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
elif compat == "think-tags":
if not think_open and not think_closed:
open_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {"content": "<think>"}, "finish_reason": None}],
}
yield f"data: {json.dumps(open_chunk)}\n\n".encode("utf-8")
think_open = True
if think_open and not think_closed:
if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
nl_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {"content": "\n"}, "finish_reason": None}],
}
yield f"data: {json.dumps(nl_chunk)}\n\n".encode("utf-8")
pending_summary_paragraph = False
content_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {"content": delta_txt}, "finish_reason": None}],
}
yield f"data: {json.dumps(content_chunk)}\n\n".encode("utf-8")
else:
if kind == "response.reasoning_summary_text.delta":
chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [
{
"index": 0,
"delta": {"reasoning_summary": delta_txt},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
else:
chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [
{"index": 0, "delta": {"reasoning": delta_txt}, "finish_reason": None}
],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
elif isinstance(kind, str) and kind.endswith(".done"):
pass
elif kind == "response.output_text.done":
chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
elif kind == "response.failed":
err = evt.get("response", {}).get("error", {}).get("message", "response.failed")
chunk = {"error": {"message": err}}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
elif kind == "response.completed":
if compat == "think-tags" and think_open and not think_closed:
close_chunk = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": {"content": "</think>"}, "finish_reason": None}],
}
yield f"data: {json.dumps(close_chunk)}\n\n".encode("utf-8")
think_open = False
think_closed = True
yield b"data: [DONE]\n\n"
break
finally:
upstream.close()
def sse_translate_text(upstream, model: str, created: int, verbose: bool = False, vlog=None):
response_id = "cmpl-stream"
try:
for raw_line in upstream.iter_lines(decode_unicode=False):
if not raw_line:
continue
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, (bytes, bytearray)) else raw_line
if verbose and vlog:
vlog(line)
if not line.startswith("data: "):
continue
data = line[len("data: "):].strip()
if not data or data == "[DONE]":
if data == "[DONE]":
chunk = {
"id": response_id,
"object": "text_completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "text": "", "finish_reason": "stop"}],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
continue
try:
evt = json.loads(data)
except Exception:
continue
kind = evt.get("type")
if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
response_id = evt["response"].get("id") or response_id
if kind == "response.output_text.delta":
delta_text = evt.get("delta") or ""
chunk = {
"id": response_id,
"object": "text_completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "text": delta_text, "finish_reason": None}],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
elif kind == "response.output_text.done":
chunk = {
"id": response_id,
"object": "text_completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "text": "", "finish_reason": "stop"}],
}
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
elif kind == "response.completed":
yield b"data: [DONE]\n\n"
break
finally:
upstream.close()