From 885783815ce0c77d201cb43cd8b4828962dd12ba Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Wed, 22 Jul 2026 10:38:06 +0800 Subject: [PATCH 01/16] Refactor server.mjs: Improve error handling, restructure response pipes, and load models from JSON --- .dockerignore | 7 + .gitignore | 10 + AGENTS.md | 49 +++ Dockerfile | 15 + models.json | 7 + package-lock.json | 832 ++++++++++++++++++++++++++++++++++++++++++++++ server.mjs | 280 ++++++++-------- 7 files changed, 1063 insertions(+), 137 deletions(-) create mode 100644 .dockerignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 models.json create mode 100644 package-lock.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..278cf5d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +api-keys.json +*.log +.env +.git +.gitignore +README.md 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..ff603b3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# opencode-free-proxy + +Single-file Node.js Express proxy that translates OpenAI/Anthropic API calls to the Zen API at `opencode.ai`. + +## Quick start + +```bash +npm install +node server.mjs # 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. +- **No tests** — `npm test` does not exist. +- **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 rotate every 30 minutes (internal, no-op for agent work). +- **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 | + +## Files + +| Path | Purpose | +|------|---------| +| `server.mjs` | Entire application (~565 lines) | +| `models.json` | List of available models | +| `api-keys.json` | Auto-generated, **never commit** | +| `Dockerfile` | Multi-stage, runs as `node` user | +| `.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`, `pipeZenAsAnthropic`) are the main complexity — preserve them when touching. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..258dd19 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# ── Build stage ────────────────────────────────────────────── +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install --production + +# ── Run stage ──────────────────────────────────────────────── +FROM node:20-alpine AS run +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY server.mjs models.json ./ +RUN chown node:node /app /app/*.json /app/*.mjs +EXPOSE 6446 +USER node +CMD ["node", "server.mjs"] diff --git a/models.json b/models.json new file mode 100644 index 0000000..637082b --- /dev/null +++ b/models.json @@ -0,0 +1,7 @@ +[ + "deepseek-v4-flash-free", + "big-pickle", + "minimax-m2.5-free", + "nemotron-3-super-free", + "qwen3.6-plus-free" +] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7438db5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,832 @@ +{ + "name": "opencode-free-proxy", + "version": "0.9.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-free-proxy", + "version": "0.9.0", + "license": "MIT", + "dependencies": { + "express": "^4.21.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "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/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": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "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.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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" + } + } + } +} diff --git a/server.mjs b/server.mjs index a8444a2..9592092 100644 --- a/server.mjs +++ b/server.mjs @@ -3,14 +3,18 @@ import crypto from "crypto"; import https from "https"; import fs from "fs"; +// ── Config ── + 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"; +const MODELS = JSON.parse(fs.readFileSync("models.json", "utf8")); + +// ── API Keys ── -// ── API Keys ─────────────────────────────────────────────────────── const keysFile = process.env.KEYS_FILE || "./api-keys.json"; let apiKeys = {}; function loadKeys() { @@ -35,21 +39,14 @@ function auth(req) { return null; } -// ── Helpers ──────────────────────────────────────────────────────── +// ── IDs & Sessions ── + 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) { @@ -60,7 +57,8 @@ function getSession(user) { return userSessions[user].id; } -// ── Zen API transport ────────────────────────────────────────────── +// ── Zen API transport ── + function zenRequest(model, messages, stream, tools, tool_choice, sessionId) { const reqBody = { model, messages, stream: !!stream }; if (tools?.length) reqBody.tools = tools; @@ -90,111 +88,9 @@ function zenRequest(model, messages, stream, tools, tool_choice, sessionId) { }; } -// 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; +// ── Format converters ── - 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 ───────────────────────── +// Anthropic Messages request → OpenAI request body function anthropicToOpenAI(body) { const messages = []; if (body.system) { @@ -246,7 +142,7 @@ function anthropicToOpenAI(body) { return { messages, tools: tools.length ? tools : undefined }; } -// OpenAI response → Anthropic Messages format +// OpenAI response → Anthropic Messages response function openAIToAnthropic(oaiResp, model, inputTokens) { const choice = oaiResp.choices?.[0]; if (!choice) { @@ -300,6 +196,117 @@ function openAIToAnthropic(oaiResp, model, inputTokens) { }; } +// ── Response pipes ── + +function checkFirstChunkError(chunk) { + const str = chunk.toString().trim(); + if (!str.startsWith("{") || (!str.includes("FreeUsageLimitError") && !str.includes('"error"'))) return null; + try { + const parsed = JSON.parse(str); + if (parsed.error || parsed.type === "error") { + return parsed.error?.message || parsed.message || "Rate limit exceeded"; + } + } catch {} + return null; +} + +// 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 errMsg = checkFirstChunkError(chunk); + if (errMsg) { + 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; + } + + 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(); + }); +} + // Stream OpenAI SSE → Anthropic SSE function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { const msgId = ocId("msg"); @@ -344,23 +351,17 @@ function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { // 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 {} + const errMsg = checkFirstChunkError(chunk); + if (errMsg) { + 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; } } @@ -463,6 +464,7 @@ function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { req.on("timeout", () => { req.destroy(); + console.log("[ZEN TIMEOUT]"); if (!res.headersSent) { res.status(504).json({ type: "error", error: { type: "timeout_error", message: "Upstream timeout" } }); } @@ -472,7 +474,8 @@ function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { req.end(); } -// ── Routes: OpenAI format ────────────────────────────────────────── +// ── Routes: OpenAI format ── + app.get("/v1/models", (_req, res) => { res.json({ object: "list", @@ -499,7 +502,8 @@ app.post("/v1/chat/completions", (req, res) => { pipeZenResponse(options, body, stream, res); }); -// ── Routes: Anthropic Messages format ────────────────────────────── +// ── Routes: Anthropic Messages format ── + app.post("/v1/messages", async (req, res) => { const user = auth(req); if (!user) { @@ -546,13 +550,15 @@ app.post("/v1/messages", async (req, res) => { } }); -// ── Health ────────────────────────────────────────────────────────── +// ── 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 ────────────────────────────────────────────────────────── +// ── 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"); From 574befa421e09500f8ecc25d6ad82abd4c9afd2a Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Wed, 22 Jul 2026 11:05:32 +0800 Subject: [PATCH 02/16] Update models.json: Replace outdated model names with current versions --- models.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/models.json b/models.json index 637082b..d980ef3 100644 --- a/models.json +++ b/models.json @@ -1,7 +1,7 @@ [ - "deepseek-v4-flash-free", - "big-pickle", - "minimax-m2.5-free", - "nemotron-3-super-free", - "qwen3.6-plus-free" +"deepseek-v4-flash-free", +"laguna-s-2.1-free", +"mimo-v2.5-free", +"nemotron-3-ultra-free", +"north-mini-code-free" ] From 87c7414ca1ca33176b3967cde47a0e0391361c77 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Wed, 22 Jul 2026 14:53:41 +0800 Subject: [PATCH 03/16] Code refactor --- .dockerignore | 7 + AGENTS.md | 23 +- Dockerfile | 11 +- README.md | 8 +- docker-compose.dev.yaml | 27 ++ docker-compose.yaml | 23 ++ models.json | 10 +- package-lock.json | 2 +- package.json | 9 +- pnpm-lock.yaml | 580 ++++++++++++++++++++++++++++++++++++++++ server.mjs | 572 --------------------------------------- src/app.mjs | 15 ++ src/auth.mjs | 28 ++ src/config/index.mjs | 9 + src/converters.mjs | 104 +++++++ src/index.mjs | 20 ++ src/logger.mjs | 95 +++++++ src/pipes.mjs | 296 ++++++++++++++++++++ src/routes/chat.mjs | 34 +++ src/routes/health.mjs | 11 + src/routes/messages.mjs | 73 +++++ src/routes/models.mjs | 15 ++ src/session.mjs | 11 + src/utils.mjs | 7 + src/zen.mjs | 53 ++++ tests/auth.test.mjs | 38 +++ tests/health.test.mjs | 22 ++ tests/models.test.mjs | 25 ++ tests/routes.test.mjs | 38 +++ 29 files changed, 1569 insertions(+), 597 deletions(-) create mode 100644 docker-compose.dev.yaml create mode 100644 docker-compose.yaml create mode 100644 pnpm-lock.yaml delete mode 100644 server.mjs create mode 100644 src/app.mjs create mode 100644 src/auth.mjs create mode 100644 src/config/index.mjs create mode 100644 src/converters.mjs create mode 100644 src/index.mjs create mode 100644 src/logger.mjs create mode 100644 src/pipes.mjs create mode 100644 src/routes/chat.mjs create mode 100644 src/routes/health.mjs create mode 100644 src/routes/messages.mjs create mode 100644 src/routes/models.mjs create mode 100644 src/session.mjs create mode 100644 src/utils.mjs create mode 100644 src/zen.mjs create mode 100644 tests/auth.test.mjs create mode 100644 tests/health.test.mjs create mode 100644 tests/models.test.mjs create mode 100644 tests/routes.test.mjs diff --git a/.dockerignore b/.dockerignore index 278cf5d..1732c73 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,11 @@ api-keys.json .env .git .gitignore +.omo/ +.vscode/ +.idea/ +.deepeval/ +tests/ +docker/ README.md +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index ff603b3..7ee90ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ # opencode-free-proxy -Single-file Node.js Express proxy that translates OpenAI/Anthropic API calls to the Zen API at `opencode.ai`. +Modular Node.js Express proxy that translates OpenAI/Anthropic API calls to the Zen API at `opencode.ai`. ## Quick start ```bash npm install -node server.mjs # port 6446 +npm start # port 6446 # or with file watching: npm run dev ``` @@ -17,7 +17,7 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu - **ESM only** — `"type": "module"` in package.json; use `import` not `require`. - **No build step** — raw Node.js, no TypeScript, no bundler. -- **No tests** — `npm test` does not exist. +- **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) @@ -36,14 +36,25 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu | Path | Purpose | |------|---------| -| `server.mjs` | Entire application (~565 lines) | +| `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 rotation | +| `src/zen.mjs` | Zen API request builders | +| `src/converters.mjs` | Anthropic ⇄ OpenAI format converters | +| `src/pipes.mjs` | Stream / sync response forwarding | +| `src/routes/*.mjs` | Route handlers | +| `src/logger.mjs` | I/O logging utilities | | `models.json` | List of available models | | `api-keys.json` | Auto-generated, **never commit** | -| `Dockerfile` | Multi-stage, runs as `node` user | +| `Dockerfile` | Multi-stage, `node:24-alpine`, runs as `node` user | +| `docker-compose.yaml` | Production compose | +| `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`, `pipeZenAsAnthropic`) are the main complexity — preserve them when touching. +- Format conversion helpers (`anthropicToOpenAI`, `openAIToAnthropic`) and response pipes (`pipeZenResponse`, `pipeZenAsAnthropic`) are the main complexity — preserve behavior when touching. diff --git a/Dockerfile b/Dockerfile index 258dd19..887791f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,16 @@ # ── Build stage ────────────────────────────────────────────── -FROM node:20-alpine AS build +FROM node:24-alpine AS build WORKDIR /app COPY package.json ./ RUN npm install --production # ── Run stage ──────────────────────────────────────────────── -FROM node:20-alpine AS run +FROM node:24-alpine AS run WORKDIR /app COPY --from=build /app/node_modules ./node_modules -COPY server.mjs models.json ./ -RUN chown node:node /app /app/*.json /app/*.mjs +COPY models.json ./ +COPY src ./src +RUN mkdir -p /data && chown -R node:node /app /data EXPOSE 6446 USER node -CMD ["node", "server.mjs"] +CMD ["node", "src/index.mjs"] diff --git a/README.md b/README.md index f787a10..d6a9077 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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). @@ -114,9 +114,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 +137,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..1331eeb --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,27 @@ +# 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 + # 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}" + 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..7e4f1d3 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,23 @@ +# 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: + - "${PROXY_PORT:-6446}:6446" + environment: + PROXY_PORT: "6446" + KEYS_FILE: /data/api-keys.json + NODE_ENV: production + # 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}" + volumes: + # Persist auto-generated API keys across container recreations + - proxy-keys:/data + restart: unless-stopped + +volumes: + proxy-keys: diff --git a/models.json b/models.json index d980ef3..6cdc77a 100644 --- a/models.json +++ b/models.json @@ -1,7 +1,7 @@ [ -"deepseek-v4-flash-free", -"laguna-s-2.1-free", -"mimo-v2.5-free", -"nemotron-3-ultra-free", -"north-mini-code-free" + "deepseek-v4-flash-free", + "laguna-s-2.1-free", + "mimo-v2.5-free", + "nemotron-3-ultra-free", + "north-mini-code-free" ] diff --git a/package-lock.json b/package-lock.json index 7438db5..3cd3ce6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "express": "^4.21.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/accepts": { diff --git a/package.json b/package.json index 6e52acb..00f1e0e 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,17 @@ "version": "0.9.0", "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" }, "engines": { - "node": ">=18" + "node": ">=20" }, "license": "MIT" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..79a8c2c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,580 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^4.21.0 + version: 4.22.2 + +packages: + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + 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@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + 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@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + 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.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + 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'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + 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'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + 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@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + 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@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + +snapshots: + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + body-parser@1.20.6: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.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@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + depd@2.0.0: {} + + destroy@1.2.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@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.6 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + 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.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.13: {} + + 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@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + 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@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} diff --git a/server.mjs b/server.mjs deleted file mode 100644 index 9592092..0000000 --- a/server.mjs +++ /dev/null @@ -1,572 +0,0 @@ -import express from "express"; -import crypto from "crypto"; -import https from "https"; -import fs from "fs"; - -// ── Config ── - -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"; -const MODELS = JSON.parse(fs.readFileSync("models.json", "utf8")); - -// ── 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; -} - -// ── IDs & Sessions ── - -function ocId(prefix) { - const ts = Date.now().toString(16); - const rnd = crypto.randomBytes(12).toString("base64url").slice(0, 16); - return `${prefix}_${ts}${rnd}`; -} - -// 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, - }, - }; -} - -// ── Format converters ── - -// Anthropic Messages request → OpenAI request body -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 response -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, - }, - }; -} - -// ── Response pipes ── - -function checkFirstChunkError(chunk) { - const str = chunk.toString().trim(); - if (!str.startsWith("{") || (!str.includes("FreeUsageLimitError") && !str.includes('"error"'))) return null; - try { - const parsed = JSON.parse(str); - if (parsed.error || parsed.type === "error") { - return parsed.error?.message || parsed.message || "Rate limit exceeded"; - } - } catch {} - return null; -} - -// 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 errMsg = checkFirstChunkError(chunk); - if (errMsg) { - 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; - } - - 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(); - }); -} - -// 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 errMsg = checkFirstChunkError(chunk); - if (errMsg) { - 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; - } - } - - 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(); - console.log("[ZEN TIMEOUT]"); - 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..f0992d0 --- /dev/null +++ b/src/app.mjs @@ -0,0 +1,15 @@ +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"; + +export function createApp() { + const app = express(); + app.use(express.json({ limit: "10mb" })); + app.use(modelsRouter); + app.use(chatRouter); + app.use(messagesRouter); + app.use(healthRouter); + return app; +} diff --git a/src/auth.mjs b/src/auth.mjs new file mode 100644 index 0000000..15d7d6f --- /dev/null +++ b/src/auth.mjs @@ -0,0 +1,28 @@ +import fs from "fs"; +import crypto from "crypto"; +import { KEYS_FILE } from "./config/index.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: "oc-" + crypto.randomBytes(20).toString("hex"), + "user-default": "oc-" + crypto.randomBytes(20).toString("hex"), + }); + fs.writeFileSync(KEYS_FILE, JSON.stringify(apiKeys, null, 2)); + console.log("[INIT] 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; + for (const [name, key] of Object.entries(apiKeys)) { + if (tok === key) return name; + } + return null; +} diff --git a/src/config/index.mjs b/src/config/index.mjs new file mode 100644 index 0000000..39e6b99 --- /dev/null +++ b/src/config/index.mjs @@ -0,0 +1,9 @@ +import fs from "fs"; + +export const PORT = process.env.PROXY_PORT || 6446; +export const OC_VERSION = "1.15.0"; +export const PROXY_VERSION = "9"; +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"; diff --git a/src/converters.mjs b/src/converters.mjs new file mode 100644 index 0000000..faf5177 --- /dev/null +++ b/src/converters.mjs @@ -0,0 +1,104 @@ +import { ocId } from "./utils.mjs"; + +export 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"); + 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 }; +} + +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, 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, + }, + }; +} diff --git a/src/index.mjs b/src/index.mjs new file mode 100644 index 0000000..bf85cbe --- /dev/null +++ b/src/index.mjs @@ -0,0 +1,20 @@ +import { createApp } from "./app.mjs"; +import { PORT, PROXY_VERSION, MODELS } from "./config/index.mjs"; +import { loadKeys, apiKeys } from "./auth.mjs"; +import { logStatusLine } from "./logger.mjs"; + +loadKeys(); + +const app = createApp(); +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(", ")); + logStatusLine(); + for (const [name, key] of Object.entries(apiKeys)) { + console.log(` ${name.padEnd(15)} ${key}`); + } +}); diff --git a/src/logger.mjs b/src/logger.mjs new file mode 100644 index 0000000..d3dce59 --- /dev/null +++ b/src/logger.mjs @@ -0,0 +1,95 @@ +// Detailed I/O logging for the proxy. +// LOG_DETAIL=0 to disable full dumps; LOG_MAX_CHARS=N to truncate (0 = unlimited). + +export const LOG_DETAIL = process.env.LOG_DETAIL !== "0"; +export const LOG_MAX_CHARS = Number(process.env.LOG_MAX_CHARS || 0) || 0; + +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]`; +} + +export function logLine(tag, ...args) { + console.log(`[${tag}]`, new Date().toISOString(), ...args); +} + +/** Pretty-print a labeled I/O block (request/response body). */ +export function logIO(tag, label, payload) { + if (!LOG_DETAIL) return; + const body = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); + console.log(`[${tag}] ── ${label} ──\n${trunc(body)}\n[${tag}] ── end ${label} ──`); +} + +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; + }); +} + +/** Reconstruct assistant text + tool_calls from OpenAI SSE stream bytes. */ +export function parseOpenAIStreamOutput(raw) { + let content = ""; + const toolCalls = {}; + let finishReason = null; + let usage = null; + for (const line of raw.split("\n")) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (!payload || payload === "[DONE]") continue; + let parsed; + try { parsed = JSON.parse(payload); } catch { continue; } + if (parsed.usage) usage = parsed.usage; + const choice = parsed.choices?.[0]; + if (!choice) continue; + if (choice.finish_reason) finishReason = choice.finish_reason; + const delta = choice.delta || choice.message; + if (!delta) continue; + if (delta.content) content += delta.content; + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const i = tc.index ?? 0; + if (!toolCalls[i]) toolCalls[i] = { id: tc.id || "", name: "", arguments: "" }; + if (tc.id) toolCalls[i].id = tc.id; + if (tc.function?.name) toolCalls[i].name = tc.function.name; + if (tc.function?.arguments) toolCalls[i].arguments += tc.function.arguments; + } + } + } + const out = { content }; + const tcs = Object.values(toolCalls); + if (tcs.length) out.tool_calls = tcs; + if (finishReason) out.finish_reason = finishReason; + if (usage) out.usage = usage; + return out; +} + +export function parseOpenAISyncOutput(data) { + if (!data) return { raw: null }; + const choice = data.choices?.[0]; + const out = { + content: choice?.message?.content ?? null, + finish_reason: choice?.finish_reason ?? null, + usage: data.usage ?? null, + }; + if (choice?.message?.tool_calls?.length) { + out.tool_calls = choice.message.tool_calls.map((tc) => ({ + id: tc.id, + name: tc.function?.name, + arguments: tc.function?.arguments, + })); + } + return out; +} + +export function logStatusLine() { + console.log( + " Log detail:", + LOG_DETAIL ? "on" : "off", + LOG_MAX_CHARS ? `(max ${LOG_MAX_CHARS} chars)` : "(unlimited)", + ); +} diff --git a/src/pipes.mjs b/src/pipes.mjs new file mode 100644 index 0000000..690c532 --- /dev/null +++ b/src/pipes.mjs @@ -0,0 +1,296 @@ +import https from "https"; +import { logLine, logIO, parseOpenAIStreamOutput, parseOpenAISyncOutput } from "./logger.mjs"; +import { ocId } from "./utils.mjs"; + +function checkFirstChunkError(chunk) { + const str = chunk.toString().trim(); + if (!str.startsWith("{") || (!str.includes("FreeUsageLimitError") && !str.includes('"error"'))) return null; + try { + const parsed = JSON.parse(str); + if (parsed.error || parsed.type === "error") { + return parsed.error?.message || parsed.message || "Rate limit exceeded"; + } + } catch {} + return null; +} + +export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { + const chunks = []; + const t0 = Date.now(); + const req = https.request(zenOpts, (zenRes) => { + let firstChunk = null; + let headersSent = false; + let rateLimited = false; + + zenRes.on("data", (chunk) => { + if (!firstChunk) { + firstChunk = chunk; + const errMsg = checkFirstChunkError(chunk); + if (errMsg) { + rateLimited = true; + logLine("ZEN", "RATE LIMITED", errMsg); + logIO(logTag, "OUTPUT (rate_limit)", { error: 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; + } + + 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" }); + } + chunks.push(firstChunk); + res.write(firstChunk); + if (res.flush) res.flush(); + return; + } + if (headersSent) { + chunks.push(chunk); + res.write(chunk); + if (res.flush) res.flush(); + } + }); + + zenRes.on("end", () => { + if (rateLimited) return; + if (!headersSent && !firstChunk) { + logLine("ZEN", "EMPTY", "No response from Zen API"); + logIO(logTag, "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 raw = Buffer.concat(chunks).toString(); + const ms = Date.now() - t0; + if (stream) { + logIO(logTag, `OUTPUT (stream, ${ms}ms)`, parseOpenAIStreamOutput(raw)); + } else { + try { + logIO(logTag, `OUTPUT (sync, ${ms}ms)`, parseOpenAISyncOutput(JSON.parse(raw))); + } catch { + logIO(logTag, `OUTPUT (sync raw, ${ms}ms)`, raw); + } + } + res.end(); + } + }); + }); + + req.on("error", (e) => { + logLine("ZEN", "ERROR", e.message); + logIO(logTag, "OUTPUT (error)", { error: e.message }); + if (!res.headersSent) { + res.status(502).json({ error: { message: "Upstream error: " + e.message, type: "upstream_error" } }); + } + }); + + req.on("timeout", () => { + req.destroy(); + logLine("ZEN", "TIMEOUT"); + logIO(logTag, "OUTPUT (timeout)", { error: "Upstream timeout" }); + if (!res.headersSent) { + res.status(504).json({ error: { message: "Upstream timeout", type: "timeout_error" } }); + } + }); + + req.write(body); + req.end(); +} + +export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { + const msgId = ocId("msg"); + const t0 = Date.now(); + let collectedText = ""; + const collectedTools = {}; + let stopReasonLogged = null; + + 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(); + + if (!firstChunkHandled) { + firstChunkHandled = true; + const errMsg = checkFirstChunkError(chunk); + if (errMsg) { + logLine("ZEN", "RATE LIMITED", errMsg); + logIO("ANT", "OUTPUT (rate_limit)", { error: errMsg }); + 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; + } + } + + 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: "" } }); + 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 || "" }, + }); + } + 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 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"; + 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 (!headersSent) { + logIO("ANT", "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("ANT", `OUTPUT (stream, ${ms}ms)`, out); + res.end(); + }); + }); + + req.on("error", (e) => { + logLine("ZEN", "ERROR", e.message); + logIO("ANT", "OUTPUT (error)", { error: e.message }); + if (!res.headersSent) { + res.status(502).json({ type: "error", error: { type: "upstream_error", message: e.message } }); + } + }); + + req.on("timeout", () => { + req.destroy(); + logLine("ZEN", "TIMEOUT"); + logIO("ANT", "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(); +} diff --git a/src/routes/chat.mjs b/src/routes/chat.mjs new file mode 100644 index 0000000..cb6d45a --- /dev/null +++ b/src/routes/chat.mjs @@ -0,0 +1,34 @@ +import { Router } from "express"; +import { MODELS } from "../config/index.mjs"; +import { auth } from "../auth.mjs"; +import { getSession } from "../session.mjs"; +import { zenRequest } from "../zen.mjs"; +import { pipeZenResponse } from "../pipes.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" } }); + + 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); + logLine("OAI", user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary(messages))); + logIO("OAI", "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, "OAI"); +}); + +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..a6a6600 --- /dev/null +++ b/src/routes/messages.mjs @@ -0,0 +1,73 @@ +import { Router } from "express"; +import { MODELS } from "../config/index.mjs"; +import { auth } from "../auth.mjs"; +import { getSession } from "../session.mjs"; +import { zenRequest, zenRequestFull } from "../zen.mjs"; +import { pipeZenAsAnthropic } from "../pipes.mjs"; +import { anthropicToOpenAI, openAIToAnthropic } from "../converters.mjs"; +import { logLine, logIO, msgSummary } from "../logger.mjs"; + +const router = Router(); + +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" } }); + } + + 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("ANT", user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary(messages))); + logIO("ANT", "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 }, + }); + + const { body, options } = zenRequest(model, messages, stream, tools, undefined, sessionId); + + if (stream) { + pipeZenAsAnthropic(options, body, model, res, inputTokens); + } else { + try { + const t0 = Date.now(); + const zenResp = await zenRequestFull(options, body); + const ms = Date.now() - t0; + if (zenResp.status === 429 || zenResp.data?.error) { + const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; + logIO("ANT", `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)" }, + }); + } + if (!zenResp.data?.choices) { + logIO("ANT", `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("ANT", `OUTPUT (sync, ${ms}ms)`, antResp); + res.json(antResp); + } catch (e) { + logLine("ZEN", "ERROR", e.message); + logIO("ANT", "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..e17d3fc --- /dev/null +++ b/src/session.mjs @@ -0,0 +1,11 @@ +import { ocId } from "./utils.mjs"; + +const userSessions = {}; + +export 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; +} 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/src/zen.mjs b/src/zen.mjs new file mode 100644 index 0000000..f4109bb --- /dev/null +++ b/src/zen.mjs @@ -0,0 +1,53 @@ +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(); + 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(); + }); +} 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/routes.test.mjs b/tests/routes.test.mjs new file mode 100644 index 0000000..b0f05c0 --- /dev/null +++ b/tests/routes.test.mjs @@ -0,0 +1,38 @@ +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("route auth", () => { + it("returns 401 without a key on /v1/chat/completions", 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/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); + } finally { + server.close(); + } + }); + + it("returns 401 without a key on /v1/messages", 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/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: MODELS[0], messages: [{ role: "user", content: "hi" }] }), + }); + assert.strictEqual(res.status, 401); + } finally { + server.close(); + } + }); +}); From 9ac1543190cd4d38aa2d94f3c308b13aab2405b6 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Wed, 22 Jul 2026 15:13:45 +0800 Subject: [PATCH 04/16] Add ensureOpenAIIds function and corresponding tests for payload validation --- src/pipes.mjs | 119 ++++++++++++++++++++++++++++++++++++------- tests/pipes.test.mjs | 86 +++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 tests/pipes.test.mjs diff --git a/src/pipes.mjs b/src/pipes.mjs index 690c532..5a02134 100644 --- a/src/pipes.mjs +++ b/src/pipes.mjs @@ -14,13 +14,93 @@ function checkFirstChunkError(chunk) { return null; } +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; +} + export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { const chunks = []; const t0 = Date.now(); + const toolCallIds = {}; + let requestModel = ""; + try { requestModel = JSON.parse(body).model || ""; } catch {} const req = https.request(zenOpts, (zenRes) => { let firstChunk = null; let headersSent = false; let rateLimited = false; + let sseBuffer = ""; + + 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(zenRes.statusCode, { "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); + chunks.push(Buffer.from(out + "\n")); + res.write(out + "\n"); + } + if (final && sseBuffer) { + const out = transformSseLine(sseBuffer); + chunks.push(Buffer.from(out + "\n")); + res.write(out + "\n"); + } + if (res.flush) res.flush(); + } + + function transformSseLine(line) { + 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; + } + } zenRes.on("data", (chunk) => { if (!firstChunk) { @@ -39,28 +119,22 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { return; } - headersSent = true; + sendHeaders(); 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(); + sseBuffer += chunk.toString(); + flushSseBuffer(); } else { - res.writeHead(zenRes.statusCode, { "Content-Type": "application/json" }); + chunks.push(chunk); } - chunks.push(firstChunk); - res.write(firstChunk); - if (res.flush) res.flush(); return; } if (headersSent) { - chunks.push(chunk); - res.write(chunk); - if (res.flush) res.flush(); + if (stream) { + sseBuffer += chunk.toString(); + flushSseBuffer(); + } else { + chunks.push(chunk); + } } }); @@ -75,18 +149,25 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { return; } if (headersSent) { - const raw = Buffer.concat(chunks).toString(); const ms = Date.now() - t0; if (stream) { + flushSseBuffer(true); + const raw = Buffer.concat(chunks).toString(); logIO(logTag, `OUTPUT (stream, ${ms}ms)`, parseOpenAIStreamOutput(raw)); + res.end(); } else { + const raw = Buffer.concat(chunks).toString(); try { - logIO(logTag, `OUTPUT (sync, ${ms}ms)`, parseOpenAISyncOutput(JSON.parse(raw))); + const parsed = JSON.parse(raw); + const updated = ensureOpenAIIds(parsed, toolCallIds, requestModel); + const rawUpdated = JSON.stringify(updated); + logIO(logTag, `OUTPUT (sync, ${ms}ms)`, parseOpenAISyncOutput(updated)); + res.end(rawUpdated); } catch { logIO(logTag, `OUTPUT (sync raw, ${ms}ms)`, raw); + res.end(raw); } } - res.end(); } }); }); diff --git a/tests/pipes.test.mjs b/tests/pipes.test.mjs new file mode 100644 index 0000000..9108611 --- /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/pipes.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"); + }); +}); From 1f16746637002b3b9d64c434362383fc0a517b30 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Wed, 22 Jul 2026 17:40:50 +0800 Subject: [PATCH 05/16] Enhance logging and session management: - Add logging options for color and detail in configuration - Implement detailed logging for API interactions - Introduce session cleanup for stale sessions - Refactor logging functions for consistency - Update API routes to utilize new logging methods --- AGENTS.md | 4 ++ docker-compose.dev.yaml | 2 + docker-compose.yaml | 2 + src/app.mjs | 33 +++++++++++++ src/auth.mjs | 3 +- src/index.mjs | 18 +++---- src/logger.mjs | 58 +++++++++++++++++----- src/pipes.mjs | 40 +++++++-------- src/routes/chat.mjs | 6 +-- src/routes/messages.mjs | 106 +++++++++++++++++++++------------------- src/session.mjs | 31 ++++++++++-- 11 files changed, 202 insertions(+), 101 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ee90ce..0c57493 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,10 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu |----------|---------|-------| | `PROXY_PORT` | `6446` | Server listen port | | `KEYS_FILE` | `./api-keys.json` | Auto-created if missing | +| `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 diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 1331eeb..0cc1bb2 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -15,6 +15,8 @@ services: # 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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 7e4f1d3..5e062d2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,6 +14,8 @@ services: # 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: # Persist auto-generated API keys across container recreations - proxy-keys:/data diff --git a/src/app.mjs b/src/app.mjs index f0992d0..c7e563e 100644 --- a/src/app.mjs +++ b/src/app.mjs @@ -3,6 +3,14 @@ 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"; + +/** Wrap async route handlers so rejected promises reach the global error handler. */ +export function asyncHandler(fn) { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} export function createApp() { const app = express(); @@ -11,5 +19,30 @@ export function createApp() { 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 — prevents hanging requests on unhandled errors + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, next) => { + logLine("UNHANDLED ERROR", err.message, err.stack); + if (res.headersSent) { + return next(err); + } + + const status = err.status || err.statusCode || 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 index 15d7d6f..01149a8 100644 --- a/src/auth.mjs +++ b/src/auth.mjs @@ -1,6 +1,7 @@ import fs from "fs"; import crypto from "crypto"; import { KEYS_FILE } from "./config/index.mjs"; +import { logLine } from "./logger.mjs"; export const apiKeys = {}; @@ -14,7 +15,7 @@ export function loadKeys() { "user-default": "oc-" + crypto.randomBytes(20).toString("hex"), }); fs.writeFileSync(KEYS_FILE, JSON.stringify(apiKeys, null, 2)); - console.log("[INIT] Generated new API keys →", KEYS_FILE); + logLine("Generated new API keys →", KEYS_FILE); } } diff --git a/src/index.mjs b/src/index.mjs index bf85cbe..18687dc 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,20 +1,18 @@ import { createApp } from "./app.mjs"; import { PORT, PROXY_VERSION, MODELS } from "./config/index.mjs"; import { loadKeys, apiKeys } from "./auth.mjs"; -import { logStatusLine } from "./logger.mjs"; +import { logLine, logStatusLine } from "./logger.mjs"; loadKeys(); const app = createApp(); 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(", ")); + 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(); - for (const [name, key] of Object.entries(apiKeys)) { - console.log(` ${name.padEnd(15)} ${key}`); - } + logLine(" API keys:", Object.keys(apiKeys).length, "loaded"); }); diff --git a/src/logger.mjs b/src/logger.mjs index d3dce59..9891846 100644 --- a/src/logger.mjs +++ b/src/logger.mjs @@ -1,26 +1,68 @@ // 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]`; } -export function logLine(tag, ...args) { - console.log(`[${tag}]`, new Date().toISOString(), ...args); +/** 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(tag, label, payload) { +export function logIO(label, payload) { if (!LOG_DETAIL) return; const body = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); - console.log(`[${tag}] ── ${label} ──\n${trunc(body)}\n[${tag}] ── end ${label} ──`); + 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 || ""); @@ -85,11 +127,3 @@ export function parseOpenAISyncOutput(data) { } return out; } - -export function logStatusLine() { - console.log( - " Log detail:", - LOG_DETAIL ? "on" : "off", - LOG_MAX_CHARS ? `(max ${LOG_MAX_CHARS} chars)` : "(unlimited)", - ); -} diff --git a/src/pipes.mjs b/src/pipes.mjs index 5a02134..15ba282 100644 --- a/src/pipes.mjs +++ b/src/pipes.mjs @@ -43,7 +43,7 @@ export function ensureOpenAIIds(payload, toolCallIds = {}, model = "") { return payload; } -export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { +export function pipeZenResponse(zenOpts, body, stream, res) { const chunks = []; const t0 = Date.now(); const toolCallIds = {}; @@ -108,8 +108,8 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { const errMsg = checkFirstChunkError(chunk); if (errMsg) { rateLimited = true; - logLine("ZEN", "RATE LIMITED", errMsg); - logIO(logTag, "OUTPUT (rate_limit)", { error: errMsg }); + logLine("RATE LIMITED", errMsg); + logIO("OUTPUT (rate_limit)", { error: errMsg }); if (!res.headersSent) { res.status(429).json({ error: { message: errMsg + " (free model rate limit)", type: "rate_limit_error", code: "rate_limit_exceeded" } @@ -141,8 +141,8 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { zenRes.on("end", () => { if (rateLimited) return; if (!headersSent && !firstChunk) { - logLine("ZEN", "EMPTY", "No response from Zen API"); - logIO(logTag, "OUTPUT (empty)", { error: "Empty response from upstream" }); + 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" } }); } @@ -153,7 +153,7 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { if (stream) { flushSseBuffer(true); const raw = Buffer.concat(chunks).toString(); - logIO(logTag, `OUTPUT (stream, ${ms}ms)`, parseOpenAIStreamOutput(raw)); + logIO(`OUTPUT (stream, ${ms}ms)`, parseOpenAIStreamOutput(raw)); res.end(); } else { const raw = Buffer.concat(chunks).toString(); @@ -161,10 +161,10 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { const parsed = JSON.parse(raw); const updated = ensureOpenAIIds(parsed, toolCallIds, requestModel); const rawUpdated = JSON.stringify(updated); - logIO(logTag, `OUTPUT (sync, ${ms}ms)`, parseOpenAISyncOutput(updated)); + logIO(`OUTPUT (sync, ${ms}ms)`, parseOpenAISyncOutput(updated)); res.end(rawUpdated); } catch { - logIO(logTag, `OUTPUT (sync raw, ${ms}ms)`, raw); + logIO(`OUTPUT (sync raw, ${ms}ms)`, raw); res.end(raw); } } @@ -173,8 +173,8 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { }); req.on("error", (e) => { - logLine("ZEN", "ERROR", e.message); - logIO(logTag, "OUTPUT (error)", { error: e.message }); + 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" } }); } @@ -182,8 +182,8 @@ export function pipeZenResponse(zenOpts, body, stream, res, logTag = "OAI") { req.on("timeout", () => { req.destroy(); - logLine("ZEN", "TIMEOUT"); - logIO(logTag, "OUTPUT (timeout)", { error: "Upstream timeout" }); + logLine("TIMEOUT"); + logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); if (!res.headersSent) { res.status(504).json({ error: { message: "Upstream timeout", type: "timeout_error" } }); } @@ -241,8 +241,8 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { firstChunkHandled = true; const errMsg = checkFirstChunkError(chunk); if (errMsg) { - logLine("ZEN", "RATE LIMITED", errMsg); - logIO("ANT", "OUTPUT (rate_limit)", { error: errMsg }); + logLine("RATE LIMITED", errMsg); + logIO("OUTPUT (rate_limit)", { error: errMsg }); if (!res.headersSent) { res.writeHead(429, { "Content-Type": "application/json" }); res.end(JSON.stringify({ @@ -336,7 +336,7 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { zenRes.on("end", () => { if (!headersSent) { - logIO("ANT", "OUTPUT (empty)", { error: "Empty response" }); + logIO("OUTPUT (empty)", { error: "Empty response" }); if (!res.headersSent) { res.status(502).json({ type: "error", error: { type: "upstream_error", message: "Empty response" } }); } @@ -350,14 +350,14 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { }; const tools = Object.values(collectedTools); if (tools.length) out.tool_calls = tools; - logIO("ANT", `OUTPUT (stream, ${ms}ms)`, out); + logIO(`OUTPUT (stream, ${ms}ms)`, out); res.end(); }); }); req.on("error", (e) => { - logLine("ZEN", "ERROR", e.message); - logIO("ANT", "OUTPUT (error)", { error: e.message }); + 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 } }); } @@ -365,8 +365,8 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { req.on("timeout", () => { req.destroy(); - logLine("ZEN", "TIMEOUT"); - logIO("ANT", "OUTPUT (timeout)", { error: "Upstream timeout" }); + logLine("TIMEOUT"); + logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); if (!res.headersSent) { res.status(504).json({ type: "error", error: { type: "timeout_error", message: "Upstream timeout" } }); } diff --git a/src/routes/chat.mjs b/src/routes/chat.mjs index cb6d45a..d3040b3 100644 --- a/src/routes/chat.mjs +++ b/src/routes/chat.mjs @@ -18,8 +18,8 @@ router.post("/v1/chat/completions", (req, res) => { } const sessionId = getSession(user); - logLine("OAI", user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary(messages))); - logIO("OAI", "INPUT", { + logLine(user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary(messages))); + logIO("INPUT", { model, stream: !!stream, tool_choice: tool_choice || undefined, @@ -28,7 +28,7 @@ router.post("/v1/chat/completions", (req, res) => { }); const { body, options } = zenRequest(model, messages, stream, tools, tool_choice, sessionId); - pipeZenResponse(options, body, stream, res, "OAI"); + pipeZenResponse(options, body, stream, res); }); export default router; diff --git a/src/routes/messages.mjs b/src/routes/messages.mjs index a6a6600..d522322 100644 --- a/src/routes/messages.mjs +++ b/src/routes/messages.mjs @@ -9,64 +9,68 @@ import { logLine, logIO, msgSummary } from "../logger.mjs"; const router = Router(); -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" } }); - } +router.post("/v1/messages", async (req, res, next) => { + try { + 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 { 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; + const sessionId = getSession(user); + const { messages, tools } = anthropicToOpenAI(req.body); + const inputTokens = JSON.stringify(messages).length / 4 | 0; - logLine("ANT", user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary(messages))); - logIO("ANT", "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 }, - }); + 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 }, + }); - const { body, options } = zenRequest(model, messages, stream, tools, undefined, sessionId); + const { body, options } = zenRequest(model, messages, stream, tools, undefined, sessionId); - if (stream) { - pipeZenAsAnthropic(options, body, model, res, inputTokens); - } else { - try { - const t0 = Date.now(); - const zenResp = await zenRequestFull(options, body); - const ms = Date.now() - t0; - if (zenResp.status === 429 || zenResp.data?.error) { - const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; - logIO("ANT", `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)" }, - }); - } - if (!zenResp.data?.choices) { - logIO("ANT", `OUTPUT (invalid, ${ms}ms)`, { raw: zenResp.raw }); - return res.status(502).json({ - type: "error", error: { type: "upstream_error", message: "Invalid upstream response" }, - }); + if (stream) { + pipeZenAsAnthropic(options, body, model, res, inputTokens); + } else { + try { + const t0 = Date.now(); + const zenResp = await zenRequestFull(options, body); + const ms = Date.now() - t0; + if (zenResp.status === 429 || zenResp.data?.error) { + 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)" }, + }); + } + 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 } }); } - const antResp = openAIToAnthropic(zenResp.data, model, inputTokens); - logIO("ANT", `OUTPUT (sync, ${ms}ms)`, antResp); - res.json(antResp); - } catch (e) { - logLine("ZEN", "ERROR", e.message); - logIO("ANT", "OUTPUT (error)", { error: e.message }); - res.status(502).json({ type: "error", error: { type: "upstream_error", message: e.message } }); } + } catch (e) { + next(e); } }); diff --git a/src/session.mjs b/src/session.mjs index e17d3fc..4c0aa9f 100644 --- a/src/session.mjs +++ b/src/session.mjs @@ -1,11 +1,34 @@ import { ocId } from "./utils.mjs"; -const userSessions = {}; +const userSessions = new Map(); +const SESSION_TTL = 30 * 60 * 1000; // 30 minutes +const CLEANUP_INTERVAL = 60 * 1000; // 1 minute + +function cleanupStaleSessions() { + const cutoff = Date.now() - SESSION_TTL; + for (const [user, session] of userSessions) { + if (session.ts < cutoff) { + userSessions.delete(user); + } + } +} + +const cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL); +if (cleanupTimer.unref) cleanupTimer.unref(); export function getSession(user) { const now = Date.now(); - if (!userSessions[user] || now - userSessions[user].ts > 30 * 60 * 1000) { - userSessions[user] = { id: ocId("ses"), ts: 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; } - return userSessions[user].id; + // Bump timestamp on activity so active sessions stay alive. + existing.ts = now; + return existing.id; +} + +export function sessionCount() { + return userSessions.size; } From 4e413a7b6863c861a3b0b91cf1f7a55c8f532093 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 30 Jul 2026 15:43:07 +0800 Subject: [PATCH 06/16] v0.1.1 - Version now read from package.json (0.1.1) - Remove cleanup timer and dead sessionCount from session.mjs - DRY zero cache tokens in converters.mjs - Move response parsers from logger.mjs to pipes.mjs - Add GitHub Action workflow for Docker Hub publish (tag-driven) --- .github/workflows/docker-publish.yml | 47 +++++++++++++++++++++++ package.json | 2 +- src/config/index.mjs | 7 +++- src/converters.mjs | 7 ++-- src/logger.mjs | 53 -------------------------- src/pipes.mjs | 57 +++++++++++++++++++++++++++- src/session.mjs | 17 --------- 7 files changed, 114 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..337172c --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,47 @@ +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: ${{ secrets.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 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/package.json b/package.json index 00f1e0e..88652bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-free-proxy", - "version": "0.9.0", + "version": "0.1.1", "description": "Proxy server for OpenCode free-tier AI models via Zen API", "type": "module", "main": "src/index.mjs", diff --git a/src/config/index.mjs b/src/config/index.mjs index 39e6b99..eb6bf2d 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -2,7 +2,12 @@ import fs from "fs"; export const PORT = process.env.PROXY_PORT || 6446; export const OC_VERSION = "1.15.0"; -export const PROXY_VERSION = "9"; + +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"), ); diff --git a/src/converters.mjs b/src/converters.mjs index faf5177..a8bd8f3 100644 --- a/src/converters.mjs +++ b/src/converters.mjs @@ -1,5 +1,7 @@ import { ocId } from "./utils.mjs"; +const NO_CACHE = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; + export function anthropicToOpenAI(body) { const messages = []; if (body.system) { @@ -60,7 +62,7 @@ export function openAIToAnthropic(oaiResp, model, inputTokens) { 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 }, + usage: { input_tokens: inputTokens || 0, output_tokens: 0, ...NO_CACHE }, }; } @@ -97,8 +99,7 @@ export function openAIToAnthropic(oaiResp, model, inputTokens) { 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, + ...NO_CACHE, }, }; } diff --git a/src/logger.mjs b/src/logger.mjs index 9891846..20964ff 100644 --- a/src/logger.mjs +++ b/src/logger.mjs @@ -73,57 +73,4 @@ export function msgSummary(messages) { }); } -/** Reconstruct assistant text + tool_calls from OpenAI SSE stream bytes. */ -export function parseOpenAIStreamOutput(raw) { - let content = ""; - const toolCalls = {}; - let finishReason = null; - let usage = null; - for (const line of raw.split("\n")) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (!payload || payload === "[DONE]") continue; - let parsed; - try { parsed = JSON.parse(payload); } catch { continue; } - if (parsed.usage) usage = parsed.usage; - const choice = parsed.choices?.[0]; - if (!choice) continue; - if (choice.finish_reason) finishReason = choice.finish_reason; - const delta = choice.delta || choice.message; - if (!delta) continue; - if (delta.content) content += delta.content; - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const i = tc.index ?? 0; - if (!toolCalls[i]) toolCalls[i] = { id: tc.id || "", name: "", arguments: "" }; - if (tc.id) toolCalls[i].id = tc.id; - if (tc.function?.name) toolCalls[i].name = tc.function.name; - if (tc.function?.arguments) toolCalls[i].arguments += tc.function.arguments; - } - } - } - const out = { content }; - const tcs = Object.values(toolCalls); - if (tcs.length) out.tool_calls = tcs; - if (finishReason) out.finish_reason = finishReason; - if (usage) out.usage = usage; - return out; -} -export function parseOpenAISyncOutput(data) { - if (!data) return { raw: null }; - const choice = data.choices?.[0]; - const out = { - content: choice?.message?.content ?? null, - finish_reason: choice?.finish_reason ?? null, - usage: data.usage ?? null, - }; - if (choice?.message?.tool_calls?.length) { - out.tool_calls = choice.message.tool_calls.map((tc) => ({ - id: tc.id, - name: tc.function?.name, - arguments: tc.function?.arguments, - })); - } - return out; -} diff --git a/src/pipes.mjs b/src/pipes.mjs index 15ba282..76917b6 100644 --- a/src/pipes.mjs +++ b/src/pipes.mjs @@ -1,5 +1,5 @@ import https from "https"; -import { logLine, logIO, parseOpenAIStreamOutput, parseOpenAISyncOutput } from "./logger.mjs"; +import { logLine, logIO } from "./logger.mjs"; import { ocId } from "./utils.mjs"; function checkFirstChunkError(chunk) { @@ -375,3 +375,58 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { req.write(body); req.end(); } + +/** Reconstruct assistant text + tool_calls from OpenAI SSE stream bytes. */ +export function parseOpenAIStreamOutput(raw) { + let content = ""; + const toolCalls = {}; + let finishReason = null; + let usage = null; + for (const line of raw.split("\n")) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (!payload || payload === "[DONE]") continue; + let parsed; + try { parsed = JSON.parse(payload); } catch { continue; } + if (parsed.usage) usage = parsed.usage; + const choice = parsed.choices?.[0]; + if (!choice) continue; + if (choice.finish_reason) finishReason = choice.finish_reason; + const delta = choice.delta || choice.message; + if (!delta) continue; + if (delta.content) content += delta.content; + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const i = tc.index ?? 0; + if (!toolCalls[i]) toolCalls[i] = { id: tc.id || "", name: "", arguments: "" }; + if (tc.id) toolCalls[i].id = tc.id; + if (tc.function?.name) toolCalls[i].name = tc.function.name; + if (tc.function?.arguments) toolCalls[i].arguments += tc.function.arguments; + } + } + } + const out = { content }; + const tcs = Object.values(toolCalls); + if (tcs.length) out.tool_calls = tcs; + if (finishReason) out.finish_reason = finishReason; + if (usage) out.usage = usage; + return out; +} + +export function parseOpenAISyncOutput(data) { + if (!data) return { raw: null }; + const choice = data.choices?.[0]; + const out = { + content: choice?.message?.content ?? null, + finish_reason: choice?.finish_reason ?? null, + usage: data.usage ?? null, + }; + if (choice?.message?.tool_calls?.length) { + out.tool_calls = choice.message.tool_calls.map((tc) => ({ + id: tc.id, + name: tc.function?.name, + arguments: tc.function?.arguments, + })); + } + return out; +} diff --git a/src/session.mjs b/src/session.mjs index 4c0aa9f..322efbc 100644 --- a/src/session.mjs +++ b/src/session.mjs @@ -2,19 +2,6 @@ import { ocId } from "./utils.mjs"; const userSessions = new Map(); const SESSION_TTL = 30 * 60 * 1000; // 30 minutes -const CLEANUP_INTERVAL = 60 * 1000; // 1 minute - -function cleanupStaleSessions() { - const cutoff = Date.now() - SESSION_TTL; - for (const [user, session] of userSessions) { - if (session.ts < cutoff) { - userSessions.delete(user); - } - } -} - -const cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL); -if (cleanupTimer.unref) cleanupTimer.unref(); export function getSession(user) { const now = Date.now(); @@ -28,7 +15,3 @@ export function getSession(user) { existing.ts = now; return existing.id; } - -export function sessionCount() { - return userSessions.size; -} From 5d949dfcc95e92691f90b8ff07971161f4b99225 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 30 Jul 2026 15:47:49 +0800 Subject: [PATCH 07/16] fix: use vars.DOCKER_USERNAME for environment variable --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 337172c..74f5624 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,7 +23,7 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_USERNAME }} + username: ${{ vars.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) From 2162a647dccb7088e81ab75e7318f79c8983256e Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 30 Jul 2026 16:04:56 +0800 Subject: [PATCH 08/16] fix: copy package.json into runtime stage for version --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 887791f..37f855b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN npm install --production FROM node:24-alpine AS run WORKDIR /app COPY --from=build /app/node_modules ./node_modules +COPY package.json ./ COPY models.json ./ COPY src ./src RUN mkdir -p /data && chown -R node:node /app /data From e841fcc97a9728281b76f56e96b574768d742e1d Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 30 Jul 2026 16:20:14 +0800 Subject: [PATCH 09/16] ci: build multi-arch (amd64 + arm64) --- .github/workflows/docker-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 74f5624..c157288 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,6 +41,7 @@ jobs: with: context: . push: true + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha From 4d6b2fb5dd3ad759e08a647f6019398cdc09614a Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 30 Jul 2026 16:30:01 +0800 Subject: [PATCH 10/16] feat: support ADMIN_API_KEY / USER_DEFAULT_API_KEY env vars --- docker-compose.dev.yaml | 3 +++ docker-compose.yaml | 3 +++ src/auth.mjs | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 0cc1bb2..03746ca 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -12,6 +12,9 @@ services: 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}" diff --git a/docker-compose.yaml b/docker-compose.yaml index 5e062d2..219a423 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,6 +11,9 @@ services: PROXY_PORT: "6446" KEYS_FILE: /data/api-keys.json NODE_ENV: production + # 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}" diff --git a/src/auth.mjs b/src/auth.mjs index 01149a8..af6eaf8 100644 --- a/src/auth.mjs +++ b/src/auth.mjs @@ -11,8 +11,8 @@ export function loadKeys() { } catch {} if (Object.keys(apiKeys).length === 0) { Object.assign(apiKeys, { - admin: "oc-" + crypto.randomBytes(20).toString("hex"), - "user-default": "oc-" + crypto.randomBytes(20).toString("hex"), + 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); From 0dd9664bb69ac60dd469d90fecde3a21c9a720be Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 30 Jul 2026 16:37:50 +0800 Subject: [PATCH 11/16] refactor: split into single-responsibility modules, optimize code - client.mjs: Zen API HTTP request builders (renamed from zen.mjs) - to-openai.mjs / to-anthropic.mjs: directional format converters (split from converters.mjs) - pipe-openai.mjs / pipe-anthropic.mjs: response pipe per protocol (split from pipes.mjs) - utils.mjs: add shared checkFirstChunkError, eliminate duplication - to-openai.mjs: extract contentText helper, DRY text extraction - pipe-anthropic.mjs: add NO_CACHE const for usage - app.mjs: remove unused asyncHandler export --- AGENTS.md | 10 +- src/app.mjs | 7 - src/{zen.mjs => client.mjs} | 0 src/converters.mjs | 105 ---------- src/pipe-anthropic.mjs | 194 ++++++++++++++++++ src/{pipes.mjs => pipe-openai.mjs} | 315 ++++++----------------------- src/routes/chat.mjs | 4 +- src/routes/messages.mjs | 7 +- src/to-anthropic.mjs | 56 +++++ src/to-openai.mjs | 52 +++++ src/utils.mjs | 13 ++ tests/pipes.test.mjs | 2 +- 12 files changed, 393 insertions(+), 372 deletions(-) rename src/{zen.mjs => client.mjs} (100%) delete mode 100644 src/converters.mjs create mode 100644 src/pipe-anthropic.mjs rename src/{pipes.mjs => pipe-openai.mjs} (52%) create mode 100644 src/to-anthropic.mjs create mode 100644 src/to-openai.mjs diff --git a/AGENTS.md b/AGENTS.md index 0c57493..56fd6c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,11 +45,13 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu | `src/config/index.mjs` | Port, version, model list | | `src/auth.mjs` | API key loading / auth middleware helper | | `src/session.mjs` | Per-user session rotation | -| `src/zen.mjs` | Zen API request builders | -| `src/converters.mjs` | Anthropic ⇄ OpenAI format converters | -| `src/pipes.mjs` | Stream / sync response forwarding | -| `src/routes/*.mjs` | Route handlers | +| `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, `node:24-alpine`, runs as `node` user | diff --git a/src/app.mjs b/src/app.mjs index c7e563e..faf7aa3 100644 --- a/src/app.mjs +++ b/src/app.mjs @@ -5,13 +5,6 @@ import messagesRouter from "./routes/messages.mjs"; import healthRouter from "./routes/health.mjs"; import { logLine } from "./logger.mjs"; -/** Wrap async route handlers so rejected promises reach the global error handler. */ -export function asyncHandler(fn) { - return (req, res, next) => { - Promise.resolve(fn(req, res, next)).catch(next); - }; -} - export function createApp() { const app = express(); app.use(express.json({ limit: "10mb" })); diff --git a/src/zen.mjs b/src/client.mjs similarity index 100% rename from src/zen.mjs rename to src/client.mjs diff --git a/src/converters.mjs b/src/converters.mjs deleted file mode 100644 index a8bd8f3..0000000 --- a/src/converters.mjs +++ /dev/null @@ -1,105 +0,0 @@ -import { ocId } from "./utils.mjs"; - -const NO_CACHE = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; - -export 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"); - 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 }; -} - -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"; - 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, - ...NO_CACHE, - }, - }; -} diff --git a/src/pipe-anthropic.mjs b/src/pipe-anthropic.mjs new file mode 100644 index 0000000..2bd9f72 --- /dev/null +++ b/src/pipe-anthropic.mjs @@ -0,0 +1,194 @@ +import https from "https"; +import { logLine, logIO } from "./logger.mjs"; +import { ocId, checkFirstChunkError } from "./utils.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). + */ +export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { + const msgId = ocId("msg"); + const t0 = Date.now(); + let collectedText = ""; + const collectedTools = {}; + let stopReasonLogged = null; + + 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, ...NO_CACHE }, + }, + }); + } + + zenRes.on("data", (chunk) => { + const str = chunk.toString(); + + if (!firstChunkHandled) { + firstChunkHandled = true; + const errMsg = checkFirstChunkError(chunk); + if (errMsg) { + logLine("RATE LIMITED", errMsg); + logIO("OUTPUT (rate_limit)", { error: errMsg }); + 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; + } + } + + 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: "" } }); + 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 || "" }, + }); + } + 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 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"; + 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 (!headersSent) { + 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) => { + 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", () => { + req.destroy(); + 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(); +} diff --git a/src/pipes.mjs b/src/pipe-openai.mjs similarity index 52% rename from src/pipes.mjs rename to src/pipe-openai.mjs index 76917b6..1e26837 100644 --- a/src/pipes.mjs +++ b/src/pipe-openai.mjs @@ -1,18 +1,8 @@ import https from "https"; import { logLine, logIO } from "./logger.mjs"; -import { ocId } from "./utils.mjs"; +import { ocId, checkFirstChunkError } from "./utils.mjs"; -function checkFirstChunkError(chunk) { - const str = chunk.toString().trim(); - if (!str.startsWith("{") || (!str.includes("FreeUsageLimitError") && !str.includes('"error"'))) return null; - try { - const parsed = JSON.parse(str); - if (parsed.error || parsed.type === "error") { - return parsed.error?.message || parsed.message || "Rate limit exceeded"; - } - } catch {} - return null; -} +// ── shared helpers ───────────────────────────────────────────────────────── export function ensureOpenAIIds(payload, toolCallIds = {}, model = "") { if (typeof payload.object !== "string" || !payload.object) { @@ -43,6 +33,69 @@ export function ensureOpenAIIds(payload, toolCallIds = {}, model = "") { return payload; } +// ── response parsers (for logging) ───────────────────────────────────────── + +/** Reconstruct assistant text + tool_calls from OpenAI SSE stream bytes. */ +export function parseOpenAIStreamOutput(raw) { + let content = ""; + const toolCalls = {}; + let finishReason = null; + let usage = null; + for (const line of raw.split("\n")) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (!payload || payload === "[DONE]") continue; + let parsed; + try { parsed = JSON.parse(payload); } catch { continue; } + if (parsed.usage) usage = parsed.usage; + const choice = parsed.choices?.[0]; + if (!choice) continue; + if (choice.finish_reason) finishReason = choice.finish_reason; + const delta = choice.delta || choice.message; + if (!delta) continue; + if (delta.content) content += delta.content; + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const i = tc.index ?? 0; + if (!toolCalls[i]) toolCalls[i] = { id: tc.id || "", name: "", arguments: "" }; + if (tc.id) toolCalls[i].id = tc.id; + if (tc.function?.name) toolCalls[i].name = tc.function.name; + if (tc.function?.arguments) toolCalls[i].arguments += tc.function.arguments; + } + } + } + const out = { content }; + const tcs = Object.values(toolCalls); + if (tcs.length) out.tool_calls = tcs; + if (finishReason) out.finish_reason = finishReason; + if (usage) out.usage = usage; + return out; +} + +export function parseOpenAISyncOutput(data) { + if (!data) return { raw: null }; + const choice = data.choices?.[0]; + const out = { + content: choice?.message?.content ?? null, + finish_reason: choice?.finish_reason ?? null, + usage: data.usage ?? null, + }; + if (choice?.message?.tool_calls?.length) { + out.tool_calls = choice.message.tool_calls.map((tc) => ({ + id: tc.id, + name: tc.function?.name, + arguments: tc.function?.arguments, + })); + } + return out; +} + +// ── 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). + */ export function pipeZenResponse(zenOpts, body, stream, res) { const chunks = []; const t0 = Date.now(); @@ -192,241 +245,3 @@ export function pipeZenResponse(zenOpts, body, stream, res) { req.write(body); req.end(); } - -export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { - const msgId = ocId("msg"); - const t0 = Date.now(); - let collectedText = ""; - const collectedTools = {}; - let stopReasonLogged = null; - - 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(); - - if (!firstChunkHandled) { - firstChunkHandled = true; - const errMsg = checkFirstChunkError(chunk); - if (errMsg) { - logLine("RATE LIMITED", errMsg); - logIO("OUTPUT (rate_limit)", { error: errMsg }); - 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; - } - } - - 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: "" } }); - 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 || "" }, - }); - } - 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 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"; - 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 (!headersSent) { - 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) => { - 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", () => { - req.destroy(); - 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(); -} - -/** Reconstruct assistant text + tool_calls from OpenAI SSE stream bytes. */ -export function parseOpenAIStreamOutput(raw) { - let content = ""; - const toolCalls = {}; - let finishReason = null; - let usage = null; - for (const line of raw.split("\n")) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (!payload || payload === "[DONE]") continue; - let parsed; - try { parsed = JSON.parse(payload); } catch { continue; } - if (parsed.usage) usage = parsed.usage; - const choice = parsed.choices?.[0]; - if (!choice) continue; - if (choice.finish_reason) finishReason = choice.finish_reason; - const delta = choice.delta || choice.message; - if (!delta) continue; - if (delta.content) content += delta.content; - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const i = tc.index ?? 0; - if (!toolCalls[i]) toolCalls[i] = { id: tc.id || "", name: "", arguments: "" }; - if (tc.id) toolCalls[i].id = tc.id; - if (tc.function?.name) toolCalls[i].name = tc.function.name; - if (tc.function?.arguments) toolCalls[i].arguments += tc.function.arguments; - } - } - } - const out = { content }; - const tcs = Object.values(toolCalls); - if (tcs.length) out.tool_calls = tcs; - if (finishReason) out.finish_reason = finishReason; - if (usage) out.usage = usage; - return out; -} - -export function parseOpenAISyncOutput(data) { - if (!data) return { raw: null }; - const choice = data.choices?.[0]; - const out = { - content: choice?.message?.content ?? null, - finish_reason: choice?.finish_reason ?? null, - usage: data.usage ?? null, - }; - if (choice?.message?.tool_calls?.length) { - out.tool_calls = choice.message.tool_calls.map((tc) => ({ - id: tc.id, - name: tc.function?.name, - arguments: tc.function?.arguments, - })); - } - return out; -} diff --git a/src/routes/chat.mjs b/src/routes/chat.mjs index d3040b3..758fed3 100644 --- a/src/routes/chat.mjs +++ b/src/routes/chat.mjs @@ -2,8 +2,8 @@ import { Router } from "express"; import { MODELS } from "../config/index.mjs"; import { auth } from "../auth.mjs"; import { getSession } from "../session.mjs"; -import { zenRequest } from "../zen.mjs"; -import { pipeZenResponse } from "../pipes.mjs"; +import { zenRequest } from "../client.mjs"; +import { pipeZenResponse } from "../pipe-openai.mjs"; import { logLine, logIO, msgSummary } from "../logger.mjs"; const router = Router(); diff --git a/src/routes/messages.mjs b/src/routes/messages.mjs index d522322..ae27cb6 100644 --- a/src/routes/messages.mjs +++ b/src/routes/messages.mjs @@ -2,9 +2,10 @@ import { Router } from "express"; import { MODELS } from "../config/index.mjs"; import { auth } from "../auth.mjs"; import { getSession } from "../session.mjs"; -import { zenRequest, zenRequestFull } from "../zen.mjs"; -import { pipeZenAsAnthropic } from "../pipes.mjs"; -import { anthropicToOpenAI, openAIToAnthropic } from "../converters.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"; const router = Router(); diff --git a/src/to-anthropic.mjs b/src/to-anthropic.mjs new file mode 100644 index 0000000..84b6383 --- /dev/null +++ b/src/to-anthropic.mjs @@ -0,0 +1,56 @@ +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"; + 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, + ...NO_CACHE, + }, + }; +} diff --git a/src/to-openai.mjs b/src/to-openai.mjs new file mode 100644 index 0000000..c4f891e --- /dev/null +++ b/src/to-openai.mjs @@ -0,0 +1,52 @@ +/** 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 ""; +} + +/** 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)) { + const text = contentText(msg.content.filter(b => b.type === "text")); + + 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")) { + messages.push({ role: "tool", tool_call_id: b.tool_use_id, content: contentText(b.content) }); + } + } 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 }; +} diff --git a/src/utils.mjs b/src/utils.mjs index 1c4a750..c3b5966 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -5,3 +5,16 @@ export function ocId(prefix) { const rnd = crypto.randomBytes(12).toString("base64url").slice(0, 16); return `${prefix}_${ts}${rnd}`; } + +/** Check if the first response chunk from Zen API signals a rate-limit or error. */ +export function checkFirstChunkError(chunk) { + const str = chunk.toString().trim(); + if (!str.startsWith("{") || (!str.includes("FreeUsageLimitError") && !str.includes('"error"'))) return null; + try { + const parsed = JSON.parse(str); + if (parsed.error || parsed.type === "error") { + return parsed.error?.message || parsed.message || "Rate limit exceeded"; + } + } catch {} + return null; +} diff --git a/tests/pipes.test.mjs b/tests/pipes.test.mjs index 9108611..d22319e 100644 --- a/tests/pipes.test.mjs +++ b/tests/pipes.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { ensureOpenAIIds } from "../src/pipes.mjs"; +import { ensureOpenAIIds } from "../src/pipe-openai.mjs"; describe("ensureOpenAIIds", () => { it("injects a top-level id when missing", () => { From 4953254f88de733618e8c644d32a770f9a8f3aa8 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 30 Jul 2026 17:28:22 +0800 Subject: [PATCH 12/16] v0.1.2: retry on rate limit, memory leak fixes, code quality improvements - Add auto-retry (3 attempts, exponential backoff) on rate-limit errors for both streaming and synchronous paths - Fix stream-mode chunks array memory leak in pipe-openai.mjs - Fix content_block_stop protocol violation in pipe-anthropic.mjs - Fix session Map memory leak by adding periodic stale-entry cleanup - Refactor anthropicToOpenAI to reduce nesting depth - Use constant-time comparison for API key validation - Clean up global error handler to avoid res.end() after headers sent - Sync models table in README with models.json --- README.md | 16 +- package.json | 2 +- src/app.mjs | 2 +- src/auth.mjs | 4 +- src/pipe-anthropic.mjs | 331 +++++++++++++++++++++------------------- src/pipe-openai.mjs | 275 +++++++++++++++++---------------- src/routes/messages.mjs | 13 +- src/session.mjs | 9 ++ src/to-openai.mjs | 63 +++++--- 9 files changed, 400 insertions(+), 315 deletions(-) diff --git a/README.md b/README.md index d6a9077..34a1f8d 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ Done. Server is at `http://localhost:6446`. API keys are in `api-keys.json` (aut ## 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. diff --git a/package.json b/package.json index 88652bd..7e9b8bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-free-proxy", - "version": "0.1.1", + "version": "0.1.2", "description": "Proxy server for OpenCode free-tier AI models via Zen API", "type": "module", "main": "src/index.mjs", diff --git a/src/app.mjs b/src/app.mjs index faf7aa3..fadca09 100644 --- a/src/app.mjs +++ b/src/app.mjs @@ -23,7 +23,7 @@ export function createApp() { app.use((err, req, res, next) => { logLine("UNHANDLED ERROR", err.message, err.stack); if (res.headersSent) { - return next(err); + return res.end(); } const status = err.status || err.statusCode || 500; diff --git a/src/auth.mjs b/src/auth.mjs index af6eaf8..f440e55 100644 --- a/src/auth.mjs +++ b/src/auth.mjs @@ -22,8 +22,10 @@ export function loadKeys() { 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)) { - if (tok === key) return name; + const keyBuf = Buffer.from(key); + if (keyBuf.length === tokBuf.length && crypto.timingSafeEqual(keyBuf, tokBuf)) return name; } return null; } diff --git a/src/pipe-anthropic.mjs b/src/pipe-anthropic.mjs index 2bd9f72..ef5e717 100644 --- a/src/pipe-anthropic.mjs +++ b/src/pipe-anthropic.mjs @@ -9,186 +9,205 @@ const NO_CACHE = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; /** * 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). + * Automatically retries up to `retries` times on rate-limit (429). */ -export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { +export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, retries = 3) { const msgId = ocId("msg"); - const t0 = Date.now(); - let collectedText = ""; - const collectedTools = {}; - let stopReasonLogged = null; - - 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, ...NO_CACHE }, - }, - }); - } - zenRes.on("data", (chunk) => { - const str = chunk.toString(); + function attempt(remaining) { + const t0 = Date.now(); + let collectedText = ""; + const collectedTools = {}; + let stopReasonLogged = null; + + const req = https.request(zenOpts, (zenRes) => { + let headersSent = false; + let buffer = ""; + let outputTokens = 0; + let contentIdx = 0; + let toolIdx = -1; + let firstChunkHandled = false; + // Track which content blocks have actually been started so we + // only send content_block_stop for blocks that exist. + const startedBlocks = new Set(); + + function sendSSE(event, data) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + if (res.flush) res.flush(); + } - if (!firstChunkHandled) { - firstChunkHandled = true; - const errMsg = checkFirstChunkError(chunk); - if (errMsg) { - logLine("RATE LIMITED", errMsg); - logIO("OUTPUT (rate_limit)", { error: errMsg }); - 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)" }, - })); + 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 }, + }, + }); + } + + zenRes.on("data", (chunk) => { + const str = chunk.toString(); + + if (!firstChunkHandled) { + firstChunkHandled = true; + const errMsg = checkFirstChunkError(chunk); + if (errMsg) { + if (remaining > 0) { + logLine("RATE LIMITED, retrying", `(${remaining} left)`, errMsg); + zenRes.destroy(); + req.destroy(); + const delay = 1000 * Math.pow(2, 3 - remaining); + setTimeout(() => attempt(remaining - 1), delay); + return; + } + logLine("RATE LIMITED, exhausted retries", errMsg); + logIO("OUTPUT (rate_limit)", { error: errMsg }); + 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; } - zenRes.resume(); - return; } - } - buffer += str; - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; + 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; + 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; + let parsed; + try { parsed = JSON.parse(payload); } catch { continue; } + const delta = parsed.choices?.[0]?.delta; + if (!delta) continue; - sendHeaders(); + 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: "" } }); - contentIdx = 1; + 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); } - 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 }); + 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); } - 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 || "" }, - }); } - 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" }); } } + }); - if (parsed.choices?.[0]?.finish_reason) { - const fr = parsed.choices[0].finish_reason; - 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 }); + zenRes.on("end", () => { + if (!headersSent) { + logIO("OUTPUT (empty)", { error: "Empty response" }); + if (!res.headersSent) { + res.status(502).json({ type: "error", error: { type: "upstream_error", message: "Empty response" } }); } - - 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" }); + 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) => { + 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 } }); } }); - zenRes.on("end", () => { - if (!headersSent) { - logIO("OUTPUT (empty)", { error: "Empty response" }); - if (!res.headersSent) { - res.status(502).json({ type: "error", error: { type: "upstream_error", message: "Empty response" } }); - } - return; + req.on("timeout", () => { + req.destroy(); + logLine("TIMEOUT"); + logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); + if (!res.headersSent) { + res.status(504).json({ type: "error", error: { type: "timeout_error", message: "Upstream timeout" } }); } - 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) => { - 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", () => { - req.destroy(); - 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(); + + req.write(body); + req.end(); + } + + attempt(retries); } diff --git a/src/pipe-openai.mjs b/src/pipe-openai.mjs index 1e26837..348f5d9 100644 --- a/src/pipe-openai.mjs +++ b/src/pipe-openai.mjs @@ -1,9 +1,23 @@ import https from "https"; -import { logLine, logIO } from "./logger.mjs"; +import { logLine, logIO, LOG_DETAIL } from "./logger.mjs"; import { ocId, checkFirstChunkError } from "./utils.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"; @@ -95,153 +109,158 @@ export function parseOpenAISyncOutput(data) { /** * Relay an OpenAI-format request to the Zen API and pipe the response back * in OpenAI format (supports both streaming SSE and sync JSON). + * Automatically retries up to `retries` times on rate-limit (429). */ -export function pipeZenResponse(zenOpts, body, stream, res) { - const chunks = []; - const t0 = Date.now(); +export function pipeZenResponse(zenOpts, body, stream, res, retries = 3) { const toolCallIds = {}; let requestModel = ""; try { requestModel = JSON.parse(body).model || ""; } catch {} - const req = https.request(zenOpts, (zenRes) => { - let firstChunk = null; - let headersSent = false; - let rateLimited = false; - let sseBuffer = ""; - - 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(zenRes.statusCode, { "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); - chunks.push(Buffer.from(out + "\n")); - res.write(out + "\n"); - } - if (final && sseBuffer) { - const out = transformSseLine(sseBuffer); - chunks.push(Buffer.from(out + "\n")); - res.write(out + "\n"); + function attempt(remaining) { + 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; + + const req = https.request(zenOpts, (zenRes) => { + let firstChunk = null; + let headersSent = false; + let rateLimited = false; + let sseBuffer = ""; + + 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(zenRes.statusCode, { "Content-Type": "application/json" }); + } } - if (res.flush) res.flush(); - } - function transformSseLine(line) { - 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; + 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(); } - } - zenRes.on("data", (chunk) => { - if (!firstChunk) { - firstChunk = chunk; - const errMsg = checkFirstChunkError(chunk); - if (errMsg) { - rateLimited = true; - logLine("RATE LIMITED", errMsg); - logIO("OUTPUT (rate_limit)", { error: errMsg }); - if (!res.headersSent) { - res.status(429).json({ - error: { message: errMsg + " (free model rate limit)", type: "rate_limit_error", code: "rate_limit_exceeded" } - }); + zenRes.on("data", (chunk) => { + if (!firstChunk) { + firstChunk = chunk; + const errMsg = checkFirstChunkError(chunk); + if (errMsg) { + if (remaining > 0) { + logLine("RATE LIMITED, retrying", `(${remaining} left)`, errMsg); + zenRes.destroy(); + req.destroy(); + const delay = 1000 * Math.pow(2, 3 - remaining); + setTimeout(() => attempt(remaining - 1), delay); + return; + } + rateLimited = true; + logLine("RATE LIMITED, exhausted retries", errMsg); + logIO("OUTPUT (rate_limit)", { error: 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; + } + + sendHeaders(); + if (stream) { + sseBuffer += chunk.toString(); + flushSseBuffer(); + } else { + chunks.push(chunk); } - zenRes.resume(); return; } + if (headersSent) { + if (stream) { + sseBuffer += chunk.toString(); + flushSseBuffer(); + } else { + chunks.push(chunk); + } + } + }); - sendHeaders(); - if (stream) { - sseBuffer += chunk.toString(); - flushSseBuffer(); - } else { - chunks.push(chunk); + zenRes.on("end", () => { + if (rateLimited) return; + if (!headersSent && !firstChunk) { + 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; } - return; - } - if (headersSent) { - if (stream) { - sseBuffer += chunk.toString(); - flushSseBuffer(); - } else { - chunks.push(chunk); + if (headersSent) { + const ms = Date.now() - t0; + if (stream) { + flushSseBuffer(true); + if (streamLogLines !== null) { + logIO(`OUTPUT (stream, ${ms}ms)`, parseOpenAIStreamOutput(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)`, parseOpenAISyncOutput(updated)); + res.end(rawUpdated); + } catch { + logIO(`OUTPUT (sync raw, ${ms}ms)`, raw); + res.end(raw); + } + } } - } + }); }); - zenRes.on("end", () => { - if (rateLimited) return; - if (!headersSent && !firstChunk) { - 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; + req.on("error", (e) => { + 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" } }); } - if (headersSent) { - const ms = Date.now() - t0; - if (stream) { - flushSseBuffer(true); - const raw = Buffer.concat(chunks).toString(); - logIO(`OUTPUT (stream, ${ms}ms)`, parseOpenAIStreamOutput(raw)); - 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)`, parseOpenAISyncOutput(updated)); - res.end(rawUpdated); - } catch { - logIO(`OUTPUT (sync raw, ${ms}ms)`, raw); - res.end(raw); - } - } + }); + + req.on("timeout", () => { + req.destroy(); + logLine("TIMEOUT"); + logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); + if (!res.headersSent) { + res.status(504).json({ error: { message: "Upstream timeout", type: "timeout_error" } }); } }); - }); - req.on("error", (e) => { - 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", () => { - req.destroy(); - 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(); + } - req.write(body); - req.end(); + attempt(retries); } diff --git a/src/routes/messages.mjs b/src/routes/messages.mjs index ae27cb6..37a6134 100644 --- a/src/routes/messages.mjs +++ b/src/routes/messages.mjs @@ -46,7 +46,18 @@ router.post("/v1/messages", async (req, res, next) => { } else { try { const t0 = Date.now(); - const zenResp = await zenRequestFull(options, body); + // Retry up to 3 times with exponential backoff on rate-limit + let zenResp; + for (let attempt = 0; attempt <= 3; attempt++) { + zenResp = await zenRequestFull(options, body); + if (zenResp.status !== 429 && !zenResp.data?.error) break; + if (attempt < 3) { + const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; + logLine(`RATE LIMITED, retrying (${3 - attempt} left)`, errMsg); + const delay = 1000 * Math.pow(2, attempt); + await new Promise(r => setTimeout(r, delay)); + } + } const ms = Date.now() - t0; if (zenResp.status === 429 || zenResp.data?.error) { const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; diff --git a/src/session.mjs b/src/session.mjs index 322efbc..db08690 100644 --- a/src/session.mjs +++ b/src/session.mjs @@ -3,6 +3,15 @@ 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); diff --git a/src/to-openai.mjs b/src/to-openai.mjs index c4f891e..776579e 100644 --- a/src/to-openai.mjs +++ b/src/to-openai.mjs @@ -5,37 +5,60 @@ function contentText(content) { return ""; } +/** 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"); + + // Assistant with tool calls + if (toolUses.length && msg.role === "assistant") { + return [buildToolUseMessage(text, toolUses)]; + } + + // Tool result blocks + if (blocks.some(b => b.type === "tool_result")) { + return buildToolResultMessages(blocks); + } + + // Plain text array (or non-tool content blocks) + return [{ role: msg.role, content: text }]; +} + /** 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)) { - const text = contentText(msg.content.filter(b => b.type === "text")); - - 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")) { - messages.push({ role: "tool", tool_call_id: b.tool_use_id, content: contentText(b.content) }); - } - } else { - messages.push({ role: msg.role, content: text }); - } + messages.push(...convertContentBlock(msg)); } } From 2ef1a98c7acd69f7811fb3eeda1e82ef25bb2edc Mon Sep 17 00:00:00 2001 From: Jayden LEE Date: Tue, 4 Aug 2026 22:32:04 +0800 Subject: [PATCH 13/16] v0.1.3: rate-limit resilience, Express 5 hardening, safer Docker Retry free-tier 429s with session rotation, backoff jitter, and client-abort stop; harden Express 5 body/listen handling; lock deps with npm ci and harden production compose (read-only rootfs, cap_drop, loopback bind). --- AGENTS.md | 13 +- Dockerfile | 38 ++- docker-compose.yaml | 28 ++- package-lock.json | 499 +++++++++++++++++++++------------------- package.json | 4 +- pnpm-lock.yaml | 290 +++++++++++------------ src/app.mjs | 6 +- src/client.mjs | 5 +- src/config/index.mjs | 7 + src/index.mjs | 7 +- src/pipe-anthropic.mjs | 165 +++++++++++-- src/pipe-openai.mjs | 165 +++++++++++-- src/retry.mjs | 151 ++++++++++++ src/routes/chat.mjs | 7 +- src/routes/messages.mjs | 211 ++++++++++++----- src/session.mjs | 7 + src/utils.mjs | 13 -- tests/retry.test.mjs | 133 +++++++++++ tests/routes.test.mjs | 75 ++++-- 19 files changed, 1276 insertions(+), 548 deletions(-) create mode 100644 src/retry.mjs create mode 100644 tests/retry.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 56fd6c2..1ece768 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,8 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu - `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 rotate every 30 minutes (internal, no-op for agent work). +- **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 @@ -31,6 +32,9 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu |----------|---------|-------| | `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 | @@ -44,7 +48,8 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu | `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 rotation | +| `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 | @@ -54,8 +59,8 @@ API keys are auto-generated into `api-keys.json` on first run — no `.env` setu | `src/routes/*.mjs` | Route handlers | | `models.json` | List of available models | | `api-keys.json` | Auto-generated, **never commit** | -| `Dockerfile` | Multi-stage, `node:24-alpine`, runs as `node` user | -| `docker-compose.yaml` | Production compose | +| `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) | diff --git a/Dockerfile b/Dockerfile index 37f855b..2a87276 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,39 @@ +# syntax=docker/dockerfile:1 # ── Build stage ────────────────────────────────────────────── FROM node:24-alpine AS build WORKDIR /app -COPY package.json ./ -RUN npm install --production + +# 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 -COPY --from=build /app/node_modules ./node_modules -COPY package.json ./ -COPY models.json ./ -COPY src ./src -RUN mkdir -p /data && chown -R node:node /app /data -EXPOSE 6446 + +# 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/docker-compose.yaml b/docker-compose.yaml index 219a423..dad478d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,22 +6,40 @@ services: image: opencode-free-proxy:latest container_name: opencode-free-proxy ports: - - "${PROXY_PORT:-6446}:6446" + # 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 - # API keys — omit to auto-generate + # 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:-}" - # Full I/O logs (set LOG_DETAIL=0 to disable; LOG_MAX_CHARS to truncate) - LOG_DETAIL: "${LOG_DETAIL:-1}" + # Production default: less I/O leakage in logs + LOG_DETAIL: "${LOG_DETAIL:-0}" LOG_MAX_CHARS: "${LOG_MAX_CHARS:-0}" - # FORCE_COLOR=1 if using `docker compose logs -f` (non-TTY) 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: diff --git a/package-lock.json b/package-lock.json index 3cd3ce6..b6d1619 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,61 +1,68 @@ { "name": "opencode-free-proxy", - "version": "0.9.0", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-free-proxy", - "version": "0.9.0", + "version": "0.1.3", "license": "MIT", "dependencies": { - "express": "^4.21.0" + "express": "^5.2.1" }, "engines": { "node": ">=20" } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "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": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "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": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "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": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "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": { @@ -97,15 +104,16 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -127,18 +135,29 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "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": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/depd": { @@ -150,16 +169,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -235,45 +244,42 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" + "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", @@ -281,21 +287,24 @@ } }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "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": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/forwarded": { @@ -308,12 +317,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "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.6" + "node": ">= 0.8" } }, "node_modules/function-bind": { @@ -419,15 +428,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "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" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/inherits": { @@ -445,6 +458,12 @@ "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", @@ -455,75 +474,65 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "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.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", + "node": ">= 0.8" + }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "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": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" + "node": ">=18" }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "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": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "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.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "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": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "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" @@ -553,6 +562,15 @@ "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", @@ -563,10 +581,14 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" + "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", @@ -598,48 +620,48 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "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": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "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.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "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", @@ -648,48 +670,48 @@ "license": "MIT" }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "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": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/send/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/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "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": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -789,16 +811,34 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "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": { @@ -810,15 +850,6 @@ "node": ">= 0.8" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -827,6 +858,12 @@ "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 7e9b8bc..58c31b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-free-proxy", - "version": "0.1.2", + "version": "0.1.3", "description": "Proxy server for OpenCode free-tier AI models via Zen API", "type": "module", "main": "src/index.mjs", @@ -10,7 +10,7 @@ "test": "node --test tests/*.test.mjs" }, "dependencies": { - "express": "^4.21.0" + "express": "^5.2.1" }, "engines": { "node": ">=20" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79a8c2c..d56f1ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,21 +9,18 @@ importers: .: dependencies: express: - specifier: ^4.21.0 - version: 4.22.2 + specifier: ^5.2.1 + version: 5.2.1 packages: - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - - body-parser@1.20.6: - resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -37,23 +34,29 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + 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'} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + 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@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -64,10 +67,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -98,21 +97,21 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - express@4.22.2: - resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} - engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} + 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@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - 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==} @@ -141,8 +140,8 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} inherits@2.0.4: @@ -152,42 +151,34 @@ packages: 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@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + 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@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} object-inspect@1.13.4: @@ -198,12 +189,15 @@ packages: 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@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + 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==} @@ -217,23 +211,24 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -262,45 +257,39 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - 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'} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - 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@1.3.8: + accepts@2.0.0: dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + mime-types: 3.0.2 + negotiator: 1.0.0 - array-flatten@1.1.1: {} - - body-parser@1.20.6: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 + content-type: 2.0.0 + debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.4.24 + iconv-lite: 0.7.3 on-finished: 2.4.1 qs: 6.15.3 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 + raw-body: 3.0.2 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -316,24 +305,22 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 + content-disposition@1.1.0: {} content-type@1.0.5: {} - cookie-signature@1.0.7: {} + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} cookie@0.7.2: {} - debug@2.6.9: + debug@4.4.3: dependencies: - ms: 2.0.0 + ms: 2.1.3 depd@2.0.0: {} - destroy@1.2.0: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -356,57 +343,53 @@ snapshots: etag@1.8.1: {} - express@4.22.2: + express@5.2.1: dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.6 - content-disposition: 0.5.4 + 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.0.7 - debug: 2.6.9 + 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: 1.3.2 - fresh: 0.5.2 + finalhandler: 2.1.1 + fresh: 2.0.0 http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 on-finished: 2.4.1 + once: 1.4.0 parseurl: 1.3.3 - path-to-regexp: 0.1.13 proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color - finalhandler@1.3.2: + finalhandler@2.1.1: dependencies: - debug: 2.6.9 + 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 - unpipe: 1.0.0 transitivePeerDependencies: - supports-color forwarded@0.2.0: {} - fresh@0.5.2: {} + fresh@2.0.0: {} function-bind@1.1.2: {} @@ -444,7 +427,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.4.24: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -452,27 +435,23 @@ snapshots: ipaddr.js@1.9.1: {} - math-intrinsics@1.1.0: {} + is-promise@4.0.0: {} - media-typer@0.3.0: {} + math-intrinsics@1.1.0: {} - merge-descriptors@1.0.3: {} + media-typer@1.1.1: {} - methods@1.1.2: {} + merge-descriptors@2.0.0: {} - mime-db@1.52.0: {} + mime-db@1.54.0: {} - mime-types@2.1.35: + mime-types@3.0.2: dependencies: - mime-db: 1.52.0 - - mime@1.6.0: {} - - ms@2.0.0: {} + mime-db: 1.54.0 ms@2.1.3: {} - negotiator@0.6.3: {} + negotiator@1.0.0: {} object-inspect@1.13.4: {} @@ -480,9 +459,13 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + parseurl@1.3.3: {} - path-to-regexp@0.1.13: {} + path-to-regexp@8.4.2: {} proxy-addr@2.0.7: dependencies: @@ -496,28 +479,34 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.3: + raw-body@3.0.2: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.4.24 + iconv-lite: 0.7.3 unpipe: 1.0.0 - safe-buffer@5.2.1: {} + 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@0.19.2: + send@1.2.1: dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - fresh: 0.5.2 + fresh: 2.0.0 http-errors: 2.0.1 - mime: 1.6.0 + mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 @@ -525,12 +514,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.3: + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.2 + send: 1.2.1 transitivePeerDependencies: - supports-color @@ -568,13 +557,14 @@ snapshots: toidentifier@1.0.1: {} - type-is@1.6.18: + type-is@2.1.0: dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 unpipe@1.0.0: {} - utils-merge@1.0.1: {} - vary@1.1.2: {} + + wrappy@1.0.2: {} diff --git a/src/app.mjs b/src/app.mjs index fadca09..34a272e 100644 --- a/src/app.mjs +++ b/src/app.mjs @@ -18,7 +18,7 @@ export function createApp() { res.status(404).json({ error: { message: "Not found", type: "not_found_error" } }); }); - // Global error handler — prevents hanging requests on unhandled errors + // Global error handler — Express 5 also forwards rejected promises from async routes here. // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { logLine("UNHANDLED ERROR", err.message, err.stack); @@ -26,7 +26,9 @@ export function createApp() { return res.end(); } - const status = err.status || err.statusCode || 500; + // 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"; diff --git a/src/client.mjs b/src/client.mjs index f4109bb..9b27dd0 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -38,10 +38,11 @@ export function zenRequestFull(zenOpts, body) { 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 }); + resolve({ status: zenRes.statusCode, data: JSON.parse(raw), raw, headers }); } catch { - resolve({ status: zenRes.statusCode, data: null, raw }); + resolve({ status: zenRes.statusCode, data: null, raw, headers }); } }); }); diff --git a/src/config/index.mjs b/src/config/index.mjs index eb6bf2d..ee79120 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -12,3 +12,10 @@ 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 index 18687dc..ecaf326 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -6,7 +6,12 @@ import { logLine, logStatusLine } from "./logger.mjs"; loadKeys(); const app = createApp(); -app.listen(PORT, "0.0.0.0", () => { +// 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"); diff --git a/src/pipe-anthropic.mjs b/src/pipe-anthropic.mjs index ef5e717..f3b2c09 100644 --- a/src/pipe-anthropic.mjs +++ b/src/pipe-anthropic.mjs @@ -1,6 +1,17 @@ import https from "https"; +import { MAX_RETRIES } from "./config/index.mjs"; import { logLine, logIO } from "./logger.mjs"; -import { ocId, checkFirstChunkError } from "./utils.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 }; @@ -9,27 +20,97 @@ const NO_CACHE = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; /** * 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). - * Automatically retries up to `retries` times on rate-limit (429). + * 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, retries = 3) { +export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = {}) { + const { user, clientReq, retries = MAX_RETRIES } = typeof ctx === "number" ? { retries: ctx } : ctx; const msgId = ocId("msg"); + let currentOpts = zenOpts; + let aborted = false; + if (clientReq) { + clientReq.on("close", () => { 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(zenOpts, (zenRes) => { + const req = https.request(currentOpts, (zenRes) => { let headersSent = false; let buffer = ""; let outputTokens = 0; let contentIdx = 0; let toolIdx = -1; let firstChunkHandled = false; - // Track which content blocks have actually been started so we - // only send content_block_stop for blocks that exist. + 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`); @@ -57,31 +138,46 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, retri }); } + 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 errMsg = checkFirstChunkError(chunk); - if (errMsg) { - if (remaining > 0) { - logLine("RATE LIMITED, retrying", `(${remaining} left)`, errMsg); - zenRes.destroy(); - req.destroy(); - const delay = 1000 * Math.pow(2, 3 - remaining); - setTimeout(() => attempt(remaining - 1), delay); - return; - } - logLine("RATE LIMITED, exhausted retries", errMsg); - logIO("OUTPUT (rate_limit)", { error: errMsg }); - 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)" }, - })); - } + 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; } } @@ -168,7 +264,18 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, retri }); 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" } }); @@ -189,6 +296,10 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, retri }); 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) { @@ -197,7 +308,11 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, retri }); 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) { diff --git a/src/pipe-openai.mjs b/src/pipe-openai.mjs index 348f5d9..e362b05 100644 --- a/src/pipe-openai.mjs +++ b/src/pipe-openai.mjs @@ -1,6 +1,17 @@ import https from "https"; +import { MAX_RETRIES } from "./config/index.mjs"; import { logLine, logIO, LOG_DETAIL } from "./logger.mjs"; -import { ocId, checkFirstChunkError } from "./utils.mjs"; +import { ocId } from "./utils.mjs"; +import { + parseErrorPayload, + planRetry, + logAndScheduleRetry, + isClientGone, + isTransientNetworkError, + isTransientHttpStatus, + withFreshSession, + withFreshRequestId, +} from "./retry.mjs"; // ── shared helpers ───────────────────────────────────────────────────────── @@ -109,24 +120,93 @@ export function parseOpenAISyncOutput(data) { /** * Relay an OpenAI-format request to the Zen API and pipe the response back * in OpenAI format (supports both streaming SSE and sync JSON). - * Automatically retries up to `retries` times on rate-limit (429). + * 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, retries = 3) { +export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { + const { user, clientReq, retries = MAX_RETRIES } = typeof ctx === "number" ? { retries: ctx } : ctx; const toolCallIds = {}; let requestModel = ""; try { requestModel = JSON.parse(body).model || ""; } catch {} + let currentOpts = zenOpts; + let aborted = false; + if (clientReq) { + clientReq.on("close", () => { 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(zenOpts, (zenRes) => { + const req = https.request(currentOpts, (zenRes) => { let firstChunk = null; let headersSent = false; - let rateLimited = false; + let skipEnd = false; let sseBuffer = ""; + const status = zenRes.statusCode || 0; function sendHeaders() { if (headersSent) return; @@ -141,7 +221,7 @@ export function pipeZenResponse(zenOpts, body, stream, res, retries = 3) { }); res.flushHeaders(); } else { - res.writeHead(zenRes.statusCode, { "Content-Type": "application/json" }); + res.writeHead(status, { "Content-Type": "application/json" }); } } @@ -162,28 +242,45 @@ export function pipeZenResponse(zenOpts, body, stream, res, retries = 3) { 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 errMsg = checkFirstChunkError(chunk); - if (errMsg) { - if (remaining > 0) { - logLine("RATE LIMITED, retrying", `(${remaining} left)`, errMsg); - zenRes.destroy(); - req.destroy(); - const delay = 1000 * Math.pow(2, 3 - remaining); - setTimeout(() => attempt(remaining - 1), delay); - return; - } - rateLimited = true; - logLine("RATE LIMITED, exhausted retries", errMsg); - logIO("OUTPUT (rate_limit)", { error: errMsg }); - if (!res.headersSent) { - res.status(429).json({ - error: { message: errMsg + " (free model rate limit)", type: "rate_limit_error", code: "rate_limit_exceeded" } - }); - } + 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; } @@ -207,8 +304,18 @@ export function pipeZenResponse(zenOpts, body, stream, res, retries = 3) { }); zenRes.on("end", () => { - if (rateLimited) return; + 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) { @@ -242,6 +349,10 @@ export function pipeZenResponse(zenOpts, body, stream, res, retries = 3) { }); 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) { @@ -250,7 +361,11 @@ export function pipeZenResponse(zenOpts, body, stream, res, retries = 3) { }); 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) { diff --git a/src/retry.mjs b/src/retry.mjs new file mode 100644 index 0000000..559ae6b --- /dev/null +++ b/src/retry.mjs @@ -0,0 +1,151 @@ +import { MAX_RETRIES, 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) { + if (res?.writableEnded || res?.destroyed || res?.closed) return true; + if (clientReq?.destroyed || 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, + }; +} + +/** @deprecated use parseErrorPayload; kept for any external callers */ +export function checkFirstChunkError(chunk) { + return parseErrorPayload(chunk)?.message ?? null; +} + +/** + * 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); +} + +export { MAX_RETRIES }; diff --git a/src/routes/chat.mjs b/src/routes/chat.mjs index 758fed3..0a00456 100644 --- a/src/routes/chat.mjs +++ b/src/routes/chat.mjs @@ -12,6 +12,11 @@ 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(", ")}` } }); @@ -28,7 +33,7 @@ router.post("/v1/chat/completions", (req, res) => { }); const { body, options } = zenRequest(model, messages, stream, tools, tool_choice, sessionId); - pipeZenResponse(options, body, stream, res); + pipeZenResponse(options, body, stream, res, { user, clientReq: req }); }); export default router; diff --git a/src/routes/messages.mjs b/src/routes/messages.mjs index 37a6134..6666b6a 100644 --- a/src/routes/messages.mjs +++ b/src/routes/messages.mjs @@ -1,5 +1,5 @@ import { Router } from "express"; -import { MODELS } from "../config/index.mjs"; +import { MODELS, MAX_RETRIES } from "../config/index.mjs"; import { auth } from "../auth.mjs"; import { getSession } from "../session.mjs"; import { zenRequest, zenRequestFull } from "../client.mjs"; @@ -7,82 +7,169 @@ 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(); -router.post("/v1/messages", async (req, res, next) => { - try { - const user = auth(req); - if (!user) { - return res.status(401).json({ type: "error", error: { type: "authentication_error", message: "Invalid API key" } }); - } +// 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" } }); + } - 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(", ")}` }, - }); - } + // 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 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 }, + 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 }, + }); - const { body, options } = zenRequest(model, messages, stream, tools, undefined, sessionId); + 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; + } - if (stream) { - pipeZenAsAnthropic(options, body, model, res, inputTokens); - } else { try { - const t0 = Date.now(); - // Retry up to 3 times with exponential backoff on rate-limit - let zenResp; - for (let attempt = 0; attempt <= 3; attempt++) { - zenResp = await zenRequestFull(options, body); - if (zenResp.status !== 429 && !zenResp.data?.error) break; - if (attempt < 3) { - const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; - logLine(`RATE LIMITED, retrying (${3 - attempt} left)`, errMsg); - const delay = 1000 * Math.pow(2, attempt); - await new Promise(r => setTimeout(r, delay)); - } + 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; } - const ms = Date.now() - t0; - if (zenResp.status === 429 || zenResp.data?.error) { + 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"; - 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 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; } - 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 } }); + 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 = Number(zenResp.status) >= 400 ? Number(zenResp.status) : 502; + if (!Number.isInteger(status) || status < 100 || status > 999) 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) { - next(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 } }); } }); diff --git a/src/session.mjs b/src/session.mjs index db08690..1851922 100644 --- a/src/session.mjs +++ b/src/session.mjs @@ -24,3 +24,10 @@ export function getSession(user) { 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/utils.mjs b/src/utils.mjs index c3b5966..1c4a750 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -5,16 +5,3 @@ export function ocId(prefix) { const rnd = crypto.randomBytes(12).toString("base64url").slice(0, 16); return `${prefix}_${ts}${rnd}`; } - -/** Check if the first response chunk from Zen API signals a rate-limit or error. */ -export function checkFirstChunkError(chunk) { - const str = chunk.toString().trim(); - if (!str.startsWith("{") || (!str.includes("FreeUsageLimitError") && !str.includes('"error"'))) return null; - try { - const parsed = JSON.parse(str); - if (parsed.error || parsed.type === "error") { - return parsed.error?.message || parsed.message || "Rate limit exceeded"; - } - } catch {} - return null; -} diff --git a/tests/retry.test.mjs b/tests/retry.test.mjs new file mode 100644 index 0000000..dbc5d57 --- /dev/null +++ b/tests/retry.test.mjs @@ -0,0 +1,133 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + rateLimitRetryDelay, + delayFromRetryAfter, + isRateLimitPayload, + isRateLimitResponse, + isTransientNetworkError, + isTransientHttpStatus, + parseErrorPayload, + planRetry, + 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("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 index b0f05c0..7d6802a 100644 --- a/tests/routes.test.mjs +++ b/tests/routes.test.mjs @@ -1,38 +1,79 @@ -import { describe, it } from "node:test"; +import { describe, it, after } from "node:test"; import assert from "node:assert"; -import { createApp } from "../src/app.mjs"; -import { MODELS } from "../src/config/index.mjs"; +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 () => { - const app = createApp(); - const server = app.listen(0); - const { port } = server.address(); - try { + 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); - } finally { - server.close(); - } + }); }); it("returns 401 without a key on /v1/messages", async () => { - const app = createApp(); - const server = app.listen(0); - const { port } = server.address(); - try { + 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); - } finally { - server.close(); - } + }); + }); +}); + +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); + }); }); }); From aba93c046e25922262f02d285a2ff206a13db167 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 6 Aug 2026 10:57:24 +0800 Subject: [PATCH 14/16] v0.1.4: pass reasoning_content through for thinking-mode models Inject an empty reasoning_content field on assistant history messages so opencode.ai's Zen/Console provider accepts multi-turn continuations on thinking-mode models (fixes 'The reasoning_content in the thinking mode must be passed back to the API'). Map Anthropic thinking blocks to reasoning_content in the OpenAI converter. --- package-lock.json | 4 +-- package.json | 2 +- src/reasoning.mjs | 25 ++++++++++++++ src/routes/chat.mjs | 3 ++ src/to-openai.mjs | 23 ++++++++++-- tests/reasoning.test.mjs | 75 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/reasoning.mjs create mode 100644 tests/reasoning.test.mjs diff --git a/package-lock.json b/package-lock.json index b6d1619..3bdb382 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-free-proxy", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-free-proxy", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "express": "^5.2.1" diff --git a/package.json b/package.json index 58c31b4..d9e606e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-free-proxy", - "version": "0.1.3", + "version": "0.1.4", "description": "Proxy server for OpenCode free-tier AI models via Zen API", "type": "module", "main": "src/index.mjs", 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/routes/chat.mjs b/src/routes/chat.mjs index 0a00456..1fe15dd 100644 --- a/src/routes/chat.mjs +++ b/src/routes/chat.mjs @@ -4,6 +4,7 @@ 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(); @@ -23,6 +24,8 @@ router.post("/v1/chat/completions", (req, res) => { } 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, diff --git a/src/to-openai.mjs b/src/to-openai.mjs index 776579e..e0cf80d 100644 --- a/src/to-openai.mjs +++ b/src/to-openai.mjs @@ -1,3 +1,5 @@ +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; @@ -5,6 +7,15 @@ function contentText(content) { 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 { @@ -30,10 +41,13 @@ 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") { - return [buildToolUseMessage(text, toolUses)]; + const m = buildToolUseMessage(text, toolUses); + m.reasoning_content = reasoning; + return [m]; } // Tool result blocks @@ -42,7 +56,9 @@ function convertContentBlock(msg) { } // Plain text array (or non-tool content blocks) - return [{ role: msg.role, content: text }]; + 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. */ @@ -71,5 +87,8 @@ export function anthropicToOpenAI(body) { }, })); + // Thinking-mode models require reasoning_content on assistant history messages. + ensureAssistantReasoning(messages); + return { messages, tools: tools.length ? tools : undefined }; } 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 From 22b2c5e82a6d002dff8128e3a887a6283551c284 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Thu, 6 Aug 2026 16:43:54 +0800 Subject: [PATCH 15/16] v0.1.5: fix false 'CLIENT GONE' detection that left clients hanging The proxy logged "CLIENT GONE, aborting retries" immediately and returned without a response because Node fires req 'close' and sets req.destroyed as soon as the request body is fully consumed, even while the client is still connected and waiting. Detect real client disconnects from the response side (res 'close' before the response is sent) and rely on req.aborted for genuine early aborts, rather than req 'close' / req.destroyed. Fixes hangs on all three paths: /v1/chat/completions (sync + stream) and /v1/messages (sync). --- package-lock.json | 4 ++-- package.json | 2 +- src/pipe-anthropic.mjs | 10 +++++++--- src/pipe-openai.mjs | 10 +++++++--- src/retry.mjs | 8 +++++++- tests/retry.test.mjs | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3bdb382..250a657 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-free-proxy", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-free-proxy", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "express": "^5.2.1" diff --git a/package.json b/package.json index d9e606e..b92eb27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-free-proxy", - "version": "0.1.4", + "version": "0.1.5", "description": "Proxy server for OpenCode free-tier AI models via Zen API", "type": "module", "main": "src/index.mjs", diff --git a/src/pipe-anthropic.mjs b/src/pipe-anthropic.mjs index f3b2c09..a1d499b 100644 --- a/src/pipe-anthropic.mjs +++ b/src/pipe-anthropic.mjs @@ -33,9 +33,13 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = let currentOpts = zenOpts; let aborted = false; - if (clientReq) { - clientReq.on("close", () => { aborted = true; }); - } + // 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); diff --git a/src/pipe-openai.mjs b/src/pipe-openai.mjs index e362b05..a67bf16 100644 --- a/src/pipe-openai.mjs +++ b/src/pipe-openai.mjs @@ -135,9 +135,13 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { let currentOpts = zenOpts; let aborted = false; - if (clientReq) { - clientReq.on("close", () => { aborted = true; }); - } + // 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); diff --git a/src/retry.mjs b/src/retry.mjs index 559ae6b..b3c5b45 100644 --- a/src/retry.mjs +++ b/src/retry.mjs @@ -31,8 +31,14 @@ export function sleep(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; - if (clientReq?.destroyed || clientReq?.aborted) 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; } diff --git a/tests/retry.test.mjs b/tests/retry.test.mjs index dbc5d57..aea4dde 100644 --- a/tests/retry.test.mjs +++ b/tests/retry.test.mjs @@ -9,6 +9,7 @@ import { isTransientHttpStatus, parseErrorPayload, planRetry, + isClientGone, withFreshSession, withFreshRequestId, } from "../src/retry.mjs"; @@ -98,6 +99,38 @@ describe("planRetry", () => { }); }); +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"); From 9594ec974524a5029136a537c7a9e4ea3b734718 Mon Sep 17 00:00:00 2001 From: Jayden Lee Date: Mon, 24 Aug 2026 10:20:43 +0800 Subject: [PATCH 16/16] v0.1.6: update model list, remove dead code (-67 lines) --- models.json | 7 +-- package.json | 2 +- src/app.mjs | 5 +- src/pipe-anthropic.mjs | 116 +++++++++++++++++++++++++++++++--------- src/pipe-openai.mjs | 107 ++++++++++++++---------------------- src/retry.mjs | 45 ++++++++++------ src/routes/messages.mjs | 95 +++++++++++++++++++++++++------- src/to-anthropic.mjs | 5 +- 8 files changed, 246 insertions(+), 136 deletions(-) diff --git a/models.json b/models.json index 6cdc77a..a460425 100644 --- a/models.json +++ b/models.json @@ -1,7 +1,8 @@ [ - "deepseek-v4-flash-free", - "laguna-s-2.1-free", + "hy3-free", "mimo-v2.5-free", + "muse-spark-1.2-contributor-free", "nemotron-3-ultra-free", - "north-mini-code-free" + "nemotron-3.5-lightning-free", + "x-preview-f-free" ] diff --git a/package.json b/package.json index b92eb27..effd231 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-free-proxy", - "version": "0.1.5", + "version": "0.1.6", "description": "Proxy server for OpenCode free-tier AI models via Zen API", "type": "module", "main": "src/index.mjs", diff --git a/src/app.mjs b/src/app.mjs index 34a272e..74d4fc0 100644 --- a/src/app.mjs +++ b/src/app.mjs @@ -15,11 +15,12 @@ export function createApp() { // 404 fallback — return JSON for unknown routes app.use((_req, res) => { - res.status(404).json({ error: { message: "Not found", type: "not_found_error" } }); + 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. - // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { logLine("UNHANDLED ERROR", err.message, err.stack); if (res.headersSent) { diff --git a/src/pipe-anthropic.mjs b/src/pipe-anthropic.mjs index a1d499b..87a3a5b 100644 --- a/src/pipe-anthropic.mjs +++ b/src/pipe-anthropic.mjs @@ -27,8 +27,15 @@ const NO_CACHE = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; * @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 } = typeof ctx === "number" ? { retries: ctx } : ctx; +export function pipeZenAsAnthropic( + zenOpts, + body, + model, + res, + inputTokens, + ctx = {}, +) { + const { user, clientReq, retries = MAX_RETRIES } = ctx; const msgId = ocId("msg"); let currentOpts = zenOpts; @@ -64,10 +71,15 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = 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)" }, - })); + res.end( + JSON.stringify({ + type: "error", + error: { + type: "rate_limit_error", + message: errMsg + " (free model rate limit)", + }, + }), + ); } function failUpstream(status, errMsg, type = "upstream_error") { @@ -75,7 +87,9 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = terminalHandled = true; logLine("UPSTREAM ERROR", errMsg); logIO("OUTPUT (error)", { error: errMsg }); - res.status(status).json({ type: "error", error: { type, message: errMsg } }); + res + .status(status) + .json({ type: "error", error: { type, message: errMsg } }); } /** @returns {boolean} true if a retry was scheduled */ @@ -127,7 +141,7 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", + Connection: "keep-alive", "X-Accel-Buffering": "no", }); res.flushHeaders(); @@ -135,9 +149,17 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = 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 }, + id: msgId, + type: "message", + role: "assistant", + content: [], + model, + stop_reason: null, + usage: { + input_tokens: inputTokens || 0, + output_tokens: 0, + ...NO_CACHE, + }, }, }); } @@ -196,7 +218,11 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = if (payload === "[DONE]") continue; let parsed; - try { parsed = JSON.parse(payload); } catch { continue; } + try { + parsed = JSON.parse(payload); + } catch { + continue; + } const delta = parsed.choices?.[0]?.delta; if (!delta) continue; @@ -205,12 +231,17 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = 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: "" } }); + 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, + type: "content_block_delta", + index: 0, delta: { type: "text_delta", text: delta.content }, }); outputTokens += Math.ceil(delta.content.length / 4); @@ -221,24 +252,41 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = const idx = tc.index ?? 0; if (idx > toolIdx) { if (toolIdx === -1 && contentIdx > 0) { - sendSSE("content_block_stop", { type: "content_block_stop", index: 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: "" }; + 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 || "" }, + 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; + 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 }, + type: "content_block_delta", + index: blockIdx, + delta: { + type: "input_json_delta", + partial_json: tc.function.arguments, + }, }); outputTokens += Math.ceil(tc.function.arguments.length / 4); } @@ -249,7 +297,10 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = 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 }); + sendSSE("content_block_stop", { + type: "content_block_stop", + index: i, + }); } let stopReason = "end_turn"; @@ -282,7 +333,12 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = } logIO("OUTPUT (empty)", { error: "Empty response" }); if (!res.headersSent) { - res.status(502).json({ type: "error", error: { type: "upstream_error", message: "Empty response" } }); + res + .status(502) + .json({ + type: "error", + error: { type: "upstream_error", message: "Empty response" }, + }); } return; } @@ -307,7 +363,12 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = 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 } }); + res + .status(502) + .json({ + type: "error", + error: { type: "upstream_error", message: e.message }, + }); } }); @@ -320,7 +381,12 @@ export function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens, ctx = logLine("TIMEOUT"); logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); if (!res.headersSent) { - res.status(504).json({ type: "error", error: { type: "timeout_error", message: "Upstream timeout" } }); + res + .status(504) + .json({ + type: "error", + error: { type: "timeout_error", message: "Upstream timeout" }, + }); } }); diff --git a/src/pipe-openai.mjs b/src/pipe-openai.mjs index a67bf16..6b99e59 100644 --- a/src/pipe-openai.mjs +++ b/src/pipe-openai.mjs @@ -58,63 +58,6 @@ export function ensureOpenAIIds(payload, toolCallIds = {}, model = "") { return payload; } -// ── response parsers (for logging) ───────────────────────────────────────── - -/** Reconstruct assistant text + tool_calls from OpenAI SSE stream bytes. */ -export function parseOpenAIStreamOutput(raw) { - let content = ""; - const toolCalls = {}; - let finishReason = null; - let usage = null; - for (const line of raw.split("\n")) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (!payload || payload === "[DONE]") continue; - let parsed; - try { parsed = JSON.parse(payload); } catch { continue; } - if (parsed.usage) usage = parsed.usage; - const choice = parsed.choices?.[0]; - if (!choice) continue; - if (choice.finish_reason) finishReason = choice.finish_reason; - const delta = choice.delta || choice.message; - if (!delta) continue; - if (delta.content) content += delta.content; - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const i = tc.index ?? 0; - if (!toolCalls[i]) toolCalls[i] = { id: tc.id || "", name: "", arguments: "" }; - if (tc.id) toolCalls[i].id = tc.id; - if (tc.function?.name) toolCalls[i].name = tc.function.name; - if (tc.function?.arguments) toolCalls[i].arguments += tc.function.arguments; - } - } - } - const out = { content }; - const tcs = Object.values(toolCalls); - if (tcs.length) out.tool_calls = tcs; - if (finishReason) out.finish_reason = finishReason; - if (usage) out.usage = usage; - return out; -} - -export function parseOpenAISyncOutput(data) { - if (!data) return { raw: null }; - const choice = data.choices?.[0]; - const out = { - content: choice?.message?.content ?? null, - finish_reason: choice?.finish_reason ?? null, - usage: data.usage ?? null, - }; - if (choice?.message?.tool_calls?.length) { - out.tool_calls = choice.message.tool_calls.map((tc) => ({ - id: tc.id, - name: tc.function?.name, - arguments: tc.function?.arguments, - })); - } - return out; -} - // ── main pipe ────────────────────────────────────────────────────────────── /** @@ -128,10 +71,12 @@ export function parseOpenAISyncOutput(data) { * @param {number} [ctx.retries] */ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { - const { user, clientReq, retries = MAX_RETRIES } = typeof ctx === "number" ? { retries: ctx } : ctx; + const { user, clientReq, retries = MAX_RETRIES } = ctx; const toolCallIds = {}; let requestModel = ""; - try { requestModel = JSON.parse(body).model || ""; } catch {} + try { + requestModel = JSON.parse(body).model || ""; + } catch {} let currentOpts = zenOpts; let aborted = false; @@ -166,7 +111,11 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { 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" }, + error: { + message: errMsg + " (free model rate limit)", + type: "rate_limit_error", + code: "rate_limit_exceeded", + }, }); } @@ -219,7 +168,7 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", + Connection: "keep-alive", "X-Accel-Buffering": "no", "Transfer-Encoding": "chunked", }); @@ -232,7 +181,7 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { function flushSseBuffer(final = false) { if (!stream) return; const lines = sseBuffer.split("\n"); - sseBuffer = final ? "" : (lines.pop() || ""); + sseBuffer = final ? "" : lines.pop() || ""; for (const line of lines) { const out = transformSseLine(line, toolCallIds, requestModel); if (streamLogLines !== null) streamLogLines += out + "\n"; @@ -323,7 +272,14 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { 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" } }); + res + .status(502) + .json({ + error: { + message: "Empty response from upstream", + type: "upstream_error", + }, + }); } return; } @@ -332,16 +288,20 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { if (stream) { flushSseBuffer(true); if (streamLogLines !== null) { - logIO(`OUTPUT (stream, ${ms}ms)`, parseOpenAIStreamOutput(streamLogLines)); + 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 updated = ensureOpenAIIds( + parsed, + toolCallIds, + requestModel, + ); const rawUpdated = JSON.stringify(updated); - logIO(`OUTPUT (sync, ${ms}ms)`, parseOpenAISyncOutput(updated)); + logIO(`OUTPUT (sync, ${ms}ms)`, updated); res.end(rawUpdated); } catch { logIO(`OUTPUT (sync raw, ${ms}ms)`, raw); @@ -360,7 +320,14 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { 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" } }); + res + .status(502) + .json({ + error: { + message: "Upstream error: " + e.message, + type: "upstream_error", + }, + }); } }); @@ -373,7 +340,11 @@ export function pipeZenResponse(zenOpts, body, stream, res, ctx = {}) { logLine("TIMEOUT"); logIO("OUTPUT (timeout)", { error: "Upstream timeout" }); if (!res.headersSent) { - res.status(504).json({ error: { message: "Upstream timeout", type: "timeout_error" } }); + res + .status(504) + .json({ + error: { message: "Upstream timeout", type: "timeout_error" }, + }); } }); diff --git a/src/retry.mjs b/src/retry.mjs index b3c5b45..7731b5e 100644 --- a/src/retry.mjs +++ b/src/retry.mjs @@ -1,4 +1,4 @@ -import { MAX_RETRIES, RETRY_BASE_MS, RETRY_MAX_MS } from "./config/index.mjs"; +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"; @@ -7,7 +7,11 @@ 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) { +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); @@ -44,7 +48,9 @@ export function isClientGone(clientReq, res) { /** 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"]; + const sessionId = user + ? rotateSession(user) + : zenOpts.headers?.["x-opencode-session"]; return { ...zenOpts, headers: { @@ -70,8 +76,18 @@ 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)) { + 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; @@ -87,9 +103,11 @@ export function isRateLimitPayload(data, raw = "") { 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; + 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; + if (/rate\s*limit|usage\s*limit|too many requests|freeusage/i.test(msg)) + return true; return false; } @@ -108,7 +126,8 @@ export function parseErrorPayload(chunkOrData, 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; + if (!str.includes("FreeUsageLimitError") && !str.includes('"error"')) + return null; try { data = JSON.parse(str); } catch { @@ -125,11 +144,6 @@ export function parseErrorPayload(chunkOrData, raw = "") { }; } -/** @deprecated use parseErrorPayload; kept for any external callers */ -export function checkFirstChunkError(chunk) { - return parseErrorPayload(chunk)?.message ?? null; -} - /** * Shared retry decision for streaming pipes. * Mutates nothing; caller applies session/opts and schedules. @@ -144,7 +158,8 @@ export function planRetry({ remaining, retries, kind, headers, errMsg }) { return { delay, rotateSession: kind === "rate_limit", - label: kind === "rate_limit" ? "RATE LIMITED, retrying" : "TRANSIENT, retrying", + label: + kind === "rate_limit" ? "RATE LIMITED, retrying" : "TRANSIENT, retrying", errMsg: errMsg || kind, }; } @@ -153,5 +168,3 @@ export function logAndScheduleRetry(plan, remaining, schedule) { logLine(plan.label, `(${remaining} left, wait ${plan.delay}ms)`, plan.errMsg); schedule(plan.delay); } - -export { MAX_RETRIES }; diff --git a/src/routes/messages.mjs b/src/routes/messages.mjs index 6666b6a..f293d34 100644 --- a/src/routes/messages.mjs +++ b/src/routes/messages.mjs @@ -26,14 +26,22 @@ const router = Router(); 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" } }); + 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" }, + error: { + type: "invalid_request_error", + message: "Request body must be JSON", + }, }); } @@ -41,15 +49,24 @@ router.post("/v1/messages", async (req, res) => { if (!MODELS.includes(model)) { return res.status(400).json({ type: "error", - error: { type: "invalid_request_error", message: `Unknown model: ${model}. Available: ${MODELS.join(", ")}` }, + 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; + const inputTokens = (JSON.stringify(messages).length / 4) | 0; - logLine(user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary(messages))); + logLine( + user, + model, + stream ? "stream" : "sync", + "msgs:", + JSON.stringify(msgSummary(messages)), + ); logIO("INPUT", { model, stream: !!stream, @@ -59,10 +76,20 @@ router.post("/v1/messages", async (req, res) => { _converted: { messages, tools: tools?.length ? tools : undefined }, }); - let { body, options } = zenRequest(model, messages, stream, tools, undefined, sessionId); + let { body, options } = zenRequest( + model, + messages, + stream, + tools, + undefined, + sessionId, + ); if (stream) { - pipeZenAsAnthropic(options, body, model, res, inputTokens, { user, clientReq: req }); + pipeZenAsAnthropic(options, body, model, res, inputTokens, { + user, + clientReq: req, + }); return; } @@ -84,7 +111,10 @@ router.post("/v1/messages", async (req, res) => { lastTransientErr = e; if (attempt < MAX_RETRIES && isTransientNetworkError(e)) { const delay = rateLimitRetryDelay(attempt); - logLine(`TRANSIENT, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, e.message); + logLine( + `TRANSIENT, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, + e.message, + ); options = withFreshRequestId(options); await sleep(delay); continue; @@ -92,12 +122,19 @@ router.post("/v1/messages", async (req, res) => { throw e; } - const rateLimited = isRateLimitResponse(zenResp.status, zenResp.data, zenResp.raw); + 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); + logLine( + `RATE LIMITED, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, + errMsg, + ); options = withFreshSession(options, user); await sleep(delay); continue; @@ -111,7 +148,10 @@ router.post("/v1/messages", async (req, res) => { if (isTransientHttpStatus(zenResp.status) && attempt < MAX_RETRIES) { const delay = rateLimitRetryDelay(attempt); - logLine(`TRANSIENT, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, `HTTP ${zenResp.status}`); + logLine( + `TRANSIENT, retrying (${MAX_RETRIES - attempt} left, wait ${delay}ms)`, + `HTTP ${zenResp.status}`, + ); options = withFreshRequestId(options); await sleep(delay); continue; @@ -127,7 +167,8 @@ router.post("/v1/messages", async (req, res) => { if (lastTransientErr) { logIO(`OUTPUT (error, ${ms}ms)`, { error: lastTransientErr.message }); return res.status(502).json({ - type: "error", error: { type: "upstream_error", message: lastTransientErr.message }, + type: "error", + error: { type: "upstream_error", message: lastTransientErr.message }, }); } @@ -135,32 +176,43 @@ router.post("/v1/messages", async (req, res) => { 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)" }, + 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 = Number(zenResp.status) >= 400 ? Number(zenResp.status) : 502; - if (!Number.isInteger(status) || status < 100 || status > 999) status = 502; + 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 }, + 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}` }, + 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" }, + type: "error", + error: { type: "upstream_error", message: "Invalid upstream response" }, }); } const antResp = openAIToAnthropic(zenResp.data, model, inputTokens); @@ -169,7 +221,12 @@ router.post("/v1/messages", async (req, res) => { } 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 } }); + res + .status(502) + .json({ + type: "error", + error: { type: "upstream_error", message: e.message }, + }); } }); diff --git a/src/to-anthropic.mjs b/src/to-anthropic.mjs index 84b6383..1c17919 100644 --- a/src/to-anthropic.mjs +++ b/src/to-anthropic.mjs @@ -24,7 +24,9 @@ export function openAIToAnthropic(oaiResp, model, inputTokens) { if (choice.message?.tool_calls) { for (const tc of choice.message.tool_calls) { let input = {}; - try { input = JSON.parse(tc.function.arguments); } catch {} + try { + input = JSON.parse(tc.function.arguments); + } catch {} content.push({ type: "tool_use", id: tc.id || ocId("toolu"), @@ -38,7 +40,6 @@ export function openAIToAnthropic(oaiResp, model, inputTokens) { 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"),