From 7fc0bafb0151ac27cd9046a1272ca1c9d57ef0b8 Mon Sep 17 00:00:00 2001 From: harrison Date: Thu, 26 Mar 2026 13:54:22 -0500 Subject: [PATCH] Initial commit: ein-ai-proxy Anthropic-compatible API proxy that routes requests to multiple providers (Anthropic, Z.ai/GLM-4.7, DeepSeek V3.2) based on model name. Zero dependencies, SSE streaming, bearer token auth. Designed to run behind Caddy/nginx on a home server, enabling Claude Code and Roo Code clients to connect from anywhere. --- .gitignore | 3 + README.md | 71 ++++++++++++ config.example.json | 15 +++ deploy.sh | 124 ++++++++++++++++++++ ein-ai-proxy.service | 19 +++ nginx-ai-proxy.conf | 46 ++++++++ package.json | 15 +++ proxy.mjs | 268 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 561 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.example.json create mode 100755 deploy.sh create mode 100644 ein-ai-proxy.service create mode 100644 nginx-ai-proxy.conf create mode 100644 package.json create mode 100644 proxy.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc1d633 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.json +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..124ade0 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# ein-ai-proxy + +Anthropic-compatible API proxy that routes requests to multiple AI providers based on model name. Built to run on a home server behind nginx, letting you use Claude Code and Roo Code from any machine while keeping costs optimized. + +## How it works + +Clients (Claude Code CLI, Roo Code, Cline, etc.) send standard Anthropic Messages API requests to your proxy. The proxy inspects the `model` field and forwards the request to the appropriate provider: + +| Client requests | Routed to | Why | +|---|---|---| +| `claude-opus-4-6` | Anthropic API | Complex tasks that need the best model | +| `claude-sonnet-4-6` | Z.ai → GLM-4.7 | Daily workhorse, $10/mo flat rate | +| `claude-haiku-4-5-*` | DeepSeek → V3.2 | Trivial tasks, pennies per token | +| `glm-4.7` (direct) | Z.ai | Explicit GLM request | +| `deepseek-chat` (direct) | DeepSeek | Explicit DeepSeek request | + +SSE streaming is fully supported — responses are piped through without buffering. + +## Setup + +```bash +# 1. Copy and edit config +cp config.example.json config.json +nano config.json # Add your API keys + set authToken + +# 2. Deploy (installs systemd service, tests proxy) +chmod +x deploy.sh +./deploy.sh + +# 3. Follow the nginx + SSL steps printed by deploy.sh +``` + +## Client Configuration + +### Claude Code CLI + +Add to `~/.claude/settings.json`: +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://ai.maybe.zone", + "ANTHROPIC_AUTH_TOKEN": "your-proxy-auth-token", + "API_TIMEOUT_MS": "3000000" + } +} +``` + +### Roo Code (VS Code) + +1. Open Roo Code settings +2. Set API Provider to **Anthropic** +3. Enter your proxy auth token as the API Key +4. Check **Use custom base URL** +5. Enter `https://ai.maybe.zone` + +## Switching models + +From Claude Code, the default Sonnet model routes to GLM-4.7. To use Opus for a complex task, use `/model` and select Opus — the proxy will route it to the real Anthropic API. + +## Files + +- `proxy.mjs` — The proxy server (zero dependencies, Node.js 18+) +- `config.json` — Your API keys and auth token (gitignored) +- `config.example.json` — Template config +- `ein-ai-proxy.service` — Systemd unit file +- `nginx-ai-proxy.conf` — Nginx reverse proxy config +- `deploy.sh` — Deployment script + +## License + +MIT diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..ea79d3a --- /dev/null +++ b/config.example.json @@ -0,0 +1,15 @@ +{ + "port": 3100, + "authToken": "CHANGE_ME_TO_A_LONG_RANDOM_STRING", + "providers": { + "anthropic": { + "apiKey": "sk-ant-YOUR_ANTHROPIC_KEY" + }, + "zai": { + "apiKey": "YOUR_ZAI_KEY" + }, + "deepseek": { + "apiKey": "YOUR_DEEPSEEK_KEY" + } + } +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..a0dd0b9 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# ============================================================= +# Ein AI Proxy - Deployment Script +# Run on: mini-server (ssh.maybe.zone) as user harrison +# ============================================================= +set -e + +PROXY_DIR="$HOME/ai-proxy" +SERVICE_NAME="ein-ai-proxy" + +echo "============================================" +echo " Ein AI Proxy - Deployment" +echo "============================================" +echo "" + +# --- Step 1: Check config exists --- +if [ ! -f "$PROXY_DIR/config.json" ]; then + echo "[!] config.json not found." + echo " Copy the example and fill in your keys:" + echo "" + echo " cp $PROXY_DIR/config.example.json $PROXY_DIR/config.json" + echo " nano $PROXY_DIR/config.json" + echo "" + echo " Then re-run this script." + exit 1 +fi + +echo "[1/5] Config found." + +# --- Step 2: Generate auth token if still default --- +AUTH_TOKEN=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$PROXY_DIR/config.json','utf-8')).authToken)") +if [ "$AUTH_TOKEN" = "CHANGE_ME_TO_A_LONG_RANDOM_STRING" ]; then + echo "[!] You haven't set a real authToken in config.json." + NEW_TOKEN=$(openssl rand -hex 32) + echo " Here's a generated one you can use:" + echo "" + echo " $NEW_TOKEN" + echo "" + echo " Edit config.json and replace the authToken value, then re-run." + exit 1 +fi + +echo "[2/5] Auth token is set." + +# --- Step 3: Test the proxy locally --- +echo "[3/5] Testing proxy startup..." +source "$HOME/.nvm/nvm.sh" +timeout 5 node "$PROXY_DIR/proxy.mjs" & +PROXY_PID=$! +sleep 2 + +HEALTH=$(curl -s http://127.0.0.1:3100/health 2>/dev/null || echo "FAIL") +kill $PROXY_PID 2>/dev/null || true +wait $PROXY_PID 2>/dev/null || true + +if echo "$HEALTH" | grep -q '"status":"ok"'; then + echo " Proxy starts and responds. Routes:" + echo "$HEALTH" | node -e " + const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf-8')); + Object.entries(d.routes).forEach(([m,r])=>console.log(' '+m+' -> '+r)); + " 2>/dev/null || echo " (could not parse routes)" +else + echo " [!] Proxy failed to start. Check config.json." + echo " Response: $HEALTH" + exit 1 +fi + +# --- Step 4: Install systemd service --- +echo "[4/5] Installing systemd service..." +sudo cp "$PROXY_DIR/$SERVICE_NAME.service" "/etc/systemd/system/$SERVICE_NAME.service" +sudo systemctl daemon-reload +sudo systemctl enable "$SERVICE_NAME" +sudo systemctl restart "$SERVICE_NAME" +sleep 2 + +if sudo systemctl is-active --quiet "$SERVICE_NAME"; then + echo " Service is running." +else + echo " [!] Service failed to start. Check logs:" + echo " sudo journalctl -u $SERVICE_NAME -n 20" + exit 1 +fi + +# --- Step 5: Nginx setup reminder --- +echo "[5/5] Nginx setup:" +echo "" +echo " 1. Create DNS A record: ai.maybe.zone -> $(curl -s ifconfig.me 2>/dev/null || echo 'YOUR_IP')" +echo "" +echo " 2. Install the nginx config:" +echo " sudo cp $PROXY_DIR/nginx-ai-proxy.conf /etc/nginx/sites-available/ai-proxy" +echo " sudo ln -sf /etc/nginx/sites-available/ai-proxy /etc/nginx/sites-enabled/" +echo " sudo nginx -t && sudo systemctl reload nginx" +echo "" +echo " 3. Get SSL cert:" +echo " sudo certbot --nginx -d ai.maybe.zone" +echo "" +echo "============================================" +echo " Deployment complete!" +echo "============================================" +echo "" +echo " Proxy running at: http://127.0.0.1:3100" +echo " After nginx+SSL: https://ai.maybe.zone" +echo "" +echo " --- Client Setup ---" +echo "" +echo " Claude Code CLI (~/.claude/settings.json):" +echo ' {' +echo ' "env": {' +echo " \"ANTHROPIC_BASE_URL\": \"https://ai.maybe.zone\"," +echo " \"ANTHROPIC_AUTH_TOKEN\": \"$AUTH_TOKEN\"," +echo ' "API_TIMEOUT_MS": "3000000"' +echo ' }' +echo ' }' +echo "" +echo " Roo Code (VS Code):" +echo " Provider: Anthropic" +echo " API Key: $AUTH_TOKEN" +echo " Custom Base URL: https://ai.maybe.zone" +echo "" +echo " Model routing:" +echo " Sonnet requests -> GLM-4.7 (Z.ai)" +echo " Opus requests -> Claude Opus (Anthropic API)" +echo " Haiku requests -> DeepSeek V3.2" +echo "" diff --git a/ein-ai-proxy.service b/ein-ai-proxy.service new file mode 100644 index 0000000..0cef1bd --- /dev/null +++ b/ein-ai-proxy.service @@ -0,0 +1,19 @@ +[Unit] +Description=Ein AI Proxy - Anthropic-compatible multi-provider router +After=network.target + +[Service] +Type=simple +User=harrison +WorkingDirectory=/home/harrison/ai-proxy +ExecStart=/home/harrison/.nvm/versions/node/v24.14.1/bin/node proxy.mjs +Restart=always +RestartSec=5 +Environment=NODE_ENV=production + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/nginx-ai-proxy.conf b/nginx-ai-proxy.conf new file mode 100644 index 0000000..c63be0b --- /dev/null +++ b/nginx-ai-proxy.conf @@ -0,0 +1,46 @@ +# Ein AI Proxy - Add this as a new server block or include in your existing nginx config +# Place at: /etc/nginx/sites-available/ai-proxy +# Then: sudo ln -s /etc/nginx/sites-available/ai-proxy /etc/nginx/sites-enabled/ +# Then: sudo nginx -t && sudo systemctl reload nginx +# +# Prerequisites: +# - DNS A record for ai.maybe.zone pointing to your server IP +# - SSL cert (use certbot: sudo certbot --nginx -d ai.maybe.zone) + +server { + listen 443 ssl http2; + server_name ai.maybe.zone; + + # SSL certs - certbot will fill these in, or point to your existing certs + # ssl_certificate /etc/letsencrypt/live/ai.maybe.zone/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/ai.maybe.zone/privkey.pem; + + # SSE streaming support - critical for Claude Code / Roo Code + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + + # Max body size for large prompts with long context + client_max_body_size 50m; + + location / { + proxy_pass http://127.0.0.1:3100; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE streaming headers + proxy_set_header Connection ''; + chunked_transfer_encoding on; + } +} + +# HTTP -> HTTPS redirect +server { + listen 80; + server_name ai.maybe.zone; + return 301 https://$host$request_uri; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fdaddb6 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "ein-ai-proxy", + "version": "1.0.0", + "description": "Anthropic-compatible proxy that routes AI requests to multiple providers (Anthropic, Z.ai, DeepSeek)", + "main": "proxy.mjs", + "type": "module", + "scripts": { + "start": "node proxy.mjs", + "dev": "node --watch proxy.mjs" + }, + "dependencies": {}, + "engines": { + "node": ">=18" + } +} diff --git a/proxy.mjs b/proxy.mjs new file mode 100644 index 0000000..b9be5d5 --- /dev/null +++ b/proxy.mjs @@ -0,0 +1,268 @@ +import { createServer } from "node:http"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Configuration ----------------------------------------------------------- + +const CONFIG_PATH = join(__dirname, "config.json"); + +function loadConfig() { + if (!existsSync(CONFIG_PATH)) { + console.error(`Config file not found: ${CONFIG_PATH}`); + console.error("Copy config.example.json to config.json and fill in your keys."); + process.exit(1); + } + return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); +} + +const config = loadConfig(); +const PORT = config.port || 3100; +const AUTH_TOKEN = config.authToken; + +// Provider definitions: maps provider name -> { baseUrl, apiKey, authStyle } +// authStyle: "anthropic" sends x-api-key header, "bearer" sends Authorization: Bearer +const PROVIDERS = { + anthropic: { + baseUrl: "https://api.anthropic.com", + apiKey: config.providers.anthropic.apiKey, + authStyle: "api-key", + }, + zai: { + baseUrl: "https://api.z.ai/api/anthropic", + apiKey: config.providers.zai.apiKey, + authStyle: "bearer", + }, + deepseek: { + baseUrl: "https://api.deepseek.com/anthropic", + apiKey: config.providers.deepseek.apiKey, + authStyle: "bearer", + }, +}; + +// Model -> provider routing table +// Claude Code sends model names like "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001" +// We map these to the correct provider + optionally rewrite the model name +const MODEL_ROUTES = { + // Opus models -> Anthropic (the expensive brain) + "claude-opus-4-6": { provider: "anthropic" }, + "claude-opus-4-5": { provider: "anthropic" }, + "claude-opus-4-5-20251101": { provider: "anthropic" }, + + // Sonnet models -> Z.ai GLM-4.7 (the workhorse) + "claude-sonnet-4-6": { provider: "zai", rewriteModel: "glm-4.7" }, + "claude-sonnet-4-5": { provider: "zai", rewriteModel: "glm-4.7" }, + "claude-sonnet-4-5-20250929": { provider: "zai", rewriteModel: "glm-4.7" }, + + // Haiku models -> DeepSeek (the cheap tier) + "claude-haiku-4-5-20251001": { provider: "deepseek", rewriteModel: "deepseek-chat" }, + "claude-haiku-4-5": { provider: "deepseek", rewriteModel: "deepseek-chat" }, + + // Direct model names (when client explicitly asks for a provider's model) + "glm-4.7": { provider: "zai" }, + "glm-4.5-air": { provider: "zai" }, + "deepseek-chat": { provider: "deepseek" }, +}; + +// Default route for unknown models +const DEFAULT_ROUTE = { provider: "zai", rewriteModel: "glm-4.7" }; + +// --- Helpers ----------------------------------------------------------------- + +function resolveRoute(model) { + // Exact match first + if (MODEL_ROUTES[model]) return MODEL_ROUTES[model]; + + // Partial match: check if model starts with a known prefix + for (const [pattern, route] of Object.entries(MODEL_ROUTES)) { + if (model.startsWith(pattern)) return route; + } + + // Fallback + console.log(`[route] Unknown model "${model}", using default -> zai/glm-4.7`); + return DEFAULT_ROUTE; +} + +function buildUpstreamHeaders(provider, originalHeaders) { + const headers = { + "content-type": "application/json", + "anthropic-version": originalHeaders["anthropic-version"] || "2023-06-01", + }; + + // Copy through any anthropic-beta headers + if (originalHeaders["anthropic-beta"]) { + headers["anthropic-beta"] = originalHeaders["anthropic-beta"]; + } + + // Auth style depends on provider + const prov = PROVIDERS[provider]; + if (prov.authStyle === "api-key") { + headers["x-api-key"] = prov.apiKey; + } else { + headers["authorization"] = `Bearer ${prov.apiKey}`; + } + + return headers; +} + +// --- Request Handler --------------------------------------------------------- + +async function handleMessages(req, res) { + // Read body + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const rawBody = Buffer.concat(chunks).toString("utf-8"); + + let body; + try { + body = JSON.parse(rawBody); + } catch { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { type: "invalid_request", message: "Invalid JSON body" } })); + return; + } + + const requestedModel = body.model || "claude-sonnet-4-6"; + const route = resolveRoute(requestedModel); + const provider = PROVIDERS[route.provider]; + + if (!provider) { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { type: "config_error", message: `Unknown provider: ${route.provider}` } })); + return; + } + + // Rewrite model name if needed + if (route.rewriteModel) { + body.model = route.rewriteModel; + } + + const isStreaming = body.stream === true; + const upstreamUrl = `${provider.baseUrl}/v1/messages`; + const upstreamHeaders = buildUpstreamHeaders(route.provider, req.headers); + + console.log( + `[proxy] ${requestedModel} -> ${route.provider}/${body.model} (stream=${isStreaming})` + ); + + try { + const upstreamRes = await fetch(upstreamUrl, { + method: "POST", + headers: upstreamHeaders, + body: JSON.stringify(body), + }); + + // Forward status code + const statusCode = upstreamRes.status; + + if (isStreaming && statusCode === 200) { + // SSE streaming: pipe through directly + res.writeHead(statusCode, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + "connection": "keep-alive", + "access-control-allow-origin": "*", + }); + + const reader = upstreamRes.body.getReader(); + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + res.write(text); + } + } catch (streamErr) { + console.error(`[proxy] Stream error from ${route.provider}:`, streamErr.message); + } finally { + res.end(); + } + } else { + // Non-streaming or error: forward the full response + const responseBody = await upstreamRes.text(); + const responseHeaders = { "content-type": upstreamRes.headers.get("content-type") || "application/json" }; + res.writeHead(statusCode, responseHeaders); + res.end(responseBody); + } + } catch (fetchErr) { + console.error(`[proxy] Fetch error to ${route.provider} (${upstreamUrl}):`, fetchErr.message); + res.writeHead(502, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: { + type: "proxy_error", + message: `Failed to reach upstream provider ${route.provider}: ${fetchErr.message}`, + }, + }) + ); + } +} + +// --- Server ------------------------------------------------------------------ + +const server = createServer(async (req, res) => { + // CORS preflight + if (req.method === "OPTIONS") { + res.writeHead(204, { + "access-control-allow-origin": "*", + "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-headers": "content-type, authorization, x-api-key, anthropic-version, anthropic-beta", + }); + res.end(); + return; + } + + const urlPath = req.url.split("?")[0]; + + // Health check (no auth required) + if (req.method === "GET" && (urlPath === "/" || urlPath === "/health")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + status: "ok", + service: "ein-ai-proxy", + providers: Object.keys(PROVIDERS), + routes: Object.fromEntries( + Object.entries(MODEL_ROUTES).map(([m, r]) => [m, `${r.provider}/${r.rewriteModel || m}`]) + ), + }) + ); + return; + } + + // Auth check (everything below requires auth) + if (AUTH_TOKEN) { + const authHeader = req.headers["authorization"] || ""; + const apiKeyHeader = req.headers["x-api-key"] || ""; + const token = authHeader.replace(/^Bearer\s+/i, "") || apiKeyHeader; + + if (token !== AUTH_TOKEN) { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { type: "authentication_error", message: "Invalid auth token" } })); + return; + } + } + + // Anthropic Messages API + if (req.method === "POST" && urlPath === "/v1/messages") { + await handleMessages(req, res); + return; + } + + // 404 for everything else + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { type: "not_found", message: `Unknown path: ${urlPath}` } })); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`[ein-ai-proxy] Listening on http://0.0.0.0:${PORT}`); + console.log(`[ein-ai-proxy] Routes:`); + for (const [model, route] of Object.entries(MODEL_ROUTES)) { + console.log(` ${model} -> ${route.provider}/${route.rewriteModel || model}`); + } + console.log(` (default) -> zai/glm-4.7`); +});