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:
@@ -0,0 +1,3 @@
|
|||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user