# pplx-proxy

Reverse proxy for [Perplexity.ai](https://www.perplexity.ai) — use your existing **Pro/Max subscription cookie** to access all models via standard APIs.

Exposes three interfaces:
- **OpenAI-compatible REST API** (`/v1/chat/completions`, `/v1/responses`) — streaming, thinking
- **MCP server** (Streamable HTTP + SSE) — 5 built-in tools
- **Debug chat UI** (`/chat`) — test everything with real-time OpenAI format validation

## Release Status (2026-09-09)

This release includes image input, local file storage, and the experimental Responses function bridge in `main`. The production deployment target is Pi5, managed by `pplx-proxy.service` on port **8892**. The limitations below still apply.

| Feature included in this release | Verified status |
|---|---|
| PNG image input | A real image request passed through the official OpenAI SDK. |
| Local `/v1/files` storage | Upload, read, and delete work; limits are 20 MiB per file and 200 MiB total. Storing a document does not mean Perplexity can read it. |
| Document input | **Incomplete.** After upload, Perplexity requires `/rest/sse/attachment_processing/subscribe`; Cloudflare currently returns 403. Document requests fail explicitly with HTTP 502. |
| Responses function calling | **Experimental and unreliable.** One complete official SDK auto-selection/client-execution/stream-continuation loop passed, but a repeat returned invalid protocol output and failed. This is a prompt bridge, not native Perplexity function calling. |
| Chat Completions function tools | Unsupported; HTTP 400. |

## How It Works

Perplexity's web frontend talks to its backend through an internal SSE endpoint (`/rest/sse/perplexity_ask`). This proxy authenticates with your session cookie via [curl_cffi](https://github.com/yifeikong/curl_cffi) (Chrome TLS fingerprinting), translates requests/responses into OpenAI and MCP formats, and keeps your session alive automatically.

No official API key needed — just your subscription.

All queries use `search_focus: "internet"` — Perplexity's built-in web search is always active, so models return real-time data (stock prices, weather, news) directly in their answers.

## Features

- **Full OpenAI format compliance** — `system_fingerprint`, `logprobs`, proper `usage` arithmetic, all fields per spec
- **Thinking/reasoning** — `thinking: true` or `reasoning_effort` param, reasoning streamed as `reasoning_content`
- **Account tier support** — free/pro/max — only exposes models your tier can access
- **Auto-discovery** — background task checks model health every 24h, auto-upgrades when versions change
- **Response cleaning** — strips Perplexity citations `[1][2]`, `<grok:*>` tags, `<?xml?>` declarations, `<script>` tags
- **Rate limit tracking** — tracks Pro Search quota, explicit error when the requested paid model has no remaining quota, notices at every 5th decrement
- **Substitution notice** — if Perplexity swaps the requested model, the answer still returns with `[Substituted by Perplexity with ...]` at the end
- **Session continuity** — uses Perplexity `backend_uuid` for eligible plain-text follow-ups; explicit instructions and function conversations retain their complete context
- **Session keep-alive** — validates at startup and every 6 hours, then persists any rotated cookie returned by Perplexity
- **Push notifications** — [ntfy.sh](https://ntfy.sh) alerts on cookie expiry or model upgrades
- **Debug chat UI** — `/chat` page with tools toggle, thinking toggle, streaming toggle, and **OpenAI format validator**
- **Dynamic model management** — add/remove models at runtime via admin API
- **Full input validation** — proper error messages for every malformed request

## Quick Start

Choose one startup mode:

| Mode | Command | Includes |
|------|---------|----------|
| Manual Python | `venv/bin/uvicorn server:app --host 0.0.0.0 --port 8892` | pplx-proxy only |
| Docker Compose | `docker compose up -d --build` | pplx-proxy, FlareSolverr, persistent runtime volume |

Manual Python is best for local development. Docker Compose is the full self-hosted stack and is recommended when you want quota data in `/health`.

### Manual Python

```bash
git clone https://github.com/jamie950315/pplx-proxy.git
cd pplx-proxy
python3 -m venv venv
venv/bin/pip install -r requirements.txt

cp .env.example .env
# Edit .env — set PPLX_COOKIE and ACCOUNT_TYPE

venv/bin/uvicorn server:app --host 0.0.0.0 --port 8892
```

Then open **http://localhost:8892/chat** to test with the debug UI.

### Docker Compose

For the complete setup, including pplx-proxy, FlareSolverr, and persistent runtime storage:

```bash
cp .env.example .env
# Edit .env — set PPLX_COOKIE, PPLX_PROXY_API_KEY, and ACCOUNT_TYPE
docker compose up -d --build
```

This starts:
- `pplx-proxy` on `http://localhost:8892`
- `flaresolverr` inside the Compose network at `http://flaresolverr:8191`
- `pplx-data` volume for `.cookie_cache.json`, `.models.json`, and `.responses_store.json`

FlareSolverr is optional for chat, streaming, MCP, and Responses API. Without it, `/health` still reports service and cookie age, but `remaining_pro` and `remaining_research` stay `null` and quota exhaustion cannot be checked locally.

## Getting Your Cookie

1. Log in to [perplexity.ai](https://www.perplexity.ai)
2. F12 → **Application** → **Cookies** → `www.perplexity.ai`
3. Copy `__Secure-next-auth.session-token`
4. Set `PPLX_COOKIE=<value>` in `.env`

## Models

| Model ID | Backend | Tier | Thinking Variant |
|----------|---------|------|-----------------|
| `auto` | Perplexity Best | free+ | — |
| `sonar` | Sonar 2 | pro+ | — |
| `gpt` / `gpt-5.6-terra` | GPT-5.6 Terra | pro+ | `gpt56_terra_thinking` |
| `gpt-5.6-sol` | GPT-5.6 Sol | max | `gpt56_sol_thinking` |
| `gpt-5.5` | GPT-5.5 | pro+ | `gpt55_thinking` |
| `gpt-mini` | GPT-5 Mini | pro+ | — |
| `gpt-nano` | GPT-5 Nano | pro+ | — |
| `sonnet` / `sonnet-5` | Claude Sonnet 5 | pro+ | `claude50sonnetthinking` |
| `sonnet-4.6` | Claude Sonnet 4.6 | pro+ | `claude46sonnetthinking` |
| `gemini` | Gemini 3.1 Pro | pro+ | — |
| `gemini-flash` | Gemini 3.5 Flash | pro+ | — |
| `grok` / `grok-4.6` | Grok 4.6 | pro+ | — |
| `grok-4.5` | Grok 4.5 | pro+ | `grok45medium` |
| `grok-4` | Grok 4 | pro+ | — |
| `grok-reasoning` | Grok 4.20 Reasoning | pro+ | — |
| `grok-non-reasoning` | Grok 4.20 Non Reasoning | pro+ | — |
| `nemotron` | Nemotron 3 Ultra | pro+ | — |
| `nemotron-3-super` | Nemotron 3 Super | pro+ | — |
| `glm-5.2` | GLM-5.2 | pro+ | — |
| `kimi-k2.6` | Kimi K2.6 | pro+ | `kimik26thinking` |
| `kimi-k3` | Kimi K3 | pro+ | — |
| `opus` / `opus-4.8` | Claude Opus 4.8 | max | `claude48opusthinking` |
| `opus-4.7` | Claude Opus 4.7 | max | `claude47opusthinking` |
| `opus-4.6` | Claude Opus 4.6 | max | `claude46opusthinking` |

Thinking variants are activated via `thinking: true` or `reasoning_effort` parameter — no separate model names needed.

Tracked candidates such as Claude Haiku 4.5, Gemini 3.1 Flash Lite, and Grok 4.20 Multi-Agent remain disabled and are skipped by discovery. Custom model IDs added through `/admin/update-models` are available on Pro and Max accounts; Free accounts only expose `auto`.

## API Endpoints

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/health` | No | Health check |
| `GET` | `/chat` | No | **Debug chat UI with OpenAI format validator** |
| `GET` | `/v1/models` | Yes | List tier-available models |
| `POST` | `/v1/chat/completions` | Yes | Chat (streaming + non-streaming + thinking) |
| `POST` | `/v1/responses` | Yes | OpenAI Responses API (stream, store, previous_response_id; experimental function bridge and image input) |
| `GET` | `/v1/responses/{id}` | Yes | Retrieve a stored response |
| `DELETE` | `/v1/responses/{id}` | Yes | Delete a stored response |
| `POST` | `/v1/responses/{id}/cancel` | Yes | Cancel an in-progress background response |
| `GET` | `/v1/responses/{id}/input_items` | Yes | List input items for a stored response |
| `POST` / `GET` | `/v1/files` | Yes | Upload/list locally stored files |
| `GET` / `DELETE` | `/v1/files/{id}` | Yes | Retrieve metadata/delete a stored file |
| `GET` | `/v1/files/{id}/content` | Yes | Read stored bytes |
| `POST` | `/<api-key>/mcp` | Key in URL | MCP Streamable HTTP |
| `GET` | `/<api-key>/sse` | Key in URL | MCP SSE |
| `GET` | `/admin/models` | Yes | Full model map |
| `POST` | `/admin/update-models` | Yes | Add/replace models |
| `POST` | `/admin/refresh-cookie` | Yes | Inject new session token |
| `POST` | `/admin/discover-models` | Yes | Run model discovery |

## Usage

### Responses API

`POST /v1/responses` accepts OpenAI Responses API requests (`input`, `instructions`, `previous_response_id`, `stream`, `store`, `reasoning`, `text.format`). Responses are stored by default so you can `GET` / `DELETE` them and continue a thread with `previous_response_id`. With `store: false`, they are not retained or retrievable.

Built-in web search is always on. Responses web-search tools are accepted because they use that existing search behavior. Background requests require `store: true`; only active background requests can be cancelled. Custom sampling values, output-token limits, automatic truncation, and strict **text-output** JSON schemas are rejected. JSON-object and non-strict text schema modes are prompt-based requests, not guaranteed structured output. File search, code interpreter, computer use, and image generation remain unsupported.

Function definitions in `tools` enable an **experimental prompt-mediated bridge**. One full SDK loop succeeded, but a subsequent auto-selection test returned non-JSON output and failed with `tool_protocol_error`. Reliability is not established; passing unit and container tests verifies the proxy behavior, not model compliance. No retry or fallback hides these failures.

The bridge works as follows: Perplexity proposes a JSON call, the proxy validates its name and arguments against the supplied schema, and the client application executes it. Malformed JSON, invalid arguments, or a violated tool choice produce HTTP 502 (`tool_protocol_error`), or `response.failed` after streaming has started. There is no ordinary-text fallback and the proxy never executes client functions. Streaming sends function events only after the entire upstream answer has passed validation.

Function `tool_choice` supports `auto`, `required`, `none`, and `{"type":"function","name":"..."}`. `parallel_tool_calls: false` limits the result to one call. Function parameter schemas, including `strict: true`, are checked locally; this does not provide native constrained generation. Send each result as `function_call_output` with the returned `call_id`. When tool definitions are omitted on a stored continuation, the proxy preserves the function context but uses `tool_choice: "none"`; resend the definitions to enable more calls.

### Function loop with the official Python SDK

Set `PPLX_PROXY_BASE_URL` to your deployed proxy, including `/v1` (for this deployment, `https://pplx.0ruka.dev/v1`). Use the existing proxy API key. Function calling remains experimental; clients must handle explicit failures.

```python
import json
import os
from openai import OpenAI

client = OpenAI(
    base_url=os.environ["PPLX_PROXY_BASE_URL"],
    api_key=os.environ["PPLX_PROXY_API_KEY"],
)
tools = [{
    "type": "function", "name": "lookup_inventory",
    "description": "Read the application's current stock count for a SKU.",
    "parameters": {
        "type": "object", "properties": {"sku": {"type": "string"}},
        "required": ["sku"], "additionalProperties": False,
    },
    "strict": True,
}]
first = client.responses.create(
    model="gpt", tools=tools, tool_choice="auto",
    input="Use lookup_inventory to check the current stock count for DEMO.",
)
calls = [item for item in first.output if item.type == "function_call"]
if not calls:
    raise RuntimeError("The model did not request the inventory lookup")
results = []
for call in calls:
    if call.name != "lookup_inventory":
        raise RuntimeError("Unexpected function")
    sku = json.loads(call.arguments)["sku"]
    # Client-side execution; replace this sample data with your own lookup.
    quantity = {"DEMO": 7}[sku]
    results.append({"type": "function_call_output", "call_id": call.call_id,
                    "output": json.dumps({"sku": sku, "quantity": quantity})})
for event in client.responses.create(
    model="gpt", previous_response_id=first.id, input=results, stream=True,
):
    if event.type == "response.output_text.delta":
        print(event.delta, end="", flush=True)
    elif event.type == "response.failed":
        raise RuntimeError(event.response.error)
```

### Image and file input

A real PNG input has passed an official SDK test. Use a Responses `input_image` content part alongside `input_text`. `/v1/files` accepts multipart `file` and `purpose` fields and returns a local `file_id`; its read/delete endpoints manage those stored bytes. Limits are **20 MiB per file** and **200 MiB total local storage**.

Document upload and model-readable document input are different steps. Document processing must finish through Perplexity's `/rest/sse/attachment_processing/subscribe` endpoint before a query can use it. That endpoint currently receives Cloudflare 403, so document input is **not working** and returns an explicit **502**. Do not treat successful local file upload as a successful document-reading test.

### OpenAI API


```bash
# Basic chat
curl -X POST http://localhost:8892/v1/chat/completions \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "sonnet", "messages": [{"role": "user", "content": "Hello"}], "stream": true}'

# With thinking
curl -X POST http://localhost:8892/v1/chat/completions \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt", "messages": [{"role": "user", "content": "Analyze X"}], "thinking": true}'

```

### Debug Chat UI

Open **http://localhost:8892/chat** (or `https://your-domain/chat`) in a browser:

- Toggle **Test unsupported tools** to verify that function-tool requests produce a visible error
- Toggle **thinking** to test reasoning mode
- Toggle **stream** for streaming vs non-streaming
- **Raw tab**: shows full request/response JSON
- **Format ✓ tab**: validates every response field against the OpenAI spec with PASS/FAIL badges

### MCP

The API key is part of the URL path for MCP authentication:

```bash
# Claude Code
claude mcp add pplx-proxy --transport http http://localhost:8892/YOUR_API_KEY/mcp

# SSE transport
# Connect to http://localhost:8892/YOUR_API_KEY/sse
```

Without `PPLX_PROXY_API_KEY` set, MCP falls back to unauthenticated `/mcp/mcp` and `/sse/sse`.

**MCP Tools:**

| Tool | Description |
|------|-------------|
| `perplexity_search` | Pro Search with model/source selection |
| `perplexity_ask` | Quick auto-mode Q&A |
| `perplexity_reason` | Reasoning with model selection |
| `perplexity_research` | Deep Research |
| `perplexity_models` | List available models for your tier |


## OpenAI Format Compliance

All responses strictly match the [OpenAI Chat Completions API spec](https://platform.openai.com/docs/api-reference/chat/object):

- `id` (chatcmpl-*), `object`, `created`, `model`, `system_fingerprint` (null)
- `choices[].index`, `choices[].logprobs` (null), `choices[].finish_reason`
- `usage.total_tokens` = `prompt_tokens` + `completion_tokens`
- Successful streaming: consistent `id`, `system_fingerprint` in every chunk, proper `[DONE]` termination

**Use `/chat` to visually verify** — the Format ✓ tab runs 20+ checks per response.

## Auto-Discovery

Every `PROBE_INTERVAL_HOURS` (default 24h), pplx-proxy checks if models are still alive. If one dies, it increments the version number (e.g., `gpt54` → `gpt55` → ... up to +1.0, capped at 10 probes) and auto-upgrades. It also probes known new model names that are missing from a persisted `.models.json`, so newly added families such as Grok, Haiku, Flash, Mini, and Nano can be added instead of only version-bumping old IDs. Thinking variants are auto-derived from `_THINKING_MAP`.

Manual trigger: `POST /admin/discover-models`

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `PPLX_COOKIE` | — | Session token (**required**) |
| `PPLX_PROXY_API_KEY` | — | Bearer auth (empty = no auth) |
| `ACCOUNT_TYPE` | `pro` | `free`, `pro`, or `max` |
| `DEFAULT_MODEL` | `gpt` | Default when not specified |
| `PPLX_PROXY_PORT` | `8892` | Listen port |
| `DATA_DIR` | `.` | Runtime file directory for `.cookie_cache.json`, `.models.json`, and `.responses_store.json` |
| `CUSTOM_PROMPTS` | file | Local prompt block prepended to every LobeHub request |
| `KEEPALIVE_HOURS` | `6` | Session ping interval |
| `PROBE_INTERVAL_HOURS` | `24` | Auto-discovery interval |
| `FLARESOLVERR_URL` | `http://localhost:8191` | FlareSolverr endpoint for `/health` quota data |
| `NTFY_TOPIC` | disabled | Unique ntfy.sh topic: maximum 64 characters using letters, numbers, `_`, or `-`; the shared public `pplx-proxy` name is rejected |
| `NTFY_URL` | `https://ntfy.sh` | ntfy server URL |
| `NTFY_COOLDOWN_SECS` | `3600` | Min interval between alerts |
| `PUBLIC_URL` | `http://localhost:8892` | URL in ntfy messages |
| `PPLX_API_VERSION` | `2.18` | Perplexity internal API ver |
| `PPLX_IMPERSONATE` | `chrome` | curl_cffi TLS fingerprint |
| `USER_AGENT` | Chrome/130 | HTTP User-Agent |
| `COOKIE_MAX_AGE_HOURS` | `168` | Cookie cache max age |
| `LOG_LEVEL` | `INFO` | Logging level |

On macOS, copy the configured private topic without exposing it or adding a trailing newline:

```bash
ssh YOUR_PI 'value=$(sed -n "s/^NTFY_TOPIC=//p" ~/pplx-proxy/.env); printf %s "$value"' | pbcopy
```

## Deployment (systemd)

```bash
sudo cp pplx-proxy.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pplx-proxy
```

## Cookie Lifecycle

```
Manual inject → validate at startup and every 6h → persist returned cookie rotation → restart uses the latest session
                                      ↓ (if Perplexity force-revokes)
                                      ntfy alert → manual re-inject
```

Re-inject without SSH:

```bash
curl -X POST https://your-domain/admin/refresh-cookie \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"session_token": "NEW_TOKEN"}'
```

## Critical Implementation Notes

**Why models say "I can't access real-time data":** This proxy must handle three issues that cause Perplexity models to ignore their own search results:

1. **`search_focus: "internet"`** must be set in every request. Without it, Perplexity defaults to `"writing"` mode where models don't incorporate search results. This is the single most important parameter.

2. **System prompts must be stripped or replaced** before sending to Perplexity. Perplexity searches ALL query text — if the system prompt says "You are an AI assistant", Perplexity finds chatbot tutorial pages and the model gets confused. Generic clients keep only whitelist-approved lines; LobeHub requests discard upstream prompt content entirely.

3. **LobeHub requests always prepend local `CUSTOM_PROMPTS`.** The proxy still detects `role: developer` and system-prompt-like user messages so it can classify the request source, but those upstream prompt blocks are never forwarded. Each LobeHub turn sends `instructions=[CUSTOM_PROMPTS]` plus preserved `history` and current `query`.

4. **Rate limit tracking** uses FlareSolverr (`FLARESOLVERR_URL`, default `http://localhost:8191`) to poll Perplexity's `/rest/rate-limit/all` endpoint with the session cookie. Requires FlareSolverr for quota fields in `/health`. When `remaining_pro` reaches 0, non-auto requests receive HTTP 429; select `auto` explicitly to use the free model.

See AGENTS.md for the full technical breakdown and MANUAL.md troubleshooting section for diagnosis steps.

## Disclaimer

Unofficial reverse proxy for personal use. Relies on Perplexity's internal web API which may change without notice. Use responsibly.

## License

MIT

### Verification and session updates

Run `./test.sh [base-url]` with the project's Python environment installed. It validates health, models, complete chat responses, streaming completion, and Responses output. HTTP errors, malformed output, and interrupted streams cause a nonzero exit status.

Run `node --test test_chat.js` for debug-page protocol checks.

Run `./inject_cookie.sh` for a hidden token prompt, or pipe a token on standard input. The helper reads the project `.env`, calls the running local service, and only replaces the session after validation. It does not restart the service. Avoid placing tokens in shell command arguments or history.

Requests exceeding the 96,000-character query limit receive an explicit error; the proxy does not silently truncate conversation content. `/health` reports cached quota immediately and schedules a refresh when needed. A successful chat stream ends with `[DONE]`; a failed stream emits an error instead of reporting success.

Chat Completions rejects unsupported generation controls with HTTP 400, including custom sampling, output-token limits, structured response formats, multiple choices, stop sequences, nondefault penalties, seeds, logprobs, and streaming usage requests. These options are not silently ignored.
