Upload 64 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +25 -0
- .env +7 -0
- .env.example +7 -0
- .gitattributes +2 -0
- .gitignore +16 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +1 -0
- .pytest_cache/v/cache/nodeids +4 -0
- Dockerfile +26 -0
- LICENSE +21 -0
- README.md +235 -10
- app/__init__.py +3 -0
- app/__pycache__/__init__.cpython-314.pyc +0 -0
- app/__pycache__/config.cpython-314.pyc +0 -0
- app/__pycache__/dependencies.cpython-314.pyc +0 -0
- app/__pycache__/main.cpython-314.pyc +0 -0
- app/api/__init__.py +1 -0
- app/api/__pycache__/__init__.cpython-314.pyc +0 -0
- app/api/__pycache__/custom.cpython-314.pyc +0 -0
- app/api/custom.py +31 -0
- app/api/v1/__init__.py +1 -0
- app/api/v1/__pycache__/__init__.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/chat.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/models.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/responses.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/router.cpython-314.pyc +0 -0
- app/api/v1/chat.py +16 -0
- app/api/v1/models.py +17 -0
- app/api/v1/responses.py +31 -0
- app/api/v1/router.py +11 -0
- app/config.py +27 -0
- app/dependencies.py +23 -0
- app/main.py +56 -0
- app/schemas/__init__.py +1 -0
- app/schemas/__pycache__/__init__.cpython-314.pyc +0 -0
- app/schemas/__pycache__/chat.cpython-314.pyc +0 -0
- app/schemas/__pycache__/responses.cpython-314.pyc +0 -0
- app/schemas/chat.py +28 -0
- app/schemas/responses.py +15 -0
- app/services/__init__.py +1 -0
- app/services/__pycache__/__init__.cpython-314.pyc +0 -0
- app/services/__pycache__/browser.cpython-314.pyc +0 -0
- app/services/__pycache__/chat.cpython-314.pyc +0 -0
- app/services/browser.py +143 -0
- app/services/chat.py +108 -0
- app/utils/__init__.py +1 -0
- app/utils/__pycache__/__init__.cpython-314.pyc +0 -0
- app/utils/__pycache__/parser.cpython-314.pyc +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Environment
|
| 6 |
+
.env
|
| 7 |
+
.env.example
|
| 8 |
+
|
| 9 |
+
# Python
|
| 10 |
+
__pycache__/
|
| 11 |
+
*.py[cod]
|
| 12 |
+
*$py.class
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
.venv/
|
| 15 |
+
venv/
|
| 16 |
+
|
| 17 |
+
# Documentation / Media
|
| 18 |
+
demo.mp4
|
| 19 |
+
gui_screenshot.png
|
| 20 |
+
n8n_preview.png
|
| 21 |
+
|
| 22 |
+
# Docker
|
| 23 |
+
docker-compose.yml
|
| 24 |
+
Dockerfile
|
| 25 |
+
.dockerignore
|
.env
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PhantomAPI Configuration
|
| 2 |
+
# Copy this file to .env and adjust values
|
| 3 |
+
API_SECRET_KEY=change-me-to-a-strong-secret
|
| 4 |
+
HOST=0.0.0.0
|
| 5 |
+
PORT=7777
|
| 6 |
+
HEADLESS=true
|
| 7 |
+
BROWSER_TIMEOUT=120000
|
.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PhantomAPI Configuration
|
| 2 |
+
# Copy this file to .env and adjust values
|
| 3 |
+
API_SECRET_KEY=change-me-to-a-strong-secret
|
| 4 |
+
HOST=0.0.0.0
|
| 5 |
+
PORT=7777
|
| 6 |
+
HEADLESS=true
|
| 7 |
+
BROWSER_TIMEOUT=120000
|
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
n8n_preview.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.egg-info/
|
| 6 |
+
dist/
|
| 7 |
+
build/
|
| 8 |
+
venv/
|
| 9 |
+
.venv/
|
| 10 |
+
.env
|
| 11 |
+
.vscode/
|
| 12 |
+
.idea/
|
| 13 |
+
.DS_Store
|
| 14 |
+
Thumbs.db
|
| 15 |
+
*.log
|
| 16 |
+
playwright-report/
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"tests/test_custom.py::test_custom_api_endpoint[asyncio]",
|
| 3 |
+
"tests/test_custom.py::test_custom_api_error_handling[asyncio]"
|
| 4 |
+
]
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install system dependencies for Playwright/Chromium
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
wget gnupg fonts-liberation libnss3 libatk-bridge2.0-0 libdrm2 \
|
| 6 |
+
libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
|
| 7 |
+
libasound2 libpangocairo-1.0-0 libgtk-3-0 \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Set up a new user named "user" with user ID 1000
|
| 11 |
+
RUN useradd -m -u 1000 user
|
| 12 |
+
USER user
|
| 13 |
+
ENV HOME=/home/user \
|
| 14 |
+
PATH=/home/user/.local/bin:$PATH
|
| 15 |
+
|
| 16 |
+
WORKDIR $HOME/app
|
| 17 |
+
|
| 18 |
+
COPY --chown=user requirements.txt .
|
| 19 |
+
RUN pip install --no-cache-dir --user -r requirements.txt \
|
| 20 |
+
&& python -m playwright install chromium
|
| 21 |
+
|
| 22 |
+
COPY --chown=user . .
|
| 23 |
+
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
CMD ["python", "run.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 mrshibly
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,235 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom: purple
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: PhantomAPI
|
| 3 |
+
emoji: 👻
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
<div align="center">
|
| 11 |
+
|
| 12 |
+
# 👻 PhantomAPI
|
| 13 |
+
|
| 14 |
+
### Turn ChatGPT into a FREE OpenAI-Compatible API
|
| 15 |
+
|
| 16 |
+
[](https://fastapi.tiangolo.com/)
|
| 17 |
+
[](https://playwright.dev/)
|
| 18 |
+
[](https://docker.com/)
|
| 19 |
+
[](LICENSE)
|
| 20 |
+
|
| 21 |
+
**The invisible proxy that bridges ChatGPT's free web interface with your AI automation workflows.**
|
| 22 |
+
|
| 23 |
+
[Quick Start](#-quick-start) · [n8n Integration](#-connecting-to-n8n) · [Architecture](#-architecture) · [Docker](#-docker-deployment)
|
| 24 |
+
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div align="center">
|
| 28 |
+
<video src="demo.mp4" width="800" controls muted autoplay loop></video>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## 🌟 What is PhantomAPI?
|
| 34 |
+
|
| 35 |
+
**PhantomAPI** is a high-performance proxy server that makes ChatGPT's free web interface behave like the official OpenAI API. It's designed as a **drop-in replacement** for any tool that speaks the OpenAI protocol — especially **n8n**.
|
| 36 |
+
|
| 37 |
+
### ✨ Key Features
|
| 38 |
+
|
| 39 |
+
| Feature | Description |
|
| 40 |
+
|:---|:---|
|
| 41 |
+
| 💸 **Zero API Costs** | Uses ChatGPT's free web interface via headless browser automation |
|
| 42 |
+
| ⚡ **Async Architecture** | Built on FastAPI with a dedicated browser thread for non-blocking requests |
|
| 43 |
+
| 🤖 **AI Agent Support** | Full tool-calling / function-calling support for n8n Agent nodes |
|
| 44 |
+
| 🔒 **API Key Auth** | Protected with Bearer token authentication |
|
| 45 |
+
| 🐳 **Docker Ready** | Deploy in seconds with `docker-compose up` |
|
| 46 |
+
| 🎨 **Built-in GUI** | A sleek dark-mode chat interface for quick testing |
|
| 47 |
+
| 📐 **Clean Architecture** | Proper FastAPI structure — routers, schemas, services, utils |
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## ⚙️ How It Works
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
┌──────────┐ HTTP/JSON ┌──────────────┐ Playwright ┌──────────────┐
|
| 55 |
+
│ n8n │ ──────────────────▶ │ PhantomAPI │ ──────────────────▶ │ ChatGPT │
|
| 56 |
+
│ (or any │ ◀────────────────── │ (FastAPI) │ ◀────────────────── │ (Web UI) │
|
| 57 |
+
│ client) │ OpenAI Schema │ │ Scrape Response │ │
|
| 58 |
+
└──────────┘ └──────────────┘ └──────────────┘
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
1. **You send** a standard OpenAI API request to PhantomAPI
|
| 62 |
+
2. **PhantomAPI** formats your messages into a prompt and types it into ChatGPT's web interface using a stealth browser
|
| 63 |
+
3. **ChatGPT responds** on the web page — PhantomAPI scrapes the text
|
| 64 |
+
4. **The response** is formatted back into the official OpenAI JSON schema and returned to you
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 🛠️ Quick Start
|
| 69 |
+
|
| 70 |
+
### Prerequisites
|
| 71 |
+
- **Python 3.10+**
|
| 72 |
+
- **Google Chrome** installed on your system
|
| 73 |
+
|
| 74 |
+
### 1. Clone & Install
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
git clone https://github.com/mrshibly/phantom-api.git
|
| 78 |
+
cd phantom-api
|
| 79 |
+
pip install -r requirements.txt
|
| 80 |
+
python -m playwright install chromium
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### 2. Configure
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
cp .env.example .env
|
| 87 |
+
# Edit .env and set your API_SECRET_KEY
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### 3. Run
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
python run.py
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
The server will start on `http://localhost:7777`.
|
| 97 |
+
|
| 98 |
+
| Endpoint | Description |
|
| 99 |
+
|:---|:---|
|
| 100 |
+
| `http://localhost:7777/` | Health check |
|
| 101 |
+
| `http://localhost:7777/docs` | Swagger UI (interactive API docs) |
|
| 102 |
+
| `http://localhost:7777/gui` | Chat GUI for quick testing |
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## 🔌 Connecting to n8n
|
| 107 |
+
|
| 108 |
+
<div align="center">
|
| 109 |
+
<img src="n8n_preview.png" width="800" alt="n8n Workflow Example">
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
### Method 1: OpenAI Node (Recommended)
|
| 113 |
+
|
| 114 |
+
1. In n8n, go to **Credentials → New → OpenAI API**
|
| 115 |
+
2. Set **Base URL** to: `http://127.0.0.1:7777/v1`
|
| 116 |
+
3. Set **API Key** to your `API_SECRET_KEY` from `.env`
|
| 117 |
+
4. Use this credential in any **OpenAI** or **AI Agent** node
|
| 118 |
+
|
| 119 |
+
> **Docker Tip:** If n8n runs in Docker, use `http://host.docker.internal:7777/v1`
|
| 120 |
+
|
| 121 |
+
### Method 2: HTTP Request Node
|
| 122 |
+
|
| 123 |
+
1. Add an **HTTP Request** node
|
| 124 |
+
2. **Method:** `POST`
|
| 125 |
+
3. **URL:** `http://127.0.0.1:7777/v1/chat/completions`
|
| 126 |
+
4. **Authentication:** Header Auth → `Authorization: Bearer YOUR_KEY`
|
| 127 |
+
5. **Body (JSON):**
|
| 128 |
+
|
| 129 |
+
```json
|
| 130 |
+
{
|
| 131 |
+
"model": "gpt-4o-mini",
|
| 132 |
+
"messages": [
|
| 133 |
+
{ "role": "user", "content": "Hello, PhantomAPI!" }
|
| 134 |
+
]
|
| 135 |
+
}
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## 📐 Architecture
|
| 141 |
+
|
| 142 |
+
```
|
| 143 |
+
phantom-api/
|
| 144 |
+
├── app/
|
| 145 |
+
│ ├── main.py # App factory, CORS, lifespan
|
| 146 |
+
│ ├── config.py # Environment-driven settings
|
| 147 |
+
│ ├── dependencies.py # Auth dependency injection
|
| 148 |
+
│ ├── api/v1/
|
| 149 |
+
│ │ ├── router.py # Route aggregator
|
| 150 |
+
│ │ ├── chat.py # POST /v1/chat/completions
|
| 151 |
+
│ │ ├── responses.py # POST /v1/responses
|
| 152 |
+
│ │ └── models.py # GET /v1/models
|
| 153 |
+
│ ├── schemas/
|
| 154 |
+
│ │ ├── chat.py # Request/Response models
|
| 155 |
+
│ │ └── responses.py # Responses API models
|
| 156 |
+
│ ├── services/
|
| 157 |
+
│ │ └── browser.py # Playwright browser engine
|
| 158 |
+
│ └── utils/
|
| 159 |
+
│ ├── prompt.py # Smart prompt builder
|
| 160 |
+
│ └── parser.py # Tool-call JSON parser
|
| 161 |
+
├── static/
|
| 162 |
+
│ └── index.html # Chat GUI
|
| 163 |
+
├── tests/
|
| 164 |
+
│ └── test_health.py # Endpoint tests
|
| 165 |
+
├── Dockerfile
|
| 166 |
+
├── docker-compose.yml
|
| 167 |
+
├── requirements.txt
|
| 168 |
+
├── .env.example
|
| 169 |
+
└── run.py # Entry point
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## 🐳 Docker Deployment
|
| 175 |
+
|
| 176 |
+
```bash
|
| 177 |
+
# Build and run
|
| 178 |
+
docker-compose up --build -d
|
| 179 |
+
|
| 180 |
+
# The server is now running on http://localhost:7777
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## 🤗 Hugging Face Spaces Deployment
|
| 186 |
+
|
| 187 |
+
You can deploy PhantomAPI directly to Hugging Face Spaces for free.
|
| 188 |
+
|
| 189 |
+
1. **Create a New Space**: Go to [huggingface.co/new-space](https://huggingface.co/new-space)
|
| 190 |
+
2. **Select SDK**: Choose **Docker**
|
| 191 |
+
3. **Choose Template**: Select **Blank** (The existing `Dockerfile` will be used automatically)
|
| 192 |
+
4. **Configure Secrets**:
|
| 193 |
+
- Go to **Settings → Variables and secrets** in your Space
|
| 194 |
+
- Add a new **Secret** named `API_SECRET_KEY` with your desired token
|
| 195 |
+
5. **Push Code**:
|
| 196 |
+
```bash
|
| 197 |
+
git remote add hf https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 198 |
+
git push -u hf main
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
PhantomAPI will automatically start on port `7860`.
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
## 🔧 API Reference
|
| 206 |
+
|
| 207 |
+
### `POST /v1/chat/completions`
|
| 208 |
+
|
| 209 |
+
Standard OpenAI Chat Completions endpoint. Supports messages, tools, and function calling.
|
| 210 |
+
|
| 211 |
+
### `POST /v1/responses`
|
| 212 |
+
|
| 213 |
+
Modern Responses API for newer n8n versions. Accepts `input` (string or messages) and optional `instructions`.
|
| 214 |
+
|
| 215 |
+
### `GET /v1/models`
|
| 216 |
+
|
| 217 |
+
Returns available model identifiers (used by n8n's model dropdown).
|
| 218 |
+
|
| 219 |
+
### `GET /`
|
| 220 |
+
|
| 221 |
+
Health check — returns server status and version.
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## 📄 License
|
| 226 |
+
|
| 227 |
+
This project is open-sourced under the [MIT License](LICENSE).
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
<div align="center">
|
| 232 |
+
|
| 233 |
+
**Built with ❤️ by [mrshibly](https://github.com/mrshibly)**
|
| 234 |
+
|
| 235 |
+
</div>
|
app/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Application package."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
app/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (245 Bytes). View file
|
|
|
app/__pycache__/config.cpython-314.pyc
ADDED
|
Binary file (1.29 kB). View file
|
|
|
app/__pycache__/dependencies.cpython-314.pyc
ADDED
|
Binary file (1.28 kB). View file
|
|
|
app/__pycache__/main.cpython-314.pyc
ADDED
|
Binary file (2.53 kB). View file
|
|
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — API package."""
|
app/api/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (212 Bytes). View file
|
|
|
app/api/__pycache__/custom.cpython-314.pyc
ADDED
|
Binary file (2.17 kB). View file
|
|
|
app/api/custom.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Custom simplified API endpoint."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from app.services.browser import engine
|
| 6 |
+
|
| 7 |
+
router = APIRouter(tags=["custom"])
|
| 8 |
+
|
| 9 |
+
class QuickPromptRequest(BaseModel):
|
| 10 |
+
prompt: str
|
| 11 |
+
|
| 12 |
+
class QuickPromptResponse(BaseModel):
|
| 13 |
+
status: str
|
| 14 |
+
text: str
|
| 15 |
+
|
| 16 |
+
@router.post("/api", response_model=QuickPromptResponse)
|
| 17 |
+
async def quick_prompt(request: QuickPromptRequest):
|
| 18 |
+
"""Simple API endpoint for quick prompts."""
|
| 19 |
+
try:
|
| 20 |
+
# Get response from browser engine
|
| 21 |
+
response_text = engine.chat(request.prompt)
|
| 22 |
+
|
| 23 |
+
return QuickPromptResponse(
|
| 24 |
+
status="success",
|
| 25 |
+
text=response_text
|
| 26 |
+
)
|
| 27 |
+
except Exception as e:
|
| 28 |
+
return QuickPromptResponse(
|
| 29 |
+
status="error",
|
| 30 |
+
text=str(e)
|
| 31 |
+
)
|
app/api/v1/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — API v1 package."""
|
app/api/v1/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (218 Bytes). View file
|
|
|
app/api/v1/__pycache__/chat.cpython-314.pyc
ADDED
|
Binary file (1.19 kB). View file
|
|
|
app/api/v1/__pycache__/models.cpython-314.pyc
ADDED
|
Binary file (701 Bytes). View file
|
|
|
app/api/v1/__pycache__/responses.cpython-314.pyc
ADDED
|
Binary file (1.63 kB). View file
|
|
|
app/api/v1/__pycache__/router.cpython-314.pyc
ADDED
|
Binary file (651 Bytes). View file
|
|
|
app/api/v1/chat.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — POST /v1/chat/completions."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
|
| 5 |
+
from app.dependencies import verify_api_key
|
| 6 |
+
from app.schemas.chat import ChatCompletionRequest
|
| 7 |
+
from app.services.chat import process_chat_completion
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post("/chat/completions", dependencies=[Depends(verify_api_key)])
|
| 13 |
+
async def chat_completions(payload: ChatCompletionRequest):
|
| 14 |
+
"""OpenAI-compatible chat completions endpoint."""
|
| 15 |
+
data = payload.model_dump()
|
| 16 |
+
return process_chat_completion(data["messages"], data["model"], data.get("tools"))
|
app/api/v1/models.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — GET /v1/models."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.get("/models")
|
| 9 |
+
async def list_models():
|
| 10 |
+
"""Return available models for n8n model dropdown."""
|
| 11 |
+
return {
|
| 12 |
+
"object": "list",
|
| 13 |
+
"data": [
|
| 14 |
+
{"id": "gpt-4o-mini", "object": "model", "owned_by": "phantom-api"},
|
| 15 |
+
{"id": "gpt-4o", "object": "model", "owned_by": "phantom-api"},
|
| 16 |
+
],
|
| 17 |
+
}
|
app/api/v1/responses.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — POST /v1/responses."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
|
| 5 |
+
from app.dependencies import verify_api_key
|
| 6 |
+
from app.schemas.responses import ResponsesRequest
|
| 7 |
+
from app.services.chat import process_responses_api
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post("/responses", dependencies=[Depends(verify_api_key)])
|
| 13 |
+
async def responses(payload: ResponsesRequest):
|
| 14 |
+
"""Modern Responses API endpoint for newer n8n versions."""
|
| 15 |
+
data = payload.model_dump()
|
| 16 |
+
input_data = data.get("input", "")
|
| 17 |
+
|
| 18 |
+
# Normalise input to messages list
|
| 19 |
+
if isinstance(input_data, str):
|
| 20 |
+
messages = [{"role": "user", "content": input_data}]
|
| 21 |
+
elif isinstance(input_data, list):
|
| 22 |
+
messages = input_data
|
| 23 |
+
else:
|
| 24 |
+
messages = []
|
| 25 |
+
|
| 26 |
+
# Inject system instructions
|
| 27 |
+
instructions = data.get("instructions", "")
|
| 28 |
+
if instructions:
|
| 29 |
+
messages.insert(0, {"role": "system", "content": instructions})
|
| 30 |
+
|
| 31 |
+
return process_responses_api(messages, data["model"], data.get("tools"))
|
app/api/v1/router.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — v1 API router aggregator."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
|
| 5 |
+
from app.api.v1 import chat, responses, models
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/v1", tags=["v1"])
|
| 8 |
+
|
| 9 |
+
router.include_router(chat.router)
|
| 10 |
+
router.include_router(responses.router)
|
| 11 |
+
router.include_router(models.router)
|
app/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Configuration via environment variables."""
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
"""Application settings loaded from .env file or environment variables."""
|
| 8 |
+
|
| 9 |
+
# --- Security ---
|
| 10 |
+
API_SECRET_KEY: str = "change-me-to-a-strong-secret"
|
| 11 |
+
|
| 12 |
+
# --- Server ---
|
| 13 |
+
HOST: str = "0.0.0.0"
|
| 14 |
+
PORT: int = 7860
|
| 15 |
+
|
| 16 |
+
# --- Browser Engine ---
|
| 17 |
+
HEADLESS: bool = True
|
| 18 |
+
BROWSER_TIMEOUT: int = 120000 # milliseconds
|
| 19 |
+
|
| 20 |
+
model_config = {
|
| 21 |
+
"env_file": ".env",
|
| 22 |
+
"env_file_encoding": "utf-8",
|
| 23 |
+
"extra": "ignore",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
settings = Settings()
|
app/dependencies.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Authentication dependencies."""
|
| 2 |
+
|
| 3 |
+
from fastapi import Request, HTTPException
|
| 4 |
+
from app.config import settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
async def verify_api_key(request: Request) -> str:
|
| 8 |
+
"""Validate the Bearer token from the Authorization header.
|
| 9 |
+
|
| 10 |
+
Returns the validated key on success, raises 401 on failure.
|
| 11 |
+
"""
|
| 12 |
+
authorization = request.headers.get("authorization", "")
|
| 13 |
+
|
| 14 |
+
if not authorization:
|
| 15 |
+
raise HTTPException(status_code=401, detail="Missing Authorization header.")
|
| 16 |
+
|
| 17 |
+
# Strip "Bearer " prefix
|
| 18 |
+
token = authorization.replace("Bearer ", "").strip()
|
| 19 |
+
|
| 20 |
+
if token != settings.API_SECRET_KEY:
|
| 21 |
+
raise HTTPException(status_code=401, detail="Invalid API Key.")
|
| 22 |
+
|
| 23 |
+
return token
|
app/main.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Application factory."""
|
| 2 |
+
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from fastapi.responses import RedirectResponse
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
|
| 11 |
+
from app import __version__
|
| 12 |
+
from app.api.v1.router import router as v1_router
|
| 13 |
+
from app.api.custom import router as custom_router
|
| 14 |
+
from app.services.browser import engine
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@asynccontextmanager
|
| 18 |
+
async def lifespan(application: FastAPI):
|
| 19 |
+
"""Start the browser engine on startup."""
|
| 20 |
+
engine.start()
|
| 21 |
+
yield
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
app = FastAPI(
|
| 25 |
+
title="PhantomAPI",
|
| 26 |
+
description="A proxy that turns free ChatGPT into an OpenAI-compatible API.",
|
| 27 |
+
version=__version__,
|
| 28 |
+
lifespan=lifespan,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
app.add_middleware(
|
| 32 |
+
CORSMiddleware,
|
| 33 |
+
allow_origins=["*"],
|
| 34 |
+
allow_credentials=True,
|
| 35 |
+
allow_methods=["*"],
|
| 36 |
+
allow_headers=["*"],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
app.include_router(v1_router)
|
| 40 |
+
app.include_router(custom_router)
|
| 41 |
+
|
| 42 |
+
static_dir = Path(__file__).parent.parent / "static"
|
| 43 |
+
if static_dir.exists():
|
| 44 |
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@app.get("/", tags=["health"])
|
| 48 |
+
async def health_check():
|
| 49 |
+
"""Health check."""
|
| 50 |
+
return {"status": "running", "service": "PhantomAPI", "version": __version__}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.get("/gui", tags=["gui"])
|
| 54 |
+
async def gui_redirect():
|
| 55 |
+
"""Redirect to Chat GUI."""
|
| 56 |
+
return RedirectResponse(url="/static/index.html")
|
app/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Schemas package."""
|
app/schemas/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (220 Bytes). View file
|
|
|
app/schemas/__pycache__/chat.cpython-314.pyc
ADDED
|
Binary file (2.98 kB). View file
|
|
|
app/schemas/__pycache__/responses.cpython-314.pyc
ADDED
|
Binary file (1.73 kB). View file
|
|
|
app/schemas/chat.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Pydantic schemas for /v1/chat/completions."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Dict, Any, Optional, Union
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Message(BaseModel):
|
| 8 |
+
"""A single message in the conversation."""
|
| 9 |
+
role: str = Field(..., description="The role of the message author.")
|
| 10 |
+
content: Union[str, List[Dict[str, Any]]] = Field(..., description="The content of the message.")
|
| 11 |
+
name: Optional[str] = Field(default=None, description="Name for tool messages.")
|
| 12 |
+
tool_calls: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool calls from assistant.")
|
| 13 |
+
tool_call_id: Optional[str] = Field(default=None, description="Tool call ID for tool responses.")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ChatCompletionRequest(BaseModel):
|
| 17 |
+
"""Request body for POST /v1/chat/completions."""
|
| 18 |
+
messages: List[Message] = Field(..., description="The conversation messages.")
|
| 19 |
+
model: str = Field(default="gpt-4o-mini", description="Model identifier.")
|
| 20 |
+
tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Available tools for function calling.")
|
| 21 |
+
temperature: Optional[float] = Field(default=None, description="Sampling temperature (ignored).")
|
| 22 |
+
max_tokens: Optional[int] = Field(default=None, description="Max tokens (ignored).")
|
| 23 |
+
|
| 24 |
+
model_config = {
|
| 25 |
+
"json_schema_extra": {
|
| 26 |
+
"examples": [{"messages": [{"role": "user", "content": "Hello!"}], "model": "gpt-4o-mini"}]
|
| 27 |
+
}
|
| 28 |
+
}
|
app/schemas/responses.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Pydantic schemas for /v1/responses."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Dict, Any, Optional, Union
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from app.schemas.chat import Message
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ResponsesRequest(BaseModel):
|
| 9 |
+
"""Request body for POST /v1/responses (Modern Responses API)."""
|
| 10 |
+
input: Union[str, List[Message]] = Field(..., description="The input text or messages.")
|
| 11 |
+
model: str = Field(default="gpt-4o-mini", description="Model identifier.")
|
| 12 |
+
tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="A list of tools the model may call.")
|
| 13 |
+
instructions: Optional[str] = Field(default="", description="System instructions.")
|
| 14 |
+
|
| 15 |
+
model_config = {"json_schema_extra": {"examples": [{"input": "Hello!", "model": "gpt-4o-mini"}]}}
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Services package."""
|
app/services/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (222 Bytes). View file
|
|
|
app/services/__pycache__/browser.cpython-314.pyc
ADDED
|
Binary file (7.83 kB). View file
|
|
|
app/services/__pycache__/chat.cpython-314.pyc
ADDED
|
Binary file (4.99 kB). View file
|
|
|
app/services/browser.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Browser automation engine.
|
| 2 |
+
|
| 3 |
+
Launches a persistent headless Chrome instance via Playwright
|
| 4 |
+
and interacts with chatgpt.com to generate responses.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import threading
|
| 9 |
+
from app.config import settings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class BrowserEngine(threading.Thread):
|
| 13 |
+
"""A dedicated thread that runs an async Playwright browser.
|
| 14 |
+
|
| 15 |
+
This avoids blocking the FastAPI event loop while still giving
|
| 16 |
+
us a persistent browser instance that can handle sequential requests.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self) -> None:
|
| 20 |
+
super().__init__(daemon=True)
|
| 21 |
+
self.loop = asyncio.new_event_loop()
|
| 22 |
+
self.ready = threading.Event()
|
| 23 |
+
self.browser = None
|
| 24 |
+
self.playwright = None
|
| 25 |
+
|
| 26 |
+
# ------------------------------------------------------------------
|
| 27 |
+
# Thread lifecycle
|
| 28 |
+
# ------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
def run(self) -> None:
|
| 31 |
+
"""Thread entry point — start browser and run the event loop forever."""
|
| 32 |
+
asyncio.set_event_loop(self.loop)
|
| 33 |
+
self.loop.run_until_complete(self._launch())
|
| 34 |
+
self.ready.set()
|
| 35 |
+
print("[PhantomAPI] ⚡ Browser engine ready.")
|
| 36 |
+
self.loop.run_forever()
|
| 37 |
+
|
| 38 |
+
async def _launch(self) -> None:
|
| 39 |
+
"""Launch a stealth Chromium browser."""
|
| 40 |
+
from playwright.async_api import async_playwright
|
| 41 |
+
|
| 42 |
+
print("[PhantomAPI] 🚀 Launching browser...")
|
| 43 |
+
self.playwright = await async_playwright().start()
|
| 44 |
+
self.browser = await self.playwright.chromium.launch(
|
| 45 |
+
headless=settings.HEADLESS,
|
| 46 |
+
channel="chrome",
|
| 47 |
+
args=[
|
| 48 |
+
"--disable-blink-features=AutomationControlled",
|
| 49 |
+
"--no-sandbox",
|
| 50 |
+
"--disable-gpu",
|
| 51 |
+
"--disable-dev-shm-usage",
|
| 52 |
+
"--disable-setuid-sandbox",
|
| 53 |
+
],
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# ------------------------------------------------------------------
|
| 57 |
+
# Public API
|
| 58 |
+
# ------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
def chat(self, prompt: str) -> str:
|
| 61 |
+
"""Send a prompt to ChatGPT and return the response text.
|
| 62 |
+
|
| 63 |
+
This is a blocking call that schedules work on the browser
|
| 64 |
+
thread's event loop and waits for the result.
|
| 65 |
+
"""
|
| 66 |
+
if not self.ready.wait(timeout=30) or self.browser is None:
|
| 67 |
+
raise RuntimeError("Browser engine is not ready. Is Chrome installed?")
|
| 68 |
+
|
| 69 |
+
future = asyncio.run_coroutine_threadsafe(
|
| 70 |
+
self._interact(prompt), self.loop
|
| 71 |
+
)
|
| 72 |
+
return future.result(timeout=settings.BROWSER_TIMEOUT // 1000 + 30)
|
| 73 |
+
|
| 74 |
+
# ------------------------------------------------------------------
|
| 75 |
+
# Private — browser interaction
|
| 76 |
+
# ------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
async def _interact(self, prompt: str) -> str:
|
| 79 |
+
"""Open a new ChatGPT session, send the prompt, and scrape the reply."""
|
| 80 |
+
context = await self.browser.new_context(
|
| 81 |
+
user_agent=(
|
| 82 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 83 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 84 |
+
"Chrome/124.0.0.0 Safari/537.36"
|
| 85 |
+
),
|
| 86 |
+
viewport={"width": 1920, "height": 1080},
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Hide the webdriver flag so ChatGPT thinks we're a real user
|
| 90 |
+
await context.add_init_script(
|
| 91 |
+
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
page = await context.new_page()
|
| 95 |
+
|
| 96 |
+
try:
|
| 97 |
+
page.set_default_timeout(settings.BROWSER_TIMEOUT)
|
| 98 |
+
|
| 99 |
+
# Navigate to ChatGPT
|
| 100 |
+
await page.goto("https://chatgpt.com/", wait_until="domcontentloaded")
|
| 101 |
+
|
| 102 |
+
# Type the prompt
|
| 103 |
+
await page.wait_for_selector("#prompt-textarea", timeout=60000)
|
| 104 |
+
await page.fill("#prompt-textarea", prompt)
|
| 105 |
+
await asyncio.sleep(0.5)
|
| 106 |
+
await page.press("#prompt-textarea", "Enter")
|
| 107 |
+
|
| 108 |
+
# Wait for the assistant to start responding
|
| 109 |
+
await page.wait_for_selector(
|
| 110 |
+
'[data-message-author-role="assistant"]',
|
| 111 |
+
timeout=settings.BROWSER_TIMEOUT,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Poll until the response stabilises (no new text for ~2 seconds)
|
| 115 |
+
last_text = ""
|
| 116 |
+
unchanged_count = 0
|
| 117 |
+
while unchanged_count < 4:
|
| 118 |
+
elements = await page.query_selector_all(
|
| 119 |
+
'[data-message-author-role="assistant"]'
|
| 120 |
+
)
|
| 121 |
+
if elements:
|
| 122 |
+
current_text = await elements[-1].inner_text()
|
| 123 |
+
if current_text == last_text and current_text.strip():
|
| 124 |
+
unchanged_count += 1
|
| 125 |
+
else:
|
| 126 |
+
last_text = current_text
|
| 127 |
+
unchanged_count = 0
|
| 128 |
+
await asyncio.sleep(0.5)
|
| 129 |
+
|
| 130 |
+
return last_text.strip()
|
| 131 |
+
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
print(f"[PhantomAPI] ❌ Browser error: {exc}")
|
| 134 |
+
raise
|
| 135 |
+
finally:
|
| 136 |
+
await page.close()
|
| 137 |
+
await context.close()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
# Singleton — created once at import time, started in app lifespan
|
| 142 |
+
# ---------------------------------------------------------------------------
|
| 143 |
+
engine = BrowserEngine()
|
app/services/chat.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Chat completion service.
|
| 2 |
+
|
| 3 |
+
All business logic for processing chat requests lives here.
|
| 4 |
+
Route handlers call these functions and return the result directly.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
from app.services.browser import engine
|
| 11 |
+
from app.utils.prompt import format_prompt
|
| 12 |
+
from app.utils.parser import parse_tool_calls
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def process_chat_completion(messages: list, model: str, tools: list | None = None) -> dict:
|
| 16 |
+
"""Process a chat completion request and return an OpenAI-compatible response."""
|
| 17 |
+
prompt = format_prompt(messages, tools=tools)
|
| 18 |
+
start = time.time()
|
| 19 |
+
|
| 20 |
+
print(f"[PhantomAPI] 📨 Request ({len(prompt)} chars)")
|
| 21 |
+
response_text = engine.chat(prompt)
|
| 22 |
+
|
| 23 |
+
p_tokens = len(prompt.split())
|
| 24 |
+
c_tokens = len(response_text.split())
|
| 25 |
+
tool_calls = parse_tool_calls(response_text) if tools else None
|
| 26 |
+
|
| 27 |
+
return _build_chat_response(response_text, tool_calls, model, int(start), p_tokens, c_tokens)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def process_responses_api(messages: list, model: str, tools: list | None = None) -> dict:
|
| 31 |
+
"""Process a Responses API request and return the formatted response."""
|
| 32 |
+
prompt = format_prompt(messages, tools=tools)
|
| 33 |
+
start = time.time()
|
| 34 |
+
|
| 35 |
+
response_text = engine.chat(prompt)
|
| 36 |
+
|
| 37 |
+
p_tokens = len(prompt.split())
|
| 38 |
+
c_tokens = len(response_text.split())
|
| 39 |
+
tool_calls = parse_tool_calls(response_text) if tools else None
|
| 40 |
+
|
| 41 |
+
return _build_responses_response(response_text, tool_calls, model, int(start), p_tokens, c_tokens)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Private response builders
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
def _build_chat_response(
|
| 49 |
+
text: str, tool_calls: list | None, model: str, created: int, p_tokens: int, c_tokens: int
|
| 50 |
+
) -> dict:
|
| 51 |
+
"""Build an OpenAI-compatible chat completion response dict."""
|
| 52 |
+
response_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
| 53 |
+
usage = {"prompt_tokens": p_tokens, "completion_tokens": c_tokens, "total_tokens": p_tokens + c_tokens}
|
| 54 |
+
|
| 55 |
+
if tool_calls:
|
| 56 |
+
message = {"role": "assistant", "content": None, "tool_calls": tool_calls}
|
| 57 |
+
finish_reason = "tool_calls"
|
| 58 |
+
else:
|
| 59 |
+
message = {"role": "assistant", "content": text}
|
| 60 |
+
finish_reason = "stop"
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"id": response_id,
|
| 64 |
+
"object": "chat.completion",
|
| 65 |
+
"created": created,
|
| 66 |
+
"model": model,
|
| 67 |
+
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
|
| 68 |
+
"usage": usage,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _build_responses_response(
|
| 73 |
+
text: str, tool_calls: list | None, model: str, created: int, p_tokens: int, c_tokens: int
|
| 74 |
+
) -> dict:
|
| 75 |
+
"""Build a Responses API response dict."""
|
| 76 |
+
response_id = f"resp-{uuid.uuid4().hex[:29]}"
|
| 77 |
+
usage = {"input_tokens": p_tokens, "output_tokens": c_tokens, "total_tokens": p_tokens + c_tokens}
|
| 78 |
+
|
| 79 |
+
if tool_calls:
|
| 80 |
+
output = [
|
| 81 |
+
{
|
| 82 |
+
"type": "function_call",
|
| 83 |
+
"id": tc["id"],
|
| 84 |
+
"call_id": tc["id"],
|
| 85 |
+
"name": tc["function"]["name"],
|
| 86 |
+
"arguments": tc["function"]["arguments"],
|
| 87 |
+
"status": "completed",
|
| 88 |
+
}
|
| 89 |
+
for tc in tool_calls
|
| 90 |
+
]
|
| 91 |
+
else:
|
| 92 |
+
output = [
|
| 93 |
+
{
|
| 94 |
+
"type": "message",
|
| 95 |
+
"role": "assistant",
|
| 96 |
+
"content": [{"type": "output_text", "text": text}],
|
| 97 |
+
}
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"id": response_id,
|
| 102 |
+
"object": "response",
|
| 103 |
+
"created_at": created,
|
| 104 |
+
"model": model,
|
| 105 |
+
"status": "completed",
|
| 106 |
+
"output": output,
|
| 107 |
+
"usage": usage,
|
| 108 |
+
}
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PhantomAPI — Utils package."""
|
app/utils/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (216 Bytes). View file
|
|
|
app/utils/__pycache__/parser.cpython-314.pyc
ADDED
|
Binary file (3.27 kB). View file
|
|
|