Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- feat(providers): add Freepik (Magnific Mystic) API-key image generation provider — async submit/poll flow with realism/fluid/zen/flexible/super_real/editorial_portraits models (#6654)
2 changes: 2 additions & 0 deletions open-sse/config/imageRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts";
import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts";
import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts";

interface ImageModelEntry {
id: string;
Expand Down Expand Up @@ -365,6 +366,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
],
supportedSizes: ["1024x1024", "1024x1792", "1792x1024"],
},
freepik: FREEPIK_IMAGE_PROVIDER,
sdwebui: {
id: "sdwebui",
baseUrl: "http://localhost:7860/sdapi/v1/txt2img",
Expand Down
26 changes: 26 additions & 0 deletions open-sse/config/providers/registry/freepik/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Freepik (Magnific Mystic) image provider registry entry.
* Extracted into its own module to keep open-sse/config/imageRegistry.ts
* under the file-size cap (god-file decomposition; semantic split).
*/
export const FREEPIK_IMAGE_PROVIDER = {
id: "freepik",
// Freepik rebranded its API docs to Magnific in April 2026; the Mystic
// endpoint itself still lives under api.freepik.com as of this writing
// (docs.freepik.com redirects to docs.magnific.com, but the API host
// has not moved). Re-verify against live docs if this ever 404s.
baseUrl: "https://api.freepik.com/v1/ai/mystic",
statusUrl: "https://api.freepik.com/v1/ai/mystic",
authType: "apikey",
authHeader: "x-freepik-api-key",
format: "freepik-image", // custom: async submit task_id, then poll GET /{task_id}
models: [
{ id: "realism", name: "Mystic Realism" },
{ id: "fluid", name: "Mystic Fluid (Imagen 3)" },
{ id: "zen", name: "Mystic Zen" },
{ id: "flexible", name: "Mystic Flexible" },
{ id: "super_real", name: "Mystic Super Real" },
{ id: "editorial_portraits", name: "Mystic Editorial Portraits" },
],
supportedSizes: ["1024x1024", "1024x1792", "1792x1024"],
};
11 changes: 11 additions & 0 deletions open-sse/handlers/imageGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { handleImagen3ImageGeneration } from "./imageGeneration/providers/imagen
import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideogram.ts";
import { handleHaiperImageGeneration } from "./imageGeneration/providers/haiper.ts";
import { handleLeonardoImageGeneration } from "./imageGeneration/providers/leonardo.ts";
import { handleFreepikImageGeneration } from "./imageGeneration/providers/freepik.ts";
import {
handleChatGptWebImageGeneration,
extractMarkdownImageUrls,
Expand Down Expand Up @@ -534,6 +535,16 @@ export async function handleImageGeneration({
log,
});
}
if (providerConfig.format === "freepik-image") {
return handleFreepikImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
});
}

if (providerConfig.format === "nvidia-nim") {
return handleNvidiaNimImageGeneration({
Expand Down
284 changes: 284 additions & 0 deletions open-sse/handlers/imageGeneration/providers/freepik.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
// Freepik (Magnific Mystic) image generation adapter.
// Async submit->poll flow modeled on leonardo.ts's generationId pattern:
// POST /v1/ai/mystic returns { data: { task_id, status } }, then
// GET /v1/ai/mystic/{task_id} is polled until status is COMPLETED/FAILED.
// Docs: https://docs.magnific.com/api-reference/mystic (Freepik rebranded to
// Magnific in April 2026; both `api.freepik.com` and the newer
// `api.magnific.com` domain/header pair are in circulation during the
// transition, so the base URL and auth header both come from providerConfig
// rather than being hardcoded here).

import { saveCallLog } from "@/lib/usageDb";
import { sleep } from "../../../utils/sleep.ts";
import { sanitizeErrorMessage } from "../../../utils/error.ts";
Comment on lines +11 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To ensure proper security controls (such as SSRF protection and DNS-rebinding guards) are applied when downloading the generated image, we should import fetchRemoteImage from @/shared/network/remoteImageFetch instead of using raw fetch.

Suggested change
import { saveCallLog } from "@/lib/usageDb";
import { sleep } from "../../../utils/sleep.ts";
import { sanitizeErrorMessage } from "../../../utils/error.ts";
import { saveCallLog } from "@/lib/usageDb";
import { sleep } from "../../../utils/sleep.ts";
import { sanitizeErrorMessage } from "../../../utils/error.ts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";


const DEFAULT_POLL_INTERVAL_MS = 4000;
const DEFAULT_POLL_TIMEOUT_MS = 180000;

function normalizePositiveNumber(value: unknown, fallback: number): number {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.floor(n);
}

interface FreepikProviderConfig {
baseUrl: string;
statusUrl?: string;
authHeader?: string;
}

interface FreepikCredentials {
apiKey?: string;
}

interface FreepikGenerationParams {
model: string;
provider: string;
providerConfig: FreepikProviderConfig;
body: Record<string, unknown>;
credentials: FreepikCredentials;
log?: { info: (tag: string, msg: string) => void; error: (tag: string, msg: string) => void };
}

interface FreepikImageResult {
success: boolean;
status?: number;
error?: string;
data?: { created: number; data: Array<{ b64_json: string }> };
}

function freepikAuthHeader(providerConfig: FreepikProviderConfig, token: string) {
const headerName = providerConfig.authHeader || "x-freepik-api-key";
return { [headerName]: token };
}

async function logAndFail(params: {
provider: string;
model: string;
startTime: number;
status: number;
error: string;
}): Promise<FreepikImageResult> {
const { provider, model, startTime, status, error } = params;
const sanitized = sanitizeErrorMessage(error);
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: sanitized.slice(0, 500),
}).catch(() => {});
return { success: false, status, error: sanitized };
}

async function submitMysticTask(params: {
providerConfig: FreepikProviderConfig;
token: string;
model: string;
prompt: string;
body: Record<string, unknown>;
}) {
const { providerConfig, token, model, prompt, body } = params;
return fetch(providerConfig.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...freepikAuthHeader(providerConfig, token),
},
body: JSON.stringify({
prompt,
model: model || "realism",
resolution: typeof body.resolution === "string" ? body.resolution : "1k",
aspect_ratio: typeof body.aspect_ratio === "string" ? body.aspect_ratio : "square_1_1",
}),
});
}

async function pollMysticTask(params: {
providerConfig: FreepikProviderConfig;
token: string;
taskId: string;
}): Promise<{ status: string; imageUrl?: string }> {
const { providerConfig, token, taskId } = params;
const statusBase = providerConfig.statusUrl || providerConfig.baseUrl;
const res = await fetch(`${statusBase}/${taskId}`, {
headers: { ...freepikAuthHeader(providerConfig, token) },
});
const json = await res.json();
const task = json?.data || json;
const status = typeof task?.status === "string" ? task.status : "IN_PROGRESS";
const generated = Array.isArray(task?.generated) ? task.generated : [];
return { status, imageUrl: typeof generated[0] === "string" ? generated[0] : undefined };
}
Comment on lines +99 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In pollMysticTask, if the polling request fails (e.g., due to rate limits, invalid API key, or server errors), res.ok will be false. Currently, the code doesn't check res.ok and attempts to parse the response as JSON. If the response is not valid JSON, it throws. However, if it is valid JSON but represents an error payload (without a status field), status will default to "IN_PROGRESS". This causes the loop in pollUntilDone to continue polling the failing endpoint until it times out. We should check res.ok and throw an error immediately to abort polling.

async function pollMysticTask(params: {
  providerConfig: FreepikProviderConfig;
  token: string;
  taskId: string;
}): Promise<{ status: string; imageUrl?: string }> {
  const { providerConfig, token, taskId } = params;
  const statusBase = providerConfig.statusUrl || providerConfig.baseUrl;
  const res = await fetch(statusBase + "/" + taskId, {
    headers: { ...freepikAuthHeader(providerConfig, token) },
  });
  if (!res.ok) {
    const errorText = await res.text();
    throw new Error("Polling failed with status " + res.status + ": " + errorText);
  }
  const json = await res.json();
  const task = json?.data || json;
  const status = typeof task?.status === "string" ? task.status : "IN_PROGRESS";
  const generated = Array.isArray(task?.generated) ? task.generated : [];
  return { status, imageUrl: typeof generated[0] === "string" ? generated[0] : undefined };
}


async function downloadGeneratedImage(imageUrl: string): Promise<
{ ok: true; b64: string } | { ok: false; status: number; error: string }
> {
const imgRes = await fetch(imageUrl);
if (!imgRes.ok) {
return { ok: false, status: imgRes.status, error: `Failed to download image: ${imgRes.status}` };
}
const buf = await imgRes.arrayBuffer();
return { ok: true, b64: Buffer.from(buf).toString("base64") };
}
Comment on lines +116 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Using raw fetch to download the generated image from an external URL returned by the provider bypasses the repository's SSRF and DNS-rebinding protection. Use fetchRemoteImage instead, which already has built-in DNS-rebinding guards (as stubbed in the unit tests).

Suggested change
async function downloadGeneratedImage(imageUrl: string): Promise<
{ ok: true; b64: string } | { ok: false; status: number; error: string }
> {
const imgRes = await fetch(imageUrl);
if (!imgRes.ok) {
return { ok: false, status: imgRes.status, error: `Failed to download image: ${imgRes.status}` };
}
const buf = await imgRes.arrayBuffer();
return { ok: true, b64: Buffer.from(buf).toString("base64") };
}
async function downloadGeneratedImage(imageUrl: string): Promise<
{ ok: true; b64: string } | { ok: false; status: number; error: string }
> {
try {
const remoteImage = await fetchRemoteImage(imageUrl);
return { ok: true, b64: remoteImage.buffer.toString("base64") };
} catch (err) {
const message = (err as Error)?.message || String(err);
return { ok: false, status: 502, error: "Failed to download image: " + message };
}
}


async function resolveCompletedResult(params: {
provider: string;
model: string;
startTime: number;
imageUrl?: string;
}): Promise<FreepikImageResult> {
const { provider, model, startTime, imageUrl } = params;
if (!imageUrl) {
return logAndFail({
provider,
model,
startTime,
status: 502,
error: "Freepik Mystic completed without a generated image URL",
});
}
const downloaded = await downloadGeneratedImage(imageUrl);
if (!downloaded.ok) {
return { success: false, status: downloaded.status, error: downloaded.error };
}
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 200,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
}).catch(() => {});
return {
success: true,
data: { created: Math.floor(Date.now() / 1000), data: [{ b64_json: downloaded.b64 }] },
};
}

async function pollUntilDone(params: {
providerConfig: FreepikProviderConfig;
token: string;
taskId: string;
provider: string;
model: string;
startTime: number;
pollIntervalMs: number;
pollTimeoutMs: number;
}): Promise<FreepikImageResult> {
const { providerConfig, token, taskId, provider, model, startTime, pollIntervalMs, pollTimeoutMs } =
params;
const deadline = Date.now() + pollTimeoutMs;

while (Date.now() < deadline) {
await sleep(pollIntervalMs);
const { status, imageUrl } = await pollMysticTask({ providerConfig, token, taskId });

if (status === "COMPLETED") {
return resolveCompletedResult({ provider, model, startTime, imageUrl });
}
if (status === "FAILED") {
return logAndFail({
provider,
model,
startTime,
status: 502,
error: "Freepik Mystic image generation failed",
});
}
}

return logAndFail({
provider,
model,
startTime,
status: 504,
error: "Freepik Mystic image generation timed out",
});
}

async function submitAndGetTaskId(params: {
providerConfig: FreepikProviderConfig;
token: string;
model: string;
prompt: string;
body: Record<string, unknown>;
provider: string;
startTime: number;
}): Promise<{ taskId: string } | { failed: FreepikImageResult }> {
const { providerConfig, token, model, prompt, body, provider, startTime } = params;
const res = await submitMysticTask({ providerConfig, token, model, prompt, body });
if (!res.ok) {
const errorText = await res.text();
return { failed: await logAndFail({ provider, model, startTime, status: res.status, error: errorText }) };
}

const submitJson = await res.json();
const taskId = submitJson?.data?.task_id || submitJson?.task_id;
if (!taskId) {
return {
failed: await logAndFail({
provider,
model,
startTime,
status: 502,
error: "Freepik Mystic did not return a task_id",
}),
};
}
return { taskId };
}

export async function handleFreepikImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}: FreepikGenerationParams): Promise<FreepikImageResult> {
const startTime = Date.now();
const token = credentials?.apiKey || "";
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, DEFAULT_POLL_INTERVAL_MS);
const pollTimeoutMs = normalizePositiveNumber(body.poll_timeout_ms, DEFAULT_POLL_TIMEOUT_MS);
if (log) {
log.info("IMAGE", `${provider}/${model} (freepik-mystic) | prompt: "${prompt.slice(0, 60)}..."`);
}

try {
const submitted = await submitAndGetTaskId({
providerConfig,
token,
model,
prompt,
body,
provider,
startTime,
});
if ("failed" in submitted) return submitted.failed;

return await pollUntilDone({
providerConfig,
token,
taskId: submitted.taskId,
provider,
model,
startTime,
pollIntervalMs,
pollTimeoutMs,
});
} catch (err) {
const message = (err as Error)?.message || String(err);
if (log) log.error("IMAGE", `${provider} freepik error: ${sanitizeErrorMessage(message)}`);
return logAndFail({
provider,
model,
startTime,
status: 502,
error: `Image provider error: ${message}`,
});
}
}
1 change: 1 addition & 0 deletions src/shared/constants/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const IMAGE_ONLY_PROVIDER_IDS = new Set([
"black-forest-labs",
"recraft",
"topaz",
"freepik",
]);

export const AGGREGATOR_PROVIDER_IDS = new Set([
Expand Down
12 changes: 12 additions & 0 deletions src/shared/constants/providers/apikey/specialty-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ export const APIKEY_PROVIDERS_SPECIALTY = {
website: "https://ideogram.ai",
authHint: "Get API key at ideogram.ai/docs/api",
},
freepik: {
id: "freepik",
alias: "fpk",
name: "Freepik (Mystic)",
icon: "image",
color: "#1B9E7F",
textIcon: "FP",
website: "https://freepik.com",
authHint: "Get API key at freepik.com/developers (Mystic image endpoint)",
hasFree: true,
freeNote: "One-time ~€5 API credit for new accounts; pay-per-use afterward.",
},
suno: {
id: "suno",
alias: "suno",
Expand Down
Loading
Loading