diff --git a/open-sse/handlers/imageProviders/huggingface.js b/open-sse/handlers/imageProviders/huggingface.js index 2093d9c3..371b18f8 100644 --- a/open-sse/handlers/imageProviders/huggingface.js +++ b/open-sse/handlers/imageProviders/huggingface.js @@ -1,18 +1,93 @@ -// HuggingFace Inference API — returns binary image -import { nowSec } from "./_base.js"; +// HuggingFace Inference Providers router — returns binary image +// +// The router is a switchboard in front of many inference providers and is +// addressed as `//`. `providerModelId` is +// the id the *provider* uses, which is not the Hub model id, so it is resolved +// through `imageConfig.modelMap` (built from the Hub API's +// inferenceProviderMapping and limited to providers the router forwards to). +// +// The legacy `api-inference.huggingface.co` host is gone (DNS ENOTFOUND) and is +// deliberately not referenced anywhere here. +import { nowSec, urlToBase64 } from "./_base.js"; import { PROVIDER_MEDIA } from "../../providers/index.js"; -const BASE_URL = PROVIDER_MEDIA["huggingface"]?.imageConfig?.baseUrl; +const imageConfig = () => PROVIDER_MEDIA["huggingface"]?.imageConfig || {}; +const BASE_URL = imageConfig().baseUrl; +const MODEL_MAP = imageConfig().modelMap || {}; + +// A plain-object lookup returns inherited truthy values for keys like "toString" or +// "constructor", which would build nonsense URLs. Resolve own keys only. +const lookup = (model) => (Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : undefined); + +// modelMap values are either a bare path (text-to-image) or { path, task }. +const mappingPath = (entry) => (typeof entry === "string" ? entry : entry.path); +const mappingTask = (entry) => (typeof entry === "string" ? "text-to-image" : entry.task || "text-to-image"); + +// A connection may point at its own endpoint (self-hosted Text Generation +// Inference / TGI container). That endpoint already knows its own model ids, so +// the router mapping does not apply and the Hub id is passed through verbatim. +function customBaseUrl(creds) { + const url = creds?.providerSpecificData?.baseUrl; + return typeof url === "string" && url.trim() ? url.trim().replace(/\/+$/, "") : null; +} + +// The router's image-to-image payload wants raw base64 — not a data URL, not a URL. +// Accept every shape our own callers use (data URL, bare base64, remote URL, array). +async function sourceImage(body) { + const raw = body?.image || (Array.isArray(body?.images) ? body.images[0] : null); + if (typeof raw !== "string" || !raw.trim()) return null; + const value = raw.trim(); + if (/^https?:\/\//i.test(value)) return await urlToBase64(value); + const match = /^data:image\/[^;]+;base64,(.+)$/i.exec(value); + return match ? match[1] : value; +} export default { - buildUrl: (model) => `${BASE_URL}/${model}`, + buildUrl: (model, creds) => { + const override = customBaseUrl(creds); + if (override) { + // The model id is client-controlled; on a custom endpoint it lands in a URL + // path verbatim, so reject traversal/query injection (mirrors sttCore's guard). + if (model.includes("..") || model.includes("//") || /[?#]/.test(model)) { + throw new Error(`HuggingFace: invalid model ID "${model}"`); + } + return `${override}/${model}`; + } + + const entry = lookup(model); + if (!entry) { + throw new Error( + `HuggingFace: no HuggingFace router mapping for model "${model}". ` + + `Add it to imageConfig.modelMap in open-sse/providers/registry/huggingface.js, ` + + `or set a custom base URL on the connection.` + ); + } + return `${BASE_URL}/${mappingPath(entry)}`; + }, buildHeaders: (creds) => { const headers = { "Content-Type": "application/json" }; const key = creds?.apiKey || creds?.accessToken; if (key) headers["Authorization"] = `Bearer ${key}`; return headers; }, - buildBody: (_model, body) => ({ inputs: body.prompt }), + buildBody: async (model, body) => { + const entry = lookup(model); + const task = mappingTask(entry || ""); + + if (task === "image-to-image") { + const image = await sourceImage(body); + if (!image) { + throw new Error( + `HuggingFace: model "${model}" requires a source image. ` + + `Send it as "image" (or "images") in the request body.` + ); + } + // inputs carries the source image; the prompt moves under parameters. + return { inputs: image, parameters: { prompt: body.prompt } }; + } + + return { inputs: body.prompt }; + }, // HF returns raw image bytes — convert to b64_json async parseResponse(response) { const buf = await response.arrayBuffer(); diff --git a/open-sse/providers/registry/huggingface.js b/open-sse/providers/registry/huggingface.js index 768b0ded..7eafea1c 100644 --- a/open-sse/providers/registry/huggingface.js +++ b/open-sse/providers/registry/huggingface.js @@ -15,6 +15,7 @@ export default { website: "https://huggingface.co", notice: { apiKeyUrl: "https://huggingface.co/settings/tokens", + text: "Runs through the Inference Providers router. Image and speech models are billed by the provider selected per model.", }, }, category: "apikey", @@ -25,10 +26,79 @@ export default { transport: null, models: [ { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", params: [], kind: "image" }, + { id: "black-forest-labs/FLUX.1-dev", name: "FLUX.1 Dev", params: [], kind: "image" }, + { id: "black-forest-labs/FLUX.1-Krea-dev", name: "FLUX.1 Krea", params: [], kind: "image" }, + { id: "black-forest-labs/FLUX.1-Kontext-dev", name: "FLUX.1 Kontext", params: [], kind: "image", capabilities: ["edit"] }, + { id: "black-forest-labs/FLUX.2-dev", name: "FLUX.2 Dev", params: [], kind: "image", capabilities: ["edit"] }, + { id: "black-forest-labs/FLUX.2-klein-9B", name: "FLUX.2 Klein 9B", params: [], kind: "image", capabilities: ["edit"] }, + { id: "black-forest-labs/FLUX.2-klein-4B", name: "FLUX.2 Klein 4B", params: [], kind: "image", capabilities: ["edit"] }, + { id: "black-forest-labs/FLUX.2-klein-base-9B", name: "FLUX.2 Klein Base 9B", params: [], kind: "image", capabilities: ["edit"] }, + { id: "black-forest-labs/FLUX.2-klein-base-4B", name: "FLUX.2 Klein Base 4B", params: [], kind: "image", capabilities: ["edit"] }, { id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", params: [], kind: "image" }, - { id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", params: ["language"], kind: "stt" }, - { id: "openai/whisper-small", name: "Whisper Small (HF)", params: ["language"], kind: "stt" }, + { id: "stabilityai/stable-diffusion-3.5-large", name: "Stable Diffusion 3.5 Large", params: [], kind: "image" }, + { id: "stabilityai/stable-diffusion-3.5-large-turbo", name: "Stable Diffusion 3.5 Large Turbo", params: [], kind: "image" }, + { id: "Qwen/Qwen-Image", name: "Qwen Image", params: [], kind: "image" }, + { id: "Qwen/Qwen-Image-2512", name: "Qwen Image 2512", params: [], kind: "image" }, + { id: "Qwen/Qwen-Image-Edit", name: "Qwen Image Edit", params: [], kind: "image", capabilities: ["edit"] }, + { id: "Qwen/Qwen-Image-Edit-2509", name: "Qwen Image Edit 2509", params: [], kind: "image", capabilities: ["edit"] }, + { id: "Qwen/Qwen-Image-Edit-2511", name: "Qwen Image Edit 2511", params: [], kind: "image", capabilities: ["edit"] }, + { id: "ideogram-ai/ideogram-4-fp8", name: "Ideogram 4", params: [], kind: "image" }, + { id: "tencent/HunyuanImage-3.0", name: "HunyuanImage 3.0", params: [], kind: "image" }, + { id: "Tongyi-MAI/Z-Image-Turbo", name: "Z-Image Turbo", params: [], kind: "image" }, + { id: "krea/Krea-2-Turbo", name: "Krea 2 Turbo", params: [], kind: "image" }, + { id: "HiDream-ai/HiDream-I1-Fast", name: "HiDream I1 Fast", params: [], kind: "image" }, + { id: "playgroundai/playground-v2.5-1024px-aesthetic", name: "Playground v2.5", params: [], kind: "image" }, + { id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", params: [], kind: "stt" }, + { id: "openai/whisper-large-v3-turbo", name: "Whisper Large v3 Turbo (HF)", params: [], kind: "stt" }, ], serviceKinds: ["image", "stt"], - imageConfig: { baseUrl: "https://api-inference.huggingface.co/models" }, + // Inference Providers router. The router is addressed as + // `//` — see open-sse/handlers/imageProviders/huggingface.js. + // `modelMap` resolves a Hub model id to the provider-resolved id the router expects. + // A plain string value is the provider path. Image-to-image models use + // `{ path, task: "image-to-image" }`: their payload differs — the source image goes in + // `inputs` and the prompt under `parameters.prompt`. See + // https://huggingface.co/docs/inference-providers/tasks/image-to-image + // Only providers the router actually forwards to are listed: replicate, wavespeed and + // deepinfra appear in the Hub's inferenceProviderMapping but reject router traffic with + // "Model not supported by provider ". + imageConfig: { + baseUrl: "https://router.huggingface.co", + modelMap: { + "black-forest-labs/FLUX.1-schnell": "fal-ai/fal-ai/flux/schnell", + "black-forest-labs/FLUX.1-dev": "fal-ai/fal-ai/flux/dev", + "black-forest-labs/FLUX.1-Krea-dev": "fal-ai/fal-ai/flux/krea", + "black-forest-labs/FLUX.1-Kontext-dev": { path: "fal-ai/fal-ai/flux-kontext/dev", task: "image-to-image" }, + "black-forest-labs/FLUX.2-dev": { path: "fal-ai/fal-ai/flux-2/edit", task: "image-to-image" }, + "black-forest-labs/FLUX.2-klein-9B": { path: "fal-ai/fal-ai/flux-2/klein/9b/edit", task: "image-to-image" }, + "black-forest-labs/FLUX.2-klein-4B": { path: "fal-ai/fal-ai/flux-2/klein/4b/distilled/edit", task: "image-to-image" }, + "black-forest-labs/FLUX.2-klein-base-9B": { path: "fal-ai/fal-ai/flux-2/klein/9b/base/edit", task: "image-to-image" }, + "black-forest-labs/FLUX.2-klein-base-4B": { path: "fal-ai/fal-ai/flux-2/klein/4b/base/edit", task: "image-to-image" }, + "stabilityai/stable-diffusion-xl-base-1.0": "fal-ai/fal-ai/fast-sdxl", + "stabilityai/stable-diffusion-3.5-large": "fal-ai/fal-ai/stable-diffusion-v35-large", + "stabilityai/stable-diffusion-3.5-large-turbo": "fal-ai/fal-ai/stable-diffusion-v35-large/turbo", + "Qwen/Qwen-Image": "fal-ai/fal-ai/qwen-image", + "Qwen/Qwen-Image-2512": "fal-ai/fal-ai/qwen-image-2512", + "Qwen/Qwen-Image-Edit": { path: "fal-ai/fal-ai/qwen-image-edit", task: "image-to-image" }, + "Qwen/Qwen-Image-Edit-2509": { path: "fal-ai/fal-ai/qwen-image-edit-2509", task: "image-to-image" }, + "Qwen/Qwen-Image-Edit-2511": { path: "fal-ai/fal-ai/qwen-image-edit-plus", task: "image-to-image" }, + "ideogram-ai/ideogram-4-fp8": "fal-ai/ideogram/v4", + "tencent/HunyuanImage-3.0": "fal-ai/fal-ai/hunyuan-image/v3/text-to-image", + "Tongyi-MAI/Z-Image-Turbo": "fal-ai/fal-ai/z-image/turbo", + "krea/Krea-2-Turbo": "fal-ai/fal-ai/krea-2/turbo", + "HiDream-ai/HiDream-I1-Fast": "fal-ai/fal-ai/hidream-i1-fast", + "playgroundai/playground-v2.5-1024px-aesthetic": "fal-ai/fal-ai/playground-v25", + }, + }, + // Speech-to-text goes through the hf-inference provider, which keeps the Hub + // model id as its provider-resolved id (`/hf-inference/models/`). + // No `params` are declared: the router's ASR payload carries only `inputs` and + // `parameters.return_timestamps` / `parameters.generation_parameters` — it has no + // language field, so a UI-declared "language" would be silently dropped. + sttConfig: { + baseUrl: "https://router.huggingface.co/hf-inference/models", + authType: "apikey", + authHeader: "bearer", + format: "huggingface-asr", + }, }; diff --git a/open-sse/providers/schema.js b/open-sse/providers/schema.js index e8b5ddd1..eddf201b 100644 --- a/open-sse/providers/schema.js +++ b/open-sse/providers/schema.js @@ -36,6 +36,13 @@ import { DEFAULT_RETRY_CONFIG, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtim * MediaConfig: { serviceKinds:[...], ttsConfig, sttConfig, embeddingConfig, imageConfig, * searchViaChat:{defaultModel,pricingUrl}, hiddenKinds } — each *Config: {baseUrl,authType,authHeader, * format,defaultModel,models:[{id,name,dimensions?}]}. + * + * imageConfig.modelMap (optional): maps a client-facing model id to a provider-resolved id when + * those differ — e.g. the HuggingFace Inference Providers router, where a Hub id like + * `black-forest-labs/FLUX.1-schnell` is addressed as `fal-ai/fal-ai/flux/schnell`. A value is + * either the provider path, or `{path, task}` when the request shape differs per task + * (HuggingFace uses task:"image-to-image" to move the prompt under `parameters.prompt`). + * Ignored by providers whose model ids are sent verbatim. */ // Shared transport defaults — provider only overrides fields that differ. diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js index ff30dcb2..4d10ba1c 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js @@ -9,8 +9,14 @@ import { Row, KIND_EXAMPLE_CONFIG } from "./exampleShared"; const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png"; const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png"; +// HuggingFace router edit models need a source image; reuse the public dog sample so +// the card is runnable as-is. The router derives it from inputs, not from the Hub host. +const HUGGINGFACE_TEST_IMAGE_URL = CLOUDFLARE_TEST_IMAGE_URL; function getImageEditDefaults(providerId, modelId) { + if (providerId === "huggingface") { + return { image: HUGGINGFACE_TEST_IMAGE_URL }; + } if (providerId !== "cloudflare-ai") return {}; if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") { return { image: CLOUDFLARE_TEST_IMAGE_URL }; diff --git a/tests/unit/huggingface-image-end-to-end.test.js b/tests/unit/huggingface-image-end-to-end.test.js new file mode 100644 index 00000000..8ce37268 --- /dev/null +++ b/tests/unit/huggingface-image-end-to-end.test.js @@ -0,0 +1,144 @@ +/** + * HuggingFace image generation — end-to-end through the real core handler. + * + * The registry/adapter tests pin the URL and payload in isolation. These tests + * drive `handleImageGenerationCore` — the same function the `/v1/images/generations` + * route calls — so the whole seam is exercised: adapter selection, buildUrl / + * buildBody / buildHeaders, the fetch call, and the binary response parse. + * + * The mocked `fetch` asserts on the exact request the router would receive, which + * is the strongest check available without burning live Inference Providers credits + * (the router bills before validating the payload, so a live probe can only prove + * the path exists, never that the body is right). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { handleImageGenerationCore } from "../../open-sse/handlers/imageGenerationCore.js"; + +const originalFetch = global.fetch; +const CREDS = { apiKey: "hf_test_token" }; + +// A 1x1 transparent PNG — enough to prove the bytes survive the round trip. +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64" +); + +function mockBinaryResponse() { + return { + ok: true, + status: 200, + arrayBuffer: async () => PNG_1X1.buffer.slice(PNG_1X1.byteOffset, PNG_1X1.byteOffset + PNG_1X1.byteLength), + }; +} + +async function generate(body, model) { + return handleImageGenerationCore({ + body, + modelInfo: { provider: "huggingface", model }, + credentials: CREDS, + log: null, + }); +} + +describe("HuggingFace image generation — end to end", () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue(mockBinaryResponse()); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("posts a text-to-image request to the fal-ai router path", async () => { + const result = await generate({ prompt: "a lighthouse at dusk" }, "black-forest-labs/FLUX.1-schnell"); + + expect(result.success).toBe(true); + + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("https://router.huggingface.co/fal-ai/fal-ai/flux/schnell"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ inputs: "a lighthouse at dusk" }); + }); + + it("authenticates with the connection's API key", async () => { + await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + const [, init] = global.fetch.mock.calls[0]; + expect(init.headers.Authorization).toBe("Bearer hf_test_token"); + }); + + it("never touches the dead api-inference host", async () => { + await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + expect(global.fetch.mock.calls[0][0]).not.toContain("api-inference.huggingface.co"); + }); + + it("posts an image-to-image request with the source image in inputs", async () => { + const result = await generate( + { prompt: "make it snow", image: "data:image/png;base64,AAAB" }, + "Qwen/Qwen-Image-Edit" + ); + + expect(result.success).toBe(true); + + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("https://router.huggingface.co/fal-ai/fal-ai/qwen-image-edit"); + // The router takes raw base64 in inputs and the prompt under parameters — + // the data-URL prefix must be stripped, not forwarded. + expect(JSON.parse(init.body)).toEqual({ + inputs: "AAAB", + parameters: { prompt: "make it snow" }, + }); + }); + + it("rejects an image-to-image model that was given no source image", async () => { + const result = await generate({ prompt: "make it snow" }, "Qwen/Qwen-Image-Edit"); + + expect(result.success).toBe(false); + expect(result.status).toBe(400); + expect(result.error).toMatch(/requires a source image/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects a model with no router mapping before calling upstream", async () => { + const result = await generate({ prompt: "x" }, "some-org/unmapped-model"); + + expect(result.success).toBe(false); + expect(result.status).toBe(400); + expect(result.error).toMatch(/no HuggingFace router mapping/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns the generated image as base64 to the client", async () => { + const result = await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + const payload = await result.response.json(); + expect(payload.data[0].b64_json).toBe(PNG_1X1.toString("base64")); + }); + + it("routes a self-hosted connection to its own endpoint", async () => { + await handleImageGenerationCore({ + body: { prompt: "x" }, + modelInfo: { provider: "huggingface", model: "my-org/my-tgi-model" }, + credentials: { apiKey: "k", providerSpecificData: { baseUrl: "https://tgi.internal" } }, + log: null, + }); + + expect(global.fetch.mock.calls[0][0]).toBe("https://tgi.internal/my-org/my-tgi-model"); + }); + + it("surfaces an upstream error instead of a broken image", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: async () => JSON.stringify({ error: "You have depleted your monthly included credits." }), + json: async () => ({ error: "You have depleted your monthly included credits." }), + }); + + const result = await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + expect(result.success).toBe(false); + expect(result.status).toBe(402); + }); +}); diff --git a/tests/unit/huggingface-router-migration.test.js b/tests/unit/huggingface-router-migration.test.js new file mode 100644 index 00000000..cfa46e3b --- /dev/null +++ b/tests/unit/huggingface-router-migration.test.js @@ -0,0 +1,349 @@ +/** + * HuggingFace registry migration to router.huggingface.co + * + * The legacy base URL `https://api-inference.huggingface.co` no longer resolves + * (DNS ENOTFOUND), so every HuggingFace image/STT request failed at the fetch + * layer. The replacement is `https://router.huggingface.co`, which routes by + * `/` — the provider-resolved id is NOT the + * Hub model id and must be resolved from the Hub API's inferenceProviderMapping. + * + * Covers: + * - imageConfig base URL is the live router host, not the dead legacy host + * - image URL builder emits the provider-resolved id, not the Hub id + * - image URL builder throws a descriptive error for unmapped models + * - sttConfig exists and points at the live router host + * - every registered public model resolves through a provider the router serves + */ + +import { describe, it, expect } from "vitest"; +import huggingface from "../../open-sse/providers/registry/huggingface.js"; +import imageAdapter from "../../open-sse/handlers/imageProviders/huggingface.js"; + +const DEAD_HOST = "api-inference.huggingface.co"; +const LIVE_ROUTER = "router.huggingface.co"; + +// Providers the router actually forwards to. replicate/wavespeed/deepinfra appear +// in the Hub's inferenceProviderMapping but reject every router request with +// "Model not supported by provider ", so they must not be used here. +const ROUTABLE_PROVIDERS = new Set(["fal-ai", "hf-inference", "nscale", "together", "novita", "hyperbolic"]); + +const imageConfig = huggingface.imageConfig; +const modelMap = imageConfig.modelMap || {}; + +// modelMap values are either a bare path (text-to-image) or +// { path, task: "image-to-image" } for models that require a source image. +const mappingPath = (value) => (typeof value === "string" ? value : value.path); +const mappingTask = (value) => (typeof value === "string" ? "text-to-image" : value.task || "text-to-image"); + +describe("HuggingFace registry — legacy host removal", () => { + it("does not use the dead api-inference host for images", () => { + expect(imageConfig.baseUrl).not.toContain(DEAD_HOST); + }); + + it("points imageConfig at the live router host", () => { + expect(imageConfig.baseUrl).toContain(LIVE_ROUTER); + }); + + it("does not use the dead api-inference host for STT", () => { + expect(huggingface.sttConfig?.baseUrl).not.toContain(DEAD_HOST); + }); +}); + +describe("HuggingFace STT dispatch", () => { + it("declares an sttConfig so sttCore can dispatch", () => { + expect(huggingface.sttConfig).toBeDefined(); + }); + + it("uses the HuggingFace ASR wire format", () => { + expect(huggingface.sttConfig.format).toBe("huggingface-asr"); + }); + + it("authenticates with a bearer API key", () => { + expect(huggingface.sttConfig.authType).toBe("apikey"); + expect(huggingface.sttConfig.authHeader).toBe("bearer"); + }); + + it("advertises stt in serviceKinds", () => { + expect(huggingface.serviceKinds).toContain("stt"); + }); + + it("points sttConfig at the hf-inference model route", () => { + expect(huggingface.sttConfig.baseUrl).toBe("https://router.huggingface.co/hf-inference/models"); + }); +}); + +describe("HuggingFace image URL builder", () => { + it("routes FLUX.1-schnell through its fal-ai provider id", () => { + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell")).toBe( + "https://router.huggingface.co/fal-ai/fal-ai/flux/schnell" + ); + }); + + it("routes SDXL through its fal-ai provider id", () => { + expect(imageAdapter.buildUrl("stabilityai/stable-diffusion-xl-base-1.0")).toBe( + "https://router.huggingface.co/fal-ai/fal-ai/fast-sdxl" + ); + }); + + it("never leaks the dead host into a built URL", () => { + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell")).not.toContain(DEAD_HOST); + }); + + it("throws a descriptive error for a model with no provider mapping", () => { + expect(() => imageAdapter.buildUrl("some-org/not-mapped-model")).toThrow(/no HuggingFace router mapping/i); + }); + + it("lets a connection override the endpoint for a self-hosted model", () => { + const creds = { providerSpecificData: { baseUrl: "https://tgi.internal/" } }; + + expect(imageAdapter.buildUrl("my-org/my-tgi-model", creds)).toBe("https://tgi.internal/my-org/my-tgi-model"); + }); + + it("does not apply the router mapping when a custom endpoint is set", () => { + const creds = { providerSpecificData: { baseUrl: "https://tgi.internal" } }; + + // The custom endpoint knows its own model ids — the Hub id passes through verbatim. + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell", creds)).toBe( + "https://tgi.internal/black-forest-labs/FLUX.1-schnell" + ); + }); + + it("ignores a blank custom endpoint", () => { + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell", { providerSpecificData: { baseUrl: " " } })).toBe( + "https://router.huggingface.co/fal-ai/fal-ai/flux/schnell" + ); + }); + + it("rejects traversal or query injection in the model id on a custom endpoint", () => { + const creds = { providerSpecificData: { baseUrl: "https://tgi.internal" } }; + + for (const model of ["x/../../admin", "org//model", "model?x=1", "model#f"]) { + expect(() => imageAdapter.buildUrl(model, creds), model).toThrow(/invalid model ID/i); + } + }); +}); + +describe("HuggingFace registry model table", () => { + const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m])); + const imageModels = huggingface.models.filter((m) => m.kind === "image"); + const sttModels = huggingface.models.filter((m) => m.kind === "stt"); + + it("every image model is present in imageConfig.modelMap", () => { + for (const model of imageModels) { + expect(modelMap[model.id], `model ${model.id} is missing from imageConfig.modelMap`).toBeTruthy(); + } + }); + + it("every modelMap entry points at a routable provider", () => { + for (const [hubId, value] of Object.entries(modelMap)) { + const provider = String(mappingPath(value)).split("/")[0]; + expect(ROUTABLE_PROVIDERS.has(provider), `${hubId} -> unsupported provider ${provider}`).toBe(true); + } + }); + + it("every modelMap entry has a provider/model path shape", () => { + for (const [hubId, value] of Object.entries(modelMap)) { + expect(String(mappingPath(value)), `${hubId} has a malformed target`).toMatch(/^[a-z0-9-]+\/[A-Za-z0-9._/-]+$/); + } + }); + + it("does not advertise whisper-small, which has no live provider", () => { + expect(modelsById["openai/whisper-small"]).toBeUndefined(); + }); + + it("advertises whisper-large-v3-turbo as its STT replacement", () => { + expect(modelsById["openai/whisper-large-v3-turbo"]?.kind).toBe("stt"); + }); + + it("exposes the FLUX family image models", () => { + for (const id of [ + "black-forest-labs/FLUX.1-schnell", + "black-forest-labs/FLUX.1-dev", + "black-forest-labs/FLUX.1-Krea-dev", + "black-forest-labs/FLUX.1-Kontext-dev", + "black-forest-labs/FLUX.2-dev", + "black-forest-labs/FLUX.2-klein-9B", + "black-forest-labs/FLUX.2-klein-4B", + "black-forest-labs/FLUX.2-klein-base-9B", + "black-forest-labs/FLUX.2-klein-base-4B", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("exposes the Qwen-Image family", () => { + for (const id of [ + "Qwen/Qwen-Image", + "Qwen/Qwen-Image-2512", + "Qwen/Qwen-Image-Edit", + "Qwen/Qwen-Image-Edit-2509", + "Qwen/Qwen-Image-Edit-2511", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("exposes the Stable Diffusion family", () => { + for (const id of [ + "stabilityai/stable-diffusion-xl-base-1.0", + "stabilityai/stable-diffusion-3.5-large", + "stabilityai/stable-diffusion-3.5-large-turbo", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("exposes the HuggingFace ASR models", () => { + for (const id of ["openai/whisper-large-v3", "openai/whisper-large-v3-turbo"]) { + expect(modelsById[id]?.kind, `${id} should be registered as an stt model`).toBe("stt"); + } + }); + + it("exposes the remaining third-party image models", () => { + for (const id of [ + "tencent/HunyuanImage-3.0", + "Tongyi-MAI/Z-Image-Turbo", + "krea/Krea-2-Turbo", + "HiDream-ai/HiDream-I1-Fast", + "playgroundai/playground-v2.5-1024px-aesthetic", + "ideogram-ai/ideogram-4-fp8", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("keeps the model table free of duplicates", () => { + const ids = huggingface.models.map((m) => m.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("keeps STT models free of image-only router mappings", () => { + for (const model of sttModels) { + expect(modelMap[model.id], `STT model ${model.id} should not be in the image model map`).toBeUndefined(); + } + }); +}); + +// The router is a switchboard in front of many providers; every Hub model that is +// `pipeline_tag: image-to-image` needs a source image, and the request shape differs +// from text-to-image: `inputs` carries the base64 source image and the prompt moves +// under `parameters.prompt`. Verified against +// https://huggingface.co/docs/inference-providers/tasks/image-to-image +describe("HuggingFace image-to-image models", () => { + const IMAGE_TO_IMAGE = [ + "black-forest-labs/FLUX.2-dev", + "black-forest-labs/FLUX.1-Kontext-dev", + "black-forest-labs/FLUX.2-klein-9B", + "black-forest-labs/FLUX.2-klein-4B", + "black-forest-labs/FLUX.2-klein-base-9B", + "black-forest-labs/FLUX.2-klein-base-4B", + "Qwen/Qwen-Image-Edit", + "Qwen/Qwen-Image-Edit-2509", + "Qwen/Qwen-Image-Edit-2511", + ]; + + it("marks every image-to-image model as such in modelMap", () => { + for (const hubId of IMAGE_TO_IMAGE) { + expect(mappingTask(modelMap[hubId]), `${hubId} must be declared image-to-image`).toBe("image-to-image"); + } + }); + + it("declares text-to-image as the default for the remaining image models", () => { + for (const [hubId, value] of Object.entries(modelMap)) { + if (IMAGE_TO_IMAGE.includes(hubId)) continue; + expect(mappingTask(value), `${hubId} should default to text-to-image`).toBe("text-to-image"); + } + }); + + it("sends the source image as inputs and the prompt under parameters", async () => { + const body = await imageAdapter.buildBody("Qwen/Qwen-Image-Edit", { + prompt: "make it snow", + image: "data:image/png;base64,AAAA", + }); + + expect(body.inputs).toBe("AAAA"); + expect(body.parameters).toEqual({ prompt: "make it snow" }); + }); + + it("accepts a source image given as a bare base64 payload", async () => { + const body = await imageAdapter.buildBody("black-forest-labs/FLUX.2-dev", { + prompt: "winter", + image: "AAAA", + }); + + expect(body.inputs).toBe("AAAA"); + }); + + it("accepts a source image given as an array", async () => { + const body = await imageAdapter.buildBody("Qwen/Qwen-Image-Edit-2509", { + prompt: "winter", + images: ["data:image/png;base64,BBBB"], + }); + + expect(body.inputs).toBe("BBBB"); + }); + + it("throws a descriptive error when an image-to-image model gets no source image", async () => { + await expect( + imageAdapter.buildBody("black-forest-labs/FLUX.1-Kontext-dev", { prompt: "winter" }) + ).rejects.toThrow(/requires a source image/i); + }); + + it("keeps the text-to-image shape prompt-only", async () => { + const body = await imageAdapter.buildBody("black-forest-labs/FLUX.1-schnell", { + prompt: "a lighthouse", + image: "data:image/png;base64,AAAA", + }); + + expect(body).toEqual({ inputs: "a lighthouse" }); + }); + + it("still throws for a model with no router mapping", () => { + expect(() => imageAdapter.buildUrl("some-org/unknown")).toThrow(/no HuggingFace router mapping/i); + }); +}); + +// The dashboard's GenericExampleCard only renders the source-image field when the +// selected model declares capabilities: ["edit"] (GenericExampleCard.js:47), and it +// then sends the value as `image`. Without the flag the edit models are unusable +// from the UI even though the adapter supports them. +describe("HuggingFace edit models reach the dashboard", () => { + const IMAGE_TO_IMAGE = ["black-forest-labs/FLUX.2-dev", "Qwen/Qwen-Image-Edit"]; + + it("declares the edit capability on image-to-image models", () => { + const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m])); + + for (const hubId of IMAGE_TO_IMAGE) { + expect(modelsById[hubId]?.capabilities, `${hubId} must declare the edit capability`).toContain("edit"); + } + }); + + it("keeps the capability on text-to-image models that do not take a source image", () => { + const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m])); + + expect(modelsById["black-forest-labs/FLUX.1-schnell"]?.capabilities || []).not.toContain("edit"); + }); +}); + +describe("HuggingFace registry prototype safety", () => { + it("does not resolve inherited object keys as models", () => { + // A plain-object map returns a truthy inherited value for these, which would + // build a URL like `/function Object() { [native code] }`. + for (const key of ["toString", "constructor", "__proto__", "hasOwnProperty"]) { + expect(() => imageAdapter.buildUrl(key)).toThrow(/no HuggingFace router mapping/i); + } + }); +}); + +describe("HuggingFace STT model parameters", () => { + const sttModels = huggingface.models.filter((m) => m.kind === "stt"); + + it("does not advertise a language parameter the ASR route cannot carry", () => { + // transcribeHuggingFace posts raw audio bytes and never reads formData, and the + // router's ASR payload has no `language` field — so a UI-declared "language" + // param is silently dropped. Declaring it lies to the dashboard. + for (const model of sttModels) { + expect(model.params, `${model.id} advertises an unusable language param`).toEqual([]); + } + }); +});