mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
* 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:
committed by
GitHub
parent
9b5415a414
commit
76c3b3b8d3
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
26
open-sse/config/providers/registry/freepik/index.ts
Normal file
26
open-sse/config/providers/registry/freepik/index.ts
Normal 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"],
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
284
open-sse/handlers/imageGeneration/providers/freepik.ts
Normal file
284
open-sse/handlers/imageGeneration/providers/freepik.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
163
tests/unit/freepik-image-handler.test.ts
Normal file
163
tests/unit/freepik-image-handler.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user