feat: add Freepik (Magnific Mystic) image generation provider (#6654) (#7597)

* feat(providers): add Freepik (Magnific Mystic) image generation provider (#6654)

Adds an official, API-key-based Freepik image-gen provider using the Mystic
endpoint (POST /v1/ai/mystic -> async task_id -> GET /v1/ai/mystic/{id}
polling), modeled on the existing leonardo.ts generationId adapter pattern.

- open-sse/config/providers/registry/freepik/index.ts: new registry module
  (kept separate to avoid pushing the frozen imageRegistry.ts over the
  file-size cap) with the 6 real Mystic style models (realism, fluid, zen,
  flexible, super_real, editorial_portraits) — not the "Flux/Imagen3" list
  from the original feature request, which independent research showed was
  stale.
- open-sse/handlers/imageGeneration/providers/freepik.ts: submit+poll
  adapter; all error paths route through sanitizeErrorMessage() (Hard Rule
  #12), configurable poll interval/timeout via body.poll_interval_ms /
  poll_timeout_ms for fast, deterministic tests.
- Registered in providers.ts (IMAGE_ONLY_PROVIDER_IDS) and
  apikey/specialty-media.ts (catalog metadata), with the corrected free-tier
  note (one-time ~€5 credit, not a recurring "100/month" allotment).

Drops the "100 free credits/month" and "Flux/Imagen3 selectable models"
claims from the original issue - verification showed the free tier is a
one-time ~€5 API credit and Imagen 3 only underlies the `fluid` style, not a
separately selectable model. Domain: api.freepik.com is still live as of
this writing despite Freepik's April-2026 API-docs rebrand to Magnific
(docs.freepik.com -> docs.magnific.com); noted inline for future
re-verification.

Closes #6654

* test: align APIKEY_PROVIDERS count to 171 after freepik + release merge (#7597)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 11:49:50 -03:00
committed by GitHub
parent 9b5415a414
commit 76c3b3b8d3
9 changed files with 505 additions and 5 deletions

View File

@@ -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)

View File

@@ -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;
@@ -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",

View File

@@ -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"],
};

View File

@@ -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,
@@ -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({

View File

@@ -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";
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 };
}
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 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}`,
});
}
}

View File

@@ -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([

View File

@@ -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",

View File

@@ -0,0 +1,163 @@
import test from "node:test";
import assert from "node:assert/strict";
import dns from "node:dns";
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts";
import { APIKEY_PROVIDERS } from "../../src/shared/constants/providers.ts";
import { IMAGE_ONLY_PROVIDER_IDS } from "../../src/shared/constants/providers.ts";
// Stub DNS for fetchRemoteImage/direct-fetch DNS-rebinding guards, mirroring
// tests/unit/nanobanana-image-handler.test.ts.
const originalDnsLookup = dns.promises.lookup;
(dns.promises as { lookup: unknown }).lookup = (async (
_hostname: string,
options?: { all?: boolean }
) => {
const record = { address: "203.0.113.1", family: 4 };
return options && options.all ? [record] : record;
}) as typeof dns.promises.lookup;
process.on("exit", () => {
(dns.promises as { lookup: unknown }).lookup = originalDnsLookup;
});
test("freepik provider is registered (registry shape)", () => {
assert.ok(APIKEY_PROVIDERS.freepik, "freepik should be in APIKEY_PROVIDERS");
assert.equal(APIKEY_PROVIDERS.freepik.id, "freepik");
assert.ok(IMAGE_ONLY_PROVIDER_IDS.has("freepik"), "freepik should be in IMAGE_ONLY_PROVIDER_IDS");
const provider = IMAGE_PROVIDERS.freepik;
assert.ok(provider, "freepik should be in IMAGE_PROVIDERS");
assert.equal(provider.format, "freepik-image");
assert.equal(provider.authType, "apikey");
assert.equal(provider.authHeader, "x-freepik-api-key");
assert.ok(provider.models.some((m) => m.id === "realism"));
assert.ok(provider.models.some((m) => m.id === "fluid"));
});
test("handleImageGeneration(freepik): async submit+poll returns b64_json payload", async () => {
const originalFetch = globalThis.fetch;
let pollCount = 0;
globalThis.fetch = (async (url: string, options: { headers?: Record<string, string>; body?: string } = {}) => {
const u = String(url);
if (u === "https://api.freepik.com/v1/ai/mystic") {
assert.equal(options.headers?.["x-freepik-api-key"], "test-key");
const parsed = JSON.parse(options.body as string);
assert.equal(parsed.prompt, "a red panda astronaut");
assert.equal(parsed.model, "realism");
return new Response(
JSON.stringify({ data: { task_id: "task-freepik-1", status: "CREATED" } }),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
if (u === "https://api.freepik.com/v1/ai/mystic/task-freepik-1") {
pollCount += 1;
if (pollCount < 2) {
return new Response(
JSON.stringify({ data: { task_id: "task-freepik-1", status: "IN_PROGRESS" } }),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
return new Response(
JSON.stringify({
data: {
task_id: "task-freepik-1",
status: "COMPLETED",
generated: ["https://cdn.example.com/freepik-result.png"],
},
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
if (u === "https://cdn.example.com/freepik-result.png") {
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 });
}
throw new Error(`Unexpected URL: ${u}`);
}) as typeof fetch;
try {
const result = await handleImageGeneration({
body: {
model: "freepik/realism",
prompt: "a red panda astronaut",
poll_interval_ms: 1,
},
credentials: { apiKey: "test-key" },
log: null,
});
assert.equal(result.success, true);
assert.equal(result.data.data.length, 1);
assert.equal(result.data.data[0].b64_json, "iVBORw==");
assert.equal(pollCount, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleImageGeneration(freepik): FAILED status returns sanitized 502 error", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string) => {
const u = String(url);
if (u === "https://api.freepik.com/v1/ai/mystic") {
return new Response(JSON.stringify({ data: { task_id: "task-fail", status: "CREATED" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (u === "https://api.freepik.com/v1/ai/mystic/task-fail") {
return new Response(JSON.stringify({ data: { task_id: "task-fail", status: "FAILED" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`Unexpected URL: ${u}`);
}) as typeof fetch;
try {
const result = await handleImageGeneration({
body: { model: "freepik/realism", prompt: "broken prompt", poll_interval_ms: 1 },
credentials: { apiKey: "test-key" },
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.match(result.error, /Freepik Mystic image generation failed/);
// Hard Rule #12: error responses must never leak a raw stack trace / file path.
assert.ok(!result.error.includes("at /"));
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleImageGeneration(freepik): submit error response is sanitized, not raw upstream body", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
// Simulate an upstream error body containing something that looks like a
// stack trace / absolute source path, to prove sanitizeErrorMessage runs.
const stackyBody = "Error: boom\n at /srv/app/handlers/mystic.ts:42:10";
return new Response(stackyBody, { status: 500 });
}) as typeof fetch;
try {
const result = await handleImageGeneration({
body: { model: "freepik/realism", prompt: "x" },
credentials: { apiKey: "test-key" },
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 500);
assert.ok(!result.error.includes("/srv/app/handlers/mystic.ts"));
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -37,12 +37,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
test("APIKEY_PROVIDERS merges the 6 family files into 170 entries (no loss / no dup)", async () => {
test("APIKEY_PROVIDERS merges the 6 family files into 171 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
assert.equal(keys.length, 170);
assert.equal(new Set(keys).size, 170, "duplicate keys after spread-merge");
assert.equal(keys.length, 171);
assert.equal(new Set(keys).size, 171, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
// strict partition (every provider in exactly one), so the sum must be exactly 170.
// strict partition (every provider in exactly one), so the sum must be exactly 171.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -62,7 +62,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 170 entries (no loss / no
seen.add(k);
}
}
assert.equal(famTotal, 170, "families must partition all 170 providers");
assert.equal(famTotal, 171, "families must partition all 171 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {