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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- **feat(api-keys):** track devices/connections per API key — an in-memory, TTL-evicted device fingerprint tracker (SHA-256 of masked IP + truncated user-agent) wired non-blocking into the chat path and surfaced via `GET /api/keys/[id]/devices` with a dashboard device-count chip. (thanks @mugnimaestra)
- **feat(providers):** support Vercel AI Gateway embeddings and image generation. (thanks @newnol)
- **feat(cli-tools):** add Crush CLI tool to the dashboard with one-click configuration. (thanks @dopaemon)
- **feat(dashboard):** suggest HuggingFace Hub media models in the media provider view. (thanks @yicone)

### 🔧 Bug Fixes

Expand Down
21 changes: 21 additions & 0 deletions open-sse/config/imageRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,27 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
models: [{ id: "sensenova-u1-fast", name: "SenseNova U1 Fast" }],
supportedSizes: ["1024x1024"],
},

// HuggingFace Hub Inference API text-to-image task. Returns raw image bytes
// (not JSON), so it uses a dedicated "huggingface-image" format handled by
// handleHuggingFaceImageGeneration. Same base URL convention as the HF
// STT/TTS entries in audioRegistry.ts. Model list is deliberately small —
// the dashboard's "suggested models" chip row (GET
// /api/v1/providers/suggested-models) surfaces additional HF Hub models
// beyond this seed list.
huggingface: {
id: "huggingface",
baseUrl: "https://api-inference.huggingface.co/models",
authType: "apikey",
authHeader: "bearer",
format: "huggingface-image",
models: [
{ id: "black-forest-labs/FLUX.1-dev", name: "FLUX.1 Dev (HF)" },
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (HF)" },
{ id: "stabilityai/stable-diffusion-xl-base-1.0", name: "Stable Diffusion XL (HF)" },
],
supportedSizes: ["1024x1024"],
},
};

/**
Expand Down
12 changes: 12 additions & 0 deletions open-sse/handlers/imageGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts
// are still used by handleImageEdit below, so they are imported (not re-defined).
import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts";
import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts";
import { handleHuggingFaceImageGeneration } from "./imageGeneration/providers/huggingface.ts";
import { handleComfyUIImageGeneration } from "./imageGeneration/providers/comfyUI.ts";
import { handleImagen3ImageGeneration } from "./imageGeneration/providers/imagen3.ts";
import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideogram.ts";
Expand Down Expand Up @@ -379,6 +380,17 @@ export async function handleImageGeneration({
});
}

if (providerConfig.format === "huggingface-image") {
return handleHuggingFaceImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
});
}

if (providerConfig.format === "fal-ai") {
return handleFalAIImageGeneration({
model,
Expand Down
90 changes: 90 additions & 0 deletions open-sse/handlers/imageGeneration/providers/huggingface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// HuggingFace Hub image-generation provider.
//
// The HF Inference API text-to-image task returns the generated image as raw
// binary bytes (e.g. `image/jpeg`), not a JSON envelope — unlike most other
// image providers wired in this file. Mirrors the shape/error-handling
// conventions used by ./hyperbolic.ts and ./leonardo.ts.

import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../../../utils/error.ts";

export async function handleHuggingFaceImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}) {
const startTime = Date.now();
const token = credentials?.apiKey || credentials?.accessToken || "";
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");

if (log) {
log.info("IMAGE", `${provider}/${model} (huggingface) | prompt: "${prompt.slice(0, 60)}..."`);
}

try {
const response = await fetch(`${providerConfig.baseUrl}/${model}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ inputs: prompt }),
});

if (!response.ok) {
const errorText = await response.text();
if (log)
log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);

saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: response.status,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: errorText.slice(0, 500),
}).catch(() => {});

return { success: false, status: response.status, error: errorText };
}

const buf = await response.arrayBuffer();

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: Buffer.from(buf).toString("base64"), revised_prompt: prompt }],
},
};
} catch (err) {
if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`);
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 502,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return {
success: false,
status: 502,
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
63 changes: 63 additions & 0 deletions open-sse/services/hfModelSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* HuggingFace Hub "suggested models" helpers.
*
* Pure, unit-testable pieces used by
* `GET /api/v1/providers/suggested-models` — that route proxies the public
* HuggingFace Hub models search API (never exposing any HF token
* client-side) and uses these helpers to map a dashboard media "kind" to an
* HF `pipeline_tag`, then sort/limit the raw search results.
*/

/** Media kinds (mirrors `RegistryMediaKind` in mediaServiceKinds.ts) that currently
* have a mapped HF Hub `pipeline_tag`. Extend as more kinds get suggestions. */
export const SUGGESTED_MODEL_KIND_PIPELINE_TAGS: Readonly<Record<string, string>> = {
image: "text-to-image",
};

export type SuggestedModelKind = keyof typeof SUGGESTED_MODEL_KIND_PIPELINE_TAGS;

/**
* Resolve a dashboard media kind (e.g. "image") to the HuggingFace Hub
* `pipeline_tag` used to search https://huggingface.co/api/models.
* Returns null for kinds without a mapped pipeline tag.
*/
export function resolveHfPipelineTag(kind: string): string | null {
return SUGGESTED_MODEL_KIND_PIPELINE_TAGS[kind] ?? null;
}

/** Minimal shape consumed from the HF Hub `/api/models` search response. */
export interface HfModelSummary {
id: string;
likes?: number;
downloads?: number;
pipeline_tag?: string;
}

export type HfSuggestedModelSortBy = "downloads" | "likes";

/**
* Pure filter/sort over raw HF Hub model search results:
* - drops entries without a usable string `id`
* - sorts descending by the requested metric (missing/non-numeric treated as 0)
* - caps the result to `limit` entries
*
* No network access — safe to unit test directly with fixture arrays.
*/
export function sortHfSuggestedModels(
models: readonly HfModelSummary[],
sortBy: HfSuggestedModelSortBy = "downloads",
limit = 20
): HfModelSummary[] {
const valid = (models ?? []).filter(
(m): m is HfModelSummary => !!m && typeof m.id === "string" && m.id.trim().length > 0
);

const sorted = [...valid].sort((a, b) => {
const bVal = Number(b[sortBy]);
const aVal = Number(a[sortBy]);
return (Number.isFinite(bVal) ? bVal : 0) - (Number.isFinite(aVal) ? aVal : 0);
});

const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 20;
return sorted.slice(0, safeLimit);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,54 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
import { buildCurl } from "../../providers/utils/buildCurl";
import { PlaygroundCard } from "./PlaygroundCard";

interface SuggestedHfModel {
id: string;
likes?: number;
downloads?: number;
}

/**
* useHfSuggestedImageModels — fetch suggested HuggingFace Hub image models
* via GET /api/v1/providers/suggested-models?type=image. Only meaningful for
* the `huggingface` provider (the only image-kind entry backed by HF Hub);
* other providers simply never trigger the fetch.
*/
function useHfSuggestedImageModels(providerId: string): SuggestedHfModel[] {
// Keep the fetched models tagged with the providerId they were fetched
// for, and derive the return value below — this avoids ever calling
// setState synchronously from the effect body (react-hooks/set-state-in-effect)
// for the "not huggingface" early-return case; switching providers simply
// stops matching the tag instead of requiring an explicit reset call.
const [fetched, setFetched] = useState<{ providerId: string; models: SuggestedHfModel[] } | null>(
null
);

useEffect(() => {
if (providerId !== "huggingface") return;
let cancelled = false;
fetch("/api/v1/providers/suggested-models?type=image")
.then((res) => (res.ok ? (res.json() as Promise<{ data?: SuggestedHfModel[] }>) : null))
.then((data) => {
if (cancelled || !data) return;
setFetched({ providerId, models: Array.isArray(data.data) ? data.data : [] });
})
.catch(() => {
// Best-effort suggestions — the static model list still works.
});
return () => {
cancelled = true;
};
}, [providerId]);

return fetched && fetched.providerId === providerId ? fetched.models : [];
}

interface Props {
providerId: string;
}
Expand Down Expand Up @@ -54,8 +96,10 @@ function ImageResultRenderer(data: unknown) {

export function ImageExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const tMedia = useTranslations("media");
const { apiKey } = useApiKey();
const { models } = useProviderModels(providerId);
const suggestedModels = useHfSuggestedImageModels(providerId);

const firstModel = models[0]?.id ?? "dall-e-3";
const [model, setModel] = useState<string>("");
Expand Down Expand Up @@ -109,7 +153,10 @@ export function ImageExampleCard({ providerId }: Props) {
}
};

const modelOptions = models.length > 0 ? models : [{ id: "dall-e-3" }];
const staticModelOptions = models.length > 0 ? models : [{ id: "dall-e-3" }];
const knownModelIds = new Set(staticModelOptions.map((m) => m.id));
const suggestedOnly = suggestedModels.filter((m) => !knownModelIds.has(m.id));
const modelOptions = [...staticModelOptions, ...suggestedOnly.map((m) => ({ id: m.id }))];

return (
<PlaygroundCard
Expand Down Expand Up @@ -137,6 +184,31 @@ export function ImageExampleCard({ providerId }: Props) {
))}
</select>
</div>
{/* Suggested models from HuggingFace Hub (image kind only) */}
{suggestedOnly.length > 0 && (
<div>
<label className="block text-xs text-text-muted mb-1">
{tMedia("suggestedModels")}
</label>
<div className="flex flex-wrap gap-1.5">
{suggestedOnly.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setModel(m.id)}
aria-pressed={effectiveModel === m.id}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
effectiveModel === m.id
? "border-primary bg-primary/10 text-primary"
: "border-border bg-bg-subtle text-text-muted hover:text-text-main"
}`}
>
{m.id}
</button>
))}
</div>
</div>
)}
{/* Size */}
<div>
<label className="block text-xs text-text-muted mb-1">{t("size")}</label>
Expand Down
Loading
Loading