Enhance image and embedding provider support

- Added new image models for GPT 5.2, 5.3, and 5.4, including capabilities for text-to-image and editing.
- Updated embedding handling to include optional dimensions in requests.
- Introduced support for custom embedding providers, allowing dynamic fetching and validation of custom nodes.
- Improved image generation handling with Codex integration, including progress tracking and error handling.
- Enhanced UI components to support adding custom embeddings and displaying their status.
This commit is contained in:
decolua
2026-04-25 16:22:30 +07:00
parent cca615eaff
commit 0b8bed5793
19 changed files with 1039 additions and 130 deletions
+9 -47
View File
@@ -10,7 +10,6 @@ import { getModelInfo } from "../services/model.js";
import { handleImageGenerationCore } from "open-sse/handlers/imageGenerationCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
// Providers that don't require credentials (noAuth)
@@ -25,66 +24,35 @@ export async function handleImageGeneration(request) {
try {
body = await request.json();
} catch {
log.warn("IMAGE", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const url = new URL(request.url);
const preferredConnectionId = request.headers.get("x-connection-id") || null;
const wantsStream = (request.headers.get("accept") || "").includes("text/event-stream");
const modelStr = body.model;
log.request("POST", `${url.pathname} | ${modelStr}`);
const apiKey = extractApiKey(request);
if (apiKey) {
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
} else {
log.debug("AUTH", "No API key provided (local mode)");
}
const settings = await getSettings();
if (settings.requireApiKey) {
if (!apiKey) {
log.warn("AUTH", "Missing API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) {
log.warn("AUTH", "Invalid API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
if (!modelStr) {
log.warn("IMAGE", "Missing model");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (!body.prompt) {
log.warn("IMAGE", "Missing prompt");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt");
}
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
if (!body.prompt) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt");
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) {
log.warn("IMAGE", "Invalid model format", { model: modelStr });
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
}
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
const { provider, model } = modelInfo;
if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr}${provider}/${model}`);
} else {
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
}
// noAuth providers — no credential needed
if (NO_AUTH_PROVIDERS.has(provider)) {
const result = await handleImageGenerationCore({
body,
modelInfo: { provider, model },
credentials: null,
log,
});
if (result.success) return result.response;
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "Image generation failed");
@@ -96,32 +64,27 @@ export async function handleImageGeneration(request) {
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId });
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const errorMsg = lastError || credentials.lastError || "Unavailable";
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("IMAGE", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
if (excludeConnectionIds.size === 0) {
log.error("AUTH", `No credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
log.warn("IMAGE", "No more accounts available", { provider });
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
const result = await handleImageGenerationCore({
body,
modelInfo: { provider, model },
credentials: refreshedCredentials,
log,
streamToClient: wantsStream,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
@@ -140,7 +103,6 @@ export async function handleImageGeneration(request) {
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
if (shouldFallback) {
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
+12 -2
View File
@@ -15,11 +15,12 @@ let selectionMutex = Promise.resolve();
* @param {Set<string>|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account)
* @param {string|null} model - Model name for per-model rate limit filtering
*/
export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null) {
export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null, options = {}) {
// Normalize to Set for consistent handling
const excludeSet = excludeConnectionIds instanceof Set
? excludeConnectionIds
: (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
const preferredConnectionId = options?.preferredConnectionId || null;
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
let resolveMutex;
@@ -87,7 +88,16 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
let connection;
if (strategy === "round-robin") {
// Pin to preferred connection if specified and available
if (preferredConnectionId) {
connection = availableConnections.find((c) => c.id === preferredConnectionId);
if (connection) {
log.info("AUTH", `${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`);
}
}
if (connection) {
// skip strategy
} else if (strategy === "round-robin") {
const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
// Sort by lastUsed (most recent first) to find current candidate
+7
View File
@@ -33,6 +33,13 @@ export async function getModelInfo(modelStr) {
if (matchedAnthropic) {
return { provider: matchedAnthropic.id, model: parsed.model };
}
// Check Custom Embedding nodes
const embeddingNodes = await getProviderNodes({ type: "custom-embedding" });
const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias);
if (matchedEmbedding) {
return { provider: matchedEmbedding.id, model: parsed.model };
}
}
return {
provider: parsed.provider,