Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
node_modules/
api-keys.json
*.log
.env
.git
.gitignore
.omo/
.vscode/
.idea/
.deepeval/
tests/
docker/
README.md
AGENTS.md
48 changes: 48 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Build & Push Docker Image

on:
push:
tags: ["v*"]
workflow_dispatch:

env:
REGISTRY: docker.io
IMAGE_NAME: jaydennleemc/opencode-proxy

jobs:
build-and-push:
runs-on: ubuntu-latest
environment: Docker
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=true
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,13 @@ node_modules/
api-keys.json
*.log
.env

# OpenCode
.omo/

# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
.DS_Store
71 changes: 71 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# opencode-free-proxy

Modular Node.js Express proxy that translates OpenAI/Anthropic API calls to the Zen API at `opencode.ai`.

## Quick start

```bash
npm install
npm start # port 6446
# or with file watching:
npm run dev
```

API keys are auto-generated into `api-keys.json` on first run — no `.env` setup needed.

## Key facts

- **ESM only** — `"type": "module"` in package.json; use `import` not `require`.
- **No build step** — raw Node.js, no TypeScript, no bundler.
- **Tests** — `npm test` runs Node built-in test runner in `tests/`.
- **Two API formats** served on the same server:
- `POST /v1/chat/completions` (OpenAI)
- `POST /v1/messages` (Anthropic)
- Auth works with either `Authorization: Bearer KEY` or `x-api-key: KEY` header.
- **Session rotation** — per-user sessions TTL 30 minutes; **force-rotated on rate-limit** before each 429 retry.
- **Retries** — up to `MAX_RETRIES` on 429 / FreeUsageLimit + transient (network, timeout, 502/503/504); stops if client disconnects; honors `Retry-After` when present.
- **Only dependency** — `express` (listed in package.json, no lockfile committed).

## Env vars

| Variable | Default | Notes |
|----------|---------|-------|
| `PROXY_PORT` | `6446` | Server listen port |
| `KEYS_FILE` | `./api-keys.json` | Auto-created if missing |
| `MAX_RETRIES` | `12` | Rate-limit / transient retries after first attempt |
| `RETRY_BASE_MS` | `1000` | First retry delay; doubles each attempt (±20% jitter) |
| `RETRY_MAX_MS` | `30000` | Cap for exponential backoff / Retry-After |
| `LOG_DETAIL` | `1` | `0` disables full I/O dumps |
| `LOG_MAX_CHARS` | `0` | Truncate logged payloads (0 = unlimited) |
| `NO_COLOR` | — | Set to `1` to disable ANSI color |
| `FORCE_COLOR` | — | Set to `1` to force ANSI color (e.g. `docker compose logs`) |

## Files

| Path | Purpose |
|------|---------|
| `src/index.mjs` | Entry point: loads keys and starts server |
| `src/app.mjs` | Express app factory |
| `src/config/index.mjs` | Port, version, model list |
| `src/auth.mjs` | API key loading / auth middleware helper |
| `src/session.mjs` | Per-user session get / rotate-on-429 |
| `src/retry.mjs` | Shared backoff, error classify, session rewrite |
| `src/client.mjs` | Zen API HTTP request builders |
| `src/to-openai.mjs` | Anthropic → OpenAI format converter |
| `src/to-anthropic.mjs` | OpenAI → Anthropic format converter |
| `src/pipe-openai.mjs` | OpenAI-format response pipe (stream + sync) |
| `src/pipe-anthropic.mjs` | Anthropic-format SSE stream pipe |
| `src/logger.mjs` | I/O logging utilities |
| `src/routes/*.mjs` | Route handlers |
| `models.json` | List of available models |
| `api-keys.json` | Auto-generated, **never commit** |
| `Dockerfile` | Multi-stage, `npm ci` + lockfile, non-root `node`, HEALTHCHECK |
| `docker-compose.yaml` | Production: read-only rootfs, cap_drop ALL, no-new-privileges |
| `docker-compose.dev.yaml` | Development compose with bind-mount |
| `.omo/` | OpenCode plans (gitignored) |

## Style

- No TypeScript, no lint config — just raw JS with Express.
- `console.log` for logging (no structured logger).
- Format conversion helpers (`anthropicToOpenAI`, `openAIToAnthropic`) and response pipes (`pipeZenResponse`, `pipeZenAsAnthropic`) are the main complexity — preserve behavior when touching.
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
# ── Build stage ──────────────────────────────────────────────
FROM node:24-alpine AS build
WORKDIR /app

# Reproducible install from lockfile (never floating npm install)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund \
&& npm cache clean --force

# ── Run stage ────────────────────────────────────────────────
FROM node:24-alpine AS run

# Minimal runtime env
ENV NODE_ENV=production \
NODE_OPTIONS=--use-openssl-ca \
PROXY_PORT=6446 \
KEYS_FILE=/data/api-keys.json

WORKDIR /app

# Drop privileges on copy — no root-owned app tree, no chown RUN
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json models.json ./
COPY --chown=node:node src ./src

# Writable keys dir only (rootfs can be read-only at runtime)
RUN mkdir -p /data && chown node:node /data

USER node

EXPOSE 6446

# Liveness: process up + HTTP stack answering
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PROXY_PORT||6446)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

# exec form — no shell, no signal loss
CMD ["node", "src/index.mjs"]
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ One server — works with any tool that speaks OpenAI or Anthropic format: Curso
git clone https://github.com/bigdata2211it-web/opencode-free-proxy.git
cd opencode-free-proxy
npm install
node server.mjs
npm start
```

Done. Server is at `http://localhost:6446`. API keys are in `api-keys.json` (auto-generated on first run).

## What you get

| Model | What it is | Reliability |
|-------|-----------|-------------|
| `deepseek-v4-flash-free` | DeepSeek V4 Flash | Solid |
| `big-pickle` | DeepSeek V4 Flash (alias) | Solid |
| `minimax-m2.5-free` | MiniMax M2.5 | Solid |
| `nemotron-3-super-free` | NVIDIA Nemotron 3 Super | Hit or miss |
| `qwen3.6-plus-free` | Qwen 3.6 Plus | Intermittent |
The server currently serves these free models (check `/v1/models` at runtime for the authoritative list):

| Model | Description |
|-------|-------------|
| `deepseek-v4-flash-free` | DeepSeek V4 Flash |
| `laguna-s-2.1-free` | Laguna S 2.1 |
| `mimo-v2.5-free` | Mimo V2.5 |
| `nemotron-3-ultra-free` | NVIDIA Nemotron 3 Ultra |
| `north-mini-code-free` | North Mini Code |

All models support streaming, tool calls, and system messages.

Expand Down Expand Up @@ -114,9 +116,9 @@ Add to `~/.config/opencode/opencode.json`:
git clone https://github.com/bigdata2211it-web/opencode-free-proxy.git
cd opencode-free-proxy
npm install
node server.mjs # foreground
npm start # foreground
# or
nohup node server.mjs > proxy.log 2>&1 & # background
nohup npm start > proxy.log 2>&1 & # background
```

If your VPS doesn't expose port 6446, use an SSH tunnel:
Expand All @@ -137,7 +139,7 @@ After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/opencode-proxy
ExecStart=/usr/bin/node server.mjs
ExecStart=/usr/bin/node src/index.mjs
Restart=always
RestartSec=5
Environment=PROXY_PORT=6446
Expand Down
32 changes: 32 additions & 0 deletions docker-compose.dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Development — bind-mounts source, hot-reload via node --watch
# Usage: docker compose -f docker-compose.dev.yaml up
services:
proxy:
image: node:24-alpine
container_name: opencode-free-proxy-dev
working_dir: /app
ports:
- "${PROXY_PORT:-6446}:6446"
environment:
PROXY_PORT: "6446"
KEYS_FILE: /app/api-keys.json
NODE_ENV: development
NODE_OPTIONS: --use-openssl-ca
# API keys — omit to auto-generate
ADMIN_API_KEY: "${ADMIN_API_KEY:-}"
USER_DEFAULT_API_KEY: "${USER_DEFAULT_API_KEY:-}"
# Full I/O logs (set LOG_DETAIL=0 to disable; LOG_MAX_CHARS to truncate)
LOG_DETAIL: "${LOG_DETAIL:-1}"
LOG_MAX_CHARS: "${LOG_MAX_CHARS:-0}"
# FORCE_COLOR=1 if using `docker compose logs -f` (non-TTY)
FORCE_COLOR: "${FORCE_COLOR:-}"
volumes:
# Edit code on host → container picks up changes immediately
- .:/app
# Keep container node_modules separate from host
- proxy-node-modules:/app/node_modules
command: sh -c "npm install --cafile=/etc/ssl/certs/ca-certificates.crt && npm run dev"
restart: unless-stopped

volumes:
proxy-node-modules:
46 changes: 46 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Production
# Usage: docker compose -f docker-compose.yaml up -d --build
services:
proxy:
build: .
image: opencode-free-proxy:latest
container_name: opencode-free-proxy
ports:
# Prefer loopback if only local clients; use 6446:6446 for LAN exposure
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-6446}:6446"
environment:
PROXY_PORT: "6446"
KEYS_FILE: /data/api-keys.json
NODE_ENV: production
# Prefer Docker secrets / compose env file over plaintext in shell history
ADMIN_API_KEY: "${ADMIN_API_KEY:-}"
USER_DEFAULT_API_KEY: "${USER_DEFAULT_API_KEY:-}"
# Production default: less I/O leakage in logs
LOG_DETAIL: "${LOG_DETAIL:-0}"
LOG_MAX_CHARS: "${LOG_MAX_CHARS:-0}"
FORCE_COLOR: "${FORCE_COLOR:-}"
volumes:
# Persist auto-generated API keys across container recreations
- proxy-keys:/data
# ── Hardening ───────────────────────────────────────────
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=16m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Non-root user comes from Dockerfile (USER node). Do not override unless
# you also chown the keys volume for that uid.
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.1"
memory: 64M
restart: unless-stopped

volumes:
proxy-keys:
8 changes: 8 additions & 0 deletions models.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[
"hy3-free",
"mimo-v2.5-free",
"muse-spark-1.2-contributor-free",
"nemotron-3-ultra-free",
"nemotron-3.5-lightning-free",
"x-preview-f-free"
]
Loading