Skip to content
Open
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
2 changes: 0 additions & 2 deletions open-sse/providers/registry/huggingface.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export default {
transport: null,
models: [
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", params: [], kind: "image" },
{ 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" },
],
serviceKinds: ["image", "stt"],
imageConfig: { baseUrl: "https://api-inference.huggingface.co/models" },
Expand Down
45 changes: 44 additions & 1 deletion src/app/(dashboard)/dashboard/providers/components/ModelsCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types";
import { Card, Button, Modal } from "@/shared/components";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { getProviderAlias } from "@/shared/constants/providers";
import { getProviderAlias, getProviderModelsFetcher } from "@/shared/constants/providers";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";

// ── ModelRow ───────────────────────────────────────────────────
export function ModelRow({ model, fullModel, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting }) {
Expand Down Expand Up @@ -116,6 +117,7 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
const [testingModelId, setTestingModelId] = useState(null);
const [testError, setTestError] = useState("");
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const [suggestedModels, setSuggestedModels] = useState([]);

const providerAlias = providerAliasOverride || getProviderAlias(providerId);
const effectiveType = kindFilter || "llm";
Expand All @@ -135,6 +137,21 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri

useEffect(() => { fetchData(); }, [fetchData]);

useEffect(() => {
const fetcher = getProviderModelsFetcher(providerId, effectiveType);
if (!fetcher) {
setSuggestedModels([]);
return;
}
let cancelled = false;
fetchSuggestedModels(fetcher).then((models) => {
if (!cancelled) setSuggestedModels(models);
});
return () => {
cancelled = true;
};
}, [providerId, effectiveType]);
Comment thread
yicone marked this conversation as resolved.

const handleSetAlias = async (modelId, alias) => {
const fullModel = `${providerAlias}/${modelId}`;
try {
Expand Down Expand Up @@ -214,6 +231,11 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
);

const displayModels = builtInModels;
const customIds = new Set(myCustomModels.map((m) => m.id));
const builtInIds = new Set(builtInModels.map((m) => m.id));
const suggestedNotAdded = suggestedModels.filter(
(m) => !builtInIds.has(m.id) && !customIds.has(m.id)
);

return (
<>
Expand Down Expand Up @@ -268,6 +290,27 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
<span className="material-symbols-outlined text-sm">add</span>
Add Model
</button>

{suggestedNotAdded.length > 0 && (
<div className="w-full mt-1">
<p className="mt-2 mb-2 text-xs text-text-muted">Suggested models from provider:</p>
<div className="flex flex-wrap gap-2">
{suggestedNotAdded.map((model) => (
<button
key={model.id}
onClick={async () => {
await handleAddCustomModel(model.id);
}}
className="flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1.5 text-xs text-text-muted transition-colors hover:border-primary/40 hover:bg-primary/5 hover:text-primary dark:border-white/10"
title={model.name || model.id}
>
<span className="material-symbols-outlined text-[13px]">add</span>
<span className="max-w-[18rem] truncate">{model.id}</span>
</button>
))}
</div>
</div>
)}
</div>
</Card>

Expand Down
10 changes: 10 additions & 0 deletions src/app/api/providers/suggested-models/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,14 @@ export const FILTERS = {
(Array.isArray(models) ? models : [])
.filter((m) => m.id?.startsWith("mimo") || m.name?.toLowerCase().includes("mimo"))
.map((m) => ({ id: m.id, name: m.name || m.id })),

"huggingface-hub": (models) =>
[...(Array.isArray(models) ? models : [])]
.filter((m) => typeof m.id === "string" && m.id.trim())
.sort(
(a, b) =>
(Number(b.downloads) || 0) - (Number(a.downloads) || 0) ||
(Number(b.likes) || 0) - (Number(a.likes) || 0)
)
.map((m) => ({ id: m.id, name: m.name || m.id })),
};
23 changes: 22 additions & 1 deletion src/shared/constants/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const MEDIA_ENTRY_KEYS = [
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
"imageConfig", "imageToTextConfig", "videoConfig", "musicConfig",
"searchViaChat", "searchConfig", "fetchConfig",
"modelsFetcher", "mediaPriority", "hiddenKinds",
"modelsFetcher", "modelsFetchers", "mediaPriority", "hiddenKinds",
];

// Build provider UI object from registry entry
Expand Down Expand Up @@ -65,6 +65,20 @@ export const THINKING_CONFIG = {
export const OAUTH_PROVIDERS = byCategory("oauth");
export const APIKEY_PROVIDERS = byCategory("apikey");

APIKEY_PROVIDERS.huggingface = {
...APIKEY_PROVIDERS.huggingface,
modelsFetchers: {
image: {
url: "https://huggingface.co/api/models?inference_provider=hf-inference&pipeline_tag=text-to-image&limit=30",
type: "huggingface-hub",
},
stt: {
url: "https://huggingface.co/api/models?inference_provider=hf-inference&pipeline_tag=automatic-speech-recognition&limit=30",
type: "huggingface-hub",
},
},
};

// Web Cookie Providers (use browser session cookie instead of API key)
export const WEB_COOKIE_PROVIDERS = byCategory("webCookie");

Expand Down Expand Up @@ -129,6 +143,13 @@ export function getProviderAlias(providerId) {
return provider?.alias || providerId;
}

export function getProviderModelsFetcher(providerId, kind) {
const provider = AI_PROVIDERS[providerId];
if (!provider) return null;
if (kind && provider.modelsFetchers?.[kind]) return provider.modelsFetchers[kind];
return provider.modelsFetcher || null;
}

// Alias to ID mapping (for quick lookup)
export const ALIAS_TO_ID = Object.values(AI_PROVIDERS).reduce((acc, p) => {
acc[p.alias] = p.id;
Expand Down
7 changes: 4 additions & 3 deletions src/shared/utils/providerModelsFetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Fetches via backend proxy to avoid CORS issues

const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const cache = new Map(); // key: fetcher.url → { data, expiresAt }
const cache = new Map(); // key: `${fetcher.type}:${fetcher.url}` → { data, expiresAt }

/**
* Fetch suggested models for a provider using its modelsFetcher config.
Expand All @@ -13,7 +13,8 @@ const cache = new Map(); // key: fetcher.url → { data, expiresAt }
export async function fetchSuggestedModels(fetcher) {
if (!fetcher?.url || !fetcher?.type) return [];

const cached = cache.get(fetcher.url);
const cacheKey = `${fetcher.type}:${fetcher.url}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) return cached.data;

try {
Expand All @@ -22,7 +23,7 @@ export async function fetchSuggestedModels(fetcher) {
if (!res.ok) return [];
const json = await res.json();
const data = json.data ?? [];
cache.set(fetcher.url, { data, expiresAt: Date.now() + CACHE_TTL_MS });
cache.set(cacheKey, { data, expiresAt: Date.now() + CACHE_TTL_MS });
return data;
} catch {
return [];
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/huggingface-suggested-models.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { FILTERS } from "@/app/api/providers/suggested-models/filters.js";
import { getProviderModelsFetcher } from "@/shared/constants/providers";

describe("HuggingFace suggested models", () => {
it("exposes kind-specific fetchers for media providers", () => {
const imageFetcher = getProviderModelsFetcher("huggingface", "image");
const sttFetcher = getProviderModelsFetcher("huggingface", "stt");
const imageUrl = new URL(imageFetcher.url);
const sttUrl = new URL(sttFetcher.url);

expect(imageFetcher.type).toBe("huggingface-hub");
expect(imageUrl.origin + imageUrl.pathname).toBe("https://huggingface.co/api/models");
expect(imageUrl.searchParams.get("inference_provider")).toBe("hf-inference");
expect(imageUrl.searchParams.get("pipeline_tag")).toBe("text-to-image");
expect(imageUrl.searchParams.get("limit")).toBe("30");

expect(sttFetcher.type).toBe("huggingface-hub");
expect(sttUrl.origin + sttUrl.pathname).toBe("https://huggingface.co/api/models");
expect(sttUrl.searchParams.get("inference_provider")).toBe("hf-inference");
expect(sttUrl.searchParams.get("pipeline_tag")).toBe("automatic-speech-recognition");
expect(sttUrl.searchParams.get("limit")).toBe("30");
});

it("sorts HuggingFace Hub suggestions by popularity", () => {
const models = FILTERS["huggingface-hub"]([
{ id: "org/model-c", downloads: 10, likes: 5 },
{ id: "org/model-a", downloads: 100, likes: 1 },
{ id: "org/model-b", downloads: 100, likes: 10 },
]);

expect(models.map((model) => model.id)).toEqual([
"org/model-b",
"org/model-a",
"org/model-c",
]);
});
});
2 changes: 1 addition & 1 deletion tests/unit/model-test-routing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ describe("model test route kind routing", () => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "hf/openai/whisper-small",
model: "hf/openai/whisper-large-v3",
kind: "stt",
}),
});
Expand Down