diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..d59a2ee6b838d1fd911801dda9d88160eee0c850
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+# Git
+.git
+.gitignore
+
+# Environment
+.env
+.env.example
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+.pytest_cache/
+.venv/
+venv/
+
+# Documentation / Media
+demo.mp4
+gui_screenshot.png
+n8n_preview.png
+
+# Docker
+docker-compose.yml
+Dockerfile
+.dockerignore
diff --git a/.env b/.env
new file mode 100644
index 0000000000000000000000000000000000000000..e1ceaeb1b5278e044e0ecf87305c7c1350a66361
--- /dev/null
+++ b/.env
@@ -0,0 +1,7 @@
+# PhantomAPI Configuration
+# Copy this file to .env and adjust values
+API_SECRET_KEY=change-me-to-a-strong-secret
+HOST=0.0.0.0
+PORT=7777
+HEADLESS=true
+BROWSER_TIMEOUT=120000
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..e1ceaeb1b5278e044e0ecf87305c7c1350a66361
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# PhantomAPI Configuration
+# Copy this file to .env and adjust values
+API_SECRET_KEY=change-me-to-a-strong-secret
+HOST=0.0.0.0
+PORT=7777
+HEADLESS=true
+BROWSER_TIMEOUT=120000
diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..977d5f0040e38e0a2181f2bf37157bd13e15459d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+demo.mp4 filter=lfs diff=lfs merge=lfs -text
+n8n_preview.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..cc80822539d2a89c6281fc35dd60b454481ef186
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.egg-info/
+dist/
+build/
+venv/
+.venv/
+.env
+.vscode/
+.idea/
+.DS_Store
+Thumbs.db
+*.log
+playwright-report/
diff --git a/.pytest_cache/.gitignore b/.pytest_cache/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..08a7f458f1f002823bc794c47ca1996a57e72c86
--- /dev/null
+++ b/.pytest_cache/.gitignore
@@ -0,0 +1,2 @@
+# Created by pytest automatically.
+*
diff --git a/.pytest_cache/CACHEDIR.TAG b/.pytest_cache/CACHEDIR.TAG
new file mode 100644
index 0000000000000000000000000000000000000000..fce15ad7eaa74e5682b644c84efb75334c112f95
--- /dev/null
+++ b/.pytest_cache/CACHEDIR.TAG
@@ -0,0 +1,4 @@
+Signature: 8a477f597d28d172789f06886806bc55
+# This file is a cache directory tag created by pytest.
+# For information about cache directory tags, see:
+# https://bford.info/cachedir/spec.html
diff --git a/.pytest_cache/README.md b/.pytest_cache/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c7526af2448672de4537dfed042ed74daadb17bf
--- /dev/null
+++ b/.pytest_cache/README.md
@@ -0,0 +1,8 @@
+# pytest cache directory #
+
+This directory contains data from the pytest's cache plugin,
+which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
+
+**Do not** commit this to version control.
+
+See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
diff --git a/.pytest_cache/v/cache/lastfailed b/.pytest_cache/v/cache/lastfailed
new file mode 100644
index 0000000000000000000000000000000000000000..9e26dfeeb6e641a33dae4961196235bdb965b21b
--- /dev/null
+++ b/.pytest_cache/v/cache/lastfailed
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/.pytest_cache/v/cache/nodeids b/.pytest_cache/v/cache/nodeids
new file mode 100644
index 0000000000000000000000000000000000000000..e8c8872da369875600b0cb0505cef59889f2d0e2
--- /dev/null
+++ b/.pytest_cache/v/cache/nodeids
@@ -0,0 +1,4 @@
+[
+ "tests/test_custom.py::test_custom_api_endpoint[asyncio]",
+ "tests/test_custom.py::test_custom_api_error_handling[asyncio]"
+]
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..33d6d6fbf0fe3fbc2015b273846e2771ace4c061
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,26 @@
+FROM python:3.11-slim
+
+# Install system dependencies for Playwright/Chromium
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ wget gnupg fonts-liberation libnss3 libatk-bridge2.0-0 libdrm2 \
+ libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
+ libasound2 libpangocairo-1.0-0 libgtk-3-0 \
+ && rm -rf /var/lib/apt/lists/*
+
+# Set up a new user named "user" with user ID 1000
+RUN useradd -m -u 1000 user
+USER user
+ENV HOME=/home/user \
+ PATH=/home/user/.local/bin:$PATH
+
+WORKDIR $HOME/app
+
+COPY --chown=user requirements.txt .
+RUN pip install --no-cache-dir --user -r requirements.txt \
+ && python -m playwright install chromium
+
+COPY --chown=user . .
+
+EXPOSE 7860
+
+CMD ["python", "run.py"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..8f5a13922b010deb923150e65bf3b9adf5032096
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 mrshibly
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 9154d2d4c5c3273896ae68742b08e37f92b75e34..c09d01cc5cb79651efcd5d824cc5ec3f670cbb92 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,235 @@
----
-title: PutuAPI
-emoji: ๐จ
-colorFrom: purple
-colorTo: yellow
-sdk: docker
-pinned: false
----
-
-Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
+---
+title: PhantomAPI
+emoji: ๐ป
+colorFrom: purple
+colorTo: indigo
+sdk: docker
+pinned: false
+---
+
+
+
+# ๐ป PhantomAPI
+
+### Turn ChatGPT into a FREE OpenAI-Compatible API
+
+[](https://fastapi.tiangolo.com/)
+[](https://playwright.dev/)
+[](https://docker.com/)
+[](LICENSE)
+
+**The invisible proxy that bridges ChatGPT's free web interface with your AI automation workflows.**
+
+[Quick Start](#-quick-start) ยท [n8n Integration](#-connecting-to-n8n) ยท [Architecture](#-architecture) ยท [Docker](#-docker-deployment)
+
+
+
+
+
+
+
+---
+
+## ๐ What is PhantomAPI?
+
+**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**.
+
+### โจ Key Features
+
+| Feature | Description |
+|:---|:---|
+| ๐ธ **Zero API Costs** | Uses ChatGPT's free web interface via headless browser automation |
+| โก **Async Architecture** | Built on FastAPI with a dedicated browser thread for non-blocking requests |
+| ๐ค **AI Agent Support** | Full tool-calling / function-calling support for n8n Agent nodes |
+| ๐ **API Key Auth** | Protected with Bearer token authentication |
+| ๐ณ **Docker Ready** | Deploy in seconds with `docker-compose up` |
+| ๐จ **Built-in GUI** | A sleek dark-mode chat interface for quick testing |
+| ๐ **Clean Architecture** | Proper FastAPI structure โ routers, schemas, services, utils |
+
+---
+
+## โ๏ธ How It Works
+
+```
+โโโโโโโโโโโโ HTTP/JSON โโโโโโโโโโโโโโโโ Playwright โโโโโโโโโโโโโโโโ
+โ n8n โ โโโโโโโโโโโโโโโโโโโถ โ PhantomAPI โ โโโโโโโโโโโโโโโโโโโถ โ ChatGPT โ
+โ (or any โ โโโโโโโโโโโโโโโโโโโ โ (FastAPI) โ โโโโโโโโโโโโโโโโโโโ โ (Web UI) โ
+โ client) โ OpenAI Schema โ โ Scrape Response โ โ
+โโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
+```
+
+1. **You send** a standard OpenAI API request to PhantomAPI
+2. **PhantomAPI** formats your messages into a prompt and types it into ChatGPT's web interface using a stealth browser
+3. **ChatGPT responds** on the web page โ PhantomAPI scrapes the text
+4. **The response** is formatted back into the official OpenAI JSON schema and returned to you
+
+---
+
+## ๐ ๏ธ Quick Start
+
+### Prerequisites
+- **Python 3.10+**
+- **Google Chrome** installed on your system
+
+### 1. Clone & Install
+
+```bash
+git clone https://github.com/mrshibly/phantom-api.git
+cd phantom-api
+pip install -r requirements.txt
+python -m playwright install chromium
+```
+
+### 2. Configure
+
+```bash
+cp .env.example .env
+# Edit .env and set your API_SECRET_KEY
+```
+
+### 3. Run
+
+```bash
+python run.py
+```
+
+The server will start on `http://localhost:7777`.
+
+| Endpoint | Description |
+|:---|:---|
+| `http://localhost:7777/` | Health check |
+| `http://localhost:7777/docs` | Swagger UI (interactive API docs) |
+| `http://localhost:7777/gui` | Chat GUI for quick testing |
+
+---
+
+## ๐ Connecting to n8n
+
+
+
+
+
+### Method 1: OpenAI Node (Recommended)
+
+1. In n8n, go to **Credentials โ New โ OpenAI API**
+2. Set **Base URL** to: `http://127.0.0.1:7777/v1`
+3. Set **API Key** to your `API_SECRET_KEY` from `.env`
+4. Use this credential in any **OpenAI** or **AI Agent** node
+
+> **Docker Tip:** If n8n runs in Docker, use `http://host.docker.internal:7777/v1`
+
+### Method 2: HTTP Request Node
+
+1. Add an **HTTP Request** node
+2. **Method:** `POST`
+3. **URL:** `http://127.0.0.1:7777/v1/chat/completions`
+4. **Authentication:** Header Auth โ `Authorization: Bearer YOUR_KEY`
+5. **Body (JSON):**
+
+```json
+{
+ "model": "gpt-4o-mini",
+ "messages": [
+ { "role": "user", "content": "Hello, PhantomAPI!" }
+ ]
+}
+```
+
+---
+
+## ๐ Architecture
+
+```
+phantom-api/
+โโโ app/
+โ โโโ main.py # App factory, CORS, lifespan
+โ โโโ config.py # Environment-driven settings
+โ โโโ dependencies.py # Auth dependency injection
+โ โโโ api/v1/
+โ โ โโโ router.py # Route aggregator
+โ โ โโโ chat.py # POST /v1/chat/completions
+โ โ โโโ responses.py # POST /v1/responses
+โ โ โโโ models.py # GET /v1/models
+โ โโโ schemas/
+โ โ โโโ chat.py # Request/Response models
+โ โ โโโ responses.py # Responses API models
+โ โโโ services/
+โ โ โโโ browser.py # Playwright browser engine
+โ โโโ utils/
+โ โโโ prompt.py # Smart prompt builder
+โ โโโ parser.py # Tool-call JSON parser
+โโโ static/
+โ โโโ index.html # Chat GUI
+โโโ tests/
+โ โโโ test_health.py # Endpoint tests
+โโโ Dockerfile
+โโโ docker-compose.yml
+โโโ requirements.txt
+โโโ .env.example
+โโโ run.py # Entry point
+```
+
+---
+
+## ๐ณ Docker Deployment
+
+```bash
+# Build and run
+docker-compose up --build -d
+
+# The server is now running on http://localhost:7777
+```
+
+---
+
+## ๐ค Hugging Face Spaces Deployment
+
+You can deploy PhantomAPI directly to Hugging Face Spaces for free.
+
+1. **Create a New Space**: Go to [huggingface.co/new-space](https://huggingface.co/new-space)
+2. **Select SDK**: Choose **Docker**
+3. **Choose Template**: Select **Blank** (The existing `Dockerfile` will be used automatically)
+4. **Configure Secrets**:
+ - Go to **Settings โ Variables and secrets** in your Space
+ - Add a new **Secret** named `API_SECRET_KEY` with your desired token
+5. **Push Code**:
+ ```bash
+ git remote add hf https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
+ git push -u hf main
+ ```
+
+PhantomAPI will automatically start on port `7860`.
+
+---
+
+## ๐ง API Reference
+
+### `POST /v1/chat/completions`
+
+Standard OpenAI Chat Completions endpoint. Supports messages, tools, and function calling.
+
+### `POST /v1/responses`
+
+Modern Responses API for newer n8n versions. Accepts `input` (string or messages) and optional `instructions`.
+
+### `GET /v1/models`
+
+Returns available model identifiers (used by n8n's model dropdown).
+
+### `GET /`
+
+Health check โ returns server status and version.
+
+---
+
+## ๐ License
+
+This project is open-sourced under the [MIT License](LICENSE).
+
+---
+
+
+
+**Built with โค๏ธ by [mrshibly](https://github.com/mrshibly)**
+
+
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6af3a2d1954d1b1c26599b6031bfda09f2c60741
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,3 @@
+"""PhantomAPI โ Application package."""
+
+__version__ = "1.0.0"
diff --git a/app/__pycache__/__init__.cpython-314.pyc b/app/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..115d83ef30cc6a393f989998b40cf732ac449bcf
Binary files /dev/null and b/app/__pycache__/__init__.cpython-314.pyc differ
diff --git a/app/__pycache__/config.cpython-314.pyc b/app/__pycache__/config.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..48bdf66062e67db276a99a6d0e40331f674f0d36
Binary files /dev/null and b/app/__pycache__/config.cpython-314.pyc differ
diff --git a/app/__pycache__/dependencies.cpython-314.pyc b/app/__pycache__/dependencies.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1e698b2de0424807c81bb155ab5c528a58b641a1
Binary files /dev/null and b/app/__pycache__/dependencies.cpython-314.pyc differ
diff --git a/app/__pycache__/main.cpython-314.pyc b/app/__pycache__/main.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9a1850e67252a411281e1fff88039659042b4b6b
Binary files /dev/null and b/app/__pycache__/main.cpython-314.pyc differ
diff --git a/app/api/__init__.py b/app/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2fd6f6896a488ef48960d25bc338a552e8d42553
--- /dev/null
+++ b/app/api/__init__.py
@@ -0,0 +1 @@
+"""PhantomAPI โ API package."""
diff --git a/app/api/__pycache__/__init__.cpython-314.pyc b/app/api/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..368a858eecb9b9116bc3d34bf331d06c2ab05d18
Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-314.pyc differ
diff --git a/app/api/__pycache__/custom.cpython-314.pyc b/app/api/__pycache__/custom.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6b48212765b692ff3c4445d768b01f57e4da083e
Binary files /dev/null and b/app/api/__pycache__/custom.cpython-314.pyc differ
diff --git a/app/api/custom.py b/app/api/custom.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc326d1153ec01f49480f06a54b05c3872fa4d74
--- /dev/null
+++ b/app/api/custom.py
@@ -0,0 +1,31 @@
+"""PhantomAPI โ Custom simplified API endpoint."""
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from app.services.browser import engine
+
+router = APIRouter(tags=["custom"])
+
+class QuickPromptRequest(BaseModel):
+ prompt: str
+
+class QuickPromptResponse(BaseModel):
+ status: str
+ text: str
+
+@router.post("/api", response_model=QuickPromptResponse)
+async def quick_prompt(request: QuickPromptRequest):
+ """Simple API endpoint for quick prompts."""
+ try:
+ # Get response from browser engine
+ response_text = engine.chat(request.prompt)
+
+ return QuickPromptResponse(
+ status="success",
+ text=response_text
+ )
+ except Exception as e:
+ return QuickPromptResponse(
+ status="error",
+ text=str(e)
+ )
diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9cde6133bf481fa6363be21725ce407a6972c4b4
--- /dev/null
+++ b/app/api/v1/__init__.py
@@ -0,0 +1 @@
+"""PhantomAPI โ API v1 package."""
diff --git a/app/api/v1/__pycache__/__init__.cpython-314.pyc b/app/api/v1/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ac4aa6e8f8e2296143f33eadc50d6eaafc67d05d
Binary files /dev/null and b/app/api/v1/__pycache__/__init__.cpython-314.pyc differ
diff --git a/app/api/v1/__pycache__/chat.cpython-314.pyc b/app/api/v1/__pycache__/chat.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..206101682403457df04bade06fef3a1b35e6fc34
Binary files /dev/null and b/app/api/v1/__pycache__/chat.cpython-314.pyc differ
diff --git a/app/api/v1/__pycache__/models.cpython-314.pyc b/app/api/v1/__pycache__/models.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..66335ed2c6fa05043672a60aef13a945506c91d1
Binary files /dev/null and b/app/api/v1/__pycache__/models.cpython-314.pyc differ
diff --git a/app/api/v1/__pycache__/responses.cpython-314.pyc b/app/api/v1/__pycache__/responses.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c0a3e7edc23b8e3cae857d65c86aecab4cabe958
Binary files /dev/null and b/app/api/v1/__pycache__/responses.cpython-314.pyc differ
diff --git a/app/api/v1/__pycache__/router.cpython-314.pyc b/app/api/v1/__pycache__/router.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6c3c099a43dae8584d038c726e7f2e2cc3f41541
Binary files /dev/null and b/app/api/v1/__pycache__/router.cpython-314.pyc differ
diff --git a/app/api/v1/chat.py b/app/api/v1/chat.py
new file mode 100644
index 0000000000000000000000000000000000000000..048aae7fbc2cf47d944bc82c2ffc5570ae5118a3
--- /dev/null
+++ b/app/api/v1/chat.py
@@ -0,0 +1,16 @@
+"""PhantomAPI โ POST /v1/chat/completions."""
+
+from fastapi import APIRouter, Depends
+
+from app.dependencies import verify_api_key
+from app.schemas.chat import ChatCompletionRequest
+from app.services.chat import process_chat_completion
+
+router = APIRouter()
+
+
+@router.post("/chat/completions", dependencies=[Depends(verify_api_key)])
+async def chat_completions(payload: ChatCompletionRequest):
+ """OpenAI-compatible chat completions endpoint."""
+ data = payload.model_dump()
+ return process_chat_completion(data["messages"], data["model"], data.get("tools"))
diff --git a/app/api/v1/models.py b/app/api/v1/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8fdf611b4d8ae5e59f965ee406b708dc2048248
--- /dev/null
+++ b/app/api/v1/models.py
@@ -0,0 +1,17 @@
+"""PhantomAPI โ GET /v1/models."""
+
+from fastapi import APIRouter
+
+router = APIRouter()
+
+
+@router.get("/models")
+async def list_models():
+ """Return available models for n8n model dropdown."""
+ return {
+ "object": "list",
+ "data": [
+ {"id": "gpt-4o-mini", "object": "model", "owned_by": "phantom-api"},
+ {"id": "gpt-4o", "object": "model", "owned_by": "phantom-api"},
+ ],
+ }
diff --git a/app/api/v1/responses.py b/app/api/v1/responses.py
new file mode 100644
index 0000000000000000000000000000000000000000..64d30b1f38421aabbf17c27d472328498ab7809f
--- /dev/null
+++ b/app/api/v1/responses.py
@@ -0,0 +1,31 @@
+"""PhantomAPI โ POST /v1/responses."""
+
+from fastapi import APIRouter, Depends
+
+from app.dependencies import verify_api_key
+from app.schemas.responses import ResponsesRequest
+from app.services.chat import process_responses_api
+
+router = APIRouter()
+
+
+@router.post("/responses", dependencies=[Depends(verify_api_key)])
+async def responses(payload: ResponsesRequest):
+ """Modern Responses API endpoint for newer n8n versions."""
+ data = payload.model_dump()
+ input_data = data.get("input", "")
+
+ # Normalise input to messages list
+ if isinstance(input_data, str):
+ messages = [{"role": "user", "content": input_data}]
+ elif isinstance(input_data, list):
+ messages = input_data
+ else:
+ messages = []
+
+ # Inject system instructions
+ instructions = data.get("instructions", "")
+ if instructions:
+ messages.insert(0, {"role": "system", "content": instructions})
+
+ return process_responses_api(messages, data["model"], data.get("tools"))
diff --git a/app/api/v1/router.py b/app/api/v1/router.py
new file mode 100644
index 0000000000000000000000000000000000000000..c6619e6ccdeaf8d9035271cc2ce598ed9d06051a
--- /dev/null
+++ b/app/api/v1/router.py
@@ -0,0 +1,11 @@
+"""PhantomAPI โ v1 API router aggregator."""
+
+from fastapi import APIRouter
+
+from app.api.v1 import chat, responses, models
+
+router = APIRouter(prefix="/v1", tags=["v1"])
+
+router.include_router(chat.router)
+router.include_router(responses.router)
+router.include_router(models.router)
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..6e9c9975e2003feb382de885857f18e2536381eb
--- /dev/null
+++ b/app/config.py
@@ -0,0 +1,27 @@
+"""PhantomAPI โ Configuration via environment variables."""
+
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+ """Application settings loaded from .env file or environment variables."""
+
+ # --- Security ---
+ API_SECRET_KEY: str = "change-me-to-a-strong-secret"
+
+ # --- Server ---
+ HOST: str = "0.0.0.0"
+ PORT: int = 7860
+
+ # --- Browser Engine ---
+ HEADLESS: bool = True
+ BROWSER_TIMEOUT: int = 120000 # milliseconds
+
+ model_config = {
+ "env_file": ".env",
+ "env_file_encoding": "utf-8",
+ "extra": "ignore",
+ }
+
+
+settings = Settings()
diff --git a/app/dependencies.py b/app/dependencies.py
new file mode 100644
index 0000000000000000000000000000000000000000..50f250908e9cbdeab7c2e2869a6ea5e5179b1e82
--- /dev/null
+++ b/app/dependencies.py
@@ -0,0 +1,23 @@
+"""PhantomAPI โ Authentication dependencies."""
+
+from fastapi import Request, HTTPException
+from app.config import settings
+
+
+async def verify_api_key(request: Request) -> str:
+ """Validate the Bearer token from the Authorization header.
+
+ Returns the validated key on success, raises 401 on failure.
+ """
+ authorization = request.headers.get("authorization", "")
+
+ if not authorization:
+ raise HTTPException(status_code=401, detail="Missing Authorization header.")
+
+ # Strip "Bearer " prefix
+ token = authorization.replace("Bearer ", "").strip()
+
+ if token != settings.API_SECRET_KEY:
+ raise HTTPException(status_code=401, detail="Invalid API Key.")
+
+ return token
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..c660d58d90ac21daf3bca319060108dc3c3857b3
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,56 @@
+"""PhantomAPI โ Application factory."""
+
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import RedirectResponse
+from fastapi.staticfiles import StaticFiles
+
+from app import __version__
+from app.api.v1.router import router as v1_router
+from app.api.custom import router as custom_router
+from app.services.browser import engine
+
+
+@asynccontextmanager
+async def lifespan(application: FastAPI):
+ """Start the browser engine on startup."""
+ engine.start()
+ yield
+
+
+app = FastAPI(
+ title="PhantomAPI",
+ description="A proxy that turns free ChatGPT into an OpenAI-compatible API.",
+ version=__version__,
+ lifespan=lifespan,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(v1_router)
+app.include_router(custom_router)
+
+static_dir = Path(__file__).parent.parent / "static"
+if static_dir.exists():
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+
+@app.get("/", tags=["health"])
+async def health_check():
+ """Health check."""
+ return {"status": "running", "service": "PhantomAPI", "version": __version__}
+
+
+@app.get("/gui", tags=["gui"])
+async def gui_redirect():
+ """Redirect to Chat GUI."""
+ return RedirectResponse(url="/static/index.html")
diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4791cc9094bec81986d76fb18811f62149b81bf6
--- /dev/null
+++ b/app/schemas/__init__.py
@@ -0,0 +1 @@
+"""PhantomAPI โ Schemas package."""
diff --git a/app/schemas/__pycache__/__init__.cpython-314.pyc b/app/schemas/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6b3209a72e9a6689f77034eb4dd48b44b6d853ff
Binary files /dev/null and b/app/schemas/__pycache__/__init__.cpython-314.pyc differ
diff --git a/app/schemas/__pycache__/chat.cpython-314.pyc b/app/schemas/__pycache__/chat.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6116634c227b3106aa628a3db88893f7aee192aa
Binary files /dev/null and b/app/schemas/__pycache__/chat.cpython-314.pyc differ
diff --git a/app/schemas/__pycache__/responses.cpython-314.pyc b/app/schemas/__pycache__/responses.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cc9b514dea8bc0617a44b06a8e9d4443bf129a83
Binary files /dev/null and b/app/schemas/__pycache__/responses.cpython-314.pyc differ
diff --git a/app/schemas/chat.py b/app/schemas/chat.py
new file mode 100644
index 0000000000000000000000000000000000000000..65497152898b20da8f36f3c51675921f05f5f251
--- /dev/null
+++ b/app/schemas/chat.py
@@ -0,0 +1,28 @@
+"""PhantomAPI โ Pydantic schemas for /v1/chat/completions."""
+
+from typing import List, Dict, Any, Optional, Union
+from pydantic import BaseModel, Field
+
+
+class Message(BaseModel):
+ """A single message in the conversation."""
+ role: str = Field(..., description="The role of the message author.")
+ content: Union[str, List[Dict[str, Any]]] = Field(..., description="The content of the message.")
+ name: Optional[str] = Field(default=None, description="Name for tool messages.")
+ tool_calls: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool calls from assistant.")
+ tool_call_id: Optional[str] = Field(default=None, description="Tool call ID for tool responses.")
+
+
+class ChatCompletionRequest(BaseModel):
+ """Request body for POST /v1/chat/completions."""
+ messages: List[Message] = Field(..., description="The conversation messages.")
+ model: str = Field(default="gpt-4o-mini", description="Model identifier.")
+ tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Available tools for function calling.")
+ temperature: Optional[float] = Field(default=None, description="Sampling temperature (ignored).")
+ max_tokens: Optional[int] = Field(default=None, description="Max tokens (ignored).")
+
+ model_config = {
+ "json_schema_extra": {
+ "examples": [{"messages": [{"role": "user", "content": "Hello!"}], "model": "gpt-4o-mini"}]
+ }
+ }
diff --git a/app/schemas/responses.py b/app/schemas/responses.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a30f82adeac4f2efabdc381927b3dde68d87079
--- /dev/null
+++ b/app/schemas/responses.py
@@ -0,0 +1,15 @@
+"""PhantomAPI โ Pydantic schemas for /v1/responses."""
+
+from typing import List, Dict, Any, Optional, Union
+from pydantic import BaseModel, Field
+from app.schemas.chat import Message
+
+
+class ResponsesRequest(BaseModel):
+ """Request body for POST /v1/responses (Modern Responses API)."""
+ input: Union[str, List[Message]] = Field(..., description="The input text or messages.")
+ model: str = Field(default="gpt-4o-mini", description="Model identifier.")
+ tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="A list of tools the model may call.")
+ instructions: Optional[str] = Field(default="", description="System instructions.")
+
+ model_config = {"json_schema_extra": {"examples": [{"input": "Hello!", "model": "gpt-4o-mini"}]}}
diff --git a/app/services/__init__.py b/app/services/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a27714534a0a74d22682c1e8b30a883f76de672d
--- /dev/null
+++ b/app/services/__init__.py
@@ -0,0 +1 @@
+"""PhantomAPI โ Services package."""
diff --git a/app/services/__pycache__/__init__.cpython-314.pyc b/app/services/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..06f0d3ab857307cfe5a2da781ff641abac7f9a87
Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-314.pyc differ
diff --git a/app/services/__pycache__/browser.cpython-314.pyc b/app/services/__pycache__/browser.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c5bc9bfe9904d10d69b56cd7b26ca090148c8e11
Binary files /dev/null and b/app/services/__pycache__/browser.cpython-314.pyc differ
diff --git a/app/services/__pycache__/chat.cpython-314.pyc b/app/services/__pycache__/chat.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cf3e45a22cfc00fd225a47bea3bdc90222110ff0
Binary files /dev/null and b/app/services/__pycache__/chat.cpython-314.pyc differ
diff --git a/app/services/browser.py b/app/services/browser.py
new file mode 100644
index 0000000000000000000000000000000000000000..7821c37173b9a93cc62a5a06e8afbc817d93e0a6
--- /dev/null
+++ b/app/services/browser.py
@@ -0,0 +1,143 @@
+"""PhantomAPI โ Browser automation engine.
+
+Launches a persistent headless Chrome instance via Playwright
+and interacts with chatgpt.com to generate responses.
+"""
+
+import asyncio
+import threading
+from app.config import settings
+
+
+class BrowserEngine(threading.Thread):
+ """A dedicated thread that runs an async Playwright browser.
+
+ This avoids blocking the FastAPI event loop while still giving
+ us a persistent browser instance that can handle sequential requests.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(daemon=True)
+ self.loop = asyncio.new_event_loop()
+ self.ready = threading.Event()
+ self.browser = None
+ self.playwright = None
+
+ # ------------------------------------------------------------------
+ # Thread lifecycle
+ # ------------------------------------------------------------------
+
+ def run(self) -> None:
+ """Thread entry point โ start browser and run the event loop forever."""
+ asyncio.set_event_loop(self.loop)
+ self.loop.run_until_complete(self._launch())
+ self.ready.set()
+ print("[PhantomAPI] โก Browser engine ready.")
+ self.loop.run_forever()
+
+ async def _launch(self) -> None:
+ """Launch a stealth Chromium browser."""
+ from playwright.async_api import async_playwright
+
+ print("[PhantomAPI] ๐ Launching browser...")
+ self.playwright = await async_playwright().start()
+ self.browser = await self.playwright.chromium.launch(
+ headless=settings.HEADLESS,
+ channel="chrome",
+ args=[
+ "--disable-blink-features=AutomationControlled",
+ "--no-sandbox",
+ "--disable-gpu",
+ "--disable-dev-shm-usage",
+ "--disable-setuid-sandbox",
+ ],
+ )
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ def chat(self, prompt: str) -> str:
+ """Send a prompt to ChatGPT and return the response text.
+
+ This is a blocking call that schedules work on the browser
+ thread's event loop and waits for the result.
+ """
+ if not self.ready.wait(timeout=30) or self.browser is None:
+ raise RuntimeError("Browser engine is not ready. Is Chrome installed?")
+
+ future = asyncio.run_coroutine_threadsafe(
+ self._interact(prompt), self.loop
+ )
+ return future.result(timeout=settings.BROWSER_TIMEOUT // 1000 + 30)
+
+ # ------------------------------------------------------------------
+ # Private โ browser interaction
+ # ------------------------------------------------------------------
+
+ async def _interact(self, prompt: str) -> str:
+ """Open a new ChatGPT session, send the prompt, and scrape the reply."""
+ context = await self.browser.new_context(
+ user_agent=(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/124.0.0.0 Safari/537.36"
+ ),
+ viewport={"width": 1920, "height": 1080},
+ )
+
+ # Hide the webdriver flag so ChatGPT thinks we're a real user
+ await context.add_init_script(
+ "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
+ )
+
+ page = await context.new_page()
+
+ try:
+ page.set_default_timeout(settings.BROWSER_TIMEOUT)
+
+ # Navigate to ChatGPT
+ await page.goto("https://chatgpt.com/", wait_until="domcontentloaded")
+
+ # Type the prompt
+ await page.wait_for_selector("#prompt-textarea", timeout=60000)
+ await page.fill("#prompt-textarea", prompt)
+ await asyncio.sleep(0.5)
+ await page.press("#prompt-textarea", "Enter")
+
+ # Wait for the assistant to start responding
+ await page.wait_for_selector(
+ '[data-message-author-role="assistant"]',
+ timeout=settings.BROWSER_TIMEOUT,
+ )
+
+ # Poll until the response stabilises (no new text for ~2 seconds)
+ last_text = ""
+ unchanged_count = 0
+ while unchanged_count < 4:
+ elements = await page.query_selector_all(
+ '[data-message-author-role="assistant"]'
+ )
+ if elements:
+ current_text = await elements[-1].inner_text()
+ if current_text == last_text and current_text.strip():
+ unchanged_count += 1
+ else:
+ last_text = current_text
+ unchanged_count = 0
+ await asyncio.sleep(0.5)
+
+ return last_text.strip()
+
+ except Exception as exc:
+ print(f"[PhantomAPI] โ Browser error: {exc}")
+ raise
+ finally:
+ await page.close()
+ await context.close()
+
+
+# ---------------------------------------------------------------------------
+# Singleton โ created once at import time, started in app lifespan
+# ---------------------------------------------------------------------------
+engine = BrowserEngine()
diff --git a/app/services/chat.py b/app/services/chat.py
new file mode 100644
index 0000000000000000000000000000000000000000..8462698e8cdb7bf3b42275bc4c1f78cf619b53d1
--- /dev/null
+++ b/app/services/chat.py
@@ -0,0 +1,108 @@
+"""PhantomAPI โ Chat completion service.
+
+All business logic for processing chat requests lives here.
+Route handlers call these functions and return the result directly.
+"""
+
+import time
+import uuid
+
+from app.services.browser import engine
+from app.utils.prompt import format_prompt
+from app.utils.parser import parse_tool_calls
+
+
+def process_chat_completion(messages: list, model: str, tools: list | None = None) -> dict:
+ """Process a chat completion request and return an OpenAI-compatible response."""
+ prompt = format_prompt(messages, tools=tools)
+ start = time.time()
+
+ print(f"[PhantomAPI] ๐จ Request ({len(prompt)} chars)")
+ response_text = engine.chat(prompt)
+
+ p_tokens = len(prompt.split())
+ c_tokens = len(response_text.split())
+ tool_calls = parse_tool_calls(response_text) if tools else None
+
+ return _build_chat_response(response_text, tool_calls, model, int(start), p_tokens, c_tokens)
+
+
+def process_responses_api(messages: list, model: str, tools: list | None = None) -> dict:
+ """Process a Responses API request and return the formatted response."""
+ prompt = format_prompt(messages, tools=tools)
+ start = time.time()
+
+ response_text = engine.chat(prompt)
+
+ p_tokens = len(prompt.split())
+ c_tokens = len(response_text.split())
+ tool_calls = parse_tool_calls(response_text) if tools else None
+
+ return _build_responses_response(response_text, tool_calls, model, int(start), p_tokens, c_tokens)
+
+
+# ---------------------------------------------------------------------------
+# Private response builders
+# ---------------------------------------------------------------------------
+
+def _build_chat_response(
+ text: str, tool_calls: list | None, model: str, created: int, p_tokens: int, c_tokens: int
+) -> dict:
+ """Build an OpenAI-compatible chat completion response dict."""
+ response_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
+ usage = {"prompt_tokens": p_tokens, "completion_tokens": c_tokens, "total_tokens": p_tokens + c_tokens}
+
+ if tool_calls:
+ message = {"role": "assistant", "content": None, "tool_calls": tool_calls}
+ finish_reason = "tool_calls"
+ else:
+ message = {"role": "assistant", "content": text}
+ finish_reason = "stop"
+
+ return {
+ "id": response_id,
+ "object": "chat.completion",
+ "created": created,
+ "model": model,
+ "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
+ "usage": usage,
+ }
+
+
+def _build_responses_response(
+ text: str, tool_calls: list | None, model: str, created: int, p_tokens: int, c_tokens: int
+) -> dict:
+ """Build a Responses API response dict."""
+ response_id = f"resp-{uuid.uuid4().hex[:29]}"
+ usage = {"input_tokens": p_tokens, "output_tokens": c_tokens, "total_tokens": p_tokens + c_tokens}
+
+ if tool_calls:
+ output = [
+ {
+ "type": "function_call",
+ "id": tc["id"],
+ "call_id": tc["id"],
+ "name": tc["function"]["name"],
+ "arguments": tc["function"]["arguments"],
+ "status": "completed",
+ }
+ for tc in tool_calls
+ ]
+ else:
+ output = [
+ {
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": text}],
+ }
+ ]
+
+ return {
+ "id": response_id,
+ "object": "response",
+ "created_at": created,
+ "model": model,
+ "status": "completed",
+ "output": output,
+ "usage": usage,
+ }
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..03fdbb592752a0fd8d304969b1d53c299d8d8d49
--- /dev/null
+++ b/app/utils/__init__.py
@@ -0,0 +1 @@
+"""PhantomAPI โ Utils package."""
diff --git a/app/utils/__pycache__/__init__.cpython-314.pyc b/app/utils/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..5708f09643ea6864032867d4ba01b5ec9c2c6fe0
Binary files /dev/null and b/app/utils/__pycache__/__init__.cpython-314.pyc differ
diff --git a/app/utils/__pycache__/parser.cpython-314.pyc b/app/utils/__pycache__/parser.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c37afa10acd8cd7317f2cd04b752bf730d687187
Binary files /dev/null and b/app/utils/__pycache__/parser.cpython-314.pyc differ
diff --git a/app/utils/__pycache__/prompt.cpython-314.pyc b/app/utils/__pycache__/prompt.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..82f72221c915024ced09973c5576a3ec54cab6e8
Binary files /dev/null and b/app/utils/__pycache__/prompt.cpython-314.pyc differ
diff --git a/app/utils/parser.py b/app/utils/parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd4d3e48f493a96195109537d83a7dbb8fcdc569
--- /dev/null
+++ b/app/utils/parser.py
@@ -0,0 +1,66 @@
+"""PhantomAPI โ Tool-call parser.
+
+Extracts structured tool-call JSON from ChatGPT's free-form text
+responses, converting them into OpenAI-compatible function call objects.
+"""
+
+import json
+import re
+import uuid
+
+
+def parse_tool_calls(response_text: str) -> list[dict] | None:
+ """Attempt to extract tool_calls JSON from the response text.
+
+ Returns a list of OpenAI-compatible tool-call dicts, or None if
+ no valid tool calls were found.
+ """
+ cleaned = response_text.strip()
+
+ # Strip markdown code fences if present
+ if "```" in cleaned:
+ match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", cleaned, re.DOTALL)
+ if match:
+ cleaned = match.group(1).strip()
+
+ # Build candidate strings to try parsing
+ candidates = [cleaned]
+ json_match = re.search(r'\{[\s\S]*"tool_calls"[\s\S]*\}', cleaned)
+ if json_match:
+ candidates.append(json_match.group(0))
+
+ for candidate in candidates:
+ try:
+ parsed = json.loads(candidate)
+ if isinstance(parsed, dict) and "tool_calls" in parsed:
+ raw_calls = parsed["tool_calls"]
+ if isinstance(raw_calls, list) and len(raw_calls) > 0:
+ return _format_calls(raw_calls)
+ except (json.JSONDecodeError, TypeError, KeyError):
+ continue
+
+ return None
+
+
+def _format_calls(raw_calls: list[dict]) -> list[dict]:
+ """Convert raw parsed tool calls into the OpenAI function-call schema."""
+ formatted = []
+ for call in raw_calls:
+ tool_name = call.get("name", "")
+ arguments = call.get("arguments", {})
+ arguments_str = (
+ json.dumps(arguments, ensure_ascii=False)
+ if isinstance(arguments, dict)
+ else str(arguments)
+ )
+ formatted.append(
+ {
+ "id": f"call_{uuid.uuid4().hex[:24]}",
+ "type": "function",
+ "function": {
+ "name": tool_name,
+ "arguments": arguments_str,
+ },
+ }
+ )
+ return formatted
diff --git a/app/utils/prompt.py b/app/utils/prompt.py
new file mode 100644
index 0000000000000000000000000000000000000000..c8bc934e867fbdd5f8921bbbfb5c10dad0fad4cb
--- /dev/null
+++ b/app/utils/prompt.py
@@ -0,0 +1,157 @@
+"""PhantomAPI โ Smart prompt builder.
+
+Converts OpenAI-style messages (with optional tool definitions)
+into a single flat prompt string suitable for the ChatGPT web UI.
+"""
+
+from typing import Any
+
+
+def format_prompt(messages: list[dict[str, Any]], tools: list | None = None) -> str:
+ """Build a single prompt string from a list of OpenAI-format messages.
+
+ When *tools* are provided and no tool results are present in the
+ conversation, inject strict JSON-output instructions so ChatGPT
+ returns parseable tool-call JSON instead of natural language.
+ """
+ parts: list[str] = []
+ system_parts: list[str] = []
+ has_tool_results = False
+ user_question = ""
+
+ for msg in messages:
+ role = msg.get("role", "")
+ msg_type = msg.get("type", "")
+ content = msg.get("content", "")
+
+ # Normalise list-style content (multimodal messages)
+ if isinstance(content, list):
+ text_bits = []
+ for item in content:
+ if isinstance(item, dict):
+ text_bits.append(item.get("text", item.get("content", str(item))))
+ else:
+ text_bits.append(str(item))
+ content = "\n".join(text_bits)
+
+ if role == "system":
+ system_parts.append(content)
+
+ elif role == "tool":
+ has_tool_results = True
+ tool_name = msg.get("name", "tool")
+ parts.append(f"[TOOL RESULT from '{tool_name}']:\n{content}")
+
+ elif msg_type == "function_call_output":
+ has_tool_results = True
+ call_id = msg.get("call_id", "")
+ output = msg.get("output", content)
+ parts.append(f"[TOOL RESULT (call_id: {call_id})]:\n{output}")
+
+ elif msg_type == "function_call":
+ func_name = msg.get("name", "?")
+ func_args = msg.get("arguments", "{}")
+ parts.append(f"[PREVIOUS TOOL CALL: Called '{func_name}' with arguments: {func_args}]")
+
+ elif role == "assistant":
+ assistant_text = content or ""
+ tool_calls_in_msg = msg.get("tool_calls", [])
+ if tool_calls_in_msg:
+ tc_descs = []
+ for tc in tool_calls_in_msg:
+ func = tc.get("function", {})
+ tc_descs.append(f"Called '{func.get('name', '?')}' with: {func.get('arguments', '{}')}")
+ assistant_text += "\n[Previous tool calls: " + "; ".join(tc_descs) + "]"
+ if assistant_text.strip():
+ parts.append(f"[Assistant]: {assistant_text}")
+
+ elif role == "user" or (msg_type == "message" and role != "system"):
+ user_question = content
+ parts.append(content)
+ has_tool_results = False
+
+ elif content:
+ parts.append(content)
+
+ # --- Build final prompt ---
+ final = ""
+
+ if system_parts:
+ if tools and not has_tool_results:
+ final += "=== YOUR ROLE ===\n"
+ final += "\n\n".join(system_parts)
+ final += "\n=== END OF ROLE ===\n\n"
+ else:
+ final += "=== SYSTEM INSTRUCTIONS (FOLLOW STRICTLY) ===\n"
+ final += "\n\n".join(system_parts)
+ final += "\n=== END OF INSTRUCTIONS ===\n\n"
+
+ if tools and not has_tool_results:
+ final += _format_tools_instruction(tools, user_question)
+
+ if has_tool_results:
+ final += "=== CONTEXT FROM TOOLS ===\n"
+ final += "The following information was retrieved by the tools you requested.\n"
+ final += "Use ONLY this information to answer the user's question.\n\n"
+
+ if parts:
+ final += "\n".join(parts)
+
+ if has_tool_results:
+ final += "\n\n=== INSTRUCTION ===\n"
+ final += "Now answer the user's question based ONLY on the tool results above.\n"
+
+ return final
+
+
+# ---------------------------------------------------------------------------
+# Private helpers
+# ---------------------------------------------------------------------------
+
+def _format_tools_instruction(tools: list[dict], user_question: str = "") -> str:
+ """Generate the mandatory tool-usage block injected into the prompt."""
+ instruction = "\n=== MANDATORY TOOL USAGE ===\n"
+ instruction += "You MUST use one of the tools below to answer this question.\n"
+ instruction += "Do NOT answer directly. Do NOT say you don't have information.\n"
+ instruction += "You MUST respond with ONLY a JSON object to call the tool.\n\n"
+
+ instruction += "RESPONSE FORMAT - respond with ONLY this JSON, nothing else:\n"
+ instruction += '{"tool_calls": [{"name": "TOOL_NAME", "arguments": {"param": "value"}}]}\n\n'
+
+ instruction += "RULES:\n"
+ instruction += "- Your ENTIRE response must be valid JSON only\n"
+ instruction += "- No markdown, no code blocks, no explanation\n"
+ instruction += "- No text before or after the JSON\n\n"
+
+ instruction += "Available tools:\n\n"
+
+ for tool in tools:
+ func = tool.get("function", tool)
+ name = func.get("name", "unknown")
+ desc = func.get("description", "No description")
+ params = func.get("parameters", {})
+
+ instruction += f"Tool: {name}\n"
+ instruction += f"Description: {desc}\n"
+
+ if params.get("properties"):
+ instruction += "Parameters:\n"
+ required_params = params.get("required", [])
+ for pname, pinfo in params["properties"].items():
+ ptype = pinfo.get("type", "string")
+ pdesc = pinfo.get("description", "")
+ req = "required" if pname in required_params else "optional"
+ instruction += f" - {pname} ({ptype}, {req}): {pdesc}\n"
+ instruction += "\n"
+
+ instruction += "=== END OF TOOLS ===\n\n"
+
+ first_tool = tools[0] if tools else {}
+ first_func = first_tool.get("function", first_tool)
+ first_name = first_func.get("name", "tool")
+
+ instruction += "EXAMPLE: If the user asks a question, respond with:\n"
+ instruction += '{"tool_calls": [{"name": "' + first_name + '", "arguments": {"input": "the user question here"}}]}\n\n'
+ instruction += "Now respond with the JSON to call the appropriate tool:\n\n"
+
+ return instruction
diff --git a/demo.mp4 b/demo.mp4
new file mode 100644
index 0000000000000000000000000000000000000000..908d1c9bb3fefd208d87ffcbf5224eb8747921ef
--- /dev/null
+++ b/demo.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eb788f2a7bf40ce720f075b841a1b8145041a51e16651362d1dbcb8e3608e2ad
+size 25609523
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4989c4f56275268134893b926566eca1438b8967
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,12 @@
+version: "3.8"
+
+services:
+ phantom-api:
+ build: .
+ container_name: phantom-api
+ ports:
+ - "7777:7777"
+ environment:
+ - API_SECRET_KEY=${API_SECRET_KEY:-change-me-to-a-strong-secret}
+ - HEADLESS=true
+ restart: unless-stopped
diff --git a/gui_screenshot.png b/gui_screenshot.png
new file mode 100644
index 0000000000000000000000000000000000000000..0682e4530226a08fcba0fbee42d1e3ec9941e10e
Binary files /dev/null and b/gui_screenshot.png differ
diff --git a/n8n_preview.png b/n8n_preview.png
new file mode 100644
index 0000000000000000000000000000000000000000..ec0411a73f364db031530bdeba5527723682a433
--- /dev/null
+++ b/n8n_preview.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f93f35a99c149e47a8eb4319689a2324b0c9c5564a4c3c1aba61821a15502126
+size 127048
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0e22e80f9f311aff28f057cd6eac12854a88b555
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+fastapi>=0.110.0,<1.0.0
+uvicorn>=0.27.0,<1.0.0
+playwright>=1.42.0,<2.0.0
+pydantic>=2.6.0,<3.0.0
+pydantic-settings>=2.1.0,<3.0.0
+python-dotenv>=1.0.0,<2.0.0
+python-multipart>=0.0.9,<1.0.0
+httpx>=0.27.0,<1.0.0
+pytest>=8.0.0,<9.0.0
diff --git a/run.py b/run.py
new file mode 100644
index 0000000000000000000000000000000000000000..07d61b593c219e6d89dd29aa99857a5fabfb9475
--- /dev/null
+++ b/run.py
@@ -0,0 +1,11 @@
+"""PhantomAPI โ Entry Point."""
+import uvicorn
+from app.config import settings
+
+if __name__ == "__main__":
+ uvicorn.run(
+ "app.main:app",
+ host=settings.HOST,
+ port=settings.PORT,
+ reload=False,
+ )
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..cf19f960977de0cea03459ad86d0aa9b02ff5b1d
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,346 @@
+
+
+
+
+
+ PhantomAPI | Chat
+
+
+
+
+
+
+
๐ป
+
PhantomAPI
+
+
+
+ Engine Online
+
+
+
+
+
+
+
+
+
+
+
+
PhantomAPI v1.0.0 โ Free ChatGPT Proxy for n8n
+
+
๐ป Hello! I'm PhantomAPI. Ask me anything.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..01101ead297fe472e7a63bfe985b3712401d98d5
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""PhantomAPI โ Tests package."""
diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..afc6341743807d708fa2d39383c9cd4649da3261
Binary files /dev/null and b/tests/__pycache__/__init__.cpython-314.pyc differ
diff --git a/tests/__pycache__/test_custom.cpython-314-pytest-8.4.2.pyc b/tests/__pycache__/test_custom.cpython-314-pytest-8.4.2.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3b0fb895b45bc73bad3e286276f198382edda6e3
Binary files /dev/null and b/tests/__pycache__/test_custom.cpython-314-pytest-8.4.2.pyc differ
diff --git a/tests/test_custom.py b/tests/test_custom.py
new file mode 100644
index 0000000000000000000000000000000000000000..62c2732af4a56e1cbf9b4a5f5fe80a5ec9d28ad7
--- /dev/null
+++ b/tests/test_custom.py
@@ -0,0 +1,45 @@
+"""PhantomAPI โ Test custom API endpoint."""
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from app.main import app
+from unittest.mock import patch
+
+@pytest.fixture
+async def client():
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
+
+@pytest.mark.anyio
+async def test_custom_api_endpoint(client):
+ """POST /api should return the specific JSON format."""
+ # Mock the browser engine to avoid running Playwright
+ with patch("app.api.custom.engine.chat") as mock_chat:
+ mock_chat.return_value = "Mocked Response"
+
+ response = await client.post(
+ "/api",
+ json={"prompt": "hello"}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert data["text"] == "Mocked Response"
+
+@pytest.mark.anyio
+async def test_custom_api_error_handling(client):
+ """POST /api should return error status on failure."""
+ with patch("app.api.custom.engine.chat") as mock_chat:
+ mock_chat.side_effect = Exception("Browser error")
+
+ response = await client.post(
+ "/api",
+ json={"prompt": "hello"}
+ )
+
+ assert response.status_code == 200 # We return 200 with status="error" in the schema
+ data = response.json()
+ assert data["status"] == "error"
+ assert data["text"] == "Browser error"
diff --git a/tests/test_health.py b/tests/test_health.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce98c3d446d2ebc1f1d95975103d00cd71a715ff
--- /dev/null
+++ b/tests/test_health.py
@@ -0,0 +1,59 @@
+"""PhantomAPI โ Basic endpoint tests."""
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from app.main import app
+
+
+@pytest.fixture
+async def client():
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
+
+
+@pytest.mark.anyio
+async def test_health_check(client):
+ """GET / should return 200 with status running."""
+ response = await client.get("/")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "running"
+ assert data["service"] == "PhantomAPI"
+
+
+@pytest.mark.anyio
+async def test_list_models(client):
+ """GET /v1/models should return model list."""
+ response = await client.get("/v1/models")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["object"] == "list"
+ assert len(data["data"]) > 0
+
+
+@pytest.mark.anyio
+async def test_chat_without_auth(client):
+ """POST /v1/chat/completions without auth should return 401."""
+ response = await client.post(
+ "/v1/chat/completions",
+ json={"messages": [{"role": "user", "content": "Hello"}]},
+ )
+ assert response.status_code == 401
+
+
+@pytest.mark.anyio
+async def test_responses_without_auth(client):
+ """POST /v1/responses without auth should return 401."""
+ response = await client.post(
+ "/v1/responses",
+ json={"input": "Hello"},
+ )
+ assert response.status_code == 401
+
+
+@pytest.mark.anyio
+async def test_gui_redirect(client):
+ """GET /gui should redirect to the static GUI."""
+ response = await client.get("/gui", follow_redirects=False)
+ assert response.status_code == 307