Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .coverage
Binary file not shown.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ dist/

# OS clutter
.DS_Store

# IDE files
ChatMock.code-workspace
.gitignore
.claude/settings.local.json
.serena/
63 changes: 63 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
hooks:
- id: ruff-check
types_or: [ python, pyi ]
args: ["--fix", "--exit-non-zero-on-fix"]
stages: [pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, post-merge, post-rewrite, manual, pre-merge-commit]
- id: ruff-format
types_or: [ python, pyi ]
stages: [pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, post-merge, post-rewrite, manual, pre-merge-commit]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-ast
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-illegal-windows-names
- id: check-json
- id: check-shebang-scripts-are-executable
- id: pretty-format-json
- id: check-merge-conflict
- id: check-symlinks
- id: check-toml
- id: check-vcs-permalinks
- id: check-xml
- id: check-yaml
- id: debug-statements
- id: destroyed-symlinks
- id: detect-aws-credentials
args: ['--allow-missing-credentials']
- id: detect-private-key
- id: end-of-file-fixer
- id: file-contents-sorter
- id: fix-byte-order-marker
- id: forbid-new-submodules
- id: forbid-submodules
- id: mixed-line-ending
- id: requirements-txt-fixer
- id: sort-simple-yaml
- id: trailing-whitespace

- repo: local
hooks:
- id: ruff-full-repo
name: Ruff full-repo lint (pre-push)
entry: uv run ruff check . --fix
language: system
pass_filenames: false
stages: [pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, post-merge, post-rewrite, manual, pre-merge-commit]
- id: pytest-coverage
name: Run tests with coverage (100%)
entry: uv run --project . pytest -q --cov=chatmock --cov-report=term-missing --cov-fail-under=100
language: system
pass_filenames: false
stages: [pre-push]

ci:
autofix_prs: false
skip: []
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,3 @@ EXPOSE 8000 1455

ENTRYPOINT ["/entrypoint.sh"]
CMD ["serve"]

18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<a href="https://github.com/RayBytes/ChatMock/blob/master/LICENSE"><img src="https://img.shields.io/github/license/RayBytes/ChatMock?color=2b9348" alt="License Badge"/></a>
</div>
</h1>

<p><b>OpenAI & Ollama compatible API powered by your ChatGPT plan.</b></p>
<p>Use your ChatGPT Plus/Pro account to call OpenAI models from code or alternate chat UIs.</p>
<br>
Expand All @@ -26,7 +26,7 @@ This does require a paid ChatGPT account.

#### GUI Application

If you're on **macOS**, you can download the GUI app from the [GitHub releases](https://github.com/RayBytes/ChatMock/releases).
If you're on **macOS**, you can download the GUI app from the [GitHub releases](https://github.com/RayBytes/ChatMock/releases).
> **Note:** Since ChatMock isn't signed with an Apple Developer ID, you may need to run the following command in your terminal to open the app:
>
> ```bash
Expand Down Expand Up @@ -69,7 +69,7 @@ Read [the docker instrunctions here](https://github.com/RayBytes/ChatMock/blob/m

# Examples

### Python
### Python

```python
from openai import OpenAI
Expand Down Expand Up @@ -99,17 +99,21 @@ curl http://127.0.0.1:8000/v1/chat/completions \
}'
```

### Docker

Read [the docker instrunctions here](https://github.com/RayBytes/ChatMock/blob/main/DOCKER.md)

# What's supported

- Tool/Function calling
- Tool/Function calling
- Vision/Image understanding
- Thinking summaries (through thinking tags)
- Thinking effort

## Notes & Limits

- Requires an active, paid ChatGPT account.
- Some context length might be taken up by internal instructions (but they dont seem to degrade the model)
- Some context length might be taken up by internal instructions (but they dont seem to degrade the model)
- Use responsibly and at your own risk. This project is not affiliated with OpenAI, and is a educational exercise.

# Supported models
Expand Down Expand Up @@ -165,7 +169,3 @@ When the model returns a thinking summary, the model will send back thinking tag
## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=RayBytes/ChatMock&type=Timeline)](https://www.star-history.com/#RayBytes/ChatMock&Timeline)




109 changes: 77 additions & 32 deletions build.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,39 @@
"""Build utilities for packaging ChatMock."""

from __future__ import annotations

import argparse
import contextlib
import os
import platform
import plistlib
import shutil
import subprocess
import sys
from pathlib import Path
import plistlib
from PIL import Image

from PIL import Image, ImageDraw

ROOT = Path(__file__).parent.resolve()
BUILD_DIR = ROOT / "build"
ICONS_DIR = BUILD_DIR / "icons"


def info(msg: str) -> None:
print(f"[build] {msg}")
"""Log a simple build message to stdout."""
sys.stdout.write(f"[build] {msg}\n")


def ensure_dirs() -> None:
"""Ensure build output directories exist."""
ICONS_DIR.mkdir(parents=True, exist_ok=True)


def load_icon_png(path: Path) -> Image.Image:
"""Load an image, center it on a square transparent canvas, and return RGBA."""
if Image is None:
raise RuntimeError("Pillow is required to process icons.")
msg = "Pillow is required to process icons."
raise RuntimeError(msg)
img = Image.open(path).convert("RGBA")
size = max(img.width, img.height)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
Expand All @@ -37,14 +44,15 @@ def load_icon_png(path: Path) -> Image.Image:


def rounded(img: Image.Image, radius_ratio: float = 0.22) -> Image.Image:
"""Apply rounded corners to an image using the given radius ratio."""
if Image is None:
return img
w, h = img.size
r = int(min(w, h) * max(0.0, min(radius_ratio, 0.5)))
if r <= 0:
return img
mask = Image.new("L", (w, h), 0)
from PIL import ImageDraw

d = ImageDraw.Draw(mask)
d.rounded_rectangle((0, 0, w, h), radius=r, fill=255)
out = img.copy()
Expand All @@ -53,6 +61,7 @@ def rounded(img: Image.Image, radius_ratio: float = 0.22) -> Image.Image:


def make_windows_ico(src_png: Path, out_ico: Path, radius_ratio: float) -> Path:
"""Create a Windows .ico file from a PNG source and return the output path."""
info("Generating Windows .ico")
square = load_icon_png(src_png)
sizes = [16, 24, 32, 48, 64, 128, 256]
Expand All @@ -62,6 +71,7 @@ def make_windows_ico(src_png: Path, out_ico: Path, radius_ratio: float) -> Path:


def make_macos_icns(src_png: Path, out_icns: Path, radius_ratio: float) -> Path:
"""Create a macOS .icns bundle from a PNG source and return the output path."""
info("Generating macOS .icns")
iconset = BUILD_DIR / "icon.iconset"
if iconset.exists():
Expand All @@ -71,52 +81,72 @@ def make_macos_icns(src_png: Path, out_icns: Path, radius_ratio: float) -> Path:
square = load_icon_png(src_png)
sizes = [16, 32, 64, 128, 256, 512, 1024]
mapping = {
16: ["icon_16x16.png", "icon_32x32.png"],
32: ["[email protected]"],
64: ["[email protected]"],
16: ["icon_16x16.png", "icon_32x32.png"],
32: ["[email protected]"],
64: ["[email protected]"],
128: ["icon_128x128.png", "icon_256x256.png"],
256: ["[email protected]"],
512: ["icon_512x512.png"],
1024:["[email protected]"],
1024: ["[email protected]"],
}
for s in sizes:
img = rounded(square.resize((s, s), Image.LANCZOS), radius_ratio)
for name in mapping.get(s, []):
img.save(iconset / name, format="PNG")

iconutil = shutil.which("iconutil")
if not iconutil:
msg = "'iconutil' not found in PATH. Install Xcode command line tools."
raise RuntimeError(msg)
try:
subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(out_icns)], check=True)
subprocess.run([iconutil, "-c", "icns", str(iconset), "-o", str(out_icns)], check=True) # noqa: S603
except Exception as e:
raise RuntimeError("Failed to create .icns. Ensure Xcode command line tools are installed (iconutil).\n"
f"Details: {e}")
msg = (
"Failed to create .icns. Ensure Xcode command line tools are installed (iconutil).\n"
f"Details: {e}"
)
raise RuntimeError(msg) from e
finally:
shutil.rmtree(iconset, ignore_errors=True)
return out_icns


def pyinstaller_add_data_arg(src: Path, dest: str) -> str:
"""Return a formatted --add-data argument for PyInstaller depending on OS."""
sep = ";" if os.name == "nt" else ":"
return f"{src}{sep}{dest}"


def run_pyinstaller(entry: Path, name: str, icon: Path | None, extra_data: list[tuple[Path, str]], bundle_id: str | None = None) -> None:
def run_pyinstaller(
entry: Path,
name: str,
icon: Path | None,
extra_data: list[tuple[Path, str]],
bundle_id: str | None = None,
) -> None:
"""Invoke PyInstaller to build the application binary."""
cmd = [
sys.executable, "-m", "PyInstaller",
"--windowed", "--noconfirm",
"--name", name,
sys.executable,
"-m",
"PyInstaller",
"--windowed",
"--noconfirm",
"--name",
name,
]
if bundle_id and platform.system().lower() == "darwin":
cmd += ["--osx-bundle-identifier", bundle_id]
if icon is not None:
cmd += ["--icon", str(icon)]
for (src, dest) in extra_data:
for src, dest in extra_data:
cmd += ["--add-data", pyinstaller_add_data_arg(src, dest)]
cmd.append(str(entry))
info("Running: " + " ".join(cmd))
subprocess.run(cmd, check=True)
subprocess.run(cmd, check=True) # noqa: S603


def patch_macos_plist(app_path: Path, bundle_id: str, icon_base_name: str = "appicon") -> None:
"""Patch the Info.plist inside a macOS .app bundle with metadata and icon name."""
info("Patching macOS Info.plist")
plist_path = app_path / "Contents" / "Info.plist"
if not plist_path.exists():
Expand All @@ -132,30 +162,44 @@ def patch_macos_plist(app_path: Path, bundle_id: str, icon_base_name: str = "app
with plist_path.open("wb") as f:
plistlib.dump(data, f)


def make_dmg(app_path: Path, dmg_path: Path, volume_name: str) -> None:
"""Create a compressed DMG containing the specified .app bundle."""
info("Creating DMG")
staging = BUILD_DIR / "dmg_staging"
if staging.exists():
shutil.rmtree(staging)
(staging).mkdir(parents=True, exist_ok=True)
shutil.rmtree(staging / app_path.name, ignore_errors=True)
shutil.copytree(app_path, staging / app_path.name, symlinks=True)
try:
os.symlink("/Applications", staging / "Applications")
except FileExistsError:
pass
with contextlib.suppress(FileExistsError):
(staging / "Applications").symlink_to(Path("/Applications"))
dmg_path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([
"hdiutil", "create", "-volname", volume_name,
"-srcfolder", str(staging),
"-format", "UDZO",
"-imagekey", "zlib-level=9",
str(dmg_path)
], check=True)
hdiutil = shutil.which("hdiutil")
if not hdiutil:
msg = "'hdiutil' not found; cannot create DMG on this platform."
raise RuntimeError(msg)
subprocess.run( # noqa: S603
[
hdiutil,
"create",
"-volname",
volume_name,
"-srcfolder",
str(staging),
"-format",
"UDZO",
"-imagekey",
"zlib-level=9",
str(dmg_path),
],
check=True,
)
shutil.rmtree(staging, ignore_errors=True)


def main() -> None:
"""CLI to generate icons and build distributables for ChatMock."""
parser = argparse.ArgumentParser()
parser.add_argument("--name", default="ChatMock")
parser.add_argument("--entry", default="gui.py")
Expand All @@ -169,9 +213,11 @@ def main() -> None:
entry = ROOT / args.entry
icon_src = ROOT / args.icon
if not entry.exists():
raise SystemExit(f"Entry not found: {entry}")
msg = f"Entry not found: {entry}"
raise SystemExit(msg)
if not icon_src.exists():
raise SystemExit(f"Icon PNG not found: {icon_src}")
msg = f"Icon PNG not found: {icon_src}"
raise SystemExit(msg)

os_name = platform.system().lower()
extra_data: list[tuple[Path, str]] = [(ROOT / "prompt.md", ".")]
Expand Down Expand Up @@ -209,6 +255,5 @@ def main() -> None:
make_dmg(app_path, dmg, args.name)



if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion chatmock.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Run the ChatMock server or GUI entry points."""

from __future__ import annotations

from chatmock.cli import main

if __name__ == "__main__":
main()

Loading