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 + +[![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)](https://fastapi.tiangolo.com/) +[![Playwright](https://img.shields.io/badge/Playwright-2EAD33?style=for-the-badge&logo=playwright&logoColor=white)](https://playwright.dev/) +[![Docker](https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white)](https://docker.com/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](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 + +
+ n8n Workflow Example +
+ +### 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 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