Implement API key authentication by introducing a new auth module. Update configuration and .env.example to support API key setup, and add authorization checks in the server endpoints.
201 lines
6.1 KiB
TypeScript
201 lines
6.1 KiB
TypeScript
/**
|
|
* @fileoverview This file sets up and runs the HTTP server that acts as a
|
|
* proxy between an OpenAI-compatible client and the Gemini API.
|
|
*/
|
|
import consola from 'consola';
|
|
import http from 'http';
|
|
import { listModels, sendChat, sendChatStream } from './chatwrapper';
|
|
import { mapRequest, mapResponse, mapStreamChunk } from './mapper.js';
|
|
import { RequestBody, GeminiResponse, GeminiStreamChunk, Part } from './types';
|
|
import { config } from './config';
|
|
import { isAuthorized } from './auth';
|
|
|
|
// ==================================================================
|
|
// Server Configuration
|
|
// ==================================================================
|
|
const PORT = config.PORT;
|
|
const VERBOSE = config.VERBOSE;
|
|
|
|
// ==================================================================
|
|
// Logger Setup
|
|
// ==================================================================
|
|
if (VERBOSE) {
|
|
consola.level = 5;
|
|
consola.info('Verbose logging enabled');
|
|
}
|
|
|
|
consola.info('Google CLI OpenAI proxy');
|
|
|
|
// ==================================================================
|
|
// HTTP Server Helpers
|
|
// ==================================================================
|
|
|
|
/**
|
|
* Sets CORS headers to allow cross-origin requests.
|
|
* @param res - The HTTP server response object.
|
|
*/
|
|
function allowCors(res: http.ServerResponse) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
|
}
|
|
|
|
/**
|
|
* Reads and parses a JSON request body.
|
|
* @param req - The HTTP incoming message object.
|
|
* @param res - The HTTP server response object.
|
|
* @returns A promise that resolves to the parsed request body
|
|
* or null if invalid.
|
|
*/
|
|
function readJSON(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): Promise<RequestBody | null> {
|
|
return new Promise((resolve) => {
|
|
let data = '';
|
|
req.on('data', (c) => (data += c));
|
|
req.on('end', () => {
|
|
if (!data) {
|
|
if (req.method === 'POST') {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
error: { message: 'Request body is missing for POST request' },
|
|
}),
|
|
);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
resolve(null);
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(data) as RequestBody);
|
|
} catch {
|
|
// Handle malformed JSON.
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { message: 'Malformed JSON' } }));
|
|
resolve(null);
|
|
return;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ==================================================================
|
|
// Main Server Logic
|
|
// ==================================================================
|
|
http
|
|
.createServer(async (req, res) => {
|
|
allowCors(res);
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
const pathname = url.pathname.replace(/\/$/, '') || '/';
|
|
consola.info(`${req.method} ${url.pathname}`);
|
|
|
|
// Handle pre-flight CORS requests.
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204).end();
|
|
return;
|
|
}
|
|
|
|
if (!isAuthorized(req, res)) {
|
|
return;
|
|
}
|
|
|
|
// Route for listing available models.
|
|
if (pathname === '/v1/models' || pathname === '/models') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
data: listModels(),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Route for chat completions.
|
|
if (
|
|
(pathname === '/chat/completions' ||
|
|
pathname === '/v1/chat/completions') &&
|
|
req.method === 'POST'
|
|
) {
|
|
const body = await readJSON(req, res);
|
|
if (!body) return;
|
|
|
|
try {
|
|
const { geminiReq, tools } = await mapRequest(body);
|
|
|
|
if (body.stream) {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
});
|
|
|
|
for await (const chunk of sendChatStream({ ...geminiReq, tools })) {
|
|
// Transform the chunk to match the expected stream format.
|
|
const transformedParts =
|
|
chunk.candidates?.[0]?.content?.parts?.map((part) => {
|
|
const transformedPart: Part = {
|
|
text: part.text,
|
|
thought: part.text?.startsWith?.('<think>') ?? false,
|
|
};
|
|
|
|
if (part.inlineData?.data) {
|
|
transformedPart.inlineData = {
|
|
mimeType: part.inlineData.mimeType ?? 'text/plain',
|
|
data: part.inlineData.data,
|
|
};
|
|
}
|
|
|
|
return transformedPart;
|
|
}) ?? [];
|
|
|
|
const streamChunk: GeminiStreamChunk = {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: transformedParts,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
res.write(
|
|
`data: ${JSON.stringify(mapStreamChunk(streamChunk))}\n\n`,
|
|
);
|
|
}
|
|
res.end('data: [DONE]\n\n');
|
|
} else {
|
|
const gResp: GeminiResponse = await sendChat({ ...geminiReq, tools });
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(mapResponse(gResp, body)));
|
|
}
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
consola.error('Proxy error ➜', error);
|
|
|
|
// Handle errors, sending them in the appropriate format for streaming
|
|
// or non-streaming responses.
|
|
if (body.stream && res.headersSent) {
|
|
res.write(
|
|
`data: ${JSON.stringify({
|
|
error: {
|
|
message: error.message,
|
|
type: 'error',
|
|
},
|
|
})}\n\n`,
|
|
);
|
|
res.end('data: [DONE]\n\n');
|
|
return;
|
|
} else {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { message: error.message } }));
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.listen(PORT, () => {
|
|
consola.info(`Listening on port :${PORT}`);
|
|
});
|