diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1732c73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules/ +api-keys.json +*.log +.env +.git +.gitignore +.omo/ +.vscode/ +.idea/ +.deepeval/ +tests/ +docker/ +README.md +AGENTS.md diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c157288 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/.gitignore b/.gitignore index d475273..ed8f423 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,13 @@ node_modules/ api-keys.json *.log .env + +# OpenCode +.omo/ + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ece768 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a87276 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index f787a10..34a1f8d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: @@ -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 diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..03746ca --- /dev/null +++ b/docker-compose.dev.yaml @@ -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: diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..dad478d --- /dev/null +++ b/docker-compose.yaml @@ -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: diff --git a/models.json b/models.json new file mode 100644 index 0000000..a460425 --- /dev/null +++ b/models.json @@ -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" +] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..250a657 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,869 @@ +{ + "name": "opencode-free-proxy", + "version": "0.1.5", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-free-proxy", + "version": "0.1.5", + "license": "MIT", + "dependencies": { + "express": "^5.2.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json index 6e52acb..effd231 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,19 @@ { "name": "opencode-free-proxy", - "version": "0.9.0", + "version": "0.1.6", "description": "Proxy server for OpenCode free-tier AI models via Zen API", "type": "module", - "main": "server.mjs", + "main": "src/index.mjs", "scripts": { - "start": "node server.mjs", - "dev": "node --watch server.mjs" + "start": "NODE_OPTIONS=--use-openssl-ca node src/index.mjs", + "dev": "NODE_OPTIONS=--use-openssl-ca node --watch src/index.mjs", + "test": "node --test tests/*.test.mjs" }, "dependencies": { - "express": "^4.21.0" + "express": "^5.2.1" }, "engines": { - "node": ">=18" + "node": ">=20" }, "license": "MIT" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d56f1ba --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,570 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + +packages: + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} diff --git a/server.mjs b/server.mjs deleted file mode 100644 index a8444a2..0000000 --- a/server.mjs +++ /dev/null @@ -1,566 +0,0 @@ -import express from "express"; -import crypto from "crypto"; -import https from "https"; -import fs from "fs"; - -const app = express(); -app.use(express.json({ limit: "10mb" })); - -const PORT = process.env.PROXY_PORT || 6446; -const OC_VERSION = "1.15.0"; -const PROXY_VERSION = "9"; - -// ── API Keys ─────────────────────────────────────────────────────── -const keysFile = process.env.KEYS_FILE || "./api-keys.json"; -let apiKeys = {}; -function loadKeys() { - try { apiKeys = JSON.parse(fs.readFileSync(keysFile, "utf8")); } catch {} - if (Object.keys(apiKeys).length === 0) { - apiKeys = { - admin: "oc-" + crypto.randomBytes(20).toString("hex"), - "user-default": "oc-" + crypto.randomBytes(20).toString("hex"), - }; - fs.writeFileSync(keysFile, JSON.stringify(apiKeys, null, 2)); - console.log("[INIT] Generated new API keys →", keysFile); - } -} -loadKeys(); - -function auth(req) { - const hdr = req.headers.authorization || req.headers["x-api-key"] || ""; - const tok = hdr.startsWith("Bearer ") ? hdr.slice(7) : hdr; - for (const [name, key] of Object.entries(apiKeys)) { - if (tok === key) return name; - } - return null; -} - -// ── Helpers ──────────────────────────────────────────────────────── -function ocId(prefix) { - const ts = Date.now().toString(16); - const rnd = crypto.randomBytes(12).toString("base64url").slice(0, 16); - return `${prefix}_${ts}${rnd}`; -} - -const MODELS = [ - "deepseek-v4-flash-free", - "big-pickle", - "minimax-m2.5-free", - "nemotron-3-super-free", - "qwen3.6-plus-free", -]; - -// Track sessions per user (rotate every 30 min) -const userSessions = {}; -function getSession(user) { - const now = Date.now(); - if (!userSessions[user] || now - userSessions[user].ts > 30 * 60 * 1000) { - userSessions[user] = { id: ocId("ses"), ts: now }; - } - return userSessions[user].id; -} - -// ── Zen API transport ────────────────────────────────────────────── -function zenRequest(model, messages, stream, tools, tool_choice, sessionId) { - const reqBody = { model, messages, stream: !!stream }; - if (tools?.length) reqBody.tools = tools; - if (tool_choice) reqBody.tool_choice = tool_choice; - const body = JSON.stringify(reqBody); - const requestId = ocId("msg"); - - return { - body, - options: { - hostname: "opencode.ai", - port: 443, - path: "/zen/v1/chat/completions", - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - "Authorization": "Bearer public", - "User-Agent": `opencode/${OC_VERSION} ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13`, - "x-opencode-client": "cli", - "x-opencode-project": "global", - "x-opencode-request": requestId, - "x-opencode-session": sessionId, - }, - timeout: 120000, - }, - }; -} - -// Pipe Zen response to client (OpenAI format passthrough) -function pipeZenResponse(zenOpts, body, stream, res) { - const req = https.request(zenOpts, (zenRes) => { - let firstChunk = null; - let headersSent = false; - - zenRes.on("data", (chunk) => { - if (!firstChunk) { - firstChunk = chunk; - const str = chunk.toString().trim(); - - if (str.startsWith("{") && (str.includes("FreeUsageLimitError") || str.includes('"error"'))) { - try { - const parsed = JSON.parse(str); - if (parsed.error || parsed.type === "error") { - const errMsg = parsed.error?.message || parsed.message || "Rate limit exceeded"; - console.log("[ZEN RATE LIMITED]", errMsg); - if (!res.headersSent) { - res.status(429).json({ - error: { message: errMsg + " (free model rate limit)", type: "rate_limit_error", code: "rate_limit_exceeded" } - }); - } - zenRes.resume(); - return; - } - } catch {} - } - - headersSent = true; - if (stream) { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - "Transfer-Encoding": "chunked", - }); - res.flushHeaders(); - } else { - res.writeHead(zenRes.statusCode, { "Content-Type": "application/json" }); - } - res.write(firstChunk); - if (res.flush) res.flush(); - return; - } - if (headersSent) { - res.write(chunk); - if (res.flush) res.flush(); - } - }); - - zenRes.on("end", () => { - if (!headersSent && !firstChunk) { - console.log("[ZEN EMPTY] No response from Zen API"); - if (!res.headersSent) { - res.status(502).json({ error: { message: "Empty response from upstream", type: "upstream_error" } }); - } - return; - } - if (headersSent) res.end(); - }); - }); - - req.on("error", (e) => { - console.log("[ZEN ERROR]", e.message); - if (!res.headersSent) { - res.status(502).json({ error: { message: "Upstream error: " + e.message, type: "upstream_error" } }); - } - }); - - req.on("timeout", () => { - req.destroy(); - console.log("[ZEN TIMEOUT]"); - if (!res.headersSent) { - res.status(504).json({ error: { message: "Upstream timeout", type: "timeout_error" } }); - } - }); - - req.write(body); - req.end(); -} - -// Collect full Zen response (non-streaming) and return parsed JSON -function zenRequestFull(zenOpts, body) { - return new Promise((resolve, reject) => { - const req = https.request(zenOpts, (zenRes) => { - const chunks = []; - zenRes.on("data", (c) => chunks.push(c)); - zenRes.on("end", () => { - const raw = Buffer.concat(chunks).toString(); - try { - resolve({ status: zenRes.statusCode, data: JSON.parse(raw), raw }); - } catch { - resolve({ status: zenRes.statusCode, data: null, raw }); - } - }); - }); - req.on("error", reject); - req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); }); - req.write(body); - req.end(); - }); -} - -// ── Anthropic Messages → OpenAI conversion ───────────────────────── -function anthropicToOpenAI(body) { - const messages = []; - if (body.system) { - const sys = typeof body.system === "string" ? body.system - : Array.isArray(body.system) ? body.system.map(b => b.text || "").join("\n") : ""; - if (sys) messages.push({ role: "system", content: sys }); - } - for (const msg of body.messages || []) { - if (typeof msg.content === "string") { - messages.push({ role: msg.role, content: msg.content }); - } else if (Array.isArray(msg.content)) { - const text = msg.content - .filter(b => b.type === "text") - .map(b => b.text) - .join("\n"); - // tool_use blocks → assistant tool_calls - const toolUses = msg.content.filter(b => b.type === "tool_use"); - if (toolUses.length && msg.role === "assistant") { - messages.push({ - role: "assistant", - content: text || null, - tool_calls: toolUses.map(t => ({ - id: t.id, - type: "function", - function: { name: t.name, arguments: JSON.stringify(t.input || {}) }, - })), - }); - } else if (msg.content.some(b => b.type === "tool_result")) { - for (const b of msg.content.filter(b => b.type === "tool_result")) { - const resultText = typeof b.content === "string" ? b.content - : Array.isArray(b.content) ? b.content.map(c => c.text || "").join("\n") : ""; - messages.push({ role: "tool", tool_call_id: b.tool_use_id, content: resultText }); - } - } else { - messages.push({ role: msg.role, content: text }); - } - } - } - - const tools = (body.tools || []).map(t => ({ - type: "function", - function: { - name: t.name, - description: t.description || "", - parameters: t.input_schema || {}, - }, - })); - - return { messages, tools: tools.length ? tools : undefined }; -} - -// OpenAI response → Anthropic Messages format -function openAIToAnthropic(oaiResp, model, inputTokens) { - const choice = oaiResp.choices?.[0]; - if (!choice) { - return { - id: ocId("msg"), - type: "message", - role: "assistant", - content: [{ type: "text", text: "" }], - model, - stop_reason: "end_turn", - usage: { input_tokens: inputTokens || 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, - }; - } - - const content = []; - if (choice.message?.content) { - content.push({ type: "text", text: choice.message.content }); - } - if (choice.message?.tool_calls) { - for (const tc of choice.message.tool_calls) { - let input = {}; - try { input = JSON.parse(tc.function.arguments); } catch {} - content.push({ - type: "tool_use", - id: tc.id || ocId("toolu"), - name: tc.function.name, - input, - }); - } - } - if (!content.length) content.push({ type: "text", text: "" }); - - let stopReason = "end_turn"; - if (choice.finish_reason === "tool_calls") stopReason = "tool_use"; - else if (choice.finish_reason === "length") stopReason = "max_tokens"; - else if (choice.finish_reason === "stop") stopReason = "end_turn"; - - return { - id: ocId("msg"), - type: "message", - role: "assistant", - content, - model, - stop_reason: stopReason, - usage: { - input_tokens: oaiResp.usage?.prompt_tokens || inputTokens || 0, - output_tokens: oaiResp.usage?.completion_tokens || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }; -} - -// Stream OpenAI SSE → Anthropic SSE -function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { - const msgId = ocId("msg"); - - const req = https.request(zenOpts, (zenRes) => { - let headersSent = false; - let buffer = ""; - let outputTokens = 0; - let contentIdx = 0; - let toolIdx = -1; - let firstChunkHandled = false; - - function sendSSE(event, data) { - res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); - if (res.flush) res.flush(); - } - - function sendHeaders() { - if (headersSent) return; - headersSent = true; - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }); - res.flushHeaders(); - - sendSSE("message_start", { - type: "message_start", - message: { - id: msgId, type: "message", role: "assistant", content: [], - model, stop_reason: null, - usage: { input_tokens: inputTokens || 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, - }, - }); - } - - zenRes.on("data", (chunk) => { - const str = chunk.toString(); - - // Check for errors on first chunk - if (!firstChunkHandled) { - firstChunkHandled = true; - const trimmed = str.trim(); - if (trimmed.startsWith("{") && (trimmed.includes("FreeUsageLimitError") || trimmed.includes('"error"'))) { - try { - const parsed = JSON.parse(trimmed); - if (parsed.error || parsed.type === "error") { - const errMsg = parsed.error?.message || parsed.message || "Rate limit"; - if (!res.headersSent) { - res.writeHead(429, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - type: "error", - error: { type: "rate_limit_error", message: errMsg + " (free model rate limit)" }, - })); - } - zenRes.resume(); - return; - } - } catch {} - } - } - - buffer += str; - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (payload === "[DONE]") continue; - - let parsed; - try { parsed = JSON.parse(payload); } catch { continue; } - const delta = parsed.choices?.[0]?.delta; - if (!delta) continue; - - sendHeaders(); - - // Text content - if (delta.content) { - if (contentIdx === 0 && toolIdx === -1) { - sendSSE("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }); - contentIdx = 1; - } - sendSSE("content_block_delta", { - type: "content_block_delta", index: 0, - delta: { type: "text_delta", text: delta.content }, - }); - outputTokens += Math.ceil(delta.content.length / 4); - } - - // Tool calls - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const idx = tc.index ?? 0; - if (idx > toolIdx) { - // Close previous text block if open - if (toolIdx === -1 && contentIdx > 0) { - sendSSE("content_block_stop", { type: "content_block_stop", index: 0 }); - } - toolIdx = idx; - const blockIdx = contentIdx > 0 ? idx + 1 : idx; - sendSSE("content_block_start", { - type: "content_block_start", index: blockIdx, - content_block: { type: "tool_use", id: tc.id || ocId("toolu"), name: tc.function?.name || "" }, - }); - } - if (tc.function?.arguments) { - const blockIdx = contentIdx > 0 ? idx + 1 : idx; - sendSSE("content_block_delta", { - type: "content_block_delta", index: blockIdx, - delta: { type: "input_json_delta", partial_json: tc.function.arguments }, - }); - outputTokens += Math.ceil(tc.function.arguments.length / 4); - } - } - } - - // Finish - if (parsed.choices?.[0]?.finish_reason) { - const fr = parsed.choices[0].finish_reason; - // Close open blocks - const totalBlocks = (contentIdx > 0 ? 1 : 0) + (toolIdx >= 0 ? toolIdx + 1 : 0); - for (let i = 0; i < totalBlocks; i++) { - sendSSE("content_block_stop", { type: "content_block_stop", index: i }); - } - - let stopReason = "end_turn"; - if (fr === "tool_calls") stopReason = "tool_use"; - else if (fr === "length") stopReason = "max_tokens"; - - sendSSE("message_delta", { - type: "message_delta", - delta: { stop_reason: stopReason }, - usage: { output_tokens: outputTokens }, - }); - sendSSE("message_stop", { type: "message_stop" }); - } - } - }); - - zenRes.on("end", () => { - if (!headersSent) { - if (!res.headersSent) { - res.status(502).json({ type: "error", error: { type: "upstream_error", message: "Empty response" } }); - } - return; - } - res.end(); - }); - }); - - req.on("error", (e) => { - console.log("[ZEN ERROR]", e.message); - if (!res.headersSent) { - res.status(502).json({ type: "error", error: { type: "upstream_error", message: e.message } }); - } - }); - - req.on("timeout", () => { - req.destroy(); - if (!res.headersSent) { - res.status(504).json({ type: "error", error: { type: "timeout_error", message: "Upstream timeout" } }); - } - }); - - req.write(body); - req.end(); -} - -// ── Routes: OpenAI format ────────────────────────────────────────── -app.get("/v1/models", (_req, res) => { - res.json({ - object: "list", - data: MODELS.map((id) => ({ - id, object: "model", created: 1779000000, owned_by: "opencode-free", - })), - }); -}); - -app.post("/v1/chat/completions", (req, res) => { - const user = auth(req); - if (!user) return res.status(401).json({ error: { message: "Invalid API key" } }); - - const { model, messages, stream, tools, tool_choice } = req.body; - if (!MODELS.includes(model)) { - return res.status(400).json({ error: { message: `Unknown model: ${model}. Available: ${MODELS.join(", ")}` } }); - } - - const sessionId = getSession(user); - const msgSummary = (messages || []).map(m => ({ role: m.role, len: (typeof m.content === "string" ? m.content : JSON.stringify(m.content || "")).length })); - console.log("[OAI]", new Date().toISOString(), user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary)); - - const { body, options } = zenRequest(model, messages, stream, tools, tool_choice, sessionId); - pipeZenResponse(options, body, stream, res); -}); - -// ── Routes: Anthropic Messages format ────────────────────────────── -app.post("/v1/messages", async (req, res) => { - const user = auth(req); - if (!user) { - return res.status(401).json({ type: "error", error: { type: "authentication_error", message: "Invalid API key" } }); - } - - const { model, stream } = req.body; - if (!MODELS.includes(model)) { - return res.status(400).json({ - type: "error", - error: { type: "invalid_request_error", message: `Unknown model: ${model}. Available: ${MODELS.join(", ")}` }, - }); - } - - const sessionId = getSession(user); - const { messages, tools } = anthropicToOpenAI(req.body); - const inputTokens = JSON.stringify(messages).length / 4 | 0; - - console.log("[ANT]", new Date().toISOString(), user, model, stream ? "stream" : "sync", "msgs:", messages.length); - - const { body, options } = zenRequest(model, messages, stream, tools, undefined, sessionId); - - if (stream) { - pipeZenAsAnthropic(options, body, model, res, inputTokens); - } else { - try { - const zenResp = await zenRequestFull(options, body); - if (zenResp.status === 429 || zenResp.data?.error) { - const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; - return res.status(429).json({ - type: "error", error: { type: "rate_limit_error", message: errMsg + " (free model rate limit)" }, - }); - } - if (!zenResp.data?.choices) { - return res.status(502).json({ - type: "error", error: { type: "upstream_error", message: "Invalid upstream response" }, - }); - } - res.json(openAIToAnthropic(zenResp.data, model, inputTokens)); - } catch (e) { - console.log("[ZEN ERROR]", e.message); - res.status(502).json({ type: "error", error: { type: "upstream_error", message: e.message } }); - } - } -}); - -// ── Health ────────────────────────────────────────────────────────── -app.get("/health", (_req, res) => res.json({ - status: "ok", version: `v${PROXY_VERSION}`, models: MODELS.length, - endpoints: ["/v1/chat/completions", "/v1/messages", "/v1/models"], -})); - -// ── Start ────────────────────────────────────────────────────────── -app.listen(PORT, "0.0.0.0", () => { - console.log(`OpenCode Free Proxy v${PROXY_VERSION} on http://0.0.0.0:${PORT}`); - console.log(" OpenAI: POST /v1/chat/completions"); - console.log(" Anthropic: POST /v1/messages"); - console.log(" Models: GET /v1/models"); - console.log(" Health: GET /health"); - console.log(" Models:", MODELS.join(", ")); - for (const [name, key] of Object.entries(apiKeys)) { - console.log(` ${name.padEnd(15)} ${key}`); - } -}); diff --git a/src/app.mjs b/src/app.mjs new file mode 100644 index 0000000..74d4fc0 --- /dev/null +++ b/src/app.mjs @@ -0,0 +1,44 @@ +import express from "express"; +import modelsRouter from "./routes/models.mjs"; +import chatRouter from "./routes/chat.mjs"; +import messagesRouter from "./routes/messages.mjs"; +import healthRouter from "./routes/health.mjs"; +import { logLine } from "./logger.mjs"; + +export function createApp() { + const app = express(); + app.use(express.json({ limit: "10mb" })); + app.use(modelsRouter); + app.use(chatRouter); + app.use(messagesRouter); + app.use(healthRouter); + + // 404 fallback — return JSON for unknown routes + app.use((_req, res) => { + res + .status(404) + .json({ error: { message: "Not found", type: "not_found_error" } }); + }); + + // Global error handler — Express 5 also forwards rejected promises from async routes here. + app.use((err, req, res, next) => { + logLine("UNHANDLED ERROR", err.message, err.stack); + if (res.headersSent) { + return res.end(); + } + + // Express 5: res.status() only accepts integers in 100–999 + let status = Number(err.status || err.statusCode) || 500; + if (!Number.isInteger(status) || status < 100 || status > 999) status = 500; + const message = err.message || "Internal server error"; + const type = err.type || "server_error"; + + if (req.path === "/v1/messages") { + res.status(status).json({ type: "error", error: { type, message } }); + } else { + res.status(status).json({ error: { message, type, code: err.code } }); + } + }); + + return app; +} diff --git a/src/auth.mjs b/src/auth.mjs new file mode 100644 index 0000000..f440e55 --- /dev/null +++ b/src/auth.mjs @@ -0,0 +1,31 @@ +import fs from "fs"; +import crypto from "crypto"; +import { KEYS_FILE } from "./config/index.mjs"; +import { logLine } from "./logger.mjs"; + +export const apiKeys = {}; + +export function loadKeys() { + try { + Object.assign(apiKeys, JSON.parse(fs.readFileSync(KEYS_FILE, "utf8"))); + } catch {} + if (Object.keys(apiKeys).length === 0) { + Object.assign(apiKeys, { + admin: process.env.ADMIN_API_KEY || "oc-" + crypto.randomBytes(20).toString("hex"), + "user-default": process.env.USER_DEFAULT_API_KEY || "oc-" + crypto.randomBytes(20).toString("hex"), + }); + fs.writeFileSync(KEYS_FILE, JSON.stringify(apiKeys, null, 2)); + logLine("Generated new API keys →", KEYS_FILE); + } +} + +export function auth(req) { + const hdr = req.headers.authorization || req.headers["x-api-key"] || ""; + const tok = hdr.startsWith("Bearer ") ? hdr.slice(7) : hdr; + const tokBuf = Buffer.from(tok); + for (const [name, key] of Object.entries(apiKeys)) { + const keyBuf = Buffer.from(key); + if (keyBuf.length === tokBuf.length && crypto.timingSafeEqual(keyBuf, tokBuf)) return name; + } + return null; +} diff --git a/src/client.mjs b/src/client.mjs new file mode 100644 index 0000000..9b27dd0 --- /dev/null +++ b/src/client.mjs @@ -0,0 +1,54 @@ +import https from "https"; +import { ocId } from "./utils.mjs"; +import { OC_VERSION } from "./config/index.mjs"; + +export function zenRequest(model, messages, stream, tools, tool_choice, sessionId) { + const reqBody = { model, messages, stream: !!stream }; + if (tools?.length) reqBody.tools = tools; + if (tool_choice) reqBody.tool_choice = tool_choice; + const body = JSON.stringify(reqBody); + const requestId = ocId("msg"); + + return { + body, + options: { + hostname: "opencode.ai", + port: 443, + path: "/zen/v1/chat/completions", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + "Authorization": "Bearer public", + "User-Agent": `opencode/${OC_VERSION} ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13`, + "x-opencode-client": "cli", + "x-opencode-project": "global", + "x-opencode-request": requestId, + "x-opencode-session": sessionId, + }, + timeout: 120000, + }, + }; +} + +export function zenRequestFull(zenOpts, body) { + return new Promise((resolve, reject) => { + const req = https.request(zenOpts, (zenRes) => { + const chunks = []; + zenRes.on("data", (c) => chunks.push(c)); + zenRes.on("end", () => { + const raw = Buffer.concat(chunks).toString(); + const headers = zenRes.headers; + try { + resolve({ status: zenRes.statusCode, data: JSON.parse(raw), raw, headers }); + } catch { + resolve({ status: zenRes.statusCode, data: null, raw, headers }); + } + }); + }); + req.on("error", reject); + req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); }); + req.write(body); + req.end(); + }); +} diff --git a/src/config/index.mjs b/src/config/index.mjs new file mode 100644 index 0000000..ee79120 --- /dev/null +++ b/src/config/index.mjs @@ -0,0 +1,21 @@ +import fs from "fs"; + +export const PORT = process.env.PROXY_PORT || 6446; +export const OC_VERSION = "1.15.0"; + +const pkg = JSON.parse( + fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +); +export const PROXY_VERSION = pkg.version; + +export const MODELS = JSON.parse( + fs.readFileSync(new URL("../../models.json", import.meta.url), "utf8"), +); +export const KEYS_FILE = process.env.KEYS_FILE || "./api-keys.json"; + +/** Max rate-limit retries after the first attempt (default 12). */ +export const MAX_RETRIES = Math.max(0, Number(process.env.MAX_RETRIES) || 12); +/** Base delay for first retry; doubles each attempt (default 1000ms). */ +export const RETRY_BASE_MS = Math.max(0, Number(process.env.RETRY_BASE_MS) || 1000); +/** Cap for exponential backoff (default 30000ms). */ +export const RETRY_MAX_MS = Math.max(0, Number(process.env.RETRY_MAX_MS) || 30_000); diff --git a/src/index.mjs b/src/index.mjs new file mode 100644 index 0000000..ecaf326 --- /dev/null +++ b/src/index.mjs @@ -0,0 +1,23 @@ +import { createApp } from "./app.mjs"; +import { PORT, PROXY_VERSION, MODELS } from "./config/index.mjs"; +import { loadKeys, apiKeys } from "./auth.mjs"; +import { logLine, logStatusLine } from "./logger.mjs"; + +loadKeys(); + +const app = createApp(); +// Express 5: listen errors (e.g. EADDRINUSE) are passed to this callback instead of thrown. +app.listen(PORT, "0.0.0.0", (err) => { + if (err) { + logLine("LISTEN ERROR", err.message); + process.exit(1); + } + logLine(`OpenCode Free Proxy v${PROXY_VERSION} on http://0.0.0.0:${PORT}`); + logLine(" OpenAI: POST /v1/chat/completions"); + logLine(" Anthropic: POST /v1/messages"); + logLine(" Models: GET /v1/models"); + logLine(" Health: GET /health"); + logLine(" Models:", MODELS.join(", ")); + logStatusLine(); + logLine(" API keys:", Object.keys(apiKeys).length, "loaded"); +}); diff --git a/src/logger.mjs b/src/logger.mjs new file mode 100644 index 0000000..20964ff --- /dev/null +++ b/src/logger.mjs @@ -0,0 +1,76 @@ +// Detailed I/O logging for the proxy. +// LOG_DETAIL=0 to disable full dumps; LOG_MAX_CHARS=N to truncate (0 = unlimited). +// NO_COLOR=1 disables ANSI color; FORCE_COLOR=1 forces it (default: auto on TTY). + +export const LOG_DETAIL = process.env.LOG_DETAIL !== "0"; +export const LOG_MAX_CHARS = Number(process.env.LOG_MAX_CHARS || 0) || 0; + +// ── ANSI color helpers ────────────────────────────────────────────────────── + +const USE_COLOR = + process.env.FORCE_COLOR !== undefined + ? true + : process.env.NO_COLOR === undefined && process.stdout.isTTY; + +const c = USE_COLOR + ? { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + yellow: "\x1b[33m", + green: "\x1b[32m", + red: "\x1b[31m", + gray: "\x1b[90m", + } + : // No-op passthrough when colors are off + { reset: "", bright: "", dim: "", cyan: "", yellow: "", green: "", red: "", gray: "" }; + +function labelStr(label) { + return `${c.yellow}${c.bright}[${label}]${c.reset}`; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +function trunc(str) { + if (typeof str !== "string") str = String(str); + if (!LOG_MAX_CHARS || str.length <= LOG_MAX_CHARS) return str; + return str.slice(0, LOG_MAX_CHARS) + `\n... [truncated, total ${str.length} chars]`; +} + +/** Log a one-line summary with timestamp. */ +export function logLine(...args) { + console.log("[proxy]", new Date().toISOString(), ...args); +} + +/** Pretty-print a labeled I/O block (request/response body). */ +export function logIO(label, payload) { + if (!LOG_DETAIL) return; + const body = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); + console.log( + `${labelStr(label)}\n${trunc(body)}\n${labelStr("/" + label)}`, + ); +} + +export function logStatusLine() { + console.log( + ` ${c.dim}Log detail:${c.reset}`, + LOG_DETAIL ? `${c.green}on${c.reset}` : `${c.red}off${c.reset}`, + LOG_MAX_CHARS ? `${c.gray}(max ${LOG_MAX_CHARS} chars)${c.reset}` : `${c.gray}(unlimited)${c.reset}`, + USE_COLOR ? `${c.gray}(color)${c.reset}` : "", + ); +} + +// ── downstream helpers (unchanged) ─���──────────────────────────────────────── + +export function msgSummary(messages) { + return (messages || []).map((m) => { + const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content || ""); + const s = { role: m.role, len: content.length }; + if (m.tool_calls?.length) s.tool_calls = m.tool_calls.length; + if (m.tool_call_id) s.tool_call_id = m.tool_call_id; + return s; + }); +} + + diff --git a/src/pipe-anthropic.mjs b/src/pipe-anthropic.mjs new file mode 100644 index 0000000..87a3a5b --- /dev/null +++ b/src/pipe-anthropic.mjs @@ -0,0 +1,398 @@ +import https from "https"; +import { MAX_RETRIES } from "./config/index.mjs"; +import { logLine, logIO } from "./logger.mjs"; +import { ocId } from "./utils.mjs"; +import { + parseErrorPayload, + planRetry, + logAndScheduleRetry, + isClientGone, + isTransientNetworkError, + isTransientHttpStatus, + withFreshSession, + withFreshRequestId, +} from "./retry.mjs"; + +const NO_CACHE = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; + +// ── main pipe ────────────────────────────────────────────────────────────── + +/** + * Relay an OpenAI-format request to the Zen API and pipe the response back + * as an Anthropic SSE stream (message_start / content_block_* / message_delta). + * Retries on rate-limit / transient errors with backoff; rotates session on 429. + * + * @param {object} [ctx] + * @param {string} [ctx.user] + * @param {import("http").IncomingMessage} [ctx.clientReq] + * @param {number} [ctx.retries] + */ +export function pipeZenAsAnthropic( + zenOpts, + body, + model, + res, + inputTokens, + ctx = {}, +) { + const { user, clientReq, retries = MAX_RETRIES } = ctx; + const msgId = ocId("msg"); + + let currentOpts = zenOpts; + let aborted = false; + // Do NOT key off req 'close' / req.destroyed: Node fires 'close' and marks + // destroyed as soon as the request body is fully consumed, even though the + // client is still connected and waiting. Detect a real disconnect via the + // response socket closing before the response was sent. + res.on("close", () => { + if (!res.writableEnded) aborted = true; + }); + + function gone() { + return aborted || isClientGone(clientReq, res); + } + + function attempt(remaining) { + if (gone()) { + logLine("CLIENT GONE, stop attempt"); + return; + } + + const t0 = Date.now(); + let collectedText = ""; + const collectedTools = {}; + let stopReasonLogged = null; + let intentionalClose = false; + let terminalHandled = false; + + function failRateLimit(errMsg) { + if (terminalHandled || res.headersSent) return; + terminalHandled = true; + logLine("RATE LIMITED, exhausted retries", errMsg); + logIO("OUTPUT (rate_limit)", { error: errMsg }); + res.writeHead(429, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + type: "error", + error: { + type: "rate_limit_error", + message: errMsg + " (free model rate limit)", + }, + }), + ); + } + + function failUpstream(status, errMsg, type = "upstream_error") { + if (terminalHandled || res.headersSent) return; + terminalHandled = true; + logLine("UPSTREAM ERROR", errMsg); + logIO("OUTPUT (error)", { error: errMsg }); + res + .status(status) + .json({ type: "error", error: { type, message: errMsg } }); + } + + /** @returns {boolean} true if a retry was scheduled */ + function trySchedule(kind, errMsg, headers) { + if (gone()) { + logLine("CLIENT GONE, aborting retries"); + intentionalClose = true; + return true; + } + const plan = planRetry({ remaining, retries, kind, headers, errMsg }); + if (!plan) return false; + intentionalClose = true; + logAndScheduleRetry(plan, remaining, (delay) => { + setTimeout(() => { + if (gone()) { + logLine("CLIENT GONE, stop retry"); + return; + } + if (plan.rotateSession && user) { + currentOpts = withFreshSession(currentOpts, user); + } else { + currentOpts = withFreshRequestId(currentOpts); + } + attempt(remaining - 1); + }, delay); + }); + return true; + } + + const req = https.request(currentOpts, (zenRes) => { + let headersSent = false; + let buffer = ""; + let outputTokens = 0; + let contentIdx = 0; + let toolIdx = -1; + let firstChunkHandled = false; + let skipEnd = false; + const startedBlocks = new Set(); + const status = zenRes.statusCode || 0; + + function sendSSE(event, data) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + if (res.flush) res.flush(); + } + + function sendHeaders() { + if (headersSent) return; + headersSent = true; + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + + sendSSE("message_start", { + type: "message_start", + message: { + id: msgId, + type: "message", + role: "assistant", + content: [], + model, + stop_reason: null, + usage: { + input_tokens: inputTokens || 0, + output_tokens: 0, + ...NO_CACHE, + }, + }, + }); + } + + function handleRetryable(kind, errMsg) { + if (trySchedule(kind, errMsg, zenRes.headers)) { + skipEnd = true; + zenRes.destroy(); + req.destroy(); + return true; + } + return false; + } + + zenRes.on("data", (chunk) => { + if (skipEnd || terminalHandled) return; + const str = chunk.toString(); + + if (!firstChunkHandled) { + firstChunkHandled = true; + const errInfo = parseErrorPayload(chunk); + const rateLimited = status === 429 || errInfo?.rateLimited; + + if (rateLimited) { + const errMsg = errInfo?.message || "Rate limit exceeded"; + if (handleRetryable("rate_limit", errMsg)) return; + failRateLimit(errMsg); + zenRes.resume(); + skipEnd = true; + return; + } + + if (errInfo) { + failUpstream(status >= 400 ? status : 502, errInfo.message); + zenRes.resume(); + skipEnd = true; + return; + } + + if (isTransientHttpStatus(status)) { + if (handleRetryable("transient", `HTTP ${status}`)) return; + failUpstream(status, `Upstream HTTP ${status}`); + zenRes.resume(); + skipEnd = true; + return; + } + } + + buffer += str; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") continue; + + let parsed; + try { + parsed = JSON.parse(payload); + } catch { + continue; + } + const delta = parsed.choices?.[0]?.delta; + if (!delta) continue; + + sendHeaders(); + + if (delta.content) { + collectedText += delta.content; + if (contentIdx === 0 && toolIdx === -1) { + sendSSE("content_block_start", { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }); + startedBlocks.add(0); + contentIdx = 1; + } + sendSSE("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: delta.content }, + }); + outputTokens += Math.ceil(delta.content.length / 4); + } + + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (idx > toolIdx) { + if (toolIdx === -1 && contentIdx > 0) { + sendSSE("content_block_stop", { + type: "content_block_stop", + index: 0, + }); + } + toolIdx = idx; + const blockIdx = contentIdx > 0 ? idx + 1 : idx; + const toolId = tc.id || ocId("toolu"); + collectedTools[idx] = { + id: toolId, + name: tc.function?.name || "", + arguments: "", + }; + sendSSE("content_block_start", { + type: "content_block_start", + index: blockIdx, + content_block: { + type: "tool_use", + id: toolId, + name: tc.function?.name || "", + }, + }); + startedBlocks.add(blockIdx); + } + if (tc.function?.arguments) { + if (collectedTools[idx]) + collectedTools[idx].arguments += tc.function.arguments; + const blockIdx = contentIdx > 0 ? idx + 1 : idx; + sendSSE("content_block_delta", { + type: "content_block_delta", + index: blockIdx, + delta: { + type: "input_json_delta", + partial_json: tc.function.arguments, + }, + }); + outputTokens += Math.ceil(tc.function.arguments.length / 4); + } + } + } + + if (parsed.choices?.[0]?.finish_reason) { + const fr = parsed.choices[0].finish_reason; + const sortedBlocks = [...startedBlocks].sort((a, b) => a - b); + for (const i of sortedBlocks) { + sendSSE("content_block_stop", { + type: "content_block_stop", + index: i, + }); + } + + let stopReason = "end_turn"; + if (fr === "tool_calls") stopReason = "tool_use"; + else if (fr === "length") stopReason = "max_tokens"; + stopReasonLogged = stopReason; + + sendSSE("message_delta", { + type: "message_delta", + delta: { stop_reason: stopReason }, + usage: { output_tokens: outputTokens }, + }); + sendSSE("message_stop", { type: "message_stop" }); + } + } + }); + + zenRes.on("end", () => { + if (skipEnd || terminalHandled) return; + if (!headersSent) { + if (status === 429) { + if (handleRetryable("rate_limit", "Rate limit exceeded")) return; + failRateLimit("Rate limit exceeded"); + return; + } + if (isTransientHttpStatus(status)) { + if (handleRetryable("transient", `HTTP ${status}`)) return; + failUpstream(status, `Upstream HTTP ${status}`); + return; + } + logIO("OUTPUT (empty)", { error: "Empty response" }); + if (!res.headersSent) { + res + .status(502) + .json({ + type: "error", + error: { type: "upstream_error", message: "Empty response" }, + }); + } + return; + } + const ms = Date.now() - t0; + const out = { + content: collectedText || null, + stop_reason: stopReasonLogged, + output_tokens: outputTokens, + }; + const tools = Object.values(collectedTools); + if (tools.length) out.tool_calls = tools; + logIO(`OUTPUT (stream, ${ms}ms)`, out); + res.end(); + }); + }); + + req.on("error", (e) => { + if (intentionalClose || terminalHandled) return; + if (remaining > 0 && isTransientNetworkError(e)) { + if (trySchedule("transient", e.message)) return; + } + logLine("ERROR", e.message); + logIO("OUTPUT (error)", { error: e.message }); + if (!res.headersSent) { + res + .status(502) + .json({ + type: "error", + error: { type: "upstream_error", message: e.message }, + }); + } + }); + + req.on("timeout", () => { + if (intentionalClose || terminalHandled) return; + intentionalClose = true; + req.destroy(); + if (remaining > 0 && trySchedule("transient", "Upstream timeout")) return; + intentionalClose = false; + logLine("TIMEOUT"); + logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); + if (!res.headersSent) { + res + .status(504) + .json({ + type: "error", + error: { type: "timeout_error", message: "Upstream timeout" }, + }); + } + }); + + req.write(body); + req.end(); + } + + attempt(retries); +} diff --git a/src/pipe-openai.mjs b/src/pipe-openai.mjs new file mode 100644 index 0000000..6b99e59 --- /dev/null +++ b/src/pipe-openai.mjs @@ -0,0 +1,356 @@ +import https from "https"; +import { MAX_RETRIES } from "./config/index.mjs"; +import { logLine, logIO, LOG_DETAIL } from "./logger.mjs"; +import { ocId } from "./utils.mjs"; +import { + parseErrorPayload, + planRetry, + logAndScheduleRetry, + isClientGone, + isTransientNetworkError, + isTransientHttpStatus, + withFreshSession, + withFreshRequestId, +} from "./retry.mjs"; + +// ── shared helpers ───────────────────────────────────────────────────────── + +/** Transform an SSE data line: inject missing OpenAI ids. */ +function transformSseLine(line, toolCallIds, requestModel) { + if (!line.startsWith("data: ")) return line; + const payload = line.slice(6).trim(); + if (!payload || payload === "[DONE]") return line; + try { + const parsed = JSON.parse(payload); + const updated = ensureOpenAIIds(parsed, toolCallIds, requestModel); + return "data: " + JSON.stringify(updated); + } catch { + return line; + } +} + +export function ensureOpenAIIds(payload, toolCallIds = {}, model = "") { + if (typeof payload.object !== "string" || !payload.object) { + payload.object = "chat.completion"; + } + if (typeof payload.id !== "string" || !payload.id) { + payload.id = ocId("chatcmpl"); + } + if (typeof payload.created !== "number") { + payload.created = Math.floor(Date.now() / 1000); + } + if (typeof payload.model !== "string" || !payload.model) { + payload.model = model || payload.model || ""; + } + const choice = payload.choices?.[0]; + const tcs = choice?.delta?.tool_calls ?? choice?.message?.tool_calls; + if (Array.isArray(tcs)) { + tcs.forEach((tc, arrayIdx) => { + const idx = tc.index ?? arrayIdx; + if (tc.id) { + toolCallIds[idx] = tc.id; + } else { + tc.id = toolCallIds[idx] ??= ocId("call"); + } + if (!tc.type) tc.type = "function"; + }); + } + return payload; +} + +// ── main pipe ────────────────────────────────────────────────────────────── + +/** + * Relay an OpenAI-format request to the Zen API and pipe the response back + * in OpenAI format (supports both streaming SSE and sync JSON). + * Retries on rate-limit / transient errors with backoff; rotates session on 429. + * + * @param {object} [ctx] + * @param {string} [ctx.user] API key user id (for session rotation) + * @param {import("http").IncomingMessage} [ctx.clientReq] client request (abort detection) + * @param {number} [ctx.retries] + */ +export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { + const { user, clientReq, retries = MAX_RETRIES } = ctx; + const toolCallIds = {}; + let requestModel = ""; + try { + requestModel = JSON.parse(body).model || ""; + } catch {} + + let currentOpts = zenOpts; + let aborted = false; + // Do NOT key off req 'close' / req.destroyed: Node fires 'close' and marks + // destroyed as soon as the request body is fully consumed, even though the + // client is still connected and waiting. Detect a real disconnect via the + // response socket closing before the response was sent. + res.on("close", () => { + if (!res.writableEnded) aborted = true; + }); + + function gone() { + return aborted || isClientGone(clientReq, res); + } + + function attempt(remaining) { + if (gone()) { + logLine("CLIENT GONE, stop attempt"); + return; + } + + const chunks = []; + const t0 = Date.now(); + /** Accumulate transformed SSE lines for stream-mode logging (only if LOG_DETAIL is on). */ + let streamLogLines = LOG_DETAIL ? "" : null; + let intentionalClose = false; + let terminalHandled = false; + + function failRateLimit(errMsg) { + if (terminalHandled || res.headersSent) return; + terminalHandled = true; + logLine("RATE LIMITED, exhausted retries", errMsg); + logIO("OUTPUT (rate_limit)", { error: errMsg }); + res.status(429).json({ + error: { + message: errMsg + " (free model rate limit)", + type: "rate_limit_error", + code: "rate_limit_exceeded", + }, + }); + } + + function failUpstream(status, errMsg, type = "upstream_error") { + if (terminalHandled || res.headersSent) return; + terminalHandled = true; + logLine("UPSTREAM ERROR", errMsg); + logIO("OUTPUT (error)", { error: errMsg }); + res.status(status).json({ error: { message: errMsg, type } }); + } + + /** @returns {boolean} true if a retry was scheduled */ + function trySchedule(kind, errMsg, headers) { + if (gone()) { + logLine("CLIENT GONE, aborting retries"); + intentionalClose = true; + return true; // treat as handled (do not fail to client) + } + const plan = planRetry({ remaining, retries, kind, headers, errMsg }); + if (!plan) return false; + intentionalClose = true; + logAndScheduleRetry(plan, remaining, (delay) => { + setTimeout(() => { + if (gone()) { + logLine("CLIENT GONE, stop retry"); + return; + } + if (plan.rotateSession && user) { + currentOpts = withFreshSession(currentOpts, user); + } else { + currentOpts = withFreshRequestId(currentOpts); + } + attempt(remaining - 1); + }, delay); + }); + return true; + } + + const req = https.request(currentOpts, (zenRes) => { + let firstChunk = null; + let headersSent = false; + let skipEnd = false; + let sseBuffer = ""; + const status = zenRes.statusCode || 0; + + function sendHeaders() { + if (headersSent) return; + headersSent = true; + if (stream) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + "Transfer-Encoding": "chunked", + }); + res.flushHeaders(); + } else { + res.writeHead(status, { "Content-Type": "application/json" }); + } + } + + function flushSseBuffer(final = false) { + if (!stream) return; + const lines = sseBuffer.split("\n"); + sseBuffer = final ? "" : lines.pop() || ""; + for (const line of lines) { + const out = transformSseLine(line, toolCallIds, requestModel); + if (streamLogLines !== null) streamLogLines += out + "\n"; + res.write(out + "\n"); + } + if (final && sseBuffer) { + const out = transformSseLine(sseBuffer, toolCallIds, requestModel); + if (streamLogLines !== null) streamLogLines += out + "\n"; + res.write(out + "\n"); + } + if (res.flush) res.flush(); + } + + function handleRetryable(kind, errMsg) { + if (trySchedule(kind, errMsg, zenRes.headers)) { + skipEnd = true; + zenRes.destroy(); + req.destroy(); + return true; + } + return false; + } + + zenRes.on("data", (chunk) => { + if (skipEnd || terminalHandled) return; + if (!firstChunk) { + firstChunk = chunk; + const errInfo = parseErrorPayload(chunk); + const rateLimited = status === 429 || errInfo?.rateLimited; + + if (rateLimited) { + const errMsg = errInfo?.message || "Rate limit exceeded"; + if (handleRetryable("rate_limit", errMsg)) return; + failRateLimit(errMsg); + zenRes.resume(); + skipEnd = true; + return; + } + + if (errInfo) { + // Non-rate-limit upstream error — do not retry as rate limit + failUpstream(status >= 400 ? status : 502, errInfo.message); + zenRes.resume(); + skipEnd = true; + return; + } + + if (isTransientHttpStatus(status)) { + if (handleRetryable("transient", `HTTP ${status}`)) return; + failUpstream(status, `Upstream HTTP ${status}`); + zenRes.resume(); + skipEnd = true; + return; + } + + sendHeaders(); + if (stream) { + sseBuffer += chunk.toString(); + flushSseBuffer(); + } else { + chunks.push(chunk); + } + return; + } + if (headersSent) { + if (stream) { + sseBuffer += chunk.toString(); + flushSseBuffer(); + } else { + chunks.push(chunk); + } + } + }); + + zenRes.on("end", () => { + if (skipEnd || terminalHandled) return; + if (!headersSent && !firstChunk) { + if (status === 429) { + if (handleRetryable("rate_limit", "Rate limit exceeded")) return; + failRateLimit("Rate limit exceeded"); + return; + } + if (isTransientHttpStatus(status)) { + if (handleRetryable("transient", `HTTP ${status}`)) return; + failUpstream(status, `Upstream HTTP ${status}`); + return; + } + logLine("EMPTY", "No response from Zen API"); + logIO("OUTPUT (empty)", { error: "Empty response from upstream" }); + if (!res.headersSent) { + res + .status(502) + .json({ + error: { + message: "Empty response from upstream", + type: "upstream_error", + }, + }); + } + return; + } + if (headersSent) { + const ms = Date.now() - t0; + if (stream) { + flushSseBuffer(true); + if (streamLogLines !== null) { + logIO(`OUTPUT (stream, ${ms}ms)`, streamLogLines); + } + res.end(); + } else { + const raw = Buffer.concat(chunks).toString(); + try { + const parsed = JSON.parse(raw); + const updated = ensureOpenAIIds( + parsed, + toolCallIds, + requestModel, + ); + const rawUpdated = JSON.stringify(updated); + logIO(`OUTPUT (sync, ${ms}ms)`, updated); + res.end(rawUpdated); + } catch { + logIO(`OUTPUT (sync raw, ${ms}ms)`, raw); + res.end(raw); + } + } + } + }); + }); + + req.on("error", (e) => { + if (intentionalClose || terminalHandled) return; + if (remaining > 0 && isTransientNetworkError(e)) { + if (trySchedule("transient", e.message)) return; + } + logLine("ERROR", e.message); + logIO("OUTPUT (error)", { error: e.message }); + if (!res.headersSent) { + res + .status(502) + .json({ + error: { + message: "Upstream error: " + e.message, + type: "upstream_error", + }, + }); + } + }); + + req.on("timeout", () => { + if (intentionalClose || terminalHandled) return; + intentionalClose = true; + req.destroy(); + if (remaining > 0 && trySchedule("transient", "Upstream timeout")) return; + intentionalClose = false; + logLine("TIMEOUT"); + logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); + if (!res.headersSent) { + res + .status(504) + .json({ + error: { message: "Upstream timeout", type: "timeout_error" }, + }); + } + }); + + req.write(body); + req.end(); + } + + attempt(retries); +} diff --git a/src/reasoning.mjs b/src/reasoning.mjs new file mode 100644 index 0000000..60137c5 --- /dev/null +++ b/src/reasoning.mjs @@ -0,0 +1,25 @@ +/** + * Thinking-mode (DeepSeek-style) API compatibility. + * + * Reasoning models on the upstream (Console provider) require every prior + * `assistant` message to carry a `reasoning_content` field on multi-turn + * continuations — otherwise the API rejects the request with: + * "The reasoning_content in the thinking mode must be passed back to the API." + * + * Chat UIs (VS Code Copilot's OpenAI path, Anthropic SDK clients) do not + * supply that field on history, so we inject an empty string. The upstream + * only checks that the field is present, so an empty value satisfies it while + * letting any real reasoning pass through untouched. + * + * Mutates and returns the same array. + */ +export function ensureAssistantReasoning(messages) { + for (const m of messages || []) { + if (m && typeof m === "object" && m.role === "assistant") { + if (typeof m.reasoning_content !== "string") { + m.reasoning_content = ""; + } + } + } + return messages; +} \ No newline at end of file diff --git a/src/retry.mjs b/src/retry.mjs new file mode 100644 index 0000000..7731b5e --- /dev/null +++ b/src/retry.mjs @@ -0,0 +1,170 @@ +import { RETRY_BASE_MS, RETRY_MAX_MS } from "./config/index.mjs"; +import { rotateSession } from "./session.mjs"; +import { ocId } from "./utils.mjs"; +import { logLine } from "./logger.mjs"; + +/** + * Exponential backoff with ±20% jitter. + * @param {number} attemptIndex 0 = first retry after initial failure + */ +export function rateLimitRetryDelay( + attemptIndex, + baseMs = RETRY_BASE_MS, + maxMs = RETRY_MAX_MS, +) { + const exp = Math.max(0, attemptIndex | 0); + const base = Math.min(maxMs, baseMs * 2 ** exp); + const jitter = base * 0.2 * (Math.random() * 2 - 1); + return Math.max(0, Math.min(maxMs, Math.round(base + jitter))); +} + +/** Prefer Retry-After header when present; otherwise exponential backoff. */ +export function delayFromRetryAfter(headers, attemptIndex) { + const ra = headers?.["retry-after"] ?? headers?.["Retry-After"]; + if (ra != null && ra !== "") { + const sec = Number(ra); + if (Number.isFinite(sec) && sec >= 0) { + return Math.min(RETRY_MAX_MS, Math.round(sec * 1000)); + } + } + return rateLimitRetryDelay(attemptIndex); +} + +export function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +export function isClientGone(clientReq, res) { + // A response that is ended/destroyed/closed means the client connection is + // done (we finished, or it dropped before we could finish). + if (res?.writableEnded || res?.destroyed || res?.closed) return true; + // The request's 'close'/'destroyed' are NOT reliable here: Node auto-destroys + // the req stream as soon as the request body is fully consumed (so `destroyed` + // becomes true) even though the client is still connected and waiting for our + // response. Only `req.aborted` is set on a genuine early client abort. + if (clientReq?.aborted) return true; + return false; +} + +/** New session id + fresh request id (for rate-limit retries). */ +export function withFreshSession(zenOpts, user) { + const sessionId = user + ? rotateSession(user) + : zenOpts.headers?.["x-opencode-session"]; + return { + ...zenOpts, + headers: { + ...zenOpts.headers, + "x-opencode-session": sessionId, + "x-opencode-request": ocId("msg"), + }, + }; +} + +/** Keep session, mint a new request id (for transient retries). */ +export function withFreshRequestId(zenOpts) { + return { + ...zenOpts, + headers: { + ...zenOpts.headers, + "x-opencode-request": ocId("msg"), + }, + }; +} + +export function isTransientNetworkError(err) { + if (!err) return false; + const code = err.code || ""; + const msg = String(err.message || ""); + if (msg === "timeout" || code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT") + return true; + if ( + [ + "ECONNRESET", + "ECONNREFUSED", + "EPIPE", + "ENOTFOUND", + "EAI_AGAIN", + "ECONNABORTED", + ].includes(code) + ) { + return true; + } + if (/socket hang up/i.test(msg)) return true; + return false; +} + +export function isTransientHttpStatus(status) { + return status === 502 || status === 503 || status === 504; +} + +export function isRateLimitPayload(data, raw = "") { + const s = raw || (data ? JSON.stringify(data) : ""); + if (s.includes("FreeUsageLimitError")) return true; + const type = data?.error?.type || data?.type; + const code = data?.error?.code; + if (type === "rate_limit_error" || code === "rate_limit_exceeded") + return true; + const msg = data?.error?.message || data?.message || ""; + if (/rate\s*limit|usage\s*limit|too many requests|freeusage/i.test(msg)) + return true; + return false; +} + +export function isRateLimitResponse(status, data, raw = "") { + if (status === 429) return true; + return isRateLimitPayload(data, raw); +} + +/** + * Parse a Zen first-chunk / body that may be a JSON error object. + * @returns {null | { message: string, rateLimited: boolean, data: object }} + */ +export function parseErrorPayload(chunkOrData, raw = "") { + let data = chunkOrData; + let str = raw; + if (Buffer.isBuffer(chunkOrData) || typeof chunkOrData === "string") { + str = chunkOrData.toString().trim(); + if (!str.startsWith("{")) return null; + if (!str.includes("FreeUsageLimitError") && !str.includes('"error"')) + return null; + try { + data = JSON.parse(str); + } catch { + return null; + } + } + if (!data || typeof data !== "object") return null; + if (!data.error && data.type !== "error") return null; + const message = data.error?.message || data.message || "Upstream error"; + return { + message, + rateLimited: isRateLimitPayload(data, str || JSON.stringify(data)), + data, + }; +} + +/** + * Shared retry decision for streaming pipes. + * Mutates nothing; caller applies session/opts and schedules. + */ +export function planRetry({ remaining, retries, kind, headers, errMsg }) { + if (remaining <= 0) return null; + const attemptIndex = retries - remaining; + const delay = + kind === "rate_limit" + ? delayFromRetryAfter(headers, attemptIndex) + : rateLimitRetryDelay(attemptIndex); + return { + delay, + rotateSession: kind === "rate_limit", + label: + kind === "rate_limit" ? "RATE LIMITED, retrying" : "TRANSIENT, retrying", + errMsg: errMsg || kind, + }; +} + +export function logAndScheduleRetry(plan, remaining, schedule) { + logLine(plan.label, `(${remaining} left, wait ${plan.delay}ms)`, plan.errMsg); + schedule(plan.delay); +} diff --git a/src/routes/chat.mjs b/src/routes/chat.mjs new file mode 100644 index 0000000..1fe15dd --- /dev/null +++ b/src/routes/chat.mjs @@ -0,0 +1,42 @@ +import { Router } from "express"; +import { MODELS } from "../config/index.mjs"; +import { auth } from "../auth.mjs"; +import { getSession } from "../session.mjs"; +import { zenRequest } from "../client.mjs"; +import { pipeZenResponse } from "../pipe-openai.mjs"; +import { ensureAssistantReasoning } from "../reasoning.mjs"; +import { logLine, logIO, msgSummary } from "../logger.mjs"; + +const router = Router(); + +router.post("/v1/chat/completions", (req, res) => { + const user = auth(req); + if (!user) return res.status(401).json({ error: { message: "Invalid API key" } }); + + // Express 5: unparsed body is `undefined` (was `{}` in v4) + if (req.body == null || typeof req.body !== "object") { + return res.status(400).json({ error: { message: "Request body must be JSON", type: "invalid_request_error" } }); + } + + const { model, messages, stream, tools, tool_choice } = req.body; + if (!MODELS.includes(model)) { + return res.status(400).json({ error: { message: `Unknown model: ${model}. Available: ${MODELS.join(", ")}` } }); + } + + const sessionId = getSession(user); + // Thinking-mode models require reasoning_content on assistant history messages. + ensureAssistantReasoning(messages); + logLine(user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary(messages))); + logIO("INPUT", { + model, + stream: !!stream, + tool_choice: tool_choice || undefined, + tools: tools?.length ? tools : undefined, + messages, + }); + + const { body, options } = zenRequest(model, messages, stream, tools, tool_choice, sessionId); + pipeZenResponse(options, body, stream, res, { user, clientReq: req }); +}); + +export default router; diff --git a/src/routes/health.mjs b/src/routes/health.mjs new file mode 100644 index 0000000..f6184bf --- /dev/null +++ b/src/routes/health.mjs @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { PROXY_VERSION, MODELS } from "../config/index.mjs"; + +const router = Router(); + +router.get("/health", (_req, res) => res.json({ + status: "ok", version: `v${PROXY_VERSION}`, models: MODELS.length, + endpoints: ["/v1/chat/completions", "/v1/messages", "/v1/models"], +})); + +export default router; diff --git a/src/routes/messages.mjs b/src/routes/messages.mjs new file mode 100644 index 0000000..f293d34 --- /dev/null +++ b/src/routes/messages.mjs @@ -0,0 +1,233 @@ +import { Router } from "express"; +import { MODELS, MAX_RETRIES } from "../config/index.mjs"; +import { auth } from "../auth.mjs"; +import { getSession } from "../session.mjs"; +import { zenRequest, zenRequestFull } from "../client.mjs"; +import { pipeZenAsAnthropic } from "../pipe-anthropic.mjs"; +import { anthropicToOpenAI } from "../to-openai.mjs"; +import { openAIToAnthropic } from "../to-anthropic.mjs"; +import { logLine, logIO, msgSummary } from "../logger.mjs"; +import { + rateLimitRetryDelay, + delayFromRetryAfter, + sleep, + isClientGone, + isRateLimitResponse, + isTransientHttpStatus, + isTransientNetworkError, + parseErrorPayload, + withFreshSession, + withFreshRequestId, +} from "../retry.mjs"; + +const router = Router(); + +// Express 5 auto-forwards rejected promises from async handlers to the error middleware. +router.post("/v1/messages", async (req, res) => { + const user = auth(req); + if (!user) { + return res + .status(401) + .json({ + type: "error", + error: { type: "authentication_error", message: "Invalid API key" }, + }); + } + + // Express 5: unparsed body is `undefined` (was `{}` in v4) + if (req.body == null || typeof req.body !== "object") { + return res.status(400).json({ + type: "error", + error: { + type: "invalid_request_error", + message: "Request body must be JSON", + }, + }); + } + + const { model, stream } = req.body; + if (!MODELS.includes(model)) { + return res.status(400).json({ + type: "error", + error: { + type: "invalid_request_error", + message: `Unknown model: ${model}. Available: ${MODELS.join(", ")}`, + }, + }); + } + + const sessionId = getSession(user); + const { messages, tools } = anthropicToOpenAI(req.body); + const inputTokens = (JSON.stringify(messages).length / 4) | 0; + + logLine( + user, + model, + stream ? "stream" : "sync", + "msgs:", + JSON.stringify(msgSummary(messages)), + ); + logIO("INPUT", { + model, + stream: !!stream, + system: req.body.system, + tools: req.body.tools?.length ? req.body.tools : undefined, + messages: req.body.messages, + _converted: { messages, tools: tools?.length ? tools : undefined }, + }); + + let { body, options } = zenRequest( + model, + messages, + stream, + tools, + undefined, + sessionId, + ); + + if (stream) { + pipeZenAsAnthropic(options, body, model, res, inputTokens, { + user, + clientReq: req, + }); + return; + } + + try { + const t0 = Date.now(); + let zenResp; + let lastTransientErr = null; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (isClientGone(req, res)) { + logLine("CLIENT GONE, aborting retries"); + return; + } + + try { + zenResp = await zenRequestFull(options, body); + lastTransientErr = null; + } catch (e) { + lastTransientErr = e; + if (attempt < MAX_RETRIES && isTransientNetworkError(e)) { + const delay = rateLimitRetryDelay(attempt); + logLine( + `TRANSIENT, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, + e.message, + ); + options = withFreshRequestId(options); + await sleep(delay); + continue; + } + throw e; + } + + const rateLimited = isRateLimitResponse( + zenResp.status, + zenResp.data, + zenResp.raw, + ); + if (rateLimited) { + if (attempt < MAX_RETRIES) { + const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; + const delay = delayFromRetryAfter(zenResp.headers, attempt); + logLine( + `RATE LIMITED, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, + errMsg, + ); + options = withFreshSession(options, user); + await sleep(delay); + continue; + } + break; + } + + // Non-rate-limit API error — surface immediately, do not burn retries + const errInfo = parseErrorPayload(zenResp.data, zenResp.raw); + if (errInfo) break; + + if (isTransientHttpStatus(zenResp.status) && attempt < MAX_RETRIES) { + const delay = rateLimitRetryDelay(attempt); + logLine( + `TRANSIENT, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, + `HTTP ${zenResp.status}`, + ); + options = withFreshRequestId(options); + await sleep(delay); + continue; + } + + break; + } + + if (isClientGone(req, res)) return; + + const ms = Date.now() - t0; + + if (lastTransientErr) { + logIO(`OUTPUT (error, ${ms}ms)`, { error: lastTransientErr.message }); + return res.status(502).json({ + type: "error", + error: { type: "upstream_error", message: lastTransientErr.message }, + }); + } + + if (isRateLimitResponse(zenResp.status, zenResp.data, zenResp.raw)) { + const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; + logIO(`OUTPUT (rate_limit, ${ms}ms)`, { error: errMsg }); + return res.status(429).json({ + type: "error", + error: { + type: "rate_limit_error", + message: errMsg + " (free model rate limit)", + }, + }); + } + + const errInfo = parseErrorPayload(zenResp.data, zenResp.raw); + if (errInfo) { + logIO(`OUTPUT (error, ${ms}ms)`, { error: errInfo.message }); + let status = zenResp.status >= 400 ? zenResp.status : 502; + return res.status(status).json({ + type: "error", + error: { + type: errInfo.data?.error?.type || "upstream_error", + message: errInfo.message, + }, + }); + } + + if (isTransientHttpStatus(zenResp.status)) { + logIO(`OUTPUT (error, ${ms}ms)`, { error: `HTTP ${zenResp.status}` }); + return res.status(zenResp.status).json({ + type: "error", + error: { + type: "upstream_error", + message: `Upstream HTTP ${zenResp.status}`, + }, + }); + } + + if (!zenResp.data?.choices) { + logIO(`OUTPUT (invalid, ${ms}ms)`, { raw: zenResp.raw }); + return res.status(502).json({ + type: "error", + error: { type: "upstream_error", message: "Invalid upstream response" }, + }); + } + const antResp = openAIToAnthropic(zenResp.data, model, inputTokens); + logIO(`OUTPUT (sync, ${ms}ms)`, antResp); + res.json(antResp); + } catch (e) { + logLine("ZEN", "ERROR", e.message); + logIO("OUTPUT (error)", { error: e.message }); + res + .status(502) + .json({ + type: "error", + error: { type: "upstream_error", message: e.message }, + }); + } +}); + +export default router; diff --git a/src/routes/models.mjs b/src/routes/models.mjs new file mode 100644 index 0000000..7eb81aa --- /dev/null +++ b/src/routes/models.mjs @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { MODELS } from "../config/index.mjs"; + +const router = Router(); + +router.get("/v1/models", (_req, res) => { + res.json({ + object: "list", + data: MODELS.map((id) => ({ + id, object: "model", created: 1779000000, owned_by: "opencode-free", + })), + }); +}); + +export default router; diff --git a/src/session.mjs b/src/session.mjs new file mode 100644 index 0000000..1851922 --- /dev/null +++ b/src/session.mjs @@ -0,0 +1,33 @@ +import { ocId } from "./utils.mjs"; + +const userSessions = new Map(); +const SESSION_TTL = 30 * 60 * 1000; // 30 minutes + +// Periodically evict stale sessions so the Map doesn't grow unbounded. +const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [user, session] of userSessions) { + if (now - session.ts > SESSION_TTL) userSessions.delete(user); + } +}, CLEANUP_INTERVAL).unref(); + +export function getSession(user) { + const now = Date.now(); + const existing = userSessions.get(user); + if (!existing || now - existing.ts > SESSION_TTL) { + const session = { id: ocId("ses"), ts: now }; + userSessions.set(user, session); + return session.id; + } + // Bump timestamp on activity so active sessions stay alive. + existing.ts = now; + return existing.id; +} + +/** Force a new session id (e.g. after rate-limit so free-tier quota resets). */ +export function rotateSession(user) { + const session = { id: ocId("ses"), ts: Date.now() }; + userSessions.set(user, session); + return session.id; +} diff --git a/src/to-anthropic.mjs b/src/to-anthropic.mjs new file mode 100644 index 0000000..1c17919 --- /dev/null +++ b/src/to-anthropic.mjs @@ -0,0 +1,57 @@ +import { ocId } from "./utils.mjs"; + +const NO_CACHE = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; + +/** Convert an OpenAI /v1/chat/completions response into Anthropic /v1/messages format. */ +export function openAIToAnthropic(oaiResp, model, inputTokens) { + const choice = oaiResp.choices?.[0]; + if (!choice) { + return { + id: ocId("msg"), + type: "message", + role: "assistant", + content: [{ type: "text", text: "" }], + model, + stop_reason: "end_turn", + usage: { input_tokens: inputTokens || 0, output_tokens: 0, ...NO_CACHE }, + }; + } + + const content = []; + if (choice.message?.content) { + content.push({ type: "text", text: choice.message.content }); + } + if (choice.message?.tool_calls) { + for (const tc of choice.message.tool_calls) { + let input = {}; + try { + input = JSON.parse(tc.function.arguments); + } catch {} + content.push({ + type: "tool_use", + id: tc.id || ocId("toolu"), + name: tc.function.name, + input, + }); + } + } + if (!content.length) content.push({ type: "text", text: "" }); + + let stopReason = "end_turn"; + if (choice.finish_reason === "tool_calls") stopReason = "tool_use"; + else if (choice.finish_reason === "length") stopReason = "max_tokens"; + + return { + id: ocId("msg"), + type: "message", + role: "assistant", + content, + model, + stop_reason: stopReason, + usage: { + input_tokens: oaiResp.usage?.prompt_tokens || inputTokens || 0, + output_tokens: oaiResp.usage?.completion_tokens || 0, + ...NO_CACHE, + }, + }; +} diff --git a/src/to-openai.mjs b/src/to-openai.mjs new file mode 100644 index 0000000..e0cf80d --- /dev/null +++ b/src/to-openai.mjs @@ -0,0 +1,94 @@ +import { ensureAssistantReasoning } from "./reasoning.mjs"; + +/** Extract plain text from an Anthropic content field that may be a string or an array of content blocks. */ +function contentText(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map(c => c.text || "").join("\n"); + return ""; +} + +/** Joins Anthropic `thinking` content blocks into a single reasoning string. */ +function reasoningText(blocks) { + const text = (blocks || []) + .filter(b => b.type === "thinking") + .map(b => (typeof b.thinking === "string" ? b.thinking : b.text || "")) + .join("\n"); + return text; +} + +/** Build an assistant message with tool_calls from tool_use blocks. */ +function buildToolUseMessage(text, toolUses) { + return { + role: "assistant", + content: text || null, + tool_calls: toolUses.map(t => ({ + id: t.id, + type: "function", + function: { name: t.name, arguments: JSON.stringify(t.input || {}) }, + })), + }; +} + +/** Build tool-result messages from tool_result blocks. */ +function buildToolResultMessages(toolResults) { + return toolResults + .filter(b => b.type === "tool_result") + .map(b => ({ role: "tool", tool_call_id: b.tool_use_id, content: contentText(b.content) })); +} + +/** Convert a single Anthropic message with array content into one or more OpenAI messages. */ +function convertContentBlock(msg) { + const blocks = msg.content; + const text = contentText(blocks.filter(b => b.type === "text")); + const toolUses = blocks.filter(b => b.type === "tool_use"); + const reasoning = reasoningText(blocks); + + // Assistant with tool calls + if (toolUses.length && msg.role === "assistant") { + const m = buildToolUseMessage(text, toolUses); + m.reasoning_content = reasoning; + return [m]; + } + + // Tool result blocks + if (blocks.some(b => b.type === "tool_result")) { + return buildToolResultMessages(blocks); + } + + // Plain text array (or non-tool content blocks) + const m = { role: msg.role, content: text }; + if (msg.role === "assistant") m.reasoning_content = reasoning; + return [m]; +} + +/** Convert an Anthropic /v1/messages body into OpenAI /v1/chat/completions format. */ +export function anthropicToOpenAI(body) { + const messages = []; + + if (body.system) { + const sys = contentText(body.system); + if (sys) messages.push({ role: "system", content: sys }); + } + + for (const msg of body.messages || []) { + if (typeof msg.content === "string") { + messages.push({ role: msg.role, content: msg.content }); + } else if (Array.isArray(msg.content)) { + messages.push(...convertContentBlock(msg)); + } + } + + const tools = (body.tools || []).map(t => ({ + type: "function", + function: { + name: t.name, + description: t.description || "", + parameters: t.input_schema || {}, + }, + })); + + // Thinking-mode models require reasoning_content on assistant history messages. + ensureAssistantReasoning(messages); + + return { messages, tools: tools.length ? tools : undefined }; +} diff --git a/src/utils.mjs b/src/utils.mjs new file mode 100644 index 0000000..1c4a750 --- /dev/null +++ b/src/utils.mjs @@ -0,0 +1,7 @@ +import crypto from "crypto"; + +export function ocId(prefix) { + const ts = Date.now().toString(16); + const rnd = crypto.randomBytes(12).toString("base64url").slice(0, 16); + return `${prefix}_${ts}${rnd}`; +} diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs new file mode 100644 index 0000000..22dd06f --- /dev/null +++ b/tests/auth.test.mjs @@ -0,0 +1,38 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const tmpKeys = path.join(os.tmpdir(), `opencode-test-keys-${Date.now()}.json`); +process.env.KEYS_FILE = tmpKeys; + +const { loadKeys, apiKeys, auth } = await import("../src/auth.mjs"); + +describe("auth", () => { + after(() => { + try { fs.unlinkSync(tmpKeys); } catch {} + }); + + it("generates default keys when file is missing", () => { + loadKeys(); + assert.ok(apiKeys.admin, "admin key missing"); + assert.ok(apiKeys["user-default"], "user-default key missing"); + assert.ok(fs.existsSync(tmpKeys), "keys file was not written"); + }); + + it("authenticates with Authorization: Bearer", () => { + const req = { headers: { authorization: `Bearer ${apiKeys.admin}` } }; + assert.strictEqual(auth(req), "admin"); + }); + + it("authenticates with x-api-key header", () => { + const req = { headers: { "x-api-key": apiKeys["user-default"] } }; + assert.strictEqual(auth(req), "user-default"); + }); + + it("rejects invalid keys", () => { + const req = { headers: { authorization: "Bearer invalid-key" } }; + assert.strictEqual(auth(req), null); + }); +}); diff --git a/tests/health.test.mjs b/tests/health.test.mjs new file mode 100644 index 0000000..4821594 --- /dev/null +++ b/tests/health.test.mjs @@ -0,0 +1,22 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { createApp } from "../src/app.mjs"; + +describe("GET /health", () => { + it("returns status ok and known endpoints", async () => { + const app = createApp(); + const server = app.listen(0); + const { port } = server.address(); + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.status, "ok"); + assert.ok(body.version); + assert.ok(Array.isArray(body.endpoints)); + assert.ok(body.endpoints.includes("/v1/chat/completions")); + } finally { + server.close(); + } + }); +}); diff --git a/tests/models.test.mjs b/tests/models.test.mjs new file mode 100644 index 0000000..157a685 --- /dev/null +++ b/tests/models.test.mjs @@ -0,0 +1,25 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { createApp } from "../src/app.mjs"; +import { MODELS } from "../src/config/index.mjs"; + +describe("GET /v1/models", () => { + it("lists all configured models", async () => { + const app = createApp(); + const server = app.listen(0); + const { port } = server.address(); + try { + const res = await fetch(`http://127.0.0.1:${port}/v1/models`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.object, "list"); + assert.strictEqual(body.data.length, MODELS.length); + for (const item of body.data) { + assert.strictEqual(item.object, "model"); + assert.ok(MODELS.includes(item.id)); + } + } finally { + server.close(); + } + }); +}); diff --git a/tests/pipes.test.mjs b/tests/pipes.test.mjs new file mode 100644 index 0000000..d22319e --- /dev/null +++ b/tests/pipes.test.mjs @@ -0,0 +1,86 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { ensureOpenAIIds } from "../src/pipe-openai.mjs"; + +describe("ensureOpenAIIds", () => { + it("injects a top-level id when missing", () => { + const payload = { object: "chat.completion", choices: [] }; + ensureOpenAIIds(payload); + assert.match(payload.id, /^chatcmpl_/); + }); + + it("injects object, created, and model when missing", () => { + const payload = { choices: [] }; + ensureOpenAIIds(payload, {}, "test-model"); + assert.strictEqual(payload.object, "chat.completion"); + assert.strictEqual(typeof payload.created, "number"); + assert.strictEqual(payload.model, "test-model"); + }); + + it("keeps an existing top-level id", () => { + const payload = { id: "chatcmpl-keep", choices: [] }; + ensureOpenAIIds(payload); + assert.strictEqual(payload.id, "chatcmpl-keep"); + }); + + it("injects ids and type into non-streaming message.tool_calls", () => { + const payload = { + id: "chatcmpl-1", + choices: [{ + index: 0, + message: { + role: "assistant", + content: null, + tool_calls: [ + { function: { name: "grep", arguments: "{}" } }, + { function: { name: "ls", arguments: "{}" } }, + ], + }, + finish_reason: "tool_calls", + }], + }; + ensureOpenAIIds(payload); + const tcs = payload.choices[0].message.tool_calls; + assert.match(tcs[0].id, /^call_/); + assert.match(tcs[1].id, /^call_/); + assert.notStrictEqual(tcs[0].id, tcs[1].id); + assert.strictEqual(tcs[0].type, "function"); + assert.strictEqual(tcs[1].type, "function"); + }); + + it("caches ids across streaming deltas for the same tool call index", () => { + const toolCallIds = {}; + const p1 = { + id: "chatcmpl-2", + choices: [{ delta: { tool_calls: [{ index: 0, function: { name: "grep" } }] } }], + }; + const p2 = { + id: "chatcmpl-2", + choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: "{\"p" } }] } }], + }; + const p3 = { + id: "chatcmpl-2", + choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: "attern\":\"x\"}" } }] } }], + }; + ensureOpenAIIds(p1, toolCallIds); + ensureOpenAIIds(p2, toolCallIds); + ensureOpenAIIds(p3, toolCallIds); + + assert.match(p1.choices[0].delta.tool_calls[0].id, /^call_/); + assert.strictEqual(p2.choices[0].delta.tool_calls[0].id, p1.choices[0].delta.tool_calls[0].id); + assert.strictEqual(p3.choices[0].delta.tool_calls[0].id, p1.choices[0].delta.tool_calls[0].id); + }); + + it("uses existing ids when provided by upstream", () => { + const payload = { + id: "chatcmpl-3", + choices: [{ + message: { + tool_calls: [{ id: "call_existing", function: { name: "grep" } }], + }, + }], + }; + ensureOpenAIIds(payload); + assert.strictEqual(payload.choices[0].message.tool_calls[0].id, "call_existing"); + }); +}); diff --git a/tests/reasoning.test.mjs b/tests/reasoning.test.mjs new file mode 100644 index 0000000..d768538 --- /dev/null +++ b/tests/reasoning.test.mjs @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { ensureAssistantReasoning } from "../src/reasoning.mjs"; +import { anthropicToOpenAI } from "../src/to-openai.mjs"; + +describe("ensureAssistantReasoning", () => { + it("adds empty reasoning_content to assistant messages that lack it", () => { + const messages = [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "again" }, + ]; + ensureAssistantReasoning(messages); + assert.strictEqual(messages[1].reasoning_content, ""); + }); + + it("preserves existing reasoning_content", () => { + const messages = [ + { role: "assistant", content: "sum", reasoning_content: "I compute 1+1" }, + ]; + ensureAssistantReasoning(messages); + assert.strictEqual(messages[0].reasoning_content, "I compute 1+1"); + }); + + it("leaves user and tool messages untouched", () => { + const messages = [ + { role: "user", content: "x" }, + { role: "tool", content: "r" }, + ]; + ensureAssistantReasoning(messages); + assert.strictEqual(messages[0].reasoning_content, undefined); + assert.strictEqual(messages[1].reasoning_content, undefined); + }); + + it("handles undefined / null messages gracefully", () => { + const messages = [null, undefined, { role: "assistant" }]; + ensureAssistantReasoning(messages); + assert.strictEqual(messages[2].reasoning_content, ""); + }); +}); + +describe("anthropicToOpenAI reasoning passthrough", () => { + it("maps Anthropic thinking content blocks to reasoning_content", () => { + const body = { + model: "x", + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "let me compute 1+1" }, + { type: "text", text: "2" }, + ], + }, + ], + }; + const { messages } = anthropicToOpenAI(body); + assert.strictEqual(messages[1].content, "2"); + assert.strictEqual(messages[1].reasoning_content, "let me compute 1+1"); + assert.strictEqual(messages[0].reasoning_content, undefined); + }); + + it("adds empty reasoning_content to assistant messages without thinking blocks", () => { + const body = { + model: "x", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "plain answer" }, + ], + }; + const { messages } = anthropicToOpenAI(body); + assert.strictEqual(messages[1].content, "plain answer"); + assert.strictEqual(messages[1].reasoning_content, ""); + }); +}); \ No newline at end of file diff --git a/tests/retry.test.mjs b/tests/retry.test.mjs new file mode 100644 index 0000000..aea4dde --- /dev/null +++ b/tests/retry.test.mjs @@ -0,0 +1,166 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + rateLimitRetryDelay, + delayFromRetryAfter, + isRateLimitPayload, + isRateLimitResponse, + isTransientNetworkError, + isTransientHttpStatus, + parseErrorPayload, + planRetry, + isClientGone, + withFreshSession, + withFreshRequestId, +} from "../src/retry.mjs"; +import { getSession, rotateSession } from "../src/session.mjs"; + +describe("rateLimitRetryDelay", () => { + it("stays near exponential base with ±20% jitter", () => { + for (let i = 0; i < 20; i++) { + const d0 = rateLimitRetryDelay(0, 1000, 60_000); + assert.ok(d0 >= 800 && d0 <= 1200, `attempt0=${d0}`); + const d2 = rateLimitRetryDelay(2, 1000, 60_000); + assert.ok(d2 >= 3200 && d2 <= 4800, `attempt2=${d2}`); + } + }); + + it("caps near maxMs", () => { + for (let i = 0; i < 10; i++) { + const d = rateLimitRetryDelay(10, 1000, 30_000); + assert.ok(d >= 24_000 && d <= 30_000, `capped=${d}`); + } + }); + + it("treats negative attempt as 0", () => { + const d = rateLimitRetryDelay(-1, 1000, 30_000); + assert.ok(d >= 800 && d <= 1200); + }); +}); + +describe("delayFromRetryAfter", () => { + it("uses Retry-After seconds when valid", () => { + assert.strictEqual(delayFromRetryAfter({ "retry-after": "5" }, 0), 5000); + }); + + it("falls back to exponential when header missing", () => { + const d = delayFromRetryAfter({}, 0); + assert.ok(d >= 800 && d <= 1200); + }); +}); + +describe("rate limit classification", () => { + it("detects FreeUsageLimitError and 429", () => { + assert.ok(isRateLimitPayload({ error: { message: "x", type: "FreeUsageLimitError" } }, "FreeUsageLimitError")); + assert.ok(isRateLimitResponse(429, null, "")); + assert.ok(isRateLimitPayload({ error: { type: "rate_limit_error", message: "slow down" } })); + }); + + it("does not treat generic errors as rate limit", () => { + assert.ok(!isRateLimitPayload({ error: { type: "invalid_request_error", message: "bad model" } })); + assert.ok(!isRateLimitResponse(400, { error: { message: "bad" } })); + }); + + it("parseErrorPayload flags rate-limited vs other", () => { + const rl = parseErrorPayload(Buffer.from(JSON.stringify({ + error: { message: "quota", type: "rate_limit_error" }, + }))); + assert.ok(rl.rateLimited); + assert.strictEqual(rl.message, "quota"); + + const other = parseErrorPayload(Buffer.from(JSON.stringify({ + error: { message: "nope", type: "invalid_request_error" }, + }))); + assert.ok(other); + assert.ok(!other.rateLimited); + }); +}); + +describe("transient helpers", () => { + it("classifies network errors and http statuses", () => { + assert.ok(isTransientNetworkError({ code: "ECONNRESET", message: "reset" })); + assert.ok(isTransientNetworkError({ message: "timeout" })); + assert.ok(isTransientNetworkError({ message: "socket hang up" })); + assert.ok(!isTransientNetworkError({ message: "certificate error" })); + assert.ok(isTransientHttpStatus(502)); + assert.ok(isTransientHttpStatus(503)); + assert.ok(!isTransientHttpStatus(400)); + }); +}); + +describe("planRetry", () => { + it("rotates only for rate_limit", () => { + const rl = planRetry({ remaining: 3, retries: 12, kind: "rate_limit", errMsg: "x" }); + assert.ok(rl.rotateSession); + assert.ok(rl.delay >= 0); + const tr = planRetry({ remaining: 3, retries: 12, kind: "transient", errMsg: "y" }); + assert.ok(!tr.rotateSession); + assert.strictEqual(planRetry({ remaining: 0, retries: 12, kind: "rate_limit" }), null); + }); +}); + +describe("isClientGone", () => { + // A request whose body was fully consumed has req.destroyed === true and its + // 'close' event has fired — but the client is still connected and waiting. + // This must NOT be treated as client-gone (regression: false CLIENT GONE logs + // caused the proxy to return without ever responding, hanging the client). + it("does not treat a fully-consumed request as client-gone while res is open", () => { + const consumedReq = { destroyed: true, aborted: false, complete: true }; + const openRes = { writableEnded: false, destroyed: false, closed: false }; + assert.strictEqual(isClientGone(consumedReq, openRes), false); + }); + + it("treats a genuinely aborted request as client-gone", () => { + const abortedReq = { destroyed: true, aborted: true }; + const openRes = { writableEnded: false, destroyed: false, closed: false }; + assert.strictEqual(isClientGone(abortedReq, openRes), true); + }); + + it("treats a destroyed/closed response as client-gone", () => { + const req = { destroyed: true, aborted: false }; + assert.strictEqual(isClientGone(req, { writableEnded: true }), true); + assert.strictEqual(isClientGone(req, { destroyed: true }), true); + assert.strictEqual(isClientGone(req, { closed: true }), true); + }); + + it("is false when everything is still open and waiting", () => { + assert.strictEqual( + isClientGone({ destroyed: false, aborted: false }, { writableEnded: false, destroyed: false, closed: false }), + false, + ); + }); +}); + +describe("session rotation helpers", () => { + it("rotateSession issues a new id", () => { + const a = getSession("retry-test-user"); + const b = rotateSession("retry-test-user"); + assert.notStrictEqual(a, b); + assert.strictEqual(getSession("retry-test-user"), b); + }); + + it("withFreshSession rewrites session and request headers", () => { + const opts = { + headers: { + "x-opencode-session": "ses_old", + "x-opencode-request": "msg_old", + }, + }; + const next = withFreshSession(opts, "retry-test-user-2"); + assert.notStrictEqual(next.headers["x-opencode-session"], "ses_old"); + assert.notStrictEqual(next.headers["x-opencode-request"], "msg_old"); + assert.match(next.headers["x-opencode-session"], /^ses_/); + }); + + it("withFreshRequestId keeps session", () => { + const opts = { + headers: { + "x-opencode-session": "ses_keep", + "x-opencode-request": "msg_old", + }, + }; + const next = withFreshRequestId(opts); + assert.strictEqual(next.headers["x-opencode-session"], "ses_keep"); + assert.notStrictEqual(next.headers["x-opencode-request"], "msg_old"); + }); +}); diff --git a/tests/routes.test.mjs b/tests/routes.test.mjs new file mode 100644 index 0000000..7d6802a --- /dev/null +++ b/tests/routes.test.mjs @@ -0,0 +1,79 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const tmpKeys = path.join(os.tmpdir(), `opencode-routes-keys-${Date.now()}.json`); +process.env.KEYS_FILE = tmpKeys; + +const { createApp } = await import("../src/app.mjs"); +const { MODELS } = await import("../src/config/index.mjs"); +const { loadKeys, apiKeys } = await import("../src/auth.mjs"); + +loadKeys(); + +after(() => { + try { fs.unlinkSync(tmpKeys); } catch {} +}); + +async function withServer(fn) { + const app = createApp(); + const server = app.listen(0); + const { port } = server.address(); + try { + await fn(port); + } finally { + server.close(); + } +} + +describe("route auth", () => { + it("returns 401 without a key on /v1/chat/completions", async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: MODELS[0], messages: [{ role: "user", content: "hi" }] }), + }); + assert.strictEqual(res.status, 401); + }); + }); + + it("returns 401 without a key on /v1/messages", async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: MODELS[0], messages: [{ role: "user", content: "hi" }] }), + }); + assert.strictEqual(res.status, 401); + }); + }); +}); + +describe("express 5 body handling", () => { + it("returns 400 when chat body is missing (req.body undefined)", async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { Authorization: `Bearer ${apiKeys.admin}` }, + }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.match(body.error?.message || "", /JSON/i); + }); + }); + + it("returns 400 when messages body is missing", async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: "POST", + headers: { "x-api-key": apiKeys.admin }, + }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.match(body.error?.message || "", /JSON/i); + }); + }); +});