misonL's picture
Deploy latest GPT Image Bridge
5428397 verified
Raw
History Blame Contribute Delete
8.59 kB
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();
}