diff --git a/.env.example b/.env.example deleted file mode 100644 index 8577305..0000000 --- a/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -# Required: Your Radius wallet private key (0x + 64 hex chars) -# You can also use RADROUTER_PROXY_PRIVATE_KEY_FILE to point to a file, -# or omit both to be prompted interactively. -RADROUTER_PROXY_PRIVATE_KEY=0x... - -# Optional: RadRouter URL (defaults to https://rad-router.eriksreks.workers.dev) -# RADROUTER_URL=https://rad-router.com - -# Optional: Proxy port (defaults to 4020) -# RADROUTER_PROXY_PORT=4020 - -# Optional: x402 payment scheme - "exact" (default) or "upto" -# RADROUTER_X402_SCHEME=exact - -# Optional: Override model mappings as JSON -# RADROUTER_MODEL_MAP={"my-model":"anthropic/claude-sonnet-4.5"} - -# Optional: Stream idle timeout in ms (auto-detected for reasoning models) -# RADROUTER_STREAM_TIMEOUT_MS=300000 - -# Optional: Enable payload capture debug logging -# RADROUTER_DEBUG_PAYLOAD_CAPTURE=1 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9c97bbd..0000000 --- a/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -dist -.env diff --git a/README.md b/README.md index d2e1931..e451ba2 100644 --- a/README.md +++ b/README.md @@ -1,82 +1 @@ -# RadRouter x402 Proxy - -A local proxy that sits between your AI-powered IDE (Claude Code, Zed, Cursor, etc.) and [RadRouter](https://rad-router.com). It automatically handles x402 payments on the Radius Network using your local private key — no API keys needed. - -## Quick start - -```bash -git clone rad-router-proxy -cd rad-router-proxy -npm install -cp .env.example .env -# Edit .env and add your Radius wallet private key -npm start -``` - -Then point your IDE's API base URL to: - -``` -http://localhost:4020/v1 -``` - -## How it works - -1. Your IDE sends a request to the proxy (Chat Completions, Anthropic Messages, or Responses API format). -2. The proxy normalizes the request and forwards it to RadRouter. -3. If RadRouter returns HTTP 402, the proxy automatically signs an EIP-2612 permit for SBC payment and retries. -4. The response is translated back to your IDE's expected format and streamed through. - -## Configuration - -All configuration is via environment variables (set in `.env` or your shell): - -| Variable | Required | Default | Description | -|---|---|---|---| -| `RADROUTER_PROXY_PRIVATE_KEY` | Yes* | — | Your Radius wallet private key (`0x` + 64 hex chars) | -| `RADROUTER_PROXY_PRIVATE_KEY_FILE` | — | — | Path to a file containing your private key | -| `RADROUTER_URL` | — | `https://rad-router.com` | RadRouter backend URL | -| `RADROUTER_PROXY_PORT` | — | `4020` | Local proxy port | -| `RADROUTER_X402_SCHEME` | — | `exact` | Payment scheme: `exact` or `upto` | -| `RADROUTER_MODEL_MAP` | — | — | JSON object to override model name mappings | -| `RADROUTER_STREAM_TIMEOUT_MS` | — | auto | Stream idle timeout in milliseconds | -| `RADROUTER_DEBUG_PAYLOAD_CAPTURE` | — | — | Set to `1` for verbose payload logging | - -*If no private key is provided via env vars, the proxy will prompt interactively. - -## IDE setup examples - -### Claude Code - -```bash -claude config set --global apiBaseUrl http://localhost:4020/v1 -``` - -### Zed - -In your Zed settings, set the API base URL to `http://localhost:4020/v1`. - -### Cursor / Continue / Other OpenAI-compatible - -Set the base URL to `http://localhost:4020/v1` and use any string as the API key (the proxy handles auth via x402). - -## Supported API formats - -The proxy accepts and translates: - -- **OpenAI Chat Completions** (`/v1/chat/completions`) → normalized to Responses API -- **Anthropic Messages** (`/v1/messages`) → normalized to Responses API -- **OpenAI Responses** (`/v1/responses`) → passed through directly - -Streaming is fully supported for all formats. - -## Project structure - -``` -rad-router-proxy/ -├── proxy.ts # The proxy server (single file) -├── package.json -├── tsconfig.json -├── .env.example # Template for environment variables -├── .gitignore -└── README.md -``` +# rad-router-proxy \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d9ac626..0000000 --- a/package-lock.json +++ /dev/null @@ -1,798 +0,0 @@ -{ - "name": "rad-router-proxy", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "rad-router-proxy", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "viem": "^2.47.1" - }, - "devDependencies": { - "@types/node": "20.19.27", - "tsx": "^4.20.5", - "typescript": "^5.9.2" - } - }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", - "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@types/node": { - "version": "20.19.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", - "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/abitype": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", - "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3.22.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/isows": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", - "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/ox": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.5.tgz", - "integrity": "sha512-HgmHmBveYO40H/R3K6TMrwYtHsx/u6TAB+GpZlgJCoW0Sq5Ttpjih0IZZiwGQw7T6vdW4IAyobYrE2mdAvyF8Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.11.0", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "1.9.1", - "@noble/hashes": "^1.8.0", - "@scure/bip32": "^1.7.0", - "@scure/bip39": "^1.6.0", - "abitype": "^1.2.3", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/viem": { - "version": "2.47.5", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.5.tgz", - "integrity": "sha512-nVrJEQ8GL4JoVIrMBF3wwpTUZun0cpojfnOZ+96GtDWhqxZkVdy6vOEgu+jwfXqfTA/+wrR+YsN9TBQmhDUk0g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@noble/curves": "1.9.1", - "@noble/hashes": "1.8.0", - "@scure/bip32": "1.7.0", - "@scure/bip39": "1.6.0", - "abitype": "1.2.3", - "isows": "1.0.7", - "ox": "0.14.5", - "ws": "8.18.3" - }, - "peerDependencies": { - "typescript": ">=5.0.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 75a205e..0000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "rad-router-proxy", - "version": "1.0.0", - "type": "module", - "license": "MIT", - "scripts": { - "start": "node --env-file-if-exists=.env --import tsx proxy.ts", - "check": "tsc --noEmit" - }, - "dependencies": { - "viem": "^2.47.1" - }, - "devDependencies": { - "@types/node": "20.19.27", - "tsx": "^4.20.5", - "typescript": "^5.9.2" - } -} diff --git a/proxy.ts b/proxy.ts deleted file mode 100644 index db52af9..0000000 --- a/proxy.ts +++ /dev/null @@ -1,3233 +0,0 @@ -import * as http from "http"; -import * as https from "https"; -import { promises as fs } from "fs"; -import { - createPublicClient, - createWalletClient, - http as viemHttp, - parseUnits, - formatUnits, - getAddress, - defineChain, -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -// --------------------------------------------------------------------------- -// MODEL_PRICING (inlined from server/radius.ts) -// --------------------------------------------------------------------------- - -type OpenRouterModelPricingSnapshot = { - pricing: { prompt: string; completion: string }; -}; - -function perTokenUsdToPer1kUsd(perTokenUsd: string): string { - return (parseFloat(perTokenUsd) * 1000).toFixed(6); -} - -const OPENROUTER_PRICING_SNAPSHOT: Record< - string, - OpenRouterModelPricingSnapshot -> = { - "meta-llama/llama-3.3-70b-instruct": { - pricing: { prompt: "0.0000001", completion: "0.00000032" }, - }, - "meta-llama/llama-4-maverick": { - pricing: { prompt: "0.00000015", completion: "0.0000006" }, - }, - "meta-llama/llama-4-scout": { - pricing: { prompt: "0.00000008", completion: "0.0000003" }, - }, - "deepseek/deepseek-chat-v3-0324": { - pricing: { prompt: "0.0000002", completion: "0.00000077" }, - }, - "deepseek/deepseek-r1": { - pricing: { prompt: "0.0000007", completion: "0.0000025" }, - }, - "anthropic/claude-sonnet-4": { - pricing: { prompt: "0.000003", completion: "0.000015" }, - }, - "openai/gpt-4o": { - pricing: { prompt: "0.0000025", completion: "0.00001" }, - }, - "openai/gpt-4o-mini": { - pricing: { prompt: "0.00000015", completion: "0.0000006" }, - }, - "google/gemini-2.5-pro": { - pricing: { prompt: "0.00000125", completion: "0.00001" }, - }, - "google/gemini-2.5-flash": { - pricing: { prompt: "0.0000003", completion: "0.0000025" }, - }, - "qwen/qwen-2.5-72b-instruct": { - pricing: { prompt: "0.00000012", completion: "0.00000039" }, - }, - "mistralai/mistral-large-2411": { - pricing: { prompt: "0.000002", completion: "0.000006" }, - }, -}; - -const OPENROUTER_MODEL_ALIASES: Record = { - "google/gemini-2.5-pro-preview": "google/gemini-2.5-pro", - "google/gemini-2.5-flash-preview": "google/gemini-2.5-flash", -}; - -const MODEL_PRICING: Record< - string, - { promptPer1k: string; completionPer1k: string } -> = (() => { - const pricing: Record< - string, - { promptPer1k: string; completionPer1k: string } - > = {}; - - for (const [modelId, model] of Object.entries( - OPENROUTER_PRICING_SNAPSHOT, - )) { - pricing[modelId] = { - promptPer1k: perTokenUsdToPer1kUsd(model.pricing.prompt), - completionPer1k: perTokenUsdToPer1kUsd(model.pricing.completion), - }; - } - - for (const [alias, canonical] of Object.entries(OPENROUTER_MODEL_ALIASES)) { - const canonicalPricing = pricing[canonical]; - if (canonicalPricing) { - pricing[alias] = canonicalPricing; - } - } - - return pricing; -})(); - -// --------------------------------------------------------------------------- - -const RADROUTER_URL = - process.env.RADROUTER_URL || "https://rad-router.eriksreks.workers.dev"; -const PORT = parseInt(process.env.RADROUTER_PROXY_PORT || "4020", 10); -const PRIVATE_KEY_REGEX = /^0x[a-fA-F0-9]{64}$/; -const RADIUS_EXPLORER_URL = "https://network.radiustech.xyz"; - -const DEFAULT_MODEL_MAP: Record = { - "claude-haiku-4-5-20251001": "anthropic/claude-haiku-4.5", - "claude-sonnet-4-5-20250929": "anthropic/claude-sonnet-4.5", - "claude-opus-4-5-20251101": "anthropic/claude-opus-4.5", - haiku: "anthropic/claude-haiku-4.5", - sonnet: "anthropic/claude-sonnet-4.5", - opus: "anthropic/claude-opus-4.5", - "claude-sonnet-4-6": "anthropic/claude-sonnet-4.6", -}; - -function loadModelMap(): Record { - const envValue = process.env.RADROUTER_MODEL_MAP; - if (!envValue) return DEFAULT_MODEL_MAP; - - try { - const parsed = JSON.parse(envValue); - if (!parsed || typeof parsed !== "object") { - return DEFAULT_MODEL_MAP; - } - - const envMap: Record = {}; - for (const [from, to] of Object.entries(parsed)) { - if (typeof from !== "string" || typeof to !== "string") { - continue; - } - - envMap[from.toLowerCase()] = to; - } - - return { - ...DEFAULT_MODEL_MAP, - ...envMap, - }; - } catch (err: any) { - console.warn( - `[proxy] Failed to parse RADROUTER_MODEL_MAP, using defaults: ${err?.message || "unknown error"}`, - ); - return DEFAULT_MODEL_MAP; - } -} - -const MODEL_MAP = loadModelMap(); - -function normalizeProxyPaymentScheme(value?: string): "exact" | "upto" { - return value?.toLowerCase() === "upto" ? "upto" : "exact"; -} - -const EXPECTED_PAYMENT_SCHEME = normalizeProxyPaymentScheme( - process.env.RADROUTER_X402_SCHEME, -); - -function isValidPrivateKey(value: string): value is `0x${string}` { - return PRIVATE_KEY_REGEX.test(value); -} - -function assertValidPrivateKey(value: string, source: string): `0x${string}` { - if (!isValidPrivateKey(value)) { - throw new Error( - `[proxy] Invalid private key format from ${source}. Expected 0x + 64 hex characters.`, - ); - } - return value; -} - -async function readPrivateKeyFromFile(filePath: string): Promise { - const content = await fs.readFile(filePath, "utf-8"); - return content.trim(); -} - -async function promptForPrivateKeyHidden(): Promise { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error( - "[proxy] Private key not provided. Set RADROUTER_PROXY_PRIVATE_KEY, RADROUTER_PROXY_PRIVATE_KEY_FILE, or run in an interactive terminal.", - ); - } - - return new Promise((resolve, reject) => { - const stdin = process.stdin; - let key = ""; - - const cleanup = () => { - stdin.off("data", onData); - stdin.off("error", onError); - if (stdin.isTTY) { - stdin.setRawMode(false); - } - stdin.pause(); - }; - - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - - const onData = (chunk: string | Buffer) => { - const input = - typeof chunk === "string" ? chunk : chunk.toString("utf8"); - - for (const ch of input) { - if (ch === "\r" || ch === "\n") { - cleanup(); - process.stdout.write("\n"); - resolve(key.trim()); - return; - } - if (ch === "\u0003") { - cleanup(); - process.stdout.write("\n"); - reject( - new Error( - "[proxy] Private key entry canceled by user.", - ), - ); - return; - } - if (ch === "\u0008" || ch === "\u007f") { - key = key.slice(0, -1); - continue; - } - key += ch; - } - }; - - process.stdout.write("[proxy] Enter private key (input hidden): "); - stdin.setEncoding("utf8"); - if (stdin.isTTY) { - stdin.setRawMode(true); - } - stdin.resume(); - stdin.on("data", onData); - stdin.on("error", onError); - }); -} - -async function loadPrivateKey(): Promise<`0x${string}`> { - if (process.env.RADROUTER_PROXY_PRIVATE_KEY) { - return assertValidPrivateKey( - process.env.RADROUTER_PROXY_PRIVATE_KEY.trim(), - "RADROUTER_PROXY_PRIVATE_KEY", - ); - } - - if (process.env.RADROUTER_PROXY_PRIVATE_KEY_FILE) { - const fileKey = await readPrivateKeyFromFile( - process.env.RADROUTER_PROXY_PRIVATE_KEY_FILE, - ); - return assertValidPrivateKey( - fileKey, - "RADROUTER_PROXY_PRIVATE_KEY_FILE", - ); - } - - if (process.env.RADROUTER_PRIVATE_KEY) { - console.warn( - "[proxy] RADROUTER_PRIVATE_KEY is deprecated. Please migrate to RADROUTER_PROXY_PRIVATE_KEY.", - ); - return assertValidPrivateKey( - process.env.RADROUTER_PRIVATE_KEY.trim(), - "RADROUTER_PRIVATE_KEY", - ); - } - - const promptedKey = await promptForPrivateKeyHidden(); - return assertValidPrivateKey(promptedKey, "interactive prompt"); -} - -const SBC_TOKEN = "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb" as const; -const SBC_DECIMALS = 6; - -const SBC_PERMIT_TYPES = { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" }, - ], -} as const; - -const ERC20_NONCES_ABI = [ - { - inputs: [{ name: "owner", type: "address" }], - name: "nonces", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function", - }, -] as const; - -const radiusMainnet = defineChain({ - id: 723487, - name: "Radius Network", - nativeCurrency: { decimals: 18, name: "RUSD", symbol: "RUSD" }, - rpcUrls: { default: { http: ["https://rpc.radiustech.xyz"] } }, - blockExplorers: { - default: { - name: "Radius Explorer", - url: "https://network.radiustech.xyz", - }, - }, -}); - -const ERC20_BALANCE_OF_ABI = [ - { - inputs: [{ name: "account", type: "address" }], - name: "balanceOf", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function", - }, -] as const; - -let account!: ReturnType; -let publicClient!: ReturnType; -let walletClient!: ReturnType; - -async function initializeSignerAndClients() { - const privateKey = await loadPrivateKey(); - account = privateKeyToAccount(privateKey); - - publicClient = createPublicClient({ - chain: radiusMainnet, - transport: viemHttp(), - }); - - walletClient = createWalletClient({ - account, - chain: radiusMainnet, - transport: viemHttp(), - }); -} - -interface PaymentRequirement { - scheme: string; - network: string; - maxAmountRequired: string; - payTo: string; - asset: string; -} - -interface PaymentResponse { - error: string; - x402Version: number; - accepts: PaymentRequirement[]; -} - -function logSection(title: string) { - console.log(`\n[rad-router] ${title}`); -} - -function logStep(label: string, details: string) { - console.log(`[x402] ${label.padEnd(18)} ${details}`); -} - -function toNumber(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function redactSensitive(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => redactSensitive(item)); - } - - if (!isPlainObject(value)) return value; - - const redacted: Record = {}; - for (const [k, v] of Object.entries(value)) { - const lower = k.toLowerCase(); - const shouldRedact = - lower.includes("authorization") || - lower.includes("api-key") || - lower.includes("apikey") || - lower.includes("token") || - lower.includes("secret") || - lower.includes("private") || - lower === "x-payment"; - - if (shouldRedact) { - redacted[k] = "[REDACTED]"; - continue; - } - - redacted[k] = redactSensitive(v); - } - - return redacted; -} - -function summarizeMessageContent(content: unknown): Record { - if (typeof content === "string") { - const trimmed = content.trim(); - return { - shape: "string", - chars: content.length, - trimmedChars: trimmed.length, - isEmptyTrimmed: trimmed.length === 0, - }; - } - - if (Array.isArray(content)) { - let textBlocks = 0; - let emptyTextBlocks = 0; - let whitespaceTextBlocks = 0; - - const blockTypes: Record = {}; - - for (const block of content) { - if (!isPlainObject(block)) { - blockTypes["non_object"] = (blockTypes["non_object"] || 0) + 1; - continue; - } - - const type = - typeof block.type === "string" ? block.type : "unknown_type"; - blockTypes[type] = (blockTypes[type] || 0) + 1; - - if ( - type === "input_text" || - type === "output_text" || - typeof block.text === "string" - ) { - textBlocks += 1; - const text = - typeof block.text === "string" - ? block.text - : typeof block.refusal === "string" - ? block.refusal - : ""; - if (text.length === 0) { - emptyTextBlocks += 1; - } else if (text.trim().length === 0) { - whitespaceTextBlocks += 1; - } - } - } - - return { - shape: "array", - blocks: content.length, - textBlocks, - emptyTextBlocks, - whitespaceTextBlocks, - blockTypes, - }; - } - - if (content === null) { - return { shape: "null" }; - } - - return { shape: typeof content }; -} - -function summarizeInputForDiagnostics(input: unknown): Record { - if (!Array.isArray(input)) { - return { - kind: typeof input, - isArray: false, - }; - } - - const summary: Record = { - kind: "array", - isArray: true, - totalItems: input.length, - messageItems: 0, - byRole: {} as Record, - issues: { - emptyStringContentMessages: 0, - emptyInputTextBlocks: 0, - whitespaceInputTextBlocks: 0, - }, - sample: [] as unknown[], - }; - - const byRole = summary.byRole as Record; - const issues = summary.issues as Record; - const sample = summary.sample as unknown[]; - - for (let i = 0; i < input.length; i++) { - const item = input[i]; - if (!isPlainObject(item)) continue; - - const type = typeof item.type === "string" ? item.type : "unknown"; - const role = typeof item.role === "string" ? item.role : "unknown"; - - if (type === "message") { - summary.messageItems = (summary.messageItems as number) + 1; - byRole[role] = (byRole[role] || 0) + 1; - - const contentSummary = summarizeMessageContent(item.content); - - if ( - contentSummary.shape === "string" && - (contentSummary.isEmptyTrimmed as boolean) - ) { - issues.emptyStringContentMessages += 1; - } - - if (contentSummary.shape === "array") { - issues.emptyInputTextBlocks += - typeof contentSummary.emptyTextBlocks === "number" - ? contentSummary.emptyTextBlocks - : 0; - issues.whitespaceInputTextBlocks += - typeof contentSummary.whitespaceTextBlocks === "number" - ? contentSummary.whitespaceTextBlocks - : 0; - } - - if (sample.length < 12) { - sample.push({ - index: i, - type, - role, - content: contentSummary, - }); - } - } - } - - return summary; -} - -function capturePayloadBoundary(params: { - stage: "inbound" | "normalized" | "upstream"; - method: string; - originalPath: string; - upstreamPath: string; - isStreamRequest: boolean; - body: Buffer | null; - headers?: http.IncomingHttpHeaders | Record; -}) { - const enabled = process.env.RADROUTER_DEBUG_PAYLOAD_CAPTURE === "1"; - if (!enabled) return; - - let parsed: Record | undefined; - let parseError: string | undefined; - - if (params.body) { - try { - parsed = JSON.parse(params.body.toString("utf-8")) as Record< - string, - unknown - >; - } catch (err: any) { - parseError = err?.message || "invalid json"; - } - } - - const capture = { - stage: params.stage, - method: params.method, - path: params.originalPath, - upstreamPath: params.upstreamPath, - stream: params.isStreamRequest, - bodyBytes: params.body?.length ?? 0, - parseError, - model: typeof parsed?.model === "string" ? parsed.model : undefined, - hasInput: parsed?.input !== undefined, - inputSummary: summarizeInputForDiagnostics(parsed?.input), - headers: params.headers ? redactSensitive(params.headers) : undefined, - body: parsed ? redactSensitive(parsed) : undefined, - }; - - console.log(`[proxy][payload] ${JSON.stringify(capture, null, 2)}`); -} - -function parseUsdRate(value: string): number { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function inferPromptTokensFromInput(input: unknown): number { - if (!Array.isArray(input)) return 0; - - let chars = 0; - for (const item of input) { - if (!item || typeof item !== "object") continue; - const typedItem = item as Record; - const content = typedItem.content; - - if (!Array.isArray(content)) continue; - for (const part of content) { - if (!part || typeof part !== "object") continue; - const p = part as Record; - if (typeof p.text === "string") { - chars += p.text.length; - } - } - } - - // Approximation: ~4 chars per token. - return Math.ceil(chars / 4); -} - -function computeEstimatedUsageCostSbc(params: { - model: string; - input: unknown; - explicitPromptTokens?: number; - maxOutputTokens: number; -}): { - promptTokensEstimate: number; - completionTokensEstimate: number; - promptUsd: number; - completionUsd: number; - totalUsd: number; - totalSbcAtomic: bigint; -} { - const pricing = MODEL_PRICING[params.model] || { - promptPer1k: "0.001000", - completionPer1k: "0.002000", - }; - - const promptTokensEstimate = Math.max( - 0, - params.explicitPromptTokens ?? inferPromptTokensFromInput(params.input), - ); - const completionTokensEstimate = Math.max(0, params.maxOutputTokens); - - const promptRate = parseUsdRate(pricing.promptPer1k); - const completionRate = parseUsdRate(pricing.completionPer1k); - - const promptUsd = (promptTokensEstimate / 1000) * promptRate; - const completionUsd = (completionTokensEstimate / 1000) * completionRate; - const totalUsd = promptUsd + completionUsd; - - // SBC has 6 decimals; round up to avoid under-collection in estimate. - const totalSbcAtomic = BigInt(Math.ceil(totalUsd * 1_000_000)); - - return { - promptTokensEstimate, - completionTokensEstimate, - promptUsd, - completionUsd, - totalUsd, - totalSbcAtomic, - }; -} - -function computeActualUsageCostSbc(params: { - model: string; - promptTokens: number; - completionTokens: number; -}): { - promptUsd: number; - completionUsd: number; - totalUsd: number; - totalSbcAtomic: bigint; -} { - const pricing = MODEL_PRICING[params.model] || { - promptPer1k: "0.001000", - completionPer1k: "0.002000", - }; - - const promptRate = parseUsdRate(pricing.promptPer1k); - const completionRate = parseUsdRate(pricing.completionPer1k); - - const promptUsd = (Math.max(0, params.promptTokens) / 1000) * promptRate; - const completionUsd = - (Math.max(0, params.completionTokens) / 1000) * completionRate; - const totalUsd = promptUsd + completionUsd; - const totalSbcAtomic = BigInt(Math.ceil(totalUsd * 1_000_000)); - - return { promptUsd, completionUsd, totalUsd, totalSbcAtomic }; -} - -function logPricingEstimate(params: { - model: string; - normalizedRequestBody: Buffer | null; - maxOutputTokens: number; -}) { - if (!params.normalizedRequestBody) return; - - try { - const payload = JSON.parse( - params.normalizedRequestBody.toString("utf-8"), - ) as Record; - - const estimate = computeEstimatedUsageCostSbc({ - model: params.model, - input: payload.input, - explicitPromptTokens: - typeof payload.prompt_tokens === "number" - ? payload.prompt_tokens - : undefined, - maxOutputTokens: params.maxOutputTokens, - }); - - logStep( - "pricing-est", - [ - `model=${params.model || "unknown"}`, - `prompt_est=${estimate.promptTokensEstimate}`, - `max_out=${estimate.completionTokensEstimate}`, - `usd=${estimate.totalUsd.toFixed(6)}`, - `sbc_atomic=${estimate.totalSbcAtomic.toString()}`, - ].join(" | "), - ); - } catch { - // best effort logging only - } -} - -function logActualUsageCost(params: { - model: string; - responseBody: Buffer; - authorizedMaxAmount?: string; -}) { - try { - const parsed = JSON.parse( - params.responseBody.toString("utf-8"), - ) as Record; - const usage = parsed.usage as Record | undefined; - if (!usage) return; - - const promptTokens = - toNumber(usage.input_tokens) || toNumber(usage.prompt_tokens); - const completionTokens = - toNumber(usage.output_tokens) || toNumber(usage.completion_tokens); - - const actual = computeActualUsageCostSbc({ - model: params.model, - promptTokens, - completionTokens, - }); - - let capDetail = "cap=unknown"; - if (params.authorizedMaxAmount) { - const capAtomic = BigInt( - Math.max( - 0, - Math.ceil(Number(params.authorizedMaxAmount) * 1_000_000), - ), - ); - const clamped = - actual.totalSbcAtomic > capAtomic - ? capAtomic - : actual.totalSbcAtomic; - capDetail = `cap_atomic=${capAtomic.toString()} | actual_clamped_atomic=${clamped.toString()}`; - } - - logStep( - "pricing-actual", - [ - `model=${params.model || "unknown"}`, - `prompt=${promptTokens}`, - `completion=${completionTokens}`, - `usd=${actual.totalUsd.toFixed(6)}`, - `actual_atomic=${actual.totalSbcAtomic.toString()}`, - capDetail, - ].join(" | "), - ); - } catch { - // best effort logging only - } -} - -function headerFirst( - headers: http.IncomingHttpHeaders, - name: string, -): string | undefined { - const value = headers[name]; - return Array.isArray(value) ? value[0] : value; -} - -function asTxHash(value: string | undefined): string | null { - if (!value) return null; - const txHash = value.trim(); - return /^0x[a-fA-F0-9]{64}$/.test(txHash) ? txHash : null; -} - -function extractPaymentHeaders(headers: http.IncomingHttpHeaders): { - verified?: string; - payer?: string; - tx?: string; - txState?: string; -} { - return { - verified: headerFirst(headers, "x-payment-verified"), - payer: headerFirst(headers, "x-payment-payer"), - tx: - headerFirst(headers, "x-payment-transaction-hash") ?? - headerFirst(headers, "x-payment-transaction"), - txState: headerFirst(headers, "x-payment-transaction"), - }; -} - -function logTxDetails(txLike?: string, pendingValue?: string) { - const txHash = asTxHash(txLike); - if (txHash) { - logStep("tx", `${txHash}`); - logStep("explorer", `${RADIUS_EXPLORER_URL}/tx/${txHash}`); - return; - } - - const pending = pendingValue ?? txLike; - if (pending) { - logStep("tx", `${pending} (awaiting transaction hash)`); - } -} - -function logStreamBodyPreview(responseBody: Buffer) { - const upstreamBody = responseBody.toString("utf-8").trim(); - - if (upstreamBody) { - const preview = - upstreamBody.length > 2000 - ? `${upstreamBody.slice(0, 2000)}...` - : upstreamBody; - logStep("stream-body", preview); - } else { - logStep("stream-body", "No response body captured from upstream."); - } -} - -async function fetchNonce(): Promise { - try { - const nonce = await (publicClient as any).readContract({ - address: SBC_TOKEN, - abi: ERC20_NONCES_ABI, - functionName: "nonces", - args: [account.address], - }); - return nonce; - } catch (err: any) { - console.warn( - "[x402] Could not fetch on-chain nonce, using 0:", - err.message, - ); - return BigInt(0); - } -} - -async function fetchStartupBalances(): Promise<{ - rusd: bigint; - sbc: bigint; -}> { - const [rusd, sbc] = await Promise.all([ - publicClient.getBalance({ address: account.address }), - (publicClient as any).readContract({ - address: SBC_TOKEN, - abi: ERC20_BALANCE_OF_ABI, - functionName: "balanceOf", - args: [account.address], - }) as Promise, - ]); - - return { rusd, sbc }; -} - -function validateRequirement(requirement: PaymentRequirement): string | null { - if (requirement.scheme !== EXPECTED_PAYMENT_SCHEME) { - return `Unsupported scheme: ${requirement.scheme}. Expected ${EXPECTED_PAYMENT_SCHEME}`; - } - if (requirement.network !== "radius") { - return `Wrong network: ${requirement.network}`; - } - if ( - requirement.asset && - requirement.asset.toLowerCase() !== SBC_TOKEN.toLowerCase() - ) { - return `Unsupported asset: ${requirement.asset}`; - } - if (!requirement.payTo || !/^0x[a-fA-F0-9]{40}$/.test(requirement.payTo)) { - return `Invalid payTo address: ${requirement.payTo}`; - } - return null; -} - -async function signPermit(requirement: PaymentRequirement): Promise { - const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); - const value = parseUnits(requirement.maxAmountRequired, SBC_DECIMALS); - const nonce = await fetchNonce(); - - const domain = { - name: "Stable Coin" as const, - version: "1" as const, - chainId: 723487, - verifyingContract: SBC_TOKEN as `0x${string}`, - }; - - const message = { - owner: account.address, - spender: getAddress(requirement.payTo) as `0x${string}`, - value, - nonce, - deadline, - }; - - const signature = await walletClient.signTypedData({ - account, - domain, - types: SBC_PERMIT_TYPES, - primaryType: "Permit", - message, - }); - - const r = `0x${signature.slice(2, 66)}`; - const s = `0x${signature.slice(66, 130)}`; - const v = parseInt(signature.slice(130, 132), 16); - - const payload = { - x402Version: 1, - scheme: EXPECTED_PAYMENT_SCHEME, - network: "radius", - payload: { - kind: "permit-eip2612", - owner: account.address, - spender: requirement.payTo, - value: value.toString(), - nonce: nonce.toString(), - deadline: deadline.toString(), - v, - r, - s, - }, - }; - - return Buffer.from(JSON.stringify(payload)).toString("base64"); -} - -function normalizeUpstreamPath(path: string): string { - const pathOnly = path.split("?")[0] || "/"; - - // Accept legacy OpenAI Chat Completions client paths (e.g. Zed) and - // forward them to the Responses endpoint. - if (pathOnly.endsWith("/chat/completions")) { - return path.replace(/\/chat\/completions$/, "/responses"); - } - - // Accept Anthropic-style Messages API paths (used by Claude Code) and - // forward them to the Responses endpoint. - if (pathOnly.endsWith("/messages")) { - return path.replace(/\/messages(?=\?|$)/, "/responses"); - } - - return path; -} - -function isAnthropicMessagesPath(path: string): boolean { - const pathOnly = path.split("?")[0] || "/"; - return pathOnly.endsWith("/messages"); -} - -function isChatCompletionsPath(path: string): boolean { - const pathOnly = path.split("?")[0] || "/"; - return pathOnly.endsWith("/chat/completions"); -} - -const DEFAULT_TOOL_PARAMETERS_SCHEMA = { - type: "object", - properties: {}, - additionalProperties: true, -} as const; - -function normalizeAnthropicToolsToResponses(tools: unknown): unknown { - if (!Array.isArray(tools)) return tools; - - return tools - .map((tool) => { - if (!tool || typeof tool !== "object") return null; - const t = tool as Record; - if (typeof t.name !== "string") return null; - - return { - type: "function", - name: t.name, - ...(typeof t.description === "string" - ? { description: t.description } - : {}), - parameters: - t.input_schema && typeof t.input_schema === "object" - ? t.input_schema - : DEFAULT_TOOL_PARAMETERS_SCHEMA, - }; - }) - .filter((tool) => tool !== null); -} - -function mapRequestedModel(model: unknown): unknown { - if (typeof model !== "string") return model; - const mapped = MODEL_MAP[model.toLowerCase()]; - return mapped || model; -} - -function normalizeChatToolsToResponses(tools: unknown): unknown { - if (!Array.isArray(tools)) return tools; - - return tools - .map((tool) => { - if (!tool || typeof tool !== "object") return null; - - const t = tool as Record; - const fn = - t.function && typeof t.function === "object" - ? (t.function as Record) - : null; - - const name = - typeof t.name === "string" - ? t.name - : typeof fn?.name === "string" - ? fn.name - : undefined; - const description = - typeof t.description === "string" - ? t.description - : typeof fn?.description === "string" - ? fn.description - : undefined; - const parameters = - t.parameters && typeof t.parameters === "object" - ? t.parameters - : t.input_schema && typeof t.input_schema === "object" - ? t.input_schema - : fn?.parameters && typeof fn.parameters === "object" - ? fn.parameters - : fn?.input_schema && - typeof fn.input_schema === "object" - ? fn.input_schema - : undefined; - const strict = - typeof t.strict === "boolean" - ? t.strict - : typeof fn?.strict === "boolean" - ? fn.strict - : undefined; - - if ( - typeof t.type === "string" && - (t.type.startsWith("openrouter:") || - t.type === "web_search" || - t.type === "web_search_preview") - ) { - return t; - } - - if (!name) return null; - - return { - type: "function", - name, - ...(description ? { description } : {}), - ...(strict !== undefined ? { strict } : {}), - parameters: parameters ?? DEFAULT_TOOL_PARAMETERS_SCHEMA, - }; - }) - .filter((tool) => tool !== null); -} - -function normalizeChatMessageContentToText(content: unknown): string { - if (typeof content === "string") return content; - if (content === null || content === undefined) return ""; - - if (Array.isArray(content)) { - return content - .map((part) => { - if (typeof part === "string") return part; - if (!part || typeof part !== "object") return ""; - - const p = part as Record; - if (typeof p.text === "string") return p.text; - if (typeof p.refusal === "string") return p.refusal; - - return ""; - }) - .filter((part) => part.length > 0) - .join(""); - } - - return JSON.stringify(content); -} - -function normalizeChatMessagesToResponsesInput(messages: unknown): unknown { - if (!Array.isArray(messages)) return messages; - - const items: unknown[] = []; - - for (const msg of messages) { - if (!msg || typeof msg !== "object") { - items.push(msg); - continue; - } - - const m = msg as Record; - const role = typeof m.role === "string" ? m.role : undefined; - - // Anthropic messages format: - // { role: "assistant"|"user", content: [{type:"text"...}|{type:"tool_use"...}|{type:"tool_result"...}] } - if (Array.isArray(m.content)) { - const contentParts = m.content as Array>; - - if (role === "assistant") { - let assistantText = ""; - for (const part of contentParts) { - if (!part || typeof part !== "object") continue; - - const type = typeof part.type === "string" ? part.type : ""; - if (type === "text") { - const text = - typeof part.text === "string" ? part.text : ""; - assistantText += text; - } else if (type === "tool_use") { - const callId = - (typeof part.id === "string" && part.id) || - (typeof part.tool_use_id === "string" && - part.tool_use_id) || - undefined; - const name = - typeof part.name === "string" - ? part.name - : undefined; - const input = part.input; - - if (!callId || !name) continue; - - items.push({ - type: "function_call", - call_id: callId, - name, - arguments: - typeof input === "string" - ? input - : JSON.stringify(input ?? {}), - }); - } - } - - if (assistantText.trim().length > 0) { - items.push({ - type: "message", - role: "assistant", - content: [{ type: "output_text", text: assistantText }], - }); - } - continue; - } - - if (role === "user") { - const userTextParts: string[] = []; - - for (const part of contentParts) { - if (!part || typeof part !== "object") continue; - - const type = typeof part.type === "string" ? part.type : ""; - - if (type === "text") { - const text = - typeof part.text === "string" ? part.text : ""; - if (text.trim().length > 0) { - userTextParts.push(text); - } - continue; - } - - if (type === "tool_result") { - const callId = - (typeof part.tool_use_id === "string" && - part.tool_use_id) || - (typeof part.call_id === "string" && - part.call_id) || - undefined; - - if (!callId) continue; - - const content = part.content; - const output = - typeof content === "string" - ? content - : Array.isArray(content) - ? content - .map((c) => { - if (typeof c === "string") return c; - if ( - c && - typeof c === "object" && - typeof ( - c as Record - ).text === "string" - ) { - return ( - c as Record - ).text as string; - } - return ""; - }) - .join("") - : JSON.stringify(content ?? ""); - - if (output.trim().length > 0) { - items.push({ - type: "function_call_output", - call_id: callId, - output, - }); - } - } - } - - const userText = userTextParts.join(""); - if (userText.trim().length > 0) { - items.push({ - type: "message", - role: "user", - content: [{ type: "input_text", text: userText }], - }); - } - - continue; - } - } - - // Handle assistant tool-calls from chat format. - // chat: { role:"assistant", tool_calls:[{id,type:"function",function:{name,arguments}}], content:null } - if (role === "assistant" && Array.isArray(m.tool_calls)) { - const assistantText = normalizeChatMessageContentToText(m.content); - if (assistantText) { - items.push({ - type: "message", - role: "assistant", - content: [{ type: "output_text", text: assistantText }], - }); - } - - const toolCalls = m.tool_calls as Array>; - for (const tc of toolCalls) { - const fn = - tc.function && typeof tc.function === "object" - ? (tc.function as Record) - : null; - const name = typeof fn?.name === "string" ? fn.name : undefined; - const args = fn?.arguments; - const callId = - (typeof tc.id === "string" && tc.id) || - (typeof tc.call_id === "string" && tc.call_id) || - undefined; - - if (!name || !callId) continue; - - items.push({ - type: "function_call", - call_id: callId, - name, - arguments: - typeof args === "string" - ? args - : JSON.stringify(args ?? {}), - }); - } - - continue; - } - - // Handle tool role outputs from chat format. - // chat: { role:"tool", tool_call_id:"...", content:"..." } - if (role === "tool") { - const callId = - typeof m.tool_call_id === "string" - ? m.tool_call_id - : typeof m.call_id === "string" - ? m.call_id - : undefined; - - if (callId) { - const output = normalizeChatMessageContentToText(m.content); - if (output.trim().length > 0) { - items.push({ - type: "function_call_output", - call_id: callId, - output, - }); - } - continue; - } - } - - if (role === "assistant") { - const assistantText = normalizeChatMessageContentToText(m.content); - if (assistantText) { - items.push({ - type: "message", - role: "assistant", - content: [{ type: "output_text", text: assistantText }], - }); - } - continue; - } - - if (role === "user" || role === "system" || role === "developer") { - items.push({ - type: "message", - role, - content: [ - { - type: "input_text", - text: normalizeChatMessageContentToText(m.content), - }, - ], - }); - continue; - } - - if (role === "function") { - const output = normalizeChatMessageContentToText(m.content); - if (output.trim().length > 0) { - items.push({ - type: "function_call_output", - call_id: - typeof m.name === "string" - ? m.name - : `function_${items.length}`, - output, - }); - } - continue; - } - - items.push(m); - } - - return sanitizeResponsesInputItems(items); -} - -function sanitizeResponsesInputItems(input: unknown): unknown { - if (!Array.isArray(input)) return input; - - const sanitized: unknown[] = []; - - for (const item of input) { - if (!item || typeof item !== "object") { - sanitized.push(item); - continue; - } - - const typedItem = item as Record; - if (typedItem.type === "function_call_output") { - const output = typedItem.output; - if (typeof output === "string" && output.trim().length === 0) { - continue; - } - sanitized.push(typedItem); - continue; - } - - if (typedItem.type !== "message") { - sanitized.push(typedItem); - continue; - } - - const content = typedItem.content; - - // String content: drop message if empty/whitespace-only - if (typeof content === "string") { - if (content.trim().length === 0) continue; - sanitized.push(typedItem); - continue; - } - - // Non-array content: keep unchanged (best-effort pass-through) - if (!Array.isArray(content)) { - sanitized.push(typedItem); - continue; - } - - // Array content: remove empty text blocks - const nextContent = content.filter((part) => { - if (!part || typeof part !== "object") return true; - const p = part as Record; - const type = typeof p.type === "string" ? p.type : ""; - - if ( - type === "input_text" || - type === "output_text" || - typeof p.text === "string" - ) { - const text = typeof p.text === "string" ? p.text : ""; - return text.trim().length > 0; - } - - return true; - }); - - // Drop message if all content was removed - if (nextContent.length === 0) continue; - - sanitized.push({ - ...typedItem, - content: nextContent, - }); - } - - return sanitized; -} - -// --------------------------------------------------------------------------- -// SSE frame helpers shared by both stream translators -// --------------------------------------------------------------------------- - -/** - * Parse a complete SSE frame (content between two blank lines) into an event - * type and a JSON payload. The worker emits proper SSE with the event type in - * the `event:` field and the payload object in the `data:` field, e.g.: - * - * event: response.output_text.delta - * data: {"response_id":"...","delta":"Hello"} - * - * Unlike OpenRouter's native format (which puts `type` inside the JSON), - * we must read `eventType` from the SSE field, not from the JSON body. - */ -function parseSseFrameProxy(rawFrame: string): { - eventType?: string; - payload?: Record; -} { - let eventType: string | undefined; - const dataLines: string[] = []; - - for (const line of rawFrame.split("\n")) { - if (line.startsWith("event:")) { - eventType = line.slice(6).trim(); - } else if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()); - } - } - - if (dataLines.length === 0) return { eventType }; - const dataStr = dataLines.join("\n"); - if (!dataStr || dataStr === "[DONE]") return { eventType }; - - try { - const payload = JSON.parse(dataStr) as Record; - return { eventType, payload }; - } catch { - return { eventType }; - } -} - -/** Emit a single outbound SSE event with both `event:` and `data:` fields. */ -function sseEvent(eventName: string, data: unknown): string { - return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; -} - -// --------------------------------------------------------------------------- - -interface LegacyChatStreamState { - id: string; - model: string; - created: number; - firstChunkSent: boolean; - hasToolCalls: boolean; - outputIndexToToolIndex: Map; - nextToolIndex: number; - completed: boolean; - inputTokens: number; - outputTokens: number; -} - -function legacyChatStreamChunk( - state: LegacyChatStreamState, - choices: unknown[], -): string { - return `data: ${JSON.stringify({ - id: state.id, - object: "chat.completion.chunk", - created: state.created, - model: state.model, - choices, - })}\n\n`; -} - -/** - * Translate one complete SSE frame from the worker's Responses-API format - * into an OpenAI Chat-Completions streaming chunk (or empty string to skip). - * - * The worker places the event type in the SSE `event:` field and the payload - * in the `data:` JSON — so we use parseSseFrameProxy instead of reading - * `event.type` from the data body (which is what the old line-based version - * incorrectly tried to do). - */ -function translateResponsesFrameToChat( - rawFrame: string, - state: LegacyChatStreamState, -): string { - const { eventType, payload } = parseSseFrameProxy(rawFrame); - if (!eventType || !payload) return ""; - - let out = ""; - - // Capture model from the initial response.start event. - if (eventType === "response.start") { - if (typeof payload.model === "string") state.model = payload.model; - return ""; - } - - // Emit the role-bearing first chunk before any content. - if ( - !state.firstChunkSent && - (eventType === "response.output_text.delta" || - (eventType === "response.raw" && - (payload.upstream_type === "response.output_item.added" || - payload.upstream_type === "response.output_text.delta"))) - ) { - state.firstChunkSent = true; - out += legacyChatStreamChunk(state, [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: null, - }, - ]); - } - - if (eventType === "response.output_text.delta") { - const delta = typeof payload.delta === "string" ? payload.delta : ""; - out += legacyChatStreamChunk(state, [ - { index: 0, delta: { content: delta }, finish_reason: null }, - ]); - return out; - } - - // Tool call events can arrive either as direct Responses events - // (response.output_item.added / response.function_call_arguments.delta) - // or wrapped inside response.raw passthrough. - if ( - eventType === "response.output_item.added" || - eventType === "response.function_call_arguments.delta" - ) { - const event = payload; - if (eventType === "response.output_item.added") { - const item = event.item as Record | undefined; - if (item?.type === "function_call") { - const outputIndex = - typeof event.output_index === "number" - ? event.output_index - : 0; - const toolIndex = state.nextToolIndex++; - state.outputIndexToToolIndex.set(outputIndex, toolIndex); - state.hasToolCalls = true; - - out += legacyChatStreamChunk(state, [ - { - index: 0, - delta: { - tool_calls: [ - { - index: toolIndex, - id: - (item.call_id as string) || - (item.id as string) || - `call_${Math.random().toString(36).slice(2, 10)}`, - type: "function", - function: { - name: (item.name as string) || "", - arguments: "", - }, - }, - ], - }, - finish_reason: null, - }, - ]); - } - return out; - } - - const outputIndex = - typeof event.output_index === "number" ? event.output_index : 0; - const toolIndex = state.outputIndexToToolIndex.get(outputIndex) ?? 0; - const delta = typeof event.delta === "string" ? event.delta : ""; - - out += legacyChatStreamChunk(state, [ - { - index: 0, - delta: { - tool_calls: [ - { - index: toolIndex, - function: { arguments: delta }, - }, - ], - }, - finish_reason: null, - }, - ]); - return out; - } - - if (eventType === "response.raw") { - const upstreamType = payload.upstream_type as string | undefined; - const event = payload.event as Record | undefined; - if (!event) return ""; - - if (upstreamType === "response.output_item.added") { - const item = event.item as Record | undefined; - if (item?.type === "function_call") { - const outputIndex = - typeof event.output_index === "number" - ? event.output_index - : 0; - const toolIndex = state.nextToolIndex++; - state.outputIndexToToolIndex.set(outputIndex, toolIndex); - state.hasToolCalls = true; - - out += legacyChatStreamChunk(state, [ - { - index: 0, - delta: { - tool_calls: [ - { - index: toolIndex, - id: - (item.call_id as string) || - (item.id as string) || - `call_${Math.random().toString(36).slice(2, 10)}`, - type: "function", - function: { - name: (item.name as string) || "", - arguments: "", - }, - }, - ], - }, - finish_reason: null, - }, - ]); - } - return out; - } - - if (upstreamType === "response.function_call_arguments.delta") { - const outputIndex = - typeof event.output_index === "number" ? event.output_index : 0; - const toolIndex = - state.outputIndexToToolIndex.get(outputIndex) ?? 0; - const delta = typeof event.delta === "string" ? event.delta : ""; - - out += legacyChatStreamChunk(state, [ - { - index: 0, - delta: { - tool_calls: [ - { - index: toolIndex, - function: { arguments: delta }, - }, - ], - }, - finish_reason: null, - }, - ]); - return out; - } - - return ""; - } - - if (eventType === "response.completed") { - state.completed = true; - - const usage = payload.usage as Record | undefined; - if (usage) { - if (typeof usage.input_tokens === "number") - state.inputTokens = usage.input_tokens; - if (typeof usage.output_tokens === "number") - state.outputTokens = usage.output_tokens; - } - - out += legacyChatStreamChunk(state, [ - { - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop", - }, - ]); - out += "data: [DONE]\n\n"; - return out; - } - - return ""; -} - -// --------------------------------------------------------------------------- -// Anthropic Messages API stream translator -// --------------------------------------------------------------------------- - -interface AnthropicMessagesStreamState { - id: string; - model: string; - inputTokens: number; - outputTokens: number; - hasStarted: boolean; - hasTextBlock: boolean; - textBlockIndex: number; - hasToolCalls: boolean; - outputIndexToContentIndex: Map; - nextContentIndex: number; - completed: boolean; -} - -/** - * Translate one complete SSE frame from the worker's Responses-API format - * into an Anthropic Messages-API streaming event (or empty string to skip). - * - * Mapping: - * response.start → message_start - * response.output_text.delta → content_block_start (once) + content_block_delta - * response.raw / response.output_item.added → content_block_start (tool_use) - * response.raw / response.function_call_arguments.delta → content_block_delta (input_json_delta) - * response.raw / response.output_item.done → content_block_stop - * response.completed → content_block_stop + message_delta + message_stop - */ -function translateResponsesFrameToMessages( - rawFrame: string, - state: AnthropicMessagesStreamState, -): string { - const { eventType, payload } = parseSseFrameProxy(rawFrame); - if (!eventType || !payload) return ""; - - let out = ""; - - if (eventType === "response.start" && !state.hasStarted) { - state.hasStarted = true; - if (typeof payload.model === "string") state.model = payload.model; - // Strip provider prefix: "anthropic/claude-haiku-4.5" → "claude-haiku-4.5" - const modelDisplay = state.model.includes("/") - ? state.model.split("/").slice(1).join("/") - : state.model; - - out += sseEvent("message_start", { - type: "message_start", - message: { - id: state.id, - type: "message", - role: "assistant", - content: [], - model: modelDisplay, - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: state.inputTokens, output_tokens: 0 }, - }, - }); - return out; - } - - if (eventType === "response.output_text.delta") { - const delta = typeof payload.delta === "string" ? payload.delta : ""; - - if (!state.hasTextBlock) { - state.hasTextBlock = true; - state.textBlockIndex = state.nextContentIndex++; - out += sseEvent("content_block_start", { - type: "content_block_start", - index: state.textBlockIndex, - content_block: { type: "text", text: "" }, - }); - } - - out += sseEvent("content_block_delta", { - type: "content_block_delta", - index: state.textBlockIndex, - delta: { type: "text_delta", text: delta }, - }); - return out; - } - - // Tool call events can arrive either as direct Responses events - // or wrapped inside response.raw passthrough from the worker. - if ( - eventType === "response.output_item.added" || - eventType === "response.function_call_arguments.delta" || - eventType === "response.output_item.done" - ) { - const event = payload; - - if (eventType === "response.output_item.added") { - const item = event.item as Record | undefined; - if (item?.type === "function_call") { - const outputIndex = - typeof event.output_index === "number" - ? event.output_index - : 0; - const contentIndex = state.nextContentIndex++; - state.outputIndexToContentIndex.set(outputIndex, contentIndex); - state.hasToolCalls = true; - - out += sseEvent("content_block_start", { - type: "content_block_start", - index: contentIndex, - content_block: { - type: "tool_use", - id: - (item.call_id as string) || - (item.id as string) || - `toolu_${Math.random().toString(36).slice(2, 10)}`, - name: (item.name as string) || "", - input: {}, - }, - }); - } - return out; - } - - if (eventType === "response.function_call_arguments.delta") { - const outputIndex = - typeof event.output_index === "number" ? event.output_index : 0; - const contentIndex = - state.outputIndexToContentIndex.get(outputIndex) ?? 0; - const delta = typeof event.delta === "string" ? event.delta : ""; - - out += sseEvent("content_block_delta", { - type: "content_block_delta", - index: contentIndex, - delta: { type: "input_json_delta", partial_json: delta }, - }); - return out; - } - - const item = event.item as Record | undefined; - if (item?.type === "function_call") { - const outputIndex = - typeof event.output_index === "number" ? event.output_index : 0; - const contentIndex = - state.outputIndexToContentIndex.get(outputIndex); - if (contentIndex !== undefined) { - out += sseEvent("content_block_stop", { - type: "content_block_stop", - index: contentIndex, - }); - } - } - return out; - } - - if (eventType === "response.raw") { - const upstreamType = payload.upstream_type as string | undefined; - const event = payload.event as Record | undefined; - if (!event) return ""; - - if (upstreamType === "response.output_item.added") { - const item = event.item as Record | undefined; - if (item?.type === "function_call") { - const outputIndex = - typeof event.output_index === "number" - ? event.output_index - : 0; - const contentIndex = state.nextContentIndex++; - state.outputIndexToContentIndex.set(outputIndex, contentIndex); - state.hasToolCalls = true; - - out += sseEvent("content_block_start", { - type: "content_block_start", - index: contentIndex, - content_block: { - type: "tool_use", - id: - (item.call_id as string) || - (item.id as string) || - `toolu_${Math.random().toString(36).slice(2, 10)}`, - name: (item.name as string) || "", - input: {}, - }, - }); - } - return out; - } - - if (upstreamType === "response.function_call_arguments.delta") { - const outputIndex = - typeof event.output_index === "number" ? event.output_index : 0; - const contentIndex = - state.outputIndexToContentIndex.get(outputIndex) ?? 0; - const delta = typeof event.delta === "string" ? event.delta : ""; - - out += sseEvent("content_block_delta", { - type: "content_block_delta", - index: contentIndex, - delta: { type: "input_json_delta", partial_json: delta }, - }); - return out; - } - - if (upstreamType === "response.output_item.done") { - const item = event.item as Record | undefined; - if (item?.type === "function_call") { - const outputIndex = - typeof event.output_index === "number" - ? event.output_index - : 0; - const contentIndex = - state.outputIndexToContentIndex.get(outputIndex); - if (contentIndex !== undefined) { - out += sseEvent("content_block_stop", { - type: "content_block_stop", - index: contentIndex, - }); - } - } - return out; - } - - return ""; - } - - if (eventType === "response.completed") { - state.completed = true; - - const usage = payload.usage as Record | undefined; - if (usage) { - if (typeof usage.input_tokens === "number") - state.inputTokens = usage.input_tokens; - if (typeof usage.output_tokens === "number") - state.outputTokens = usage.output_tokens; - } - - const finishReason = - typeof payload.finish_reason === "string" - ? payload.finish_reason - : "stop"; - const stopReason = - finishReason === "tool_calls" - ? "tool_use" - : finishReason === "length" - ? "max_tokens" - : "end_turn"; - - if (state.hasTextBlock) { - out += sseEvent("content_block_stop", { - type: "content_block_stop", - index: state.textBlockIndex, - }); - } - - out += sseEvent("message_delta", { - type: "message_delta", - delta: { stop_reason: stopReason, stop_sequence: null }, - usage: { output_tokens: state.outputTokens }, - }); - - out += sseEvent("message_stop", { type: "message_stop" }); - - return out; - } - - return ""; -} - -function extractResponsesText(response: Record): string { - if (typeof response.output_text === "string") return response.output_text; - - const output = response.output; - if (!Array.isArray(output)) return ""; - - const parts: string[] = []; - for (const item of output) { - if (!item || typeof item !== "object") continue; - const i = item as Record; - - if (typeof i.text === "string") { - parts.push(i.text); - } - - if (Array.isArray(i.content)) { - for (const contentItem of i.content) { - if (!contentItem || typeof contentItem !== "object") continue; - const c = contentItem as Record; - if (typeof c.text === "string") { - parts.push(c.text); - } else if (typeof c.refusal === "string") { - parts.push(c.refusal); - } - } - } - } - - return parts.join(""); -} - -function extractResponsesToolCalls( - response: Record, -): unknown[] | undefined { - const output = response.output; - if (!Array.isArray(output)) return undefined; - - const calls: unknown[] = []; - for (const item of output) { - if (!item || typeof item !== "object") continue; - const i = item as Record; - if (i.type !== "function_call") continue; - - calls.push({ - id: - (i.call_id as string) || - (i.id as string) || - `call_${Math.random().toString(36).slice(2, 10)}`, - type: "function", - function: { - name: (i.name as string) || "", - arguments: - typeof i.arguments === "string" - ? i.arguments - : JSON.stringify(i.arguments ?? {}), - }, - }); - } - - return calls.length > 0 ? calls : undefined; -} - -function shapeResponsesAsChatCompletion( - response: Record, - fallbackModel: string, -): Record { - const text = extractResponsesText(response); - const toolCalls = extractResponsesToolCalls(response); - - const message: Record = { role: "assistant" }; - if (toolCalls) { - message.content = null; - message.tool_calls = toolCalls; - } else { - message.content = text; - } - - const usage = response.usage as Record | undefined; - const promptTokens = - typeof usage?.input_tokens === "number" - ? usage.input_tokens - : typeof usage?.prompt_tokens === "number" - ? usage.prompt_tokens - : 0; - const completionTokens = - typeof usage?.output_tokens === "number" - ? usage.output_tokens - : typeof usage?.completion_tokens === "number" - ? usage.completion_tokens - : 0; - - return { - id: - (typeof response.id === "string" && response.id) || - `chatcmpl_${Math.random().toString(36).slice(2, 14)}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: - (typeof response.model === "string" && response.model) || - fallbackModel, - choices: [ - { - index: 0, - message, - finish_reason: toolCalls ? "tool_calls" : "stop", - }, - ], - usage: { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - }, - }; -} - -function writeJsonResponse( - res: http.ServerResponse, - statusCode: number, - headers: http.IncomingHttpHeaders, - payload: unknown, -) { - const responseBody = Buffer.from(JSON.stringify(payload), "utf-8"); - const nextHeaders: http.OutgoingHttpHeaders = { - ...headers, - "content-type": "application/json; charset=utf-8", - "content-length": responseBody.length.toString(), - }; - - delete nextHeaders["content-encoding"]; - delete nextHeaders["transfer-encoding"]; - - res.writeHead(statusCode, nextHeaders); - res.end(responseBody); -} - -function tryWriteShapedChatResponse( - res: http.ServerResponse, - requestPath: string, - isStreamRequest: boolean, - response: { - statusCode: number; - headers: http.IncomingHttpHeaders; - body: Buffer; - }, - requestModel: string, -): boolean { - if ( - !isChatCompletionsPath(requestPath) || - isStreamRequest || - response.statusCode >= 400 - ) { - return false; - } - - try { - const shaped = shapeResponsesAsChatCompletion( - JSON.parse(response.body.toString("utf-8")) as Record< - string, - unknown - >, - requestModel, - ); - writeJsonResponse(res, response.statusCode, response.headers, shaped); - return true; - } catch { - return false; - } -} - -function makeLegacyChatStreamingRequest( - method: string, - path: string, - headers: http.IncomingHttpHeaders, - body: Buffer | null, - extraHeaders: Record, - pipeRes: http.ServerResponse, - timeoutMs = 30_000, - fallbackModel = "", - payloadCaptureMeta?: { - originalPath: string; - upstreamPath: string; - isStreamRequest: boolean; - }, -): Promise<{ - statusCode: number; - headers: http.IncomingHttpHeaders; - body: Buffer; -}> { - return new Promise((resolve, reject) => { - const url = new URL(path, RADROUTER_URL); - const isHttps = url.protocol === "https:"; - const transport = isHttps ? https : http; - - const outHeaders: Record = {}; - for (const [key, val] of Object.entries(headers)) { - if (key === "host" || key === "connection") continue; - outHeaders[key] = val; - } - for (const [key, val] of Object.entries(extraHeaders)) { - outHeaders[key] = val; - } - if (body) { - outHeaders["content-length"] = body.length.toString(); - } - - if (payloadCaptureMeta) { - capturePayloadBoundary({ - stage: "upstream", - method, - originalPath: payloadCaptureMeta.originalPath, - upstreamPath: payloadCaptureMeta.upstreamPath, - isStreamRequest: payloadCaptureMeta.isStreamRequest, - body, - headers: outHeaders, - }); - } - - const req = transport.request( - { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method, - headers: outHeaders, - }, - (upstreamRes) => { - const statusCode = upstreamRes.statusCode || 500; - - if (statusCode >= 400) { - const chunks: Buffer[] = []; - upstreamRes.on("data", (chunk) => - chunks.push( - Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(String(chunk), "utf-8"), - ), - ); - upstreamRes.on("end", () => { - const responseBody = Buffer.concat(chunks); - pipeRes.writeHead(statusCode, upstreamRes.headers); - pipeRes.end(responseBody); - resolve({ - statusCode, - headers: upstreamRes.headers, - body: responseBody, - }); - }); - return; - } - - const nextHeaders: http.OutgoingHttpHeaders = { - ...upstreamRes.headers, - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }; - delete nextHeaders["content-length"]; - delete nextHeaders["content-encoding"]; - delete nextHeaders["transfer-encoding"]; - - pipeRes.writeHead(statusCode, nextHeaders); - - const state: LegacyChatStreamState = { - id: `chatcmpl_${Math.random().toString(36).slice(2, 14)}`, - model: fallbackModel, - created: Math.floor(Date.now() / 1000), - firstChunkSent: false, - hasToolCalls: false, - outputIndexToToolIndex: new Map(), - nextToolIndex: 0, - completed: false, - inputTokens: 0, - outputTokens: 0, - }; - - let buffered = ""; - upstreamRes.setEncoding("utf8"); - - // Split on double-newline to get complete SSE frames. - // The worker emits events with the type in the "event:" field, - // so we process full frames rather than individual lines. - upstreamRes.on("data", (chunk: string) => { - buffered += chunk; - const frames = buffered.split("\n\n"); - buffered = frames.pop() ?? ""; - - for (const frame of frames) { - if (!frame.trim()) continue; - const translated = translateResponsesFrameToChat( - frame, - state, - ); - if (translated) { - pipeRes.write(translated); - } - } - }); - - upstreamRes.on("end", () => { - if (buffered.trim()) { - const translated = translateResponsesFrameToChat( - buffered, - state, - ); - if (translated) { - pipeRes.write(translated); - } - } - - if (!state.completed) { - if (!state.firstChunkSent) { - state.firstChunkSent = true; - pipeRes.write( - legacyChatStreamChunk(state, [ - { - index: 0, - delta: { - role: "assistant", - content: "", - }, - finish_reason: null, - }, - ]), - ); - } - - pipeRes.write( - legacyChatStreamChunk(state, [ - { - index: 0, - delta: {}, - finish_reason: state.hasToolCalls - ? "tool_calls" - : "stop", - }, - ]), - ); - pipeRes.write("data: [DONE]\n\n"); - } - - pipeRes.end(); - - const streamUsageBody = Buffer.from( - JSON.stringify({ - usage: { - input_tokens: state.inputTokens, - output_tokens: state.outputTokens, - }, - }), - "utf-8", - ); - - resolve({ - statusCode, - headers: upstreamRes.headers, - body: streamUsageBody, - }); - }); - }, - ); - - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Request timeout after ${timeoutMs}ms`)); - }); - - req.on("error", reject); - if (body) req.write(body); - req.end(); - }); -} - -// --------------------------------------------------------------------------- -// Anthropic Messages API streaming request -// --------------------------------------------------------------------------- - -function makeAnthropicMessagesStreamingRequest( - method: string, - path: string, - headers: http.IncomingHttpHeaders, - body: Buffer | null, - extraHeaders: Record, - pipeRes: http.ServerResponse, - timeoutMs = 30_000, - fallbackModel = "", - payloadCaptureMeta?: { - originalPath: string; - upstreamPath: string; - isStreamRequest: boolean; - }, -): Promise<{ - statusCode: number; - headers: http.IncomingHttpHeaders; - body: Buffer; -}> { - return new Promise((resolve, reject) => { - const url = new URL(path, RADROUTER_URL); - const isHttps = url.protocol === "https:"; - const transport = isHttps ? https : http; - - const outHeaders: Record = {}; - for (const [key, val] of Object.entries(headers)) { - if (key === "host" || key === "connection") continue; - outHeaders[key] = val; - } - for (const [key, val] of Object.entries(extraHeaders)) { - outHeaders[key] = val; - } - if (body) { - outHeaders["content-length"] = body.length.toString(); - } - - if (payloadCaptureMeta) { - capturePayloadBoundary({ - stage: "upstream", - method, - originalPath: payloadCaptureMeta.originalPath, - upstreamPath: payloadCaptureMeta.upstreamPath, - isStreamRequest: payloadCaptureMeta.isStreamRequest, - body, - headers: outHeaders, - }); - } - - const req = transport.request( - { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method, - headers: outHeaders, - }, - (upstreamRes) => { - const statusCode = upstreamRes.statusCode || 500; - - if (statusCode >= 400) { - const chunks: Buffer[] = []; - upstreamRes.on("data", (chunk) => - chunks.push( - Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(String(chunk), "utf-8"), - ), - ); - upstreamRes.on("end", () => { - const responseBody = Buffer.concat(chunks); - pipeRes.writeHead(statusCode, upstreamRes.headers); - pipeRes.end(responseBody); - resolve({ - statusCode, - headers: upstreamRes.headers, - body: responseBody, - }); - }); - return; - } - - const nextHeaders: http.OutgoingHttpHeaders = { - ...upstreamRes.headers, - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }; - delete nextHeaders["content-length"]; - delete nextHeaders["content-encoding"]; - delete nextHeaders["transfer-encoding"]; - - pipeRes.writeHead(statusCode, nextHeaders); - - // Strip provider prefix for the model display name. - const modelDisplay = fallbackModel.includes("/") - ? fallbackModel.split("/").slice(1).join("/") - : fallbackModel; - - const state: AnthropicMessagesStreamState = { - id: `msg_${Math.random().toString(36).slice(2, 18)}`, - model: modelDisplay || fallbackModel, - inputTokens: 0, - outputTokens: 0, - hasStarted: false, - hasTextBlock: false, - textBlockIndex: 0, - hasToolCalls: false, - outputIndexToContentIndex: new Map(), - nextContentIndex: 0, - completed: false, - }; - - let buffered = ""; - upstreamRes.setEncoding("utf8"); - - // Split on double-newline to get complete SSE frames. - // The worker emits events with the type in the "event:" field, - // so we process full frames rather than individual lines. - upstreamRes.on("data", (chunk: string) => { - buffered += chunk; - const frames = buffered.split("\n\n"); - buffered = frames.pop() ?? ""; - - for (const frame of frames) { - if (!frame.trim()) continue; - const translated = translateResponsesFrameToMessages( - frame, - state, - ); - if (translated) { - pipeRes.write(translated); - } - } - }); - - upstreamRes.on("end", () => { - if (buffered.trim()) { - const translated = translateResponsesFrameToMessages( - buffered, - state, - ); - if (translated) { - pipeRes.write(translated); - } - } - - // Emit a minimal well-formed completion if the upstream - // ended without sending a response.completed event. - if (!state.completed) { - if (!state.hasStarted) { - pipeRes.write( - sseEvent("message_start", { - type: "message_start", - message: { - id: state.id, - type: "message", - role: "assistant", - content: [], - model: state.model, - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 0, - output_tokens: 0, - }, - }, - }), - ); - } - - if (state.hasTextBlock) { - pipeRes.write( - sseEvent("content_block_stop", { - type: "content_block_stop", - index: state.textBlockIndex, - }), - ); - } - - pipeRes.write( - sseEvent("message_delta", { - type: "message_delta", - delta: { - stop_reason: state.hasToolCalls - ? "tool_use" - : "end_turn", - stop_sequence: null, - }, - usage: { output_tokens: state.outputTokens }, - }), - ); - - pipeRes.write( - sseEvent("message_stop", { - type: "message_stop", - }), - ); - } - - pipeRes.end(); - - const streamUsageBody = Buffer.from( - JSON.stringify({ - usage: { - input_tokens: state.inputTokens, - output_tokens: state.outputTokens, - }, - }), - "utf-8", - ); - - resolve({ - statusCode, - headers: upstreamRes.headers, - body: streamUsageBody, - }); - }); - }, - ); - - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Request timeout after ${timeoutMs}ms`)); - }); - - req.on("error", reject); - if (body) req.write(body); - req.end(); - }); -} - -function makeRequest( - method: string, - path: string, - headers: http.IncomingHttpHeaders, - body: Buffer | null, - extraHeaders: Record, - pipeRes?: http.ServerResponse, - timeoutMs = 30_000, - payloadCaptureMeta?: { - originalPath: string; - upstreamPath: string; - isStreamRequest: boolean; - }, -): Promise<{ - statusCode: number; - headers: http.IncomingHttpHeaders; - body: Buffer; -}> { - return new Promise((resolve, reject) => { - const url = new URL(path, RADROUTER_URL); - const isHttps = url.protocol === "https:"; - const transport = isHttps ? https : http; - - const outHeaders: Record = {}; - for (const [key, val] of Object.entries(headers)) { - if (key === "host" || key === "connection") continue; - outHeaders[key] = val; - } - for (const [key, val] of Object.entries(extraHeaders)) { - outHeaders[key] = val; - } - if (body) { - outHeaders["content-length"] = body.length.toString(); - } - - if (payloadCaptureMeta) { - capturePayloadBoundary({ - stage: "upstream", - method, - originalPath: payloadCaptureMeta.originalPath, - upstreamPath: payloadCaptureMeta.upstreamPath, - isStreamRequest: payloadCaptureMeta.isStreamRequest, - body, - headers: outHeaders, - }); - } - - const req = transport.request( - { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method, - headers: outHeaders, - }, - (res) => { - const statusCode = res.statusCode || 500; - - // For successful stream responses, keep piping directly to client. - if (pipeRes && statusCode < 400) { - pipeRes.writeHead(statusCode, res.headers); - res.pipe(pipeRes); - resolve({ - statusCode, - headers: res.headers, - body: Buffer.alloc(0), - }); - return; - } - - // For non-stream requests OR stream errors, buffer body so we can - // log/report upstream failure details before returning. - const chunks: Buffer[] = []; - res.on("data", (chunk) => chunks.push(chunk)); - res.on("end", () => { - const responseBody = Buffer.concat(chunks); - - if (pipeRes) { - pipeRes.writeHead(statusCode, res.headers); - pipeRes.end(responseBody); - } - - resolve({ - statusCode, - headers: res.headers, - body: responseBody, - }); - }); - }, - ); - - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Request timeout after ${timeoutMs}ms`)); - }); - - req.on("error", reject); - if (body) req.write(body); - req.end(); - }); -} - -const server = http.createServer(async (req, res) => { - const method = req.method || "GET"; - const path = req.url || "/"; - const upstreamPath = normalizeUpstreamPath(path); - - logSection(`${method} ${path}`); - if (upstreamPath !== path) { - logStep("rewrite", `${path} -> ${upstreamPath}`); - } - - const chunks: Buffer[] = []; - req.on("data", (chunk) => chunks.push(chunk)); - - req.on("end", async () => { - const body = chunks.length > 0 ? Buffer.concat(chunks) : null; - - let normalizedBody = body; - let isStreamRequest = false; - let streamIdleTimeoutMs = 30_000; - let requestModel = ""; - let requestMaxOutputTokens = 0; - if (body) { - capturePayloadBoundary({ - stage: "inbound", - method, - originalPath: path, - upstreamPath, - isStreamRequest: false, - body, - headers: req.headers, - }); - - try { - const parsed = JSON.parse(body.toString("utf-8")) as Record< - string, - unknown - >; - const bodyRewrites: string[] = []; - - // Convert legacy chat-completions request shape to Responses shape. - // This keeps compatibility with clients (like Zed) still posting - // chat-style requests to /v1/chat/completions. - if ( - isChatCompletionsPath(path) || - isAnthropicMessagesPath(path) - ) { - if (Array.isArray(parsed.messages)) { - const normalizedFromMessages = - normalizeChatMessagesToResponsesInput( - parsed.messages, - ); - const normalizedMessagesInput = Array.isArray( - normalizedFromMessages, - ) - ? normalizedFromMessages - : [normalizedFromMessages]; - parsed.input = Array.isArray(parsed.input) - ? [ - ...normalizedMessagesInput, - ...(sanitizeResponsesInputItems( - parsed.input, - ) as unknown[]), - ] - : normalizedFromMessages; - bodyRewrites.push("messages->typed-input"); - } - delete parsed.messages; - - if ( - parsed.max_output_tokens === undefined && - typeof parsed.max_tokens === "number" - ) { - parsed.max_output_tokens = parsed.max_tokens; - delete parsed.max_tokens; - bodyRewrites.push("max_tokens->max_output_tokens"); - } - - if ( - isAnthropicMessagesPath(path) && - parsed.input === undefined && - parsed.system !== undefined - ) { - parsed.input = [ - { - type: "message", - role: "system", - content: [ - { - type: "input_text", - text: - typeof parsed.system === "string" - ? parsed.system - : JSON.stringify(parsed.system), - }, - ], - }, - ]; - bodyRewrites.push("system->input"); - } - - if ( - parsed.tools === undefined && - Array.isArray(parsed.functions) - ) { - parsed.tools = parsed.functions; - delete parsed.functions; - bodyRewrites.push("functions->tools"); - } - - if (parsed.tools !== undefined) { - parsed.tools = isAnthropicMessagesPath(path) - ? normalizeAnthropicToolsToResponses(parsed.tools) - : normalizeChatToolsToResponses(parsed.tools); - bodyRewrites.push("tools->responses-tools"); - - if ( - Array.isArray(parsed.tools) && - parsed.tools.length === 0 - ) { - delete parsed.tools; - bodyRewrites.push("dropped-empty-tools"); - } - } - - if ( - parsed.tool_choice === undefined && - parsed.function_call !== undefined - ) { - const functionCall = parsed.function_call; - - if (typeof functionCall === "string") { - parsed.tool_choice = functionCall; - } else if ( - functionCall && - typeof functionCall === "object" && - typeof (functionCall as Record) - .name === "string" - ) { - parsed.tool_choice = { - type: "function", - name: (functionCall as Record) - .name as string, - }; - } - - delete parsed.function_call; - bodyRewrites.push("function_call->tool_choice"); - } - - if ( - parsed.tool_choice && - typeof parsed.tool_choice === "object" && - !Array.isArray(parsed.tool_choice) - ) { - const tc = parsed.tool_choice as Record< - string, - unknown - >; - const fn = - tc.function && typeof tc.function === "object" - ? (tc.function as Record) - : null; - - if ( - tc.type === "function" && - fn && - typeof fn.name === "string" - ) { - parsed.tool_choice = { - type: "function", - name: fn.name, - }; - bodyRewrites.push( - "tool_choice.function->tool_choice.name", - ); - } - } - - if ( - isAnthropicMessagesPath(path) && - parsed.tool_choice && - typeof parsed.tool_choice === "object" && - !Array.isArray(parsed.tool_choice) - ) { - const toolChoice = parsed.tool_choice as Record< - string, - unknown - >; - - if (toolChoice.type === "any") { - parsed.tool_choice = "required"; - bodyRewrites.push("tool_choice.any->required"); - } else if ( - toolChoice.type === "tool" && - typeof toolChoice.name === "string" - ) { - parsed.tool_choice = { - type: "function", - name: toolChoice.name, - }; - bodyRewrites.push("tool_choice.tool->function"); - } - } - } - - const mappedModel = mapRequestedModel(parsed.model); - if ( - typeof parsed.model === "string" && - typeof mappedModel === "string" && - parsed.model !== mappedModel - ) { - bodyRewrites.push(`model:${parsed.model}->${mappedModel}`); - parsed.model = mappedModel; - } - - if (isAnthropicMessagesPath(path)) { - delete parsed.system; - delete parsed.max_tokens; - delete parsed.stop_sequences; - delete parsed.metadata; - } - - parsed.input = sanitizeResponsesInputItems(parsed.input); - parsed.messages = sanitizeResponsesInputItems(parsed.messages); - - normalizedBody = Buffer.from(JSON.stringify(parsed), "utf-8"); - - if (bodyRewrites.length > 0) { - logStep("normalize", bodyRewrites.join("; ")); - } - - isStreamRequest = parsed.stream === true; - - normalizedBody = Buffer.from(JSON.stringify(parsed), "utf-8"); - - capturePayloadBoundary({ - stage: "normalized", - method, - originalPath: path, - upstreamPath, - isStreamRequest, - body: normalizedBody, - }); - - requestModel = - typeof parsed.model === "string" ? parsed.model : ""; - const model = requestModel.toLowerCase(); - const explicitTimeoutMs = Number( - process.env.RADROUTER_STREAM_TIMEOUT_MS || 0, - ); - const responseMaxOutput = - typeof parsed.max_output_tokens === "number" - ? parsed.max_output_tokens - : 0; - requestMaxOutputTokens = responseMaxOutput; - const responseReasoning = - typeof parsed.reasoning === "object" - ? parsed.reasoning - : null; - const isReasoningModel = - model.includes("gpt-5") || - model.includes("o1") || - model.includes("o3") || - Boolean(responseReasoning); - - if ( - isStreamRequest && - (isReasoningModel || responseMaxOutput > 8192) - ) { - streamIdleTimeoutMs = 300_000; - } - if (isStreamRequest && explicitTimeoutMs > 0) { - streamIdleTimeoutMs = explicitTimeoutMs; - } - } catch {} - } - - try { - const firstResponse = await makeRequest( - method, - upstreamPath, - req.headers, - normalizedBody, - {}, - undefined, - 30_000, - { - originalPath: path, - upstreamPath, - isStreamRequest, - }, - ); - - if (firstResponse.statusCode !== 402) { - logStep( - "upstream", - `Direct response ${firstResponse.statusCode} (no payment required)`, - ); - - if ( - tryWriteShapedChatResponse( - res, - path, - isStreamRequest, - firstResponse, - requestModel, - ) - ) { - return; - } - - res.writeHead(firstResponse.statusCode, firstResponse.headers); - res.end(firstResponse.body); - return; - } - - logStep("challenge", "Received HTTP 402 payment challenge"); - - let paymentData: PaymentResponse; - try { - paymentData = JSON.parse(firstResponse.body.toString("utf-8")); - } catch { - res.writeHead(502); - res.end( - JSON.stringify({ - error: "Invalid 402 response from RadRouter", - }), - ); - return; - } - - if (!paymentData.accepts || paymentData.accepts.length === 0) { - res.writeHead(502); - res.end( - JSON.stringify({ - error: "No payment options in 402 response", - }), - ); - return; - } - - const requirement = paymentData.accepts[0]; - - const validationError = validateRequirement(requirement); - if (validationError) { - console.error( - `[x402] Requirement validation failed: ${validationError}`, - ); - res.writeHead(502); - res.end( - JSON.stringify({ - error: `Payment requirement not supported: ${validationError}`, - }), - ); - return; - } - - console.log( - `[x402] requirement ${requirement.maxAmountRequired} SBC for ${upstreamPath}`, - ); - - logPricingEstimate({ - model: requestModel, - normalizedRequestBody: normalizedBody, - maxOutputTokens: requestMaxOutputTokens, - }); - - const xPayment = await signPermit(requirement); - logStep("permit", `Signed by ${account.address}`); - logStep("retry", "Forwarding paid request to RadRouter"); - - if (isStreamRequest) { - const paidStreamResponse = isChatCompletionsPath(path) - ? await makeLegacyChatStreamingRequest( - method, - upstreamPath, - req.headers, - normalizedBody, - { "x-payment": xPayment }, - res, - streamIdleTimeoutMs, - requestModel, - { - originalPath: path, - upstreamPath, - isStreamRequest, - }, - ) - : isAnthropicMessagesPath(path) - ? await makeAnthropicMessagesStreamingRequest( - method, - upstreamPath, - req.headers, - normalizedBody, - { "x-payment": xPayment }, - res, - streamIdleTimeoutMs, - requestModel, - { - originalPath: path, - upstreamPath, - isStreamRequest, - }, - ) - : await makeRequest( - method, - upstreamPath, - req.headers, - normalizedBody, - { "x-payment": xPayment }, - res, - streamIdleTimeoutMs, - ); - - const { verified, payer, tx } = extractPaymentHeaders( - paidStreamResponse.headers, - ); - - logStep( - "result", - `Stream upstream status ${paidStreamResponse.statusCode} (idle timeout ${streamIdleTimeoutMs}ms)`, - ); - - if (verified !== undefined) { - logStep("verified", `${verified}`); - } - if (payer) { - logStep("payer", `${payer}`); - } - if (tx) { - logTxDetails(tx); - } - - if (paidStreamResponse.statusCode < 400) { - logActualUsageCost({ - model: requestModel, - responseBody: paidStreamResponse.body, - authorizedMaxAmount: requirement.maxAmountRequired, - }); - } - - if (paidStreamResponse.statusCode >= 400) { - const upstreamBody = paidStreamResponse.body - .toString("utf-8") - .trim(); - - console.warn( - `[x402] stream-warning Paid stream returned status ${paidStreamResponse.statusCode}.`, - ); - - logStreamBodyPreview(paidStreamResponse.body); - } - } else { - const paidResponse = await makeRequest( - method, - upstreamPath, - req.headers, - normalizedBody, - { - "x-payment": xPayment, - }, - undefined, - 30_000, - { - originalPath: path, - upstreamPath, - isStreamRequest, - }, - ); - - if (paidResponse.statusCode === 200) { - logStep("result", "Payment accepted. Response delivered."); - } else { - logStep( - "result", - `Payment response status ${paidResponse.statusCode}`, - ); - } - - if (paidResponse.statusCode < 400) { - logActualUsageCost({ - model: requestModel, - responseBody: paidResponse.body, - authorizedMaxAmount: requirement.maxAmountRequired, - }); - } - - const { tx, txState } = extractPaymentHeaders( - paidResponse.headers, - ); - logTxDetails(tx, txState); - - if ( - tryWriteShapedChatResponse( - res, - path, - isStreamRequest, - paidResponse, - requestModel, - ) - ) { - return; - } - - res.writeHead(paidResponse.statusCode, paidResponse.headers); - res.end(paidResponse.body); - } - } catch (err: any) { - console.error("[proxy] Error:", err.message); - if (!res.headersSent) { - res.writeHead(502); - res.end( - JSON.stringify({ error: "Proxy error: " + err.message }), - ); - } - } - }); -}); - -async function startProxy() { - try { - await initializeSignerAndClients(); - } catch (err: any) { - console.error( - err?.message || - "[proxy] Failed to initialize private key. Check key source configuration.", - ); - process.exit(1); - } - - server.listen(PORT, "127.0.0.1", onServerListen); -} - -async function onServerListen() { - console.log(""); - console.log(" RadRouter x402 Proxy"); - console.log(" ===================="); - console.log(` Wallet: ${account.address}`); - console.log(` Proxy: http://localhost:${PORT}`); - console.log(` RadRouter: ${RADROUTER_URL}`); - console.log(` Network: Radius (Chain ID 723487)`); - - try { - const { rusd, sbc } = await fetchStartupBalances(); - console.log( - ` Balance: ${formatUnits(rusd, 18)} RUSD | ${formatUnits(sbc, SBC_DECIMALS)} SBC`, - ); - } catch (err: any) { - console.warn( - ` Balance: unavailable (${err?.message || "failed to fetch balances"})`, - ); - } - - console.log(""); - console.log(" Point your IDE to:"); - console.log(` http://localhost:${PORT}/v1`); - console.log( - " (chat/completions requests are normalized to Responses API automatically)", - ); - console.log(""); - console.log(" Start command:"); - console.log(" npm run proxy:start"); - console.log(""); - console.log(" Payments are signed automatically with your local key."); - console.log(" Press Ctrl+C to stop."); - console.log(""); -} - -void startProxy(); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 06958e7..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "." - }, - "include": ["*.ts"] -}