Files
9router/open-sse/handlers/imageGenerationCore.js
decolua 45731ae639 feat: add OpenCode Go provider and support for custom models
- Introduced OpenCode Go provider with relevant configurations.
- Enhanced model management by allowing users to add and delete custom models.
- Updated UI components to support model selection for image types.
- Adjusted sidebar visibility to include image media kinds.
2026-04-22 14:16:21 +07:00

321 lines
9.0 KiB
JavaScript

import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { getExecutor } from "../executors/index.js";
// Image provider configurations
const IMAGE_PROVIDERS = {
openai: {
baseUrl: "https://api.openai.com/v1/images/generations",
format: "openai",
},
gemini: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
format: "gemini",
},
minimax: {
baseUrl: "https://api.minimaxi.com/v1/images/generations",
format: "openai",
},
openrouter: {
baseUrl: "https://openrouter.ai/api/v1/images/generations",
format: "openai",
},
nanobanana: {
baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate",
format: "nanobanana",
},
sdwebui: {
baseUrl: "http://localhost:7860/sdapi/v1/txt2img",
format: "sdwebui",
},
comfyui: {
baseUrl: "http://localhost:8188",
format: "comfyui",
},
huggingface: {
baseUrl: "https://api-inference.huggingface.co/models",
format: "huggingface",
},
};
/**
* Build image generation URL
*/
function buildImageUrl(provider, model, credentials) {
const config = IMAGE_PROVIDERS[provider];
if (!config) return null;
switch (provider) {
case "gemini": {
const apiKey = credentials?.apiKey || credentials?.accessToken;
const modelId = model.replace(/^models\//, "");
return `${config.baseUrl}/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`;
}
case "huggingface":
return `${config.baseUrl}/${model}`;
default:
return config.baseUrl;
}
}
/**
* Build request headers
*/
function buildImageHeaders(provider, credentials) {
const headers = { "Content-Type": "application/json" };
if (provider === "gemini") {
return headers;
}
if (provider === "openrouter") {
headers["Authorization"] = `Bearer ${credentials?.apiKey || credentials?.accessToken}`;
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
headers["X-Title"] = "Endpoint Proxy";
return headers;
}
if (provider === "huggingface") {
headers["Authorization"] = `Bearer ${credentials?.apiKey || credentials?.accessToken}`;
return headers;
}
if (credentials?.apiKey || credentials?.accessToken) {
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
}
return headers;
}
/**
* Build request body based on provider format
*/
function buildImageBody(provider, model, body) {
const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body;
switch (provider) {
case "gemini":
return {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
},
};
case "sdwebui": {
const [width, height] = size.split("x").map(Number);
return {
prompt,
width: width || 512,
height: height || 512,
steps: 20,
batch_size: n,
};
}
case "nanobanana": {
const sizeMap = {
"1024x1024": "1:1",
"1024x1792": "9:16",
"1792x1024": "16:9",
};
return {
prompt,
type: "TEXTTOIAMGE",
numImages: n,
image_size: sizeMap[size] || "1:1",
};
}
default:
// OpenAI-compatible format
const requestBody = { model, prompt, n, size };
if (quality) requestBody.quality = quality;
if (style) requestBody.style = style;
if (response_format) requestBody.response_format = response_format;
return requestBody;
}
}
/**
* Normalize response to OpenAI format
*/
function normalizeImageResponse(responseBody, provider, prompt) {
// Already in OpenAI format
if (responseBody.created && Array.isArray(responseBody.data)) {
return responseBody;
}
const timestamp = Math.floor(Date.now() / 1000);
switch (provider) {
case "gemini": {
const parts = responseBody.candidates?.[0]?.content?.parts || [];
const images = parts
.filter((p) => p.inlineData?.data)
.map((p) => ({ b64_json: p.inlineData.data }));
return {
created: timestamp,
data: images.length > 0 ? images : [{ b64_json: "", revised_prompt: prompt }],
};
}
case "sdwebui": {
const images = Array.isArray(responseBody.images)
? responseBody.images.map((img) => ({ b64_json: img }))
: [];
return { created: timestamp, data: images };
}
case "nanobanana": {
if (responseBody.image) {
return {
created: timestamp,
data: [{ b64_json: responseBody.image, revised_prompt: prompt }],
};
}
return { created: timestamp, data: [] };
}
case "huggingface": {
// HuggingFace returns binary image data
return responseBody;
}
default:
return responseBody;
}
}
/**
* Core image generation handler
* @param {object} options
* @param {object} options.body - Request body { model, prompt, n, size, ... }
* @param {object} options.modelInfo - { provider, model }
* @param {object} options.credentials - Provider credentials
* @param {object} [options.log] - Logger
* @param {function} [options.onCredentialsRefreshed] - Called when creds are refreshed
* @param {function} [options.onRequestSuccess] - Called on success
* @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>}
*/
export async function handleImageGenerationCore({
body,
modelInfo,
credentials,
log,
onCredentialsRefreshed,
onRequestSuccess,
}) {
const { provider, model } = modelInfo;
if (!body.prompt) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt");
}
const url = buildImageUrl(provider, model, credentials);
if (!url) {
return createErrorResult(
HTTP_STATUS.BAD_REQUEST,
`Provider '${provider}' does not support image generation`
);
}
const headers = buildImageHeaders(provider, credentials);
const requestBody = buildImageBody(provider, model, body);
log?.debug?.("IMAGE", `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..."`);
let providerResponse;
try {
providerResponse = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
});
} catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
log?.debug?.("IMAGE", `Fetch error: ${errMsg}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
}
// Handle 401/403 — try token refresh
const executor = getExecutor(provider);
if (
!executor?.noAuth &&
(providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
providerResponse.status === HTTP_STATUS.FORBIDDEN)
) {
const newCredentials = await refreshWithRetry(
() => executor.refreshCredentials(credentials, log),
3,
log
);
if (newCredentials?.accessToken || newCredentials?.apiKey) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for image generation`);
Object.assign(credentials, newCredentials);
if (onCredentialsRefreshed && newCredentials) {
await onCredentialsRefreshed(newCredentials);
}
try {
const retryHeaders = buildImageHeaders(provider, credentials);
const retryUrl = provider === "gemini" ? buildImageUrl(provider, model, credentials) : url;
providerResponse = await fetch(retryUrl, {
method: "POST",
headers: retryHeaders,
body: JSON.stringify(requestBody),
});
} catch (retryError) {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`);
}
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
}
}
if (!providerResponse.ok) {
const { statusCode, message } = await parseUpstreamError(providerResponse);
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
log?.debug?.("IMAGE", `Provider error: ${errMsg}`);
return createErrorResult(statusCode, errMsg);
}
let responseBody;
try {
// HuggingFace returns binary image data
if (provider === "huggingface") {
const buffer = await providerResponse.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
responseBody = {
created: Math.floor(Date.now() / 1000),
data: [{ b64_json: base64 }],
};
} else {
responseBody = await providerResponse.json();
}
} catch (parseError) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid response from ${provider}`);
}
if (onRequestSuccess) {
await onRequestSuccess();
}
const normalized = normalizeImageResponse(responseBody, provider, body.prompt);
log?.debug?.("IMAGE", `Success | images=${normalized.data?.length || 0}`);
return {
success: true,
response: new Response(JSON.stringify(normalized), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}