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.
This commit is contained in:
harrison
2026-03-26 13:54:22 -05:00
commit 7fc0bafb01
8 changed files with 561 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
config.json
node_modules/
*.log
+71
View File
@@ -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
+15
View File
@@ -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"
}
}
}
Executable
+124
View File
@@ -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 ""
+19
View File
@@ -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
+46
View File
@@ -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;
}
+15
View File
@@ -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"
}
}
+268
View File
@@ -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`);
});