codexmobile-relay / server /image-generator-api.js
Codex
deploy: CodexMobile Relay
90f0300
Raw
History Blame Contribute Delete
8.86 kB
import fs from 'node:fs/promises';
import path from 'node:path';
import { hasSupportedImageMagic } from './http-utils.js';
import { DEFAULT_OPENAI_COMPATIBLE_BASE_URL, openAICompatibleConfig } from './provider-api.js';
import { CODEXMOBILE_GENERATED_ROOT } from './runtime-paths.js';
export const GENERATED_ROOT = CODEXMOBILE_GENERATED_ROOT;
export const DEFAULT_IMAGE_BASE_URL = DEFAULT_OPENAI_COMPATIBLE_BASE_URL;
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
export const IMAGE_TIMEOUT_MS = Number(process.env.CODEXMOBILE_IMAGE_TIMEOUT_MS || 420000);
export const IMAGE_MAX_ATTEMPTS = Math.max(1, Number(process.env.CODEXMOBILE_IMAGE_MAX_ATTEMPTS || 3));
export const IMAGE_FALLBACK_MAX_ATTEMPTS = Math.max(1, Number(process.env.CODEXMOBILE_IMAGE_FALLBACK_MAX_ATTEMPTS || 2));
export const IMAGE_RETRY_BASE_DELAY_MS = Number(process.env.CODEXMOBILE_IMAGE_RETRY_BASE_DELAY_MS || 1600);
export const GENERATE_PATTERNS = [
/(?:生成|画|绘制|制作|设计|出图|创建|做一张|来一张).*(?:图片|图像|图|照片|海报|宣传图|壁纸|头像|插画|封面|logo)/i,
/(?:图片|图像|图|照片|海报|宣传图|壁纸|头像|插画|封面|logo).*(?:生成|画|绘制|制作|设计|出图|创建|做)/i,
/\b(?:generate|create|draw|make)\b.*\b(?:image|photo|picture|poster|wallpaper|avatar|logo)\b/i
];
export const EDIT_PATTERNS = [
/(?:修改|编辑|改|换|去掉|移除|添加|变成|修图|美化|上色|扩图|换背景|抠图)/i,
/\b(?:edit|modify|change|remove|add|retouch|replace|background)\b/i
];
export function safeErrorMessage(error) {
const message = String(error?.message || error || '图片生成失败').trim();
return message.replace(/Bearer\s+[A-Za-z0-9._~-]+/g, 'Bearer [hidden]');
}
export function isTransientImageError(error) {
const message = String(error?.message || error || '').toLowerCase();
return (
message.includes('stream disconnected') ||
message.includes('before completion') ||
message.includes('requesttimeout') ||
message.includes('timeout') ||
message.includes('context canceled') ||
message.includes('internal_server_error') ||
message.includes('fetch failed') ||
message.includes('econnreset') ||
message.includes('socket hang up') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504')
);
}
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function imageMimeToExt(mimeType) {
const mime = String(mimeType || '').toLowerCase();
if (mime.includes('jpeg') || mime.includes('jpg')) {
return 'jpg';
}
if (mime.includes('webp')) {
return 'webp';
}
return 'png';
}
export function parseDataUrl(value) {
const match = String(value || '').match(/^data:([^;,]+);base64,(.+)$/s);
if (!match) {
return null;
}
return {
mimeType: match[1] || 'image/png',
b64: match[2]
};
}
export function detectImageIntent(message, attachments) {
const text = String(message || '').trim();
const imageAttachments = attachments.filter((attachment) => attachment.kind === 'image');
if (imageAttachments.length && EDIT_PATTERNS.some((pattern) => pattern.test(text))) {
return 'edit';
}
if (GENERATE_PATTERNS.some((pattern) => pattern.test(text))) {
return imageAttachments.length ? 'edit' : 'generate';
}
if (imageAttachments.length && /(生成|出图|重绘|做成|变成|generate|create|draw|make)/i.test(text)) {
return 'edit';
}
return null;
}
export function isImageRequest(message, attachments = []) {
return Boolean(detectImageIntent(message, attachments));
}
export async function imageApiConfig(config = {}) {
const providerConfig = await openAICompatibleConfig({
baseUrl: process.env.CODEXMOBILE_IMAGE_BASE_URL || config.baseUrl,
defaultBaseUrl: DEFAULT_IMAGE_BASE_URL,
apiKeys: [process.env.CODEXMOBILE_IMAGE_API_KEY]
});
return {
...providerConfig,
model: process.env.CODEXMOBILE_IMAGE_MODEL || DEFAULT_IMAGE_MODEL
};
}
export async function readImageAttachment(attachment) {
const data = await fs.readFile(attachment.path);
const mimeType = attachment.mimeType || 'image/png';
if (!hasSupportedImageMagic(data, mimeType)) {
throw new Error('invalid_image_attachment');
}
return {
name: attachment.name || path.basename(attachment.path),
mimeType,
data
};
}
export function canFallbackEditToGeneration({ intent, attachments, error }) {
return intent === 'edit' && attachments.some((attachment) => attachment.kind === 'image') && isTransientImageError(error);
}
export function buildFallbackGenerationPrompt(prompt, attachments, error) {
const imageNames = attachments
.filter((attachment) => attachment.kind === 'image')
.map((attachment) => attachment.name)
.filter(Boolean)
.join(', ');
const reason = safeErrorMessage(error);
return [
'Create a new image as a fallback because the image editing API disconnected.',
imageNames ? `The user attached reference image file(s): ${imageNames}. Use the user request as the main visual direction.` : '',
`User request: ${prompt}`,
`Previous edit error: ${reason}`,
'Do not mention the technical failure in the image. Produce a polished final image that best matches the request.'
].filter(Boolean).join('\n');
}
export async function requestImageGeneration({ prompt, attachments, config, forceGenerate = false }) {
const intent = forceGenerate ? 'generate' : detectImageIntent(prompt, attachments) || 'generate';
const imageConfig = await imageApiConfig(config);
const imageAttachments = forceGenerate ? [] : attachments.filter((attachment) => attachment.kind === 'image');
const imageFiles = [];
for (const attachment of imageAttachments) {
imageFiles.push(await readImageAttachment(attachment));
}
async function sendRequest(apiKey) {
const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
const timeoutSignal = AbortSignal.timeout(IMAGE_TIMEOUT_MS);
if (intent === 'edit' && imageFiles.length) {
const form = new FormData();
form.append('prompt', prompt);
form.append('model', imageConfig.model);
form.append('response_format', 'b64_json');
form.append('size', '1024x1024');
for (const image of imageFiles) {
form.append('image', new Blob([image.data], { type: image.mimeType }), image.name);
}
return fetch(`${imageConfig.baseUrl}/images/edits`, {
method: 'POST',
headers,
body: form,
signal: timeoutSignal
});
}
return fetch(`${imageConfig.baseUrl}/images/generations`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers
},
body: JSON.stringify({
model: imageConfig.model,
prompt,
n: 1,
size: '1024x1024',
response_format: 'b64_json'
}),
signal: timeoutSignal
});
}
const apiKeys = imageConfig.apiKeys.length ? imageConfig.apiKeys : [''];
let response = null;
let text = '';
let lastError = null;
for (let index = 0; index < apiKeys.length; index += 1) {
response = await sendRequest(apiKeys[index]);
text = await response.text();
if (response.ok) {
break;
}
let errorMessage = text;
try {
const parsed = text ? JSON.parse(text) : null;
errorMessage = parsed?.error?.message || parsed?.error || parsed?.message || text;
} catch {
// Keep the raw text.
}
lastError = new Error(errorMessage || `图片接口返回 ${response.status}`);
const invalidKey = /invalid api key|incorrect api key|unauthorized|401/i.test(errorMessage || '') || response.status === 401;
if (!invalidKey || index === apiKeys.length - 1) {
break;
}
console.warn(`[image] API key #${index + 1} failed, trying next key.`);
}
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
const message = data?.error?.message || data?.message || lastError?.message || text || `图片接口返回 ${response.status}`;
throw new Error(message);
}
const items = Array.isArray(data?.data) ? data.data : [];
const images = items
.map((item) => {
if (item.b64_json) {
return {
b64: item.b64_json,
mimeType: data?.output_format ? `image/${data.output_format}` : 'image/png',
revisedPrompt: item.revised_prompt || ''
};
}
const parsed = parseDataUrl(item.url);
return parsed ? { ...parsed, revisedPrompt: item.revised_prompt || '' } : null;
})
.filter(Boolean);
if (!images.length) {
throw new Error('图片接口没有返回图片内容');
}
return {
intent,
model: imageConfig.model,
images,
usage: data?.usage || null
};
}