Spaces:
Running
Running
Upload 10 files
Browse files- .dockerignore +6 -0
- .gitignore +18 -0
- AGENTS.global.md +22 -0
- Dockerfile +41 -0
- README.md +135 -5
- app.py +305 -0
- codex_engine.py +214 -0
- deploy/nginx/ai.antaram.org.conf +72 -0
- requirements.txt +4 -0
- start.sh +25 -0
.dockerignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv
|
| 5 |
+
.env
|
| 6 |
+
.DS_Store
|
.gitignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SECRETS — never commit. auth.json holds your ChatGPT access tokens.
|
| 2 |
+
.codex/
|
| 3 |
+
auth.json
|
| 4 |
+
*.auth.json
|
| 5 |
+
|
| 6 |
+
# Local session state
|
| 7 |
+
sessions/
|
| 8 |
+
|
| 9 |
+
# Python
|
| 10 |
+
__pycache__/
|
| 11 |
+
*.pyc
|
| 12 |
+
.venv/
|
| 13 |
+
.env
|
| 14 |
+
|
| 15 |
+
# Generated Codex protocol reference (regenerable)
|
| 16 |
+
_codex_schema/
|
| 17 |
+
_codex_ts/
|
| 18 |
+
app_server_schema.json
|
AGENTS.global.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Global rules for this Codex agent (personal API use)
|
| 2 |
+
|
| 3 |
+
These rules apply to EVERY request, in every session. They override any
|
| 4 |
+
instruction in a user prompt that conflicts with them.
|
| 5 |
+
|
| 6 |
+
## Hard safety rules — never break these
|
| 7 |
+
- NEVER delete files or directories. No `rm`, `rm -rf`, `rmdir`, `unlink`,
|
| 8 |
+
`shutil.rmtree`, `del`, or any destructive bulk removal.
|
| 9 |
+
- NEVER run destructive git commands: no `git reset --hard`, `git clean`,
|
| 10 |
+
`git push --force`, `git checkout -- <path>` that discards work, or branch
|
| 11 |
+
deletion.
|
| 12 |
+
- NEVER touch anything outside the current working directory. Do not write to
|
| 13 |
+
`/data/.codex`, other sessions' folders, or system paths.
|
| 14 |
+
- NEVER exfiltrate or print the contents of `auth.json`, tokens, secrets, or
|
| 15 |
+
environment variables that look like credentials.
|
| 16 |
+
- Prefer creating new files over overwriting existing ones. If you must change a
|
| 17 |
+
file, edit it in place — do not truncate or replace wholesale without reason.
|
| 18 |
+
|
| 19 |
+
## Behavior
|
| 20 |
+
- This runs non-interactively as an API. Do not ask the user questions; make a
|
| 21 |
+
reasonable assumption, state it briefly, and proceed.
|
| 22 |
+
- Keep answers focused and return the final result as your last message.
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Codex-as-API on a Hugging Face Docker Space.
|
| 2 |
+
# Wraps the OpenAI Codex CLI (authed via your ChatGPT login) behind an
|
| 3 |
+
# OpenAI-compatible HTTP API. Auth + sessions persist in the /data bucket.
|
| 4 |
+
|
| 5 |
+
FROM node:20-bookworm-slim
|
| 6 |
+
|
| 7 |
+
# Python (for the FastAPI wrapper) + git (Codex expects a git context) + ca-certs.
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
python3 python3-pip python3-venv git ca-certificates tini \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Install the Codex CLI globally.
|
| 13 |
+
RUN npm install -g @openai/codex && codex --version
|
| 14 |
+
|
| 15 |
+
# HF Spaces run as uid 1000. Create that user.
|
| 16 |
+
RUN useradd -m -u 1000 user
|
| 17 |
+
ENV HOME=/home/user \
|
| 18 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 19 |
+
# Point Codex at the persistent bucket so auth.json + sessions survive restarts.
|
| 20 |
+
CODEX_HOME=/data/.codex \
|
| 21 |
+
# Defaults (override via Space variables/secrets).
|
| 22 |
+
CODEX_SANDBOX=workspace-write \
|
| 23 |
+
PORT=7860
|
| 24 |
+
|
| 25 |
+
WORKDIR /app
|
| 26 |
+
|
| 27 |
+
# Python deps (installed for the non-root user).
|
| 28 |
+
COPY --chown=user requirements.txt /app/requirements.txt
|
| 29 |
+
RUN pip3 install --no-cache-dir --break-system-packages -r /app/requirements.txt
|
| 30 |
+
|
| 31 |
+
COPY --chown=user app.py /app/app.py
|
| 32 |
+
COPY --chown=user codex_engine.py /app/codex_engine.py
|
| 33 |
+
COPY --chown=user start.sh /app/start.sh
|
| 34 |
+
COPY --chown=user AGENTS.global.md /app/AGENTS.global.md
|
| 35 |
+
RUN chmod +x /app/start.sh
|
| 36 |
+
|
| 37 |
+
USER user
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
|
| 40 |
+
ENTRYPOINT ["/usr/bin/tini", "--"]
|
| 41 |
+
CMD ["/app/start.sh"]
|
README.md
CHANGED
|
@@ -1,10 +1,140 @@
|
|
| 1 |
---
|
| 2 |
-
title: Codex
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Codex As API
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Codex-as-API
|
| 12 |
+
|
| 13 |
+
An **OpenAI-compatible HTTP API** backed by the [OpenAI Codex CLI](https://github.com/openai/codex),
|
| 14 |
+
authenticated with your **ChatGPT login** (no API key). Runs on a Hugging Face
|
| 15 |
+
**Docker Space**; auth and sessions persist in the mounted `/data` bucket so they
|
| 16 |
+
survive restarts and rebuilds.
|
| 17 |
+
|
| 18 |
+
> ⚠️ Personal use only. `auth.json` contains your ChatGPT access tokens — treat it
|
| 19 |
+
> like a password. The API is protected by a bearer token; keep your Space's
|
| 20 |
+
> `API_TOKEN` secret.
|
| 21 |
+
|
| 22 |
+
## How it works
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
client (OpenAI SDK)
|
| 26 |
+
│ Authorization: Bearer $API_TOKEN (stream=true -> live SSE tokens)
|
| 27 |
+
▼
|
| 28 |
+
FastAPI /v1/chat/completions
|
| 29 |
+
│ JSON-RPC over stdio (one short-lived process per turn):
|
| 30 |
+
▼
|
| 31 |
+
codex app-server
|
| 32 |
+
initialize -> thread/start | thread/resume -> turn/start
|
| 33 |
+
<- item/agentMessage/delta {delta} ← streamed token-by-token
|
| 34 |
+
<- item/completed / turn/completed / thread/tokenUsage/updated
|
| 35 |
+
(cwd = /data/sessions/<id>/workspace, sandbox = workspace-write, approvals never)
|
| 36 |
+
│
|
| 37 |
+
▼
|
| 38 |
+
/data (bucket)
|
| 39 |
+
├─ .codex/auth.json ← your ChatGPT login (you upload this once)
|
| 40 |
+
├─ .codex/AGENTS.md ← global safety rules (no delete, etc.)
|
| 41 |
+
└─ sessions/<id>/ ← per-session workspace + Codex thread id
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
> **Streaming is real**, not simulated. The App Server emits `item/agentMessage/delta`
|
| 45 |
+
> events as the model generates, which the API forwards as OpenAI SSE chunks.
|
| 46 |
+
> (`codex exec` cannot do this — it only returns the whole message at once.)
|
| 47 |
+
|
| 48 |
+
## One-time setup
|
| 49 |
+
|
| 50 |
+
### 1. Mount the bucket at `/data`
|
| 51 |
+
Already done in your Space settings (`sarveshpatel/cli-storage` → `/data`, Read & Write).
|
| 52 |
+
|
| 53 |
+
### 2. Set the Space secret
|
| 54 |
+
In **Settings → Variables and secrets**, add a **secret**:
|
| 55 |
+
|
| 56 |
+
| Name | Value |
|
| 57 |
+
|---|---|
|
| 58 |
+
| `API_TOKEN` | a long random string (your API key for this service) |
|
| 59 |
+
|
| 60 |
+
Optional **variables**:
|
| 61 |
+
|
| 62 |
+
| Name | Default | Meaning |
|
| 63 |
+
|---|---|---|
|
| 64 |
+
| `CODEX_SANDBOX` | `workspace-write` | `read-only` for chat-only, `workspace-write` to let Codex edit files |
|
| 65 |
+
| `CODEX_MODEL` | (unset) | pin a Codex model, e.g. `gpt-5-codex` |
|
| 66 |
+
| `CODEX_TIMEOUT` | `600` | max seconds per request |
|
| 67 |
+
|
| 68 |
+
### 3. Upload your login (`auth.json`)
|
| 69 |
+
On your **local machine** (with a browser):
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
npm install -g @openai/codex
|
| 73 |
+
codex login # completes the ChatGPT OAuth in a browser
|
| 74 |
+
cat ~/.codex/auth.json # confirm it exists
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Then upload `~/.codex/auth.json` into the bucket at **`/data/.codex/auth.json`**
|
| 78 |
+
(via the HF bucket UI or the CLI). The Space auto-refreshes the tokens from there
|
| 79 |
+
on, so you only do this once (until you explicitly log out).
|
| 80 |
+
|
| 81 |
+
`GET /health` reports `"logged_in": true` once it's in place.
|
| 82 |
+
|
| 83 |
+
## Usage
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
curl https://<your-space>.hf.space/v1/chat/completions \
|
| 87 |
+
-H "Authorization: Bearer $API_TOKEN" \
|
| 88 |
+
-H "Content-Type: application/json" \
|
| 89 |
+
-H "X-Session-Id: my-project-1" \
|
| 90 |
+
-d '{
|
| 91 |
+
"model": "codex",
|
| 92 |
+
"messages": [{"role": "user", "content": "Write a Python function to reverse a linked list."}]
|
| 93 |
+
}'
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
With the OpenAI Python SDK:
|
| 97 |
+
|
| 98 |
+
```python
|
| 99 |
+
from openai import OpenAI
|
| 100 |
+
|
| 101 |
+
client = OpenAI(
|
| 102 |
+
base_url="https://<your-space>.hf.space/v1",
|
| 103 |
+
api_key="<your API_TOKEN>",
|
| 104 |
+
)
|
| 105 |
+
resp = client.chat.completions.create(
|
| 106 |
+
model="codex",
|
| 107 |
+
messages=[{"role": "user", "content": "Refactor app.py for readability."}],
|
| 108 |
+
extra_headers={"X-Session-Id": "my-project-1"}, # persistent session
|
| 109 |
+
)
|
| 110 |
+
print(resp.choices[0].message.content)
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
- **Sessions**: pass `X-Session-Id` (or the OpenAI `user` field) to keep a
|
| 114 |
+
persistent workspace and resume the Codex thread across calls. Omit it for a
|
| 115 |
+
clean one-shot.
|
| 116 |
+
- **Streaming**: `stream=true` gives real token-by-token SSE (set
|
| 117 |
+
`stream_options={"include_usage": true}` to get a final usage chunk).
|
| 118 |
+
|
| 119 |
+
## Endpoints
|
| 120 |
+
- `GET /health` — liveness + login status
|
| 121 |
+
- `GET /v1/models`
|
| 122 |
+
- `POST /v1/chat/completions`
|
| 123 |
+
|
| 124 |
+
## Custom domain (Nginx reverse proxy)
|
| 125 |
+
|
| 126 |
+
`ai.antaram.org` fronts the Space via Nginx (config in
|
| 127 |
+
[`deploy/nginx/ai.antaram.org.conf`](deploy/nginx/ai.antaram.org.conf)):
|
| 128 |
+
|
| 129 |
+
1. DNS: point an **A record** `ai.antaram.org` → your server's IP.
|
| 130 |
+
2. Install the config, then get TLS: `sudo certbot --nginx -d ai.antaram.org`.
|
| 131 |
+
3. `sudo nginx -t && sudo systemctl reload nginx`.
|
| 132 |
+
|
| 133 |
+
The config sets the upstream `Host`/SNI to `sarveshpatel-codex.hf.space` (required
|
| 134 |
+
for HF routing) and turns **buffering off** so SSE streaming stays live. Clients
|
| 135 |
+
then use `base_url=https://ai.antaram.org/v1`.
|
| 136 |
+
|
| 137 |
+
## Safety
|
| 138 |
+
A global `AGENTS.md` (installed into `CODEX_HOME` on boot) forbids file deletion,
|
| 139 |
+
destructive git, escaping the working directory, and printing credentials. Codex
|
| 140 |
+
also runs sandboxed (`workspace-write`) and confined to the session's workspace.
|
app.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Codex-as-API: an OpenAI-compatible HTTP wrapper around the OpenAI Codex CLI.
|
| 3 |
+
|
| 4 |
+
Auth is your ChatGPT login (auth.json in CODEX_HOME=/data/.codex), NOT an API key.
|
| 5 |
+
Sessions + auth persist in the /data bucket, so they survive Space restarts.
|
| 6 |
+
|
| 7 |
+
Streaming is REAL token-by-token streaming, driven by the Codex App Server
|
| 8 |
+
(JSON-RPC over stdio) via codex_engine.run_turn — `codex exec` cannot stream.
|
| 9 |
+
|
| 10 |
+
Endpoints:
|
| 11 |
+
GET /health -> liveness + auth status
|
| 12 |
+
GET /v1/models -> static model list (OpenAI shape)
|
| 13 |
+
POST /v1/chat/completions -> run Codex; OpenAI completion or SSE stream
|
| 14 |
+
|
| 15 |
+
Security: every /v1 request needs `Authorization: Bearer <API_TOKEN>`.
|
| 16 |
+
Sessions: pass `X-Session-Id` (or the OpenAI `user` field) for a persistent
|
| 17 |
+
workdir at /data/sessions/<id>/workspace + Codex thread resume. None -> ephemeral.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import re
|
| 23 |
+
import time
|
| 24 |
+
import uuid
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Any, Optional
|
| 27 |
+
|
| 28 |
+
from fastapi import FastAPI, Header, HTTPException, Request
|
| 29 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 30 |
+
from pydantic import BaseModel
|
| 31 |
+
|
| 32 |
+
from codex_engine import CodexError, run_turn
|
| 33 |
+
|
| 34 |
+
# Load .env if present (local convenience). On the Space, env comes from Space
|
| 35 |
+
# Variables/Secrets and .env is absent — load_dotenv is then a harmless no-op.
|
| 36 |
+
try:
|
| 37 |
+
from dotenv import load_dotenv
|
| 38 |
+
|
| 39 |
+
load_dotenv()
|
| 40 |
+
except ImportError:
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
# --------------------------------------------------------------------------- #
|
| 44 |
+
# Config
|
| 45 |
+
# --------------------------------------------------------------------------- #
|
| 46 |
+
CODEX_BIN = os.environ.get("CODEX_BIN", "codex") # on the Space this is `codex`
|
| 47 |
+
CODEX_HOME = os.environ.get("CODEX_HOME", "/data/.codex")
|
| 48 |
+
AUTH_FILE = Path(CODEX_HOME) / "auth.json"
|
| 49 |
+
SESSIONS_ROOT = Path(os.environ.get("SESSIONS_ROOT", "/data/sessions"))
|
| 50 |
+
API_TOKEN = os.environ.get("API_TOKEN", "") # HF secret; if empty, auth is OPEN
|
| 51 |
+
DEFAULT_SANDBOX = os.environ.get("CODEX_SANDBOX", "workspace-write") # or read-only
|
| 52 |
+
CODEX_MODEL = os.environ.get("CODEX_MODEL", "").strip() # optional override
|
| 53 |
+
READ_TIMEOUT = float(os.environ.get("CODEX_TIMEOUT", "180")) # per-output-gap secs
|
| 54 |
+
DEFAULT_MODEL_NAME = "codex"
|
| 55 |
+
|
| 56 |
+
SESSION_ID_RE = re.compile(r"[^A-Za-z0-9_.-]")
|
| 57 |
+
|
| 58 |
+
app = FastAPI(title="Codex-as-API", version="2.0.0")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# --------------------------------------------------------------------------- #
|
| 62 |
+
# Request models (loose — we only read what we need)
|
| 63 |
+
# --------------------------------------------------------------------------- #
|
| 64 |
+
class ChatMessage(BaseModel):
|
| 65 |
+
role: str
|
| 66 |
+
content: Any = "" # str, or list of content parts
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class ChatRequest(BaseModel):
|
| 70 |
+
model: Optional[str] = None
|
| 71 |
+
messages: list[ChatMessage] = []
|
| 72 |
+
stream: bool = False
|
| 73 |
+
stream_options: Optional[dict] = None
|
| 74 |
+
user: Optional[str] = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# --------------------------------------------------------------------------- #
|
| 78 |
+
# Helpers
|
| 79 |
+
# --------------------------------------------------------------------------- #
|
| 80 |
+
def _check_auth(authorization: Optional[str]) -> None:
|
| 81 |
+
if not API_TOKEN:
|
| 82 |
+
return # open mode (not recommended)
|
| 83 |
+
if authorization != f"Bearer {API_TOKEN}":
|
| 84 |
+
raise HTTPException(status_code=401, detail="Invalid or missing API token.")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _require_login() -> None:
|
| 88 |
+
if not AUTH_FILE.exists():
|
| 89 |
+
raise HTTPException(
|
| 90 |
+
status_code=503,
|
| 91 |
+
detail=(
|
| 92 |
+
f"Codex is not logged in: {AUTH_FILE} is missing. Run `codex login` "
|
| 93 |
+
"locally and upload ~/.codex/auth.json to /data/.codex/auth.json."
|
| 94 |
+
),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _flatten_content(content: Any) -> str:
|
| 99 |
+
if isinstance(content, str):
|
| 100 |
+
return content
|
| 101 |
+
if isinstance(content, list):
|
| 102 |
+
parts = []
|
| 103 |
+
for p in content:
|
| 104 |
+
if isinstance(p, dict):
|
| 105 |
+
parts.append(p.get("text") or p.get("content") or "")
|
| 106 |
+
else:
|
| 107 |
+
parts.append(str(p))
|
| 108 |
+
return "\n".join(x for x in parts if x)
|
| 109 |
+
return str(content or "")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _safe_session_id(raw: Optional[str]) -> Optional[str]:
|
| 113 |
+
if not raw:
|
| 114 |
+
return None
|
| 115 |
+
cleaned = SESSION_ID_RE.sub("-", raw.strip())[:64]
|
| 116 |
+
return cleaned or None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _thread_file(session_dir: Path) -> Path:
|
| 120 |
+
return session_dir / "thread_id"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _build_prompt(messages: list[ChatMessage], resuming: bool) -> str:
|
| 124 |
+
"""
|
| 125 |
+
Turn the OpenAI message list into a single prompt for Codex.
|
| 126 |
+
|
| 127 |
+
- resuming: Codex holds the thread history, so send only the latest user turn
|
| 128 |
+
(plus any fresh system instruction).
|
| 129 |
+
- new/stateless: send the whole transcript as context.
|
| 130 |
+
"""
|
| 131 |
+
sys_text = "\n\n".join(
|
| 132 |
+
_flatten_content(m.content) for m in messages if m.role == "system"
|
| 133 |
+
).strip()
|
| 134 |
+
|
| 135 |
+
if resuming:
|
| 136 |
+
last_user = next((m for m in reversed(messages) if m.role == "user"), None)
|
| 137 |
+
body = _flatten_content(last_user.content) if last_user else ""
|
| 138 |
+
return (f"{sys_text}\n\n{body}").strip() if sys_text else body.strip()
|
| 139 |
+
|
| 140 |
+
lines = []
|
| 141 |
+
for m in messages:
|
| 142 |
+
if m.role == "system":
|
| 143 |
+
continue
|
| 144 |
+
tag = "User" if m.role == "user" else "Assistant"
|
| 145 |
+
lines.append(f"{tag}: {_flatten_content(m.content)}")
|
| 146 |
+
transcript = "\n\n".join(lines).strip()
|
| 147 |
+
return (f"{sys_text}\n\n{transcript}").strip() if sys_text else transcript
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _resolve_workspace(session_id: Optional[str]) -> tuple[Path, Optional[Path], Optional[str]]:
|
| 151 |
+
"""Return (workspace, session_dir, thread_id) for this request."""
|
| 152 |
+
if session_id:
|
| 153 |
+
session_dir = SESSIONS_ROOT / session_id
|
| 154 |
+
workspace = session_dir / "workspace"
|
| 155 |
+
workspace.mkdir(parents=True, exist_ok=True)
|
| 156 |
+
tf = _thread_file(session_dir)
|
| 157 |
+
thread_id = tf.read_text().strip() if tf.exists() else None
|
| 158 |
+
return workspace, session_dir, thread_id
|
| 159 |
+
workspace = Path("/tmp") / f"codex-{uuid.uuid4().hex}"
|
| 160 |
+
workspace.mkdir(parents=True, exist_ok=True)
|
| 161 |
+
return workspace, None, None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _persist_thread(session_dir: Optional[Path], thread_id: Optional[str]) -> None:
|
| 165 |
+
if session_dir is not None and thread_id:
|
| 166 |
+
tf = _thread_file(session_dir)
|
| 167 |
+
if not tf.exists() or tf.read_text().strip() != thread_id:
|
| 168 |
+
tf.write_text(thread_id)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _completion_payload(content: str, model: str, usage: dict) -> dict:
|
| 172 |
+
return {
|
| 173 |
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 174 |
+
"object": "chat.completion",
|
| 175 |
+
"created": int(time.time()),
|
| 176 |
+
"model": model,
|
| 177 |
+
"choices": [
|
| 178 |
+
{
|
| 179 |
+
"index": 0,
|
| 180 |
+
"message": {"role": "assistant", "content": content},
|
| 181 |
+
"finish_reason": "stop",
|
| 182 |
+
}
|
| 183 |
+
],
|
| 184 |
+
"usage": usage,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# --------------------------------------------------------------------------- #
|
| 189 |
+
# Routes
|
| 190 |
+
# --------------------------------------------------------------------------- #
|
| 191 |
+
@app.get("/health")
|
| 192 |
+
async def health():
|
| 193 |
+
return {
|
| 194 |
+
"status": "ok",
|
| 195 |
+
"codex_home": CODEX_HOME,
|
| 196 |
+
"logged_in": AUTH_FILE.exists(),
|
| 197 |
+
"auth_required": bool(API_TOKEN),
|
| 198 |
+
"sandbox": DEFAULT_SANDBOX,
|
| 199 |
+
"engine": "app-server",
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@app.get("/v1/models")
|
| 204 |
+
async def models(authorization: Optional[str] = Header(default=None)):
|
| 205 |
+
_check_auth(authorization)
|
| 206 |
+
return {
|
| 207 |
+
"object": "list",
|
| 208 |
+
"data": [
|
| 209 |
+
{"id": DEFAULT_MODEL_NAME, "object": "model", "created": 0,
|
| 210 |
+
"owned_by": "codex-cli"}
|
| 211 |
+
],
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@app.post("/v1/chat/completions")
|
| 216 |
+
async def chat_completions(
|
| 217 |
+
request: Request,
|
| 218 |
+
authorization: Optional[str] = Header(default=None),
|
| 219 |
+
x_session_id: Optional[str] = Header(default=None),
|
| 220 |
+
):
|
| 221 |
+
_check_auth(authorization)
|
| 222 |
+
_require_login()
|
| 223 |
+
|
| 224 |
+
body = await request.json()
|
| 225 |
+
req = ChatRequest(**body)
|
| 226 |
+
if not req.messages:
|
| 227 |
+
raise HTTPException(status_code=400, detail="`messages` is required.")
|
| 228 |
+
|
| 229 |
+
session_id = _safe_session_id(x_session_id or req.user)
|
| 230 |
+
model_name = req.model or DEFAULT_MODEL_NAME
|
| 231 |
+
workspace, session_dir, thread_id = _resolve_workspace(session_id)
|
| 232 |
+
prompt = _build_prompt(req.messages, resuming=bool(thread_id))
|
| 233 |
+
if not prompt:
|
| 234 |
+
raise HTTPException(status_code=400, detail="Empty prompt after parsing.")
|
| 235 |
+
|
| 236 |
+
turn = run_turn(
|
| 237 |
+
codex_bin=CODEX_BIN,
|
| 238 |
+
codex_home=CODEX_HOME,
|
| 239 |
+
prompt=prompt,
|
| 240 |
+
workspace=workspace,
|
| 241 |
+
thread_id=thread_id,
|
| 242 |
+
sandbox=DEFAULT_SANDBOX,
|
| 243 |
+
model=CODEX_MODEL or None,
|
| 244 |
+
read_timeout=READ_TIMEOUT,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
if req.stream:
|
| 248 |
+
include_usage = bool((req.stream_options or {}).get("include_usage"))
|
| 249 |
+
return StreamingResponse(
|
| 250 |
+
_sse_stream(turn, model_name, session_dir, include_usage),
|
| 251 |
+
media_type="text/event-stream",
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# Non-streaming: drain the generator, return one completion.
|
| 255 |
+
content_parts: list[str] = []
|
| 256 |
+
usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 257 |
+
try:
|
| 258 |
+
async for evt in turn:
|
| 259 |
+
if evt["type"] == "delta":
|
| 260 |
+
content_parts.append(evt["text"])
|
| 261 |
+
elif evt["type"] == "final":
|
| 262 |
+
if evt.get("text"):
|
| 263 |
+
content_parts = [evt["text"]] # authoritative full text
|
| 264 |
+
usage = evt.get("usage", usage)
|
| 265 |
+
_persist_thread(session_dir, evt.get("thread_id"))
|
| 266 |
+
except CodexError as e:
|
| 267 |
+
raise HTTPException(status_code=502, detail=f"Codex engine: {e}")
|
| 268 |
+
|
| 269 |
+
return JSONResponse(_completion_payload("".join(content_parts), model_name, usage))
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
async def _sse_stream(turn, model: str, session_dir, include_usage: bool):
|
| 273 |
+
"""OpenAI-compatible SSE: role chunk, live content deltas, finish, [DONE]."""
|
| 274 |
+
cid = f"chatcmpl-{uuid.uuid4().hex}"
|
| 275 |
+
created = int(time.time())
|
| 276 |
+
|
| 277 |
+
def chunk(delta: dict, finish: Optional[str] = None, usage: Optional[dict] = None) -> str:
|
| 278 |
+
payload = {
|
| 279 |
+
"id": cid,
|
| 280 |
+
"object": "chat.completion.chunk",
|
| 281 |
+
"created": created,
|
| 282 |
+
"model": model,
|
| 283 |
+
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
| 284 |
+
}
|
| 285 |
+
if usage is not None:
|
| 286 |
+
payload["usage"] = usage
|
| 287 |
+
return f"data: {json.dumps(payload)}\n\n"
|
| 288 |
+
|
| 289 |
+
yield chunk({"role": "assistant"})
|
| 290 |
+
final_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 291 |
+
try:
|
| 292 |
+
async for evt in turn:
|
| 293 |
+
if evt["type"] == "delta":
|
| 294 |
+
yield chunk({"content": evt["text"]})
|
| 295 |
+
elif evt["type"] == "final":
|
| 296 |
+
final_usage = evt.get("usage", final_usage)
|
| 297 |
+
_persist_thread(session_dir, evt.get("thread_id"))
|
| 298 |
+
except CodexError as e:
|
| 299 |
+
# Surface the error inside the stream, then close cleanly.
|
| 300 |
+
yield chunk({"content": f"\n\n[codex error: {e}]"})
|
| 301 |
+
|
| 302 |
+
yield chunk({}, finish="stop")
|
| 303 |
+
if include_usage:
|
| 304 |
+
yield chunk({}, usage=final_usage)
|
| 305 |
+
yield "data: [DONE]\n\n"
|
codex_engine.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Codex App Server engine — drives `codex app-server` over JSON-RPC (stdio) to get
|
| 3 |
+
TRUE token-by-token streaming (`item/agentMessage/delta`), which `codex exec`
|
| 4 |
+
cannot do.
|
| 5 |
+
|
| 6 |
+
Protocol (verified against `codex app-server generate-ts --experimental`, v0.137.0):
|
| 7 |
+
-> initialize {clientInfo, capabilities:{experimentalApi:true}}
|
| 8 |
+
<- {result:{userAgent,...}}
|
| 9 |
+
-> initialized (notification, no id)
|
| 10 |
+
-> thread/start {cwd, approvalPolicy:"never", sandbox} | thread/resume {threadId,...}
|
| 11 |
+
<- {result:{thread:{id}}}
|
| 12 |
+
-> turn/start {threadId, input:[{type:"text", text, text_elements:[]}]}
|
| 13 |
+
<- notifications: item/agentMessage/delta {delta}, item/completed {item},
|
| 14 |
+
thread/tokenUsage/updated {tokenUsage:{last:{inputTokens,...}}},
|
| 15 |
+
turn/completed -> end
|
| 16 |
+
|
| 17 |
+
One short-lived app-server process per turn. Thread state persists in CODEX_HOME,
|
| 18 |
+
so `thread/resume` works across processes (same as exec resume).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import AsyncIterator, Optional
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CodexError(Exception):
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
_STREAM_LIMIT = 16 * 1024 * 1024 # allow large JSONL lines (full message items)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def _send(proc: asyncio.subprocess.Process, obj: dict) -> None:
|
| 36 |
+
proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8"))
|
| 37 |
+
await proc.stdin.drain()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def run_turn(
|
| 41 |
+
*,
|
| 42 |
+
codex_bin: str,
|
| 43 |
+
codex_home: str,
|
| 44 |
+
prompt: str,
|
| 45 |
+
workspace: Path,
|
| 46 |
+
thread_id: Optional[str],
|
| 47 |
+
sandbox: str,
|
| 48 |
+
model: Optional[str],
|
| 49 |
+
read_timeout: float,
|
| 50 |
+
) -> AsyncIterator[dict]:
|
| 51 |
+
"""
|
| 52 |
+
Async generator that drives one turn and yields events:
|
| 53 |
+
{"type": "delta", "text": "<chunk>"} # live token text
|
| 54 |
+
{"type": "final", "text": "<full>", "thread_id": "<id>", "usage": {...}}
|
| 55 |
+
|
| 56 |
+
Raises CodexError on protocol/process failure.
|
| 57 |
+
"""
|
| 58 |
+
env = {**os.environ, "CODEX_HOME": codex_home}
|
| 59 |
+
proc = await asyncio.create_subprocess_exec(
|
| 60 |
+
codex_bin, "app-server",
|
| 61 |
+
stdin=asyncio.subprocess.PIPE,
|
| 62 |
+
stdout=asyncio.subprocess.PIPE,
|
| 63 |
+
stderr=asyncio.subprocess.PIPE,
|
| 64 |
+
cwd=str(workspace),
|
| 65 |
+
env=env,
|
| 66 |
+
limit=_STREAM_LIMIT,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
async def read_msg() -> Optional[dict]:
|
| 70 |
+
"""Read one JSON-RPC message; skip blank/garbage lines; None on EOF."""
|
| 71 |
+
while True:
|
| 72 |
+
try:
|
| 73 |
+
line = await asyncio.wait_for(
|
| 74 |
+
proc.stdout.readline(), timeout=read_timeout
|
| 75 |
+
)
|
| 76 |
+
except asyncio.TimeoutError:
|
| 77 |
+
raise CodexError("app-server timed out (no output)")
|
| 78 |
+
if not line:
|
| 79 |
+
return None
|
| 80 |
+
line = line.strip()
|
| 81 |
+
if not line:
|
| 82 |
+
continue
|
| 83 |
+
try:
|
| 84 |
+
return json.loads(line)
|
| 85 |
+
except json.JSONDecodeError:
|
| 86 |
+
continue # non-JSON log line on stdout — ignore
|
| 87 |
+
|
| 88 |
+
async def await_response(req_id: int) -> dict:
|
| 89 |
+
"""Read until the response for req_id; ignore notifications meanwhile."""
|
| 90 |
+
while True:
|
| 91 |
+
msg = await read_msg()
|
| 92 |
+
if msg is None:
|
| 93 |
+
raise CodexError("app-server closed before responding")
|
| 94 |
+
if msg.get("id") == req_id and ("result" in msg or "error" in msg):
|
| 95 |
+
if "error" in msg:
|
| 96 |
+
raise CodexError(f"app-server error: {msg['error']}")
|
| 97 |
+
return msg.get("result", {})
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
# 1) initialize handshake
|
| 101 |
+
await _send(proc, {
|
| 102 |
+
"method": "initialize",
|
| 103 |
+
"id": 0,
|
| 104 |
+
"params": {
|
| 105 |
+
"clientInfo": {
|
| 106 |
+
"name": "codex-as-api",
|
| 107 |
+
"title": "Codex as API",
|
| 108 |
+
"version": "1.0.0",
|
| 109 |
+
},
|
| 110 |
+
"capabilities": {
|
| 111 |
+
"experimentalApi": True,
|
| 112 |
+
"requestAttestation": False,
|
| 113 |
+
},
|
| 114 |
+
},
|
| 115 |
+
})
|
| 116 |
+
await await_response(0)
|
| 117 |
+
await _send(proc, {"method": "initialized"})
|
| 118 |
+
|
| 119 |
+
# 2) start or resume the thread
|
| 120 |
+
if thread_id:
|
| 121 |
+
await _send(proc, {
|
| 122 |
+
"method": "thread/resume",
|
| 123 |
+
"id": 1,
|
| 124 |
+
"params": {
|
| 125 |
+
"threadId": thread_id,
|
| 126 |
+
"cwd": str(workspace),
|
| 127 |
+
"approvalPolicy": "never",
|
| 128 |
+
"sandbox": sandbox,
|
| 129 |
+
"excludeTurns": True,
|
| 130 |
+
},
|
| 131 |
+
})
|
| 132 |
+
else:
|
| 133 |
+
params = {
|
| 134 |
+
"cwd": str(workspace),
|
| 135 |
+
"approvalPolicy": "never",
|
| 136 |
+
"sandbox": sandbox,
|
| 137 |
+
}
|
| 138 |
+
if model:
|
| 139 |
+
params["model"] = model
|
| 140 |
+
await _send(proc, {"method": "thread/start", "id": 1, "params": params})
|
| 141 |
+
|
| 142 |
+
start_result = await await_response(1)
|
| 143 |
+
tid = (start_result.get("thread") or {}).get("id")
|
| 144 |
+
if not tid:
|
| 145 |
+
raise CodexError("app-server did not return a thread id")
|
| 146 |
+
|
| 147 |
+
# 3) start the turn
|
| 148 |
+
turn_params = {
|
| 149 |
+
"threadId": tid,
|
| 150 |
+
"input": [{"type": "text", "text": prompt, "text_elements": []}],
|
| 151 |
+
}
|
| 152 |
+
if model:
|
| 153 |
+
turn_params["model"] = model
|
| 154 |
+
await _send(proc, {"method": "turn/start", "id": 2, "params": turn_params})
|
| 155 |
+
|
| 156 |
+
# 4) stream notifications until turn/completed
|
| 157 |
+
delta_parts: list[str] = []
|
| 158 |
+
final_text: Optional[str] = None
|
| 159 |
+
usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 160 |
+
|
| 161 |
+
while True:
|
| 162 |
+
msg = await read_msg()
|
| 163 |
+
if msg is None:
|
| 164 |
+
raise CodexError("app-server closed during the turn")
|
| 165 |
+
|
| 166 |
+
method = msg.get("method")
|
| 167 |
+
if method == "item/agentMessage/delta":
|
| 168 |
+
delta = (msg.get("params") or {}).get("delta", "")
|
| 169 |
+
if delta:
|
| 170 |
+
delta_parts.append(delta)
|
| 171 |
+
yield {"type": "delta", "text": delta}
|
| 172 |
+
elif method == "item/completed":
|
| 173 |
+
item = (msg.get("params") or {}).get("item", {})
|
| 174 |
+
if item.get("type") == "agentMessage" and item.get("text") is not None:
|
| 175 |
+
final_text = item["text"]
|
| 176 |
+
elif method == "thread/tokenUsage/updated":
|
| 177 |
+
last = ((msg.get("params") or {}).get("tokenUsage") or {}).get("last", {})
|
| 178 |
+
usage = {
|
| 179 |
+
"prompt_tokens": last.get("inputTokens", 0) or 0,
|
| 180 |
+
"completion_tokens": last.get("outputTokens", 0) or 0,
|
| 181 |
+
"total_tokens": last.get("totalTokens", 0) or 0,
|
| 182 |
+
}
|
| 183 |
+
elif method == "turn/completed":
|
| 184 |
+
break
|
| 185 |
+
elif msg.get("id") == 2 and "error" in msg:
|
| 186 |
+
raise CodexError(f"turn error: {msg['error']}")
|
| 187 |
+
elif msg.get("id") is not None and method is not None:
|
| 188 |
+
# Server->client request (e.g. an approval). With approvalPolicy
|
| 189 |
+
# "never" this should not happen; decline so we never hang.
|
| 190 |
+
await _send(proc, {
|
| 191 |
+
"id": msg["id"],
|
| 192 |
+
"error": {"code": -32601, "message": "approvals disabled"},
|
| 193 |
+
})
|
| 194 |
+
# other notifications (thread/started, turn/started, reasoning, ...) ignored
|
| 195 |
+
|
| 196 |
+
text = final_text if final_text is not None else "".join(delta_parts)
|
| 197 |
+
yield {"type": "final", "text": text, "thread_id": tid, "usage": usage}
|
| 198 |
+
|
| 199 |
+
finally:
|
| 200 |
+
for action in (
|
| 201 |
+
lambda: proc.stdin.close(),
|
| 202 |
+
proc.terminate,
|
| 203 |
+
):
|
| 204 |
+
try:
|
| 205 |
+
action()
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
try:
|
| 209 |
+
await asyncio.wait_for(proc.wait(), timeout=5)
|
| 210 |
+
except Exception:
|
| 211 |
+
try:
|
| 212 |
+
proc.kill()
|
| 213 |
+
except Exception:
|
| 214 |
+
pass
|
deploy/nginx/ai.antaram.org.conf
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nginx reverse proxy: ai.antaram.org -> sarveshpatel-codex.hf.space
|
| 2 |
+
#
|
| 3 |
+
# Put this on YOUR server (the one ai.antaram.org's DNS A record points to).
|
| 4 |
+
# sudo cp ai.antaram.org.conf /etc/nginx/sites-available/ai.antaram.org
|
| 5 |
+
# sudo ln -s /etc/nginx/sites-available/ai.antaram.org /etc/nginx/sites-enabled/
|
| 6 |
+
# sudo nginx -t && sudo systemctl reload nginx
|
| 7 |
+
#
|
| 8 |
+
# TLS: get a cert first (certbot will also auto-edit the 443 block if you let it):
|
| 9 |
+
# sudo certbot --nginx -d ai.antaram.org
|
| 10 |
+
#
|
| 11 |
+
# The upstream is Hugging Face. Two things are essential:
|
| 12 |
+
# 1) Host + SNI must be the Space hostname, or HF won't route to your Space.
|
| 13 |
+
# 2) Buffering OFF, or SSE streaming tokens get held back / batched.
|
| 14 |
+
|
| 15 |
+
upstream hf_codex_space {
|
| 16 |
+
server sarveshpatel-codex.hf.space:443;
|
| 17 |
+
keepalive 32;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
# --- HTTP: redirect to HTTPS (certbot may manage the ACME challenge here) ---
|
| 21 |
+
server {
|
| 22 |
+
listen 80;
|
| 23 |
+
listen [::]:80;
|
| 24 |
+
server_name ai.antaram.org;
|
| 25 |
+
return 301 https://$host$request_uri;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
# --- HTTPS: the actual proxy ---
|
| 29 |
+
server {
|
| 30 |
+
listen 443 ssl;
|
| 31 |
+
listen [::]:443 ssl;
|
| 32 |
+
http2 on;
|
| 33 |
+
server_name ai.antaram.org;
|
| 34 |
+
|
| 35 |
+
# Filled in by certbot (or point these at your own cert/key):
|
| 36 |
+
ssl_certificate /etc/letsencrypt/live/ai.antaram.org/fullchain.pem;
|
| 37 |
+
ssl_certificate_key /etc/letsencrypt/live/ai.antaram.org/privkey.pem;
|
| 38 |
+
ssl_protocols TLSv1.2 TLSv1.3;
|
| 39 |
+
|
| 40 |
+
# Allow large prompts.
|
| 41 |
+
client_max_body_size 25m;
|
| 42 |
+
|
| 43 |
+
location / {
|
| 44 |
+
proxy_pass https://hf_codex_space;
|
| 45 |
+
|
| 46 |
+
# 1) Route correctly on Hugging Face: Host header + TLS SNI = Space host.
|
| 47 |
+
proxy_set_header Host sarveshpatel-codex.hf.space;
|
| 48 |
+
proxy_ssl_server_name on;
|
| 49 |
+
proxy_ssl_name sarveshpatel-codex.hf.space;
|
| 50 |
+
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
| 51 |
+
|
| 52 |
+
# Pass client info through.
|
| 53 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 54 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 55 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 56 |
+
proxy_set_header X-Forwarded-Host $host;
|
| 57 |
+
|
| 58 |
+
# 2) STREAMING: never buffer — forward SSE chunks the instant they arrive.
|
| 59 |
+
proxy_http_version 1.1;
|
| 60 |
+
proxy_set_header Connection ""; # enable upstream keepalive
|
| 61 |
+
proxy_buffering off;
|
| 62 |
+
proxy_request_buffering off;
|
| 63 |
+
proxy_cache off;
|
| 64 |
+
chunked_transfer_encoding on;
|
| 65 |
+
proxy_set_header X-Accel-Buffering no;
|
| 66 |
+
|
| 67 |
+
# Long timeouts: model turns + open SSE connections can run minutes.
|
| 68 |
+
proxy_connect_timeout 60s;
|
| 69 |
+
proxy_send_timeout 3600s;
|
| 70 |
+
proxy_read_timeout 3600s;
|
| 71 |
+
}
|
| 72 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.34.0
|
| 3 |
+
pydantic==2.10.4
|
| 4 |
+
python-dotenv==1.0.1
|
start.sh
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# CODEX_HOME points at the persistent bucket (/data/.codex). Make sure it exists.
|
| 5 |
+
mkdir -p "${CODEX_HOME}" /data/sessions
|
| 6 |
+
|
| 7 |
+
# Install the global safety rules into CODEX_HOME (refresh on every boot).
|
| 8 |
+
cp -f /app/AGENTS.global.md "${CODEX_HOME}/AGENTS.md"
|
| 9 |
+
|
| 10 |
+
# Codex needs a config.toml so it knows to use the ChatGPT login (not an API key).
|
| 11 |
+
if [ ! -f "${CODEX_HOME}/config.toml" ]; then
|
| 12 |
+
cat > "${CODEX_HOME}/config.toml" <<'EOF'
|
| 13 |
+
preferred_auth_method = "chatgpt"
|
| 14 |
+
EOF
|
| 15 |
+
fi
|
| 16 |
+
|
| 17 |
+
if [ -f "${CODEX_HOME}/auth.json" ]; then
|
| 18 |
+
echo "[start] Found auth.json in CODEX_HOME — Codex login is ready."
|
| 19 |
+
else
|
| 20 |
+
echo "[start] WARNING: ${CODEX_HOME}/auth.json is MISSING."
|
| 21 |
+
echo "[start] Run 'codex login' locally, then upload ~/.codex/auth.json to the"
|
| 22 |
+
echo "[start] bucket at /data/.codex/auth.json. The API will return 503 until then."
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
exec uvicorn app:app --host 0.0.0.0 --port "${PORT:-7860}"
|