| import Fastify, { type FastifyError } from 'fastify'; |
| import rateLimit from '@fastify/rate-limit'; |
| import { loadConfig } from './config.js'; |
| import { createAuthHook } from './auth.js'; |
| import { forwardRequest } from './proxy.js'; |
| import { |
| ANTHROPIC_ROUTES, |
| ANTHROPIC_FORWARDED_HEADERS, |
| GEMINI_FORWARDED_HEADERS, |
| } from './types.js'; |
| import type { ProviderConfig } from './types.js'; |
|
|
| const config = loadConfig(); |
|
|
| const app = Fastify({ |
| logger: { |
| level: config.logLevel, |
| |
| redact: ['req.headers.authorization', 'req.headers["x-api-key"]', 'req.headers["x-goog-api-key"]'], |
| }, |
| bodyLimit: config.bodyLimit, |
| trustProxy: true, |
| }); |
|
|
| |
| await app.register(rateLimit, { |
| max: config.rateLimitMax, |
| timeWindow: config.rateLimitWindowMs, |
| addHeadersOnExceeding: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true }, |
| addHeaders: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true, 'retry-after': true }, |
| }); |
|
|
| |
| if (config.corsOrigin) { |
| try { |
| |
| |
| const corsPlugin = await import( '@fastify/cors' + ''); |
| |
| await app.register(corsPlugin.default ?? corsPlugin, { origin: config.corsOrigin }); |
| } catch { |
| app.log.warn('CORS_ORIGIN is set but @fastify/cors is not installed. Run: npm install @fastify/cors'); |
| } |
| } |
|
|
| |
| app.addHook('onSend', async (_request, reply) => { |
| reply.header('X-Content-Type-Options', 'nosniff'); |
| reply.header('X-Frame-Options', 'DENY'); |
| }); |
|
|
| |
| const authHook = createAuthHook(config.proxyAuthToken); |
|
|
| |
| |
| |
| |
| async function validateContentType( |
| request: Parameters<typeof authHook>[0], |
| reply: Parameters<typeof authHook>[1], |
| ): Promise<void> { |
| const ct = request.headers['content-type']; |
| if (!ct || !ct.includes('application/json')) { |
| reply.code(415).send({ error: 'Unsupported Media Type. Expected application/json.' }); |
| } |
| } |
|
|
| |
|
|
| |
| app.get('/health', async (_request, reply) => { |
| reply.send({ status: 'ok' }); |
| }); |
| app.get('/', async (_request, reply) => { |
| reply.send({ status: 'ok' }); |
| }); |
|
|
| |
|
|
| if (config.anthropicApiKey) { |
| const anthropicProvider: ProviderConfig = { |
| name: 'anthropic', |
| baseUrl: config.anthropicBaseUrl, |
| apiKey: config.anthropicApiKey, |
| apiKeyHeader: 'x-api-key', |
| forwardedHeaders: ANTHROPIC_FORWARDED_HEADERS, |
| }; |
|
|
| for (const route of ANTHROPIC_ROUTES) { |
| app.post(route, { |
| onRequest: [authHook, validateContentType], |
| }, async (request, reply) => { |
| const upstreamUrl = `${anthropicProvider.baseUrl}${route}`; |
| await forwardRequest(request, reply, upstreamUrl, anthropicProvider, config, app.log); |
| }); |
| } |
|
|
| app.log.info('Anthropic API relay routes registered'); |
| } else { |
| app.log.info('ANTHROPIC_API_KEY not set – Anthropic relay disabled'); |
| } |
|
|
| |
|
|
| if (config.geminiApiKey) { |
| const geminiProvider: ProviderConfig = { |
| name: 'gemini', |
| baseUrl: config.geminiBaseUrl, |
| apiKey: config.geminiApiKey, |
| apiKeyHeader: 'x-goog-api-key', |
| forwardedHeaders: GEMINI_FORWARDED_HEADERS, |
| }; |
|
|
| |
| |
| |
| |
| |
| app.all('/v1beta/*', { |
| onRequest: [authHook], |
| }, async (request, reply) => { |
| const wildcard = (request.params as Record<string, string>)['*']; |
|
|
| if (!wildcard) { |
| reply.code(400).send({ error: 'Invalid Gemini API path' }); |
| return; |
| } |
|
|
| |
| const qsIndex = request.url.indexOf('?'); |
| const queryString = qsIndex !== -1 ? request.url.slice(qsIndex) : ''; |
|
|
| const upstreamUrl = `${geminiProvider.baseUrl}/v1beta/${wildcard}${queryString}`; |
| await forwardRequest(request, reply, upstreamUrl, geminiProvider, config, app.log); |
| }); |
|
|
| app.log.info('Gemini API relay routes registered'); |
| } else { |
| app.log.info('GEMINI_API_KEY not set – Gemini relay disabled'); |
| } |
|
|
| |
|
|
| |
| app.setNotFoundHandler((request, reply) => { |
| app.log.warn({ method: request.method, url: request.url }, 'Route not found'); |
| reply.code(404).send({ error: 'Not found' }); |
| }); |
|
|
| |
| |
| app.setErrorHandler((error: FastifyError, _request, reply) => { |
| const statusCode = error.statusCode ?? 500; |
|
|
| if (statusCode === 405) { |
| reply.code(405).send({ error: 'Method not allowed' }); |
| return; |
| } |
| if (statusCode === 429) { |
| |
| reply.code(429).send({ error: 'Too many requests. Please retry later.' }); |
| return; |
| } |
|
|
| app.log.error({ err: error }, 'Unhandled error'); |
| reply.code(statusCode).send({ error: 'Internal server error' }); |
| }); |
|
|
| |
|
|
| const start = async (): Promise<void> => { |
| try { |
| await app.listen({ port: config.port, host: config.host }); |
| app.log.info(`Proxy listening on ${config.host}:${config.port}`); |
| } catch (err) { |
| app.log.fatal({ err }, 'Failed to start server'); |
| process.exit(1); |
| } |
| }; |
|
|
| |
|
|
| const shutdown = async (signal: string): Promise<void> => { |
| app.log.info(`Received ${signal}, shutting down gracefully…`); |
| try { |
| await app.close(); |
| app.log.info('Server closed.'); |
| process.exit(0); |
| } catch (err) { |
| app.log.error({ err }, 'Error during shutdown'); |
| process.exit(1); |
| } |
| }; |
|
|
| process.on('SIGTERM', () => void shutdown('SIGTERM')); |
| process.on('SIGINT', () => void shutdown('SIGINT')); |
|
|
| |
| process.on('unhandledRejection', (reason) => { |
| app.log.error({ reason }, 'Unhandled promise rejection'); |
| }); |
|
|
| await start(); |
|
|