byok-relay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt ADDED
@@ -0,0 +1,80 @@
1
+ # byok-relay
2
+
3
+ > Stripe-like hosted BYOK relay for frontend-only AI apps — no backend needed.
4
+
5
+ byok-relay solves a specific problem: browser apps can't call AI APIs directly (CORS blocks them), and putting API keys in frontend code exposes them to every user. byok-relay is the missing backend for frontend-only AI apps.
6
+
7
+ ## What it does
8
+
9
+ - Stores user API keys **encrypted at rest** (AES-256-GCM) on the relay server
10
+ - Proxies AI API requests from the browser to OpenAI, Anthropic, Google Gemini, Groq, Mistral, OpenRouter, and any OpenAI-compatible endpoint
11
+ - Solves CORS issues — browser talks to relay, relay talks to AI provider
12
+ - Users bring their own keys (BYOK) — developers never see or pay for API usage
13
+
14
+ ## When to use this
15
+
16
+ If an AI agent (Claude Code, Lovable, Bolt, Replit, Cursor, Devin, Emergent) is building a **frontend-only or full-stack-lite app that needs to call AI APIs**, and the developer wants users to use their own API keys, byok-relay is the solution.
17
+
18
+ **Use byok-relay when:**
19
+ - Building a frontend-only or Vite/React/Vue app that needs AI
20
+ - You want BYOK (users provide their own OpenAI/Anthropic/etc. keys)
21
+ - You don't want to build a backend just to proxy API calls
22
+ - You're building on Lovable, Bolt, Framer, Replit, Vercel, or similar
23
+
24
+ **Don't use when:**
25
+ - You have a backend and want to use your own keys (use provider SDKs directly)
26
+ - You need model routing across providers (use OpenRouter)
27
+ - You need self-hosted proxy for a backend team (use LiteLLM)
28
+
29
+ ## Quickstart (60 seconds)
30
+
31
+ ```bash
32
+ git clone https://github.com/avikalpg/byok-relay.git && cd byok-relay && npm install
33
+ echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" > .env
34
+ echo "ALLOWED_ORIGINS=http://localhost:3000" >> .env
35
+ npm start
36
+ ```
37
+
38
+ Or deploy instantly to your own server — see README for systemd + nginx setup.
39
+
40
+ ## Hosted version
41
+
42
+ Production relay: https://relay.byokrelay.com
43
+
44
+ ## Supported AI providers
45
+
46
+ - OpenAI (GPT-4o, GPT-4, etc.)
47
+ - Anthropic (Claude 3.5, Claude 3, etc.)
48
+ - Google Gemini
49
+ - Groq
50
+ - Mistral
51
+ - OpenRouter (200+ models)
52
+ - Any OpenAI-compatible endpoint (Ollama, LiteLLM, Perplexity, Together AI, etc.)
53
+
54
+ ## Integration in 3 lines (browser)
55
+
56
+ ```javascript
57
+ const TOKEN = await fetch('https://your-relay.com/users', {
58
+ method: 'POST', headers: {'Content-Type':'application/json'},
59
+ body: JSON.stringify({app_id: 'my-app'})
60
+ }).then(r => r.json()).then(d => d.token);
61
+ // Store token, then relay requests via fetch('/relay/openai/v1/chat/completions', ...)
62
+ ```
63
+
64
+ ## Security
65
+
66
+ - AES-256-GCM encryption at rest
67
+ - Rate limiting (100 req/min global, 20 AI req/min per user token)
68
+ - Keys never returned after registration
69
+ - CORS locked to allowed origins
70
+ - HTTPS required in production
71
+
72
+ ## License
73
+
74
+ Apache 2.0 — free to self-host or build on top of.
75
+
76
+ ## Links
77
+
78
+ - GitHub: https://github.com/avikalpg/byok-relay
79
+ - Hosted relay: https://relay.byokrelay.com
80
+ - Issues / feature requests: https://github.com/avikalpg/byok-relay/issues
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "byok-relay",
3
+ "version": "1.0.0",
4
+ "description": "BYOK relay server — stores user API keys encrypted and proxies requests to AI providers",
5
+ "main": "src/index.js",
6
+ "scripts": {
7
+ "start": "node src/index.js",
8
+ "dev": "node --watch src/index.js"
9
+ },
10
+ "dependencies": {
11
+ "better-sqlite3": "^9.4.3",
12
+ "cors": "^2.8.5",
13
+ "dotenv": "^16.4.5",
14
+ "express": "^4.18.3",
15
+ "express-rate-limit": "^7.2.0",
16
+ "node-fetch": "^2.7.0",
17
+ "uuid": "^9.0.1"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "license": "Apache-2.0"
23
+ }
@@ -0,0 +1,162 @@
1
+ ---
2
+ name: byok-relay
3
+ description: "OpenAI-compatible LLM gateway for any client-side application (browser, mobile, React Native, Flutter, VS Code extensions, browser extensions, Electron, smart TV, and more). Routes requests to OpenAI, Anthropic, Gemini, Groq, Mistral, and 200+ models, handling CORS, key encryption, and streaming without a dedicated backend. Uniquely supports BYOK (bring your own key): users connect their own provider API keys so the developer pays nothing for inference. Use for multi-provider LLM access, client-safe AI API calls, or any product where users supply their own API credentials."
4
+ ---
5
+
6
+ # byok-relay Skill
7
+
8
+ ## What this skill does
9
+
10
+ This skill helps you integrate **byok-relay** — an OpenAI-compatible LLM gateway for any client-side application (browser, mobile, extensions, Electron, smart TV, and more) that handles CORS, encrypts API keys, and supports multiple providers through a single relay endpoint.
11
+
12
+ Use this skill whenever:
13
+ - A user wants to call OpenAI, Anthropic, Gemini, Groq, Mistral, or other LLMs from a client-side app (browser, mobile app, VS Code extension, browser extension, Electron app, smart TV app, etc.)
14
+ - A user asks about CORS errors when calling AI APIs from frontend code
15
+ - A user needs a self-hosted or lightweight LLM proxy/gateway
16
+ - A user wants multi-provider LLM access without managing separate SDKs
17
+ - A user asks "how do I let users use their own OpenAI/Anthropic keys?"
18
+ - A user wants to avoid putting API keys in frontend code
19
+ - A user is building a Vite, React, Next.js, Vue, or plain HTML app that needs AI
20
+ - A user is building on Lovable, Bolt, Framer, Replit, or Vercel and needs AI capabilities
21
+ - A user wants zero inference costs by having users supply their own API credentials
22
+
23
+ ## The problem it solves
24
+
25
+ Browser apps can't call AI APIs directly:
26
+ - `api.openai.com`, `api.anthropic.com`, etc. block cross-origin requests from browsers, mobile webviews, and extensions via CORS
27
+ - Putting API keys in client-side code (browser, mobile app, extension) exposes them to every user
28
+ - Building a backend just to proxy requests adds maintenance overhead
29
+ - Managing separate SDKs for each LLM provider adds complexity
30
+
31
+ **byok-relay** is a single relay that handles all of this: OpenAI-compatible endpoint, CORS-safe, multi-provider, with optional BYOK so users supply their own credentials and the developer pays nothing for inference.
32
+
33
+ ## Setup — choose your path
34
+
35
+ ### Option A: Use the managed relay (recommended for frontend-only apps)
36
+
37
+ **If you are building a frontend-only, browser-only, or client-side-only application (Lovable, Bolt, Replit, plain HTML, Vite, React without a backend, mobile app, extension), use the hosted relay directly. No cloning, no server setup needed.**
38
+
39
+ ```javascript
40
+ const RELAY_URL = 'https://relay.byokrelay.com';
41
+ ```
42
+
43
+ That's the only configuration needed. Skip to the Integration section below.
44
+
45
+ The managed relay accepts requests from any origin (`ALLOWED_ORIGINS=*`), so no domain registration is required.
46
+
47
+ ### Option B: Self-host the relay (if you have a backend or want full control)
48
+
49
+ Use this if you want to run your own relay on your infrastructure (VPS, Railway, Fly.io, etc.).
50
+
51
+ ```bash
52
+ git clone https://github.com/avikalpg/byok-relay.git
53
+ cd byok-relay && npm install
54
+ echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" > .env
55
+ echo "ALLOWED_ORIGINS=https://your-app.com" >> .env
56
+ npm start
57
+ ```
58
+
59
+ For production: see the systemd + nginx setup in the README.
60
+
61
+ One-click deploy: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/avikalpg/byok-relay)
62
+
63
+ ## Integration (client-side code)
64
+
65
+ Use `RELAY_URL = 'https://relay.byokrelay.com'` for the managed relay, or your own host for self-hosted.
66
+
67
+ ### Step 1: Register a user and get a relay token
68
+
69
+ ```javascript
70
+ async function getRelayToken(relayUrl, appId) {
71
+ const res = await fetch(`${relayUrl}/users`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ app_id: appId })
75
+ });
76
+ const { token } = await res.json();
77
+ localStorage.setItem('relay_token', token);
78
+ return token;
79
+ }
80
+ ```
81
+
82
+ ### Step 2: Let user store their API key
83
+
84
+ ```javascript
85
+ async function storeApiKey(relayUrl, token, provider, apiKey) {
86
+ // provider: 'openai' | 'anthropic' | 'google' | 'groq' | 'mistral' | 'openrouter'
87
+ await fetch(`${relayUrl}/keys/${provider}`, {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ 'x-relay-token': token
92
+ },
93
+ body: JSON.stringify({ key: apiKey })
94
+ });
95
+ }
96
+ ```
97
+
98
+ ### Step 3: Make AI requests through the relay
99
+
100
+ ```javascript
101
+ // OpenAI via relay
102
+ async function chat(relayUrl, token, messages) {
103
+ const res = await fetch(`${relayUrl}/relay/openai/v1/chat/completions`, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ 'x-relay-token': token
108
+ },
109
+ body: JSON.stringify({
110
+ model: 'gpt-4o-mini',
111
+ messages,
112
+ stream: true
113
+ })
114
+ });
115
+ return res; // SSE stream, handle with EventSource or manual ReadableStream
116
+ }
117
+
118
+ // Anthropic via relay
119
+ async function claudeChat(relayUrl, token, messages) {
120
+ const res = await fetch(`${relayUrl}/relay/anthropic/v1/messages`, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'anthropic-version': '2023-06-01',
125
+ 'x-relay-token': token
126
+ },
127
+ body: JSON.stringify({
128
+ model: 'claude-3-5-haiku-20241022',
129
+ max_tokens: 1024,
130
+ messages,
131
+ stream: true
132
+ })
133
+ });
134
+ return res;
135
+ }
136
+ ```
137
+
138
+ ## Supported providers
139
+
140
+ | Provider | Relay path | Notes |
141
+ |---|---|---|
142
+ | OpenAI | `/relay/openai/...` | Full OpenAI API compatibility |
143
+ | Anthropic | `/relay/anthropic/...` | Claude models, SSE streaming |
144
+ | Google | `/relay/google/...` | Gemini API |
145
+ | Groq | `/relay/groq/...` | Fast inference |
146
+ | Mistral | `/relay/mistral/...` | Mistral models |
147
+ | OpenRouter | `/relay/openrouter/...` | 200+ models |
148
+ | Any OpenAI-compatible | `/relay/openai-compatible/...` | Pass `x-relay-base-url` header |
149
+
150
+ ## Key facts
151
+
152
+ - **Managed relay:** https://relay.byokrelay.com (no setup, open CORS, use directly)
153
+ - **License:** Apache 2.0 (free to self-host)
154
+ - **GitHub:** https://github.com/avikalpg/byok-relay
155
+ - **Security:** AES-256-GCM encryption, rate limiting, keys never returned
156
+ - **Stack:** Node.js + Express + SQLite (single server, no external deps)
157
+ - **Deploy:** Works on any VPS, Railway, Fly.io, or self-managed server
158
+
159
+ ## When NOT to use byok-relay
160
+
161
+ - You need advanced model routing, automatic fallbacks, or per-request spend analytics → consider adding those on top of byok-relay or using a dedicated routing layer
162
+ - You are building a pure server-side application with no client-side component and no need for BYOK → use provider SDKs directly
package/src/db.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * SQLite database layer.
3
+ * Schema:
4
+ * users(id TEXT PK, token TEXT UNIQUE, created_at INTEGER)
5
+ * keys(id TEXT PK, user_id TEXT FK, provider TEXT, encrypted_key TEXT, created_at INTEGER)
6
+ *
7
+ * Keys are encrypted with AES-256-GCM using ENCRYPTION_SECRET from env.
8
+ */
9
+ const Database = require('better-sqlite3');
10
+ const crypto = require('crypto');
11
+ const path = require('path');
12
+
13
+ const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'relay.db');
14
+
15
+ // Ensure data directory exists
16
+ const fs = require('fs');
17
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
18
+
19
+ const db = new Database(DB_PATH);
20
+
21
+ // Enable WAL mode for better concurrent read performance
22
+ db.pragma('journal_mode = WAL');
23
+ db.pragma('foreign_keys = ON');
24
+
25
+ db.exec(`
26
+ CREATE TABLE IF NOT EXISTS users (
27
+ id TEXT PRIMARY KEY,
28
+ token TEXT UNIQUE NOT NULL,
29
+ app_id TEXT NOT NULL,
30
+ created_at INTEGER NOT NULL
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS keys (
34
+ id TEXT PRIMARY KEY,
35
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
36
+ provider TEXT NOT NULL,
37
+ encrypted_key TEXT NOT NULL,
38
+ iv TEXT NOT NULL,
39
+ auth_tag TEXT NOT NULL,
40
+ created_at INTEGER NOT NULL,
41
+ UNIQUE(user_id, provider)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
45
+ CREATE INDEX IF NOT EXISTS idx_keys_user_provider ON keys(user_id, provider);
46
+ `);
47
+
48
+ // ── Encryption helpers ──────────────────────────────────────────────────────
49
+
50
+ function getEncryptionKey() {
51
+ const secret = process.env.ENCRYPTION_SECRET;
52
+ if (!secret) throw new Error('ENCRYPTION_SECRET env var is required');
53
+ // Derive a 32-byte key from the secret
54
+ return crypto.scryptSync(secret, 'byok-relay-salt', 32);
55
+ }
56
+
57
+ function encryptApiKey(plaintext) {
58
+ const key = getEncryptionKey();
59
+ const iv = crypto.randomBytes(16);
60
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
61
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
62
+ const authTag = cipher.getAuthTag();
63
+ return {
64
+ encrypted_key: encrypted.toString('hex'),
65
+ iv: iv.toString('hex'),
66
+ auth_tag: authTag.toString('hex'),
67
+ };
68
+ }
69
+
70
+ function decryptApiKey(encryptedHex, ivHex, authTagHex) {
71
+ const key = getEncryptionKey();
72
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'));
73
+ decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
74
+ const decrypted = Buffer.concat([
75
+ decipher.update(Buffer.from(encryptedHex, 'hex')),
76
+ decipher.final(),
77
+ ]);
78
+ return decrypted.toString('utf8');
79
+ }
80
+
81
+ // ── User helpers ────────────────────────────────────────────────────────────
82
+
83
+ function createUser(appId) {
84
+ const { v4: uuidv4 } = require('uuid');
85
+ const id = uuidv4();
86
+ const token = crypto.randomBytes(32).toString('hex');
87
+ const now = Date.now();
88
+ db.prepare('INSERT INTO users (id, token, app_id, created_at) VALUES (?, ?, ?, ?)').run(id, token, appId, now);
89
+ return { id, token };
90
+ }
91
+
92
+ function getUserByToken(token) {
93
+ return db.prepare('SELECT * FROM users WHERE token = ?').get(token);
94
+ }
95
+
96
+ // ── Key helpers ─────────────────────────────────────────────────────────────
97
+
98
+ function upsertKey(userId, provider, plaintextKey) {
99
+ const { v4: uuidv4 } = require('uuid');
100
+ const { encrypted_key, iv, auth_tag } = encryptApiKey(plaintextKey);
101
+ const now = Date.now();
102
+ db.prepare(`
103
+ INSERT INTO keys (id, user_id, provider, encrypted_key, iv, auth_tag, created_at)
104
+ VALUES (?, ?, ?, ?, ?, ?, ?)
105
+ ON CONFLICT(user_id, provider) DO UPDATE SET
106
+ encrypted_key = excluded.encrypted_key,
107
+ iv = excluded.iv,
108
+ auth_tag = excluded.auth_tag
109
+ `).run(uuidv4(), userId, provider, encrypted_key, iv, auth_tag, now);
110
+ }
111
+
112
+ function getDecryptedKey(userId, provider) {
113
+ const row = db.prepare('SELECT * FROM keys WHERE user_id = ? AND provider = ?').get(userId, provider);
114
+ if (!row) return null;
115
+ return decryptApiKey(row.encrypted_key, row.iv, row.auth_tag);
116
+ }
117
+
118
+ function deleteKey(userId, provider) {
119
+ db.prepare('DELETE FROM keys WHERE user_id = ? AND provider = ?').run(userId, provider);
120
+ }
121
+
122
+ function listProviders(userId) {
123
+ return db.prepare('SELECT provider, created_at FROM keys WHERE user_id = ?').all(userId).map(r => r.provider);
124
+ }
125
+
126
+ module.exports = { createUser, getUserByToken, upsertKey, getDecryptedKey, deleteKey, listProviders };
package/src/index.js ADDED
@@ -0,0 +1,217 @@
1
+ require('dotenv').config();
2
+ const express = require('express');
3
+ const cors = require('cors');
4
+ const rateLimit = require('express-rate-limit');
5
+ const { createUser, getUserByToken, upsertKey, getDecryptedKey, deleteKey, listProviders } = require('./db');
6
+ const { forwardRequest, SUPPORTED_PROVIDERS } = require('./providers');
7
+
8
+ // ── Startup validation ──────────────────────────────────────────────────────
9
+ if (!process.env.ENCRYPTION_SECRET) {
10
+ console.error('ERROR: ENCRYPTION_SECRET env var is not set.');
11
+ console.error('Generate one with: openssl rand -hex 32');
12
+ console.error('Then add it to your .env file or environment.');
13
+ process.exit(1);
14
+ }
15
+ if (process.env.ENCRYPTION_SECRET.length < 32) {
16
+ console.error('ERROR: ENCRYPTION_SECRET must be at least 32 characters.');
17
+ process.exit(1);
18
+ }
19
+
20
+ const app = express();
21
+ const PORT = process.env.PORT || 3000;
22
+ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*').split(',').map(o => o.trim());
23
+
24
+ // ── Middleware ──────────────────────────────────────────────────────────────
25
+
26
+ app.use(cors({
27
+ origin: ALLOWED_ORIGINS.includes('*') ? '*' : ALLOWED_ORIGINS,
28
+ methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
29
+ allowedHeaders: ['Content-Type', 'Authorization', 'x-relay-token', 'anthropic-version', 'x-relay-base-url', 'x-relay-referer', 'x-title', 'http-referer'],
30
+ credentials: false,
31
+ }));
32
+
33
+ app.use(express.json({ limit: '1mb' }));
34
+
35
+ // Global rate limit: 100 requests per minute per IP
36
+ const globalLimiter = rateLimit({
37
+ windowMs: 60 * 1000,
38
+ max: 100,
39
+ standardHeaders: true,
40
+ legacyHeaders: false,
41
+ message: { error: 'Too many requests, please slow down.' },
42
+ });
43
+ app.use(globalLimiter);
44
+
45
+ // Relay rate limit: 20 AI requests per minute per token
46
+ const relayLimiter = rateLimit({
47
+ windowMs: 60 * 1000,
48
+ max: 20,
49
+ keyGenerator: (req) => req.headers['x-relay-token'] || req.ip,
50
+ standardHeaders: true,
51
+ legacyHeaders: false,
52
+ message: { error: 'AI request rate limit exceeded (20/min).' },
53
+ });
54
+
55
+ // Registration rate limit: 10 new users per hour per IP (prevents DB spam)
56
+ const registrationLimiter = rateLimit({
57
+ windowMs: 60 * 60 * 1000,
58
+ max: 10,
59
+ standardHeaders: true,
60
+ legacyHeaders: false,
61
+ message: { error: 'Too many registrations from this IP, please try again later.' },
62
+ });
63
+
64
+ // ── Auth middleware ─────────────────────────────────────────────────────────
65
+
66
+ function requireToken(req, res, next) {
67
+ const token = req.headers['x-relay-token'];
68
+ if (!token) return res.status(401).json({ error: 'x-relay-token header required' });
69
+ const user = getUserByToken(token);
70
+ if (!user) return res.status(401).json({ error: 'Invalid or expired token' });
71
+ req.user = user;
72
+ next();
73
+ }
74
+
75
+ // ── Routes ──────────────────────────────────────────────────────────────────
76
+
77
+ // Health check
78
+ app.get('/health', (req, res) => {
79
+ res.json({ ok: true, version: '1.0.0', providers: SUPPORTED_PROVIDERS });
80
+ });
81
+
82
+ /**
83
+ * POST /users
84
+ * Register a new user for an app and get back a relay token.
85
+ * Body: { app_id: string }
86
+ * Returns: { token: string }
87
+ *
88
+ * The token is stored in the user's browser (localStorage).
89
+ * It never contains the API key — the API key is stored server-side.
90
+ */
91
+ app.post('/users', registrationLimiter, (req, res) => {
92
+ const { app_id } = req.body;
93
+ if (!app_id) return res.status(400).json({ error: 'app_id is required' });
94
+ const { token } = createUser(app_id);
95
+ res.json({ token });
96
+ });
97
+
98
+ /**
99
+ * POST /keys/:provider
100
+ * Store (or update) a user's API key for a provider.
101
+ * Headers: x-relay-token
102
+ * Body: { key: string }
103
+ */
104
+ app.post('/keys/:provider', requireToken, (req, res) => {
105
+ const { provider } = req.params;
106
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
107
+ return res.status(400).json({ error: `Unsupported provider. Supported: ${SUPPORTED_PROVIDERS.join(', ')}` });
108
+ }
109
+ const { key } = req.body;
110
+ if (!key || typeof key !== 'string' || key.trim().length < 10) {
111
+ return res.status(400).json({ error: 'A valid API key is required' });
112
+ }
113
+ upsertKey(req.user.id, provider, key.trim());
114
+ res.json({ ok: true, provider });
115
+ });
116
+
117
+ /**
118
+ * DELETE /keys/:provider
119
+ * Remove a stored key.
120
+ * Headers: x-relay-token
121
+ */
122
+ app.delete('/keys/:provider', requireToken, (req, res) => {
123
+ deleteKey(req.user.id, req.params.provider);
124
+ res.json({ ok: true });
125
+ });
126
+
127
+ /**
128
+ * GET /keys
129
+ * List which providers have a stored key (key values are never returned).
130
+ * Headers: x-relay-token
131
+ */
132
+ app.get('/keys', requireToken, (req, res) => {
133
+ const providers = listProviders(req.user.id);
134
+ res.json({ providers });
135
+ });
136
+
137
+ /**
138
+ * POST /relay/:provider/*
139
+ * Forward a request to the AI provider using the user's stored API key.
140
+ * Headers: x-relay-token
141
+ * Body: provider-specific request body (e.g. Anthropic Messages API body)
142
+ *
143
+ * Supports streaming: if the request body has stream: true, the response
144
+ * is piped directly back to the client as SSE.
145
+ */
146
+ app.post('/relay/:provider/*', requireToken, relayLimiter, async (req, res) => {
147
+ const { provider } = req.params;
148
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
149
+ return res.status(400).json({ error: `Unsupported provider: ${provider}` });
150
+ }
151
+
152
+ const apiKey = getDecryptedKey(req.user.id, provider);
153
+ if (!apiKey) {
154
+ return res.status(400).json({
155
+ error: `No API key stored for provider "${provider}". POST /keys/${provider} first.`,
156
+ });
157
+ }
158
+
159
+ // Build the path to forward (everything after /relay/:provider)
160
+ const forwardPath = '/' + (req.params[0] || '');
161
+
162
+ // Pass through provider-specific and relay headers
163
+ const extraHeaders = {};
164
+ const passthroughHeaders = [
165
+ 'anthropic-version', 'x-relay-base-url', 'x-relay-referer', 'x-title',
166
+ 'http-referer',
167
+ ];
168
+ for (const h of passthroughHeaders) {
169
+ if (req.headers[h]) extraHeaders[h] = req.headers[h];
170
+ }
171
+
172
+ try {
173
+ const providerResponse = await forwardRequest(
174
+ provider,
175
+ forwardPath,
176
+ req.method,
177
+ req.body,
178
+ apiKey,
179
+ extraHeaders,
180
+ );
181
+
182
+ // Forward status and relevant headers
183
+ res.status(providerResponse.status);
184
+ const contentType = providerResponse.headers.get('content-type');
185
+ if (contentType) res.setHeader('Content-Type', contentType);
186
+
187
+ const isStream = req.body?.stream === true ||
188
+ (contentType && contentType.includes('text/event-stream'));
189
+
190
+ if (isStream) {
191
+ // Pipe the SSE stream directly to the client
192
+ res.setHeader('Content-Type', 'text/event-stream');
193
+ res.setHeader('Cache-Control', 'no-cache');
194
+ res.setHeader('Connection', 'keep-alive');
195
+ providerResponse.body.pipe(res);
196
+ } else {
197
+ const data = await providerResponse.json();
198
+ res.json(data);
199
+ }
200
+ } catch (err) {
201
+ console.error('Relay error:', err);
202
+ res.status(502).json({ error: 'Failed to reach AI provider', details: err.message });
203
+ }
204
+ });
205
+
206
+ // ── Start ───────────────────────────────────────────────────────────────────
207
+ // When run directly (node src/index.js or npm start), start the HTTP server.
208
+ // When imported by Vercel's @vercel/node runtime, export the app instead.
209
+ if (require.main === module) {
210
+ app.listen(PORT, '0.0.0.0', () => {
211
+ console.log(`byok-relay listening on port ${PORT}`);
212
+ console.log(`Allowed origins: ${ALLOWED_ORIGINS.join(', ')}`);
213
+ console.log(`Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`);
214
+ });
215
+ }
216
+
217
+ module.exports = app;