import { Buffer } from 'node:buffer'; import { createServer } from 'node:http'; import { pathToFileURL } from 'node:url'; import { AdapterError } from './adapter-error.mjs'; import { redactSensitiveDetails } from './redact-sensitive-details.mjs'; import { readSecretOption } from './secrets.mjs'; import { createUpstreamClient } from './upstream-client.mjs'; export function createApp(options = {}) { const client = options.client || createUpstreamClient(options); const apiKey = readAdapterApiKey(options); async function handle(request) { try { if (request.method === 'OPTIONS') { return emptyResponse(204); } const url = new URL(request.url); if (url.pathname === '/health') { return jsonResponse({ ok: true }); } if (url.pathname === '/v1/models' && request.method === 'GET') { return requireAuth(request, apiKey) || modelsResponse(); } if (url.pathname === '/v1/images/generations' && request.method === 'POST') { return requireAuth(request, apiKey) || (await generateResponse(request, client)); } if (url.pathname === '/v1/images/edits' && request.method === 'POST') { return requireAuth(request, apiKey) || (await editResponse(request, client)); } if (url.pathname === '/v1/images/variations' && request.method === 'POST') { return requireAuth(request, apiKey) || unsupportedImageEndpoint('Image variations are not supported by this bridge.'); } return errorResponse(404, 'not_found', 'Endpoint not found.'); } catch (error) { return toErrorResponse(error); } } return { handle }; } export function startServer(options = {}) { const port = readListenPort(options.port ?? process.env.PORT ?? 3099); const host = options.host ?? process.env.HOST ?? '127.0.0.1'; const apiKey = readAdapterApiKey(options); if (isPublicHost(host) && !apiKey) { throw new Error('ADAPTER_API_KEY is required when binding to a public interface.'); } const app = createApp({ ...options, apiKey }); const server = createServer(async (req, res) => { const response = await app.handle(toRequest(req)); await writeNodeResponse(res, response); }); server.listen(port, host, () => { if (!options.silent) { const address = server.address(); const actualPort = typeof address === 'object' && address ? address.port : port; console.log(`gpt-image-bridge listening on http://${host}:${actualPort}`); } }); return server; } function isPublicHost(host) { return ['0.0.0.0', '::', '::0'].includes(host); } function readListenPort(value) { const port = Number(value); if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new Error('PORT must be an integer from 0 to 65535.'); } return port; } function readAdapterApiKey(options) { return options.apiKey ?? readSecretOption('ADAPTER_API_KEY', process.env.ADAPTER_API_KEY_FILE) ?? ''; } async function generateResponse(request, client) { const body = await readRequestJson(request); const streamRequested = body?.stream === true; const result = await client.generate(streamRequested ? { ...body, stream: false } : body); assertGenerationResult(result); if (streamRequested) { return imageStreamResponse(result); } return jsonResponse(result); } async function editResponse(request, client) { if (typeof client.edit !== 'function') { return unsupportedImageEndpoint('Image edits are not supported by the configured upstream.'); } const form = await readRequestFormData(request); const streamRequested = form.get('stream') === 'true'; if (streamRequested) form.set('stream', 'false'); const result = await client.edit(form); assertGenerationResult(result); if (streamRequested) { return imageStreamResponse(result); } return jsonResponse(result); } function assertGenerationResult(result) { if (!Array.isArray(result?.data) || result.data.length === 0) { throw new AdapterError('Upstream image response did not include image data.', { status: 502, code: 'invalid_upstream_image_response' }); } for (const [index, item] of result.data.entries()) { if (typeof item?.b64_json !== 'string' || !item.b64_json) { throw new AdapterError(`Upstream image response image ${index} is missing b64_json.`, { status: 502, code: 'invalid_upstream_image_response' }); } } } function imageStreamResponse(result) { const chunks = []; for (const item of Array.isArray(result?.data) ? result.data : []) { chunks.push({ type: 'image_generation.completed', b64_json: item.b64_json, revised_prompt: item.revised_prompt, url: item.url }); } if (result?.usage) { chunks.push({ type: 'usage', usage: result.usage }); } chunks.push('[DONE]'); return new Response( chunks .map((chunk) => (chunk === '[DONE]' ? 'data: [DONE]\n\n' : `event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n`)) .join(''), { status: 200, headers: { 'access-control-allow-headers': 'authorization, content-type', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-origin': '*', 'cache-control': 'no-cache', 'content-type': 'text/event-stream' } } ); } function modelsResponse() { return jsonResponse({ object: 'list', data: [ { id: 'gpt-image-2', object: 'model', created: 0, owned_by: 'gpt-image-bridge' } ] }); } function unsupportedImageEndpoint(message) { return errorResponse(501, 'unsupported_image_endpoint', message); } function requireAuth(request, apiKey) { if (!apiKey) { return null; } const header = request.headers.get('authorization') || ''; if (header === `Bearer ${apiKey}`) { return null; } return errorResponse(401, 'invalid_adapter_api_key', 'Invalid adapter API key.'); } async function readRequestJson(request) { try { return await request.json(); } catch { throw new AdapterError('Request body must be valid JSON.', { status: 400, code: 'invalid_json' }); } } async function readRequestFormData(request) { try { return await request.formData(); } catch { throw new AdapterError('Request body must be multipart/form-data.', { status: 400, code: 'invalid_multipart_form' }); } } function toRequest(req) { const origin = `http://${req.headers.host || 'localhost'}`; return new Request(new URL(req.url || '/', origin), { method: req.method, headers: req.headers, body: ['GET', 'HEAD'].includes(req.method || '') ? undefined : req, duplex: 'half' }); } async function writeNodeResponse(res, response) { res.statusCode = response.status; response.headers.forEach((value, key) => res.setHeader(key, value)); if (!response.body) { res.end(); return; } const buffer = Buffer.from(await response.arrayBuffer()); res.end(buffer); } function toErrorResponse(error) { if (error instanceof AdapterError) { return errorResponse(error.status, error.code, error.message, error.details); } return errorResponse(500, 'internal_error', 'Internal adapter error.'); } function errorResponse(status, code, message, details) { const error = { code, message, param: null, type: errorTypeForStatus(status) }; if (details && process.env.ADAPTER_DEBUG_ERRORS === 'true') { error.details = redactSensitiveDetails(details); } return jsonResponse({ error }, { status }); } function errorTypeForStatus(status) { if (status === 401 || status === 403) { return 'authentication_error'; } if (status === 429) { return 'rate_limit_error'; } if (status >= 400 && status < 500) { return 'invalid_request_error'; } return 'api_error'; } function jsonResponse(body, init = {}) { return new Response(JSON.stringify(body), { status: init.status || 200, headers: { 'access-control-allow-headers': 'authorization, content-type', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-origin': '*', 'content-type': 'application/json' } }); } function emptyResponse(status) { return new Response(null, { status, headers: { 'access-control-allow-headers': 'authorization, content-type', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-origin': '*' } }); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { startServer(); }