diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8700910 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Port +PORT=8000 + +# Auth dir +CHATGPT_LOCAL_HOME=/data + +# show request/stream logs +VERBOSE=false + +# OAuth client id (modify only if you know what you're doing) +# CHATGPT_LOCAL_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann + +# Reasoning controls +CHATGPT_LOCAL_REASONING_EFFORT=medium # minimal|low|medium|high +CHATGPT_LOCAL_REASONING_SUMMARY=auto # auto|concise|detailed|none +CHATGPT_LOCAL_REASONING_COMPAT=think-tags # legacy|o3|think-tags|current + +# Force a specific model name +# CHATGPT_LOCAL_DEBUG_MODEL=gpt-5 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..d3aae94 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,39 @@ +# Docker Deployment + +## Quick Start +1) Setup env: + cp .env.example .env + +2) Build the image: + docker compose build + +3) Login: + docker compose run --rm --service-ports chatmock-login login + - The command prints an auth URL, copy paste it into your browser. + - Server should stop automatically once it recieves the tokens and they are saved. + +4) Start the server: + docker compose up -d chatmock + +5) Free to use it in whichever chat app you like! + +## Configuration +Set options in `.env` or pass environment variables: +- `PORT`: Container listening port (default 8000) +- `VERBOSE`: `true|false` to enable request/stream logs +- `CHATGPT_LOCAL_REASONING_EFFORT`: minimal|low|medium|high +- `CHATGPT_LOCAL_REASONING_SUMMARY`: auto|concise|detailed|none +- `CHATGPT_LOCAL_REASONING_COMPAT`: legacy|o3|think-tags|current +- `CHATGPT_LOCAL_DEBUG_MODEL`: force model override (e.g., `gpt-5`) +- `CHATGPT_LOCAL_CLIENT_ID`: OAuth client id override (rarely needed) + +## Logs +Set `VERBOSE=true` to include extra logging for debugging issues in upstream or chat app requests. Please include and use these logs when submitting bug reports. + +## Test + +``` +curl -s http://localhost:8000/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"gpt-5","messages":[{"role":"user","content":"Hello world!"}]}' | jq . +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0594e76 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app + +RUN mkdir -p /data + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 1455 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["serve"] + diff --git a/chatmock/cli.py b/chatmock/cli.py index 3e28e29..a02e08a 100644 --- a/chatmock/cli.py +++ b/chatmock/cli.py @@ -11,6 +11,7 @@ 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 +import os def cmd_login(no_browser: bool, verbose: bool) -> int: @@ -21,7 +22,8 @@ def cmd_login(no_browser: bool, verbose: bool) -> int: return 1 try: - httpd = OAuthHTTPServer(("127.0.0.1", REQUIRED_PORT), OAuthHandler, home_dir=home_dir, client_id=client_id, verbose=verbose) + bind_host = os.getenv("CHATGPT_LOCAL_LOGIN_BIND", "127.0.0.1") + httpd = OAuthHTTPServer((bind_host, 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: diff --git a/chatmock/oauth.py b/chatmock/oauth.py index 7738cf8..9ba1eff 100644 --- a/chatmock/oauth.py +++ b/chatmock/oauth.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import ssl import http.server import json import secrets @@ -10,6 +11,8 @@ import urllib.parse import urllib.request from typing import Any, Dict, Tuple +import certifi + from .models import AuthBundle, PkceCodes, TokenData from .utils import eprint, generate_pkce, parse_jwt_claims, write_auth_file @@ -34,6 +37,7 @@ LOGIN_SUCCESS_HTML = """ """ +_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) class OAuthHTTPServer(http.server.HTTPServer): def __init__( @@ -174,7 +178,8 @@ class OAuthHandler(http.server.BaseHTTPRequestHandler): data=data, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + ), + context=_SSL_CONTEXT, ) as resp: payload = json.loads(resp.read().decode()) @@ -242,7 +247,8 @@ class OAuthHandler(http.server.BaseHTTPRequestHandler): data=exchange_data, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + ), + context=_SSL_CONTEXT, ) as resp: exchange_payload = json.loads(resp.read().decode()) exchanged_access_token = exchange_payload.get("access_token") @@ -258,4 +264,3 @@ class OAuthHandler(http.server.BaseHTTPRequestHandler): } success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}" return exchanged_access_token, success_url - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d76062f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3.9" + +services: + chatmock: + build: . + image: chatmock:latest + container_name: chatmock + command: ["serve"] + env_file: .env + environment: + - CHATGPT_LOCAL_HOME=/data + ports: + - "8000:8000" + volumes: + - chatmock_data:/data + - ./prompt.md:/app/prompt.md:ro + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)\" "] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + + chatmock-login: + image: chatmock:latest + profiles: ["login"] + command: ["login"] + environment: + - CHATGPT_LOCAL_HOME=/data + - CHATGPT_LOCAL_LOGIN_BIND=0.0.0.0 + volumes: + - chatmock_data:/data + ports: + - "1455:1455" + +volumes: + chatmock_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..2e04b4d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +export CHATGPT_LOCAL_HOME="${CHATGPT_LOCAL_HOME:-/data}" + +cmd="${1:-serve}" +shift || true + +bool() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) return 0;; + *) return 1;; + esac +} + +if [[ "$cmd" == "serve" ]]; then + PORT="${PORT:-8000}" + ARGS=(serve --host 0.0.0.0 --port "${PORT}") + + if bool "${VERBOSE:-}" || bool "${CHATGPT_LOCAL_VERBOSE:-}"; then + ARGS+=(--verbose) + fi + + if [[ "$#" -gt 0 ]]; then + ARGS+=("$@") + fi + + exec python chatmock.py "${ARGS[@]}" +elif [[ "$cmd" == "login" ]]; then + ARGS=(login --no-browser) + if bool "${VERBOSE:-}" || bool "${CHATGPT_LOCAL_VERBOSE:-}"; then + ARGS+=(--verbose) + fi + + exec python chatmock.py "${ARGS[@]}" +else + exec "$cmd" "$@" +fi +