fix(huggingface): complete the Inference Providers router migration
Replace the retired api-inference.huggingface.co host with the Inference Providers router (router.huggingface.co): imageConfig.modelMap resolves Hub ids to provider-resolved ids, image-to-image models receive the source image in inputs with the prompt under parameters.prompt, and a new sttConfig wires the hf-inference ASR route. The image catalog grows to 23 models, dead whisper-small is replaced by whisper-large-v3-turbo, the unusable "language" param is dropped, and edit models declare the edit capability so the dashboard offers a source image. Adds unit and end-to-end coverage plus a model-id guard on custom endpoints.
This commit is contained in:
@@ -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 `<baseUrl>/<provider>/<providerModelId>`. `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();
|
||||
|
||||
@@ -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
|
||||
// `<baseUrl>/<provider>/<providerModelId>` — 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 <name>".
|
||||
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/<hubId>`).
|
||||
// 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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user