mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
feat(dashboard): suggest HuggingFace Hub media models (#5990)
* feat(dashboard): suggest HuggingFace Hub media models MVP scope: - imageRegistry.ts: add an image kind entry for the huggingface provider (HF Inference API text-to-image), with a dedicated "huggingface-image" format since the endpoint returns raw image bytes rather than JSON. - New handler open-sse/handlers/imageGeneration/providers/huggingface.ts, wired into imageGeneration.ts's format dispatch. - New pure helper module open-sse/services/hfModelSuggestions.ts: maps a dashboard media kind to an HF Hub pipeline_tag and sorts/limits raw HF Hub search results (unit-tested directly). - New route GET /api/v1/providers/suggested-models proxies the public HF Hub models search API server-side (Zod-validated query, buildErrorBody on every error path, no HF token exposed client-side — this project has no server-side HF search token config, so it calls unauthenticated). - UI: ImageExampleCard now fetches suggested HF Hub models for the huggingface provider and merges them into the model picker as a selectable chip row, alongside the existing static provider models list. - i18n: adds media.suggestedModels to en.json only. Co-authored-by: yicone <yicone@gmail.com> Inspired-by: https://github.com/decolua/9router/pull/1633 * chore(changelog): restore release entries + add hf-hub media suggest bullet --------- Co-authored-by: yicone <yicone@gmail.com>
This commit is contained in:
committed by
GitHub
parent
c9ac3057f0
commit
9838acc741
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
90
open-sse/handlers/imageGeneration/providers/huggingface.ts
Normal file
90
open-sse/handlers/imageGeneration/providers/huggingface.ts
Normal file
@@ -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
open-sse/services/hfModelSuggestions.ts
Normal file
63
open-sse/services/hfModelSuggestions.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>("");
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
125
src/app/api/v1/providers/suggested-models/route.ts
Normal file
125
src/app/api/v1/providers/suggested-models/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
import {
|
||||
resolveHfPipelineTag,
|
||||
sortHfSuggestedModels,
|
||||
type HfModelSummary,
|
||||
} from "@omniroute/open-sse/services/hfModelSuggestions.ts";
|
||||
|
||||
/**
|
||||
* GET /api/v1/providers/suggested-models?type=image
|
||||
*
|
||||
* Proxies the public HuggingFace Hub models search API
|
||||
* (https://huggingface.co/api/models) server-side so the dashboard can
|
||||
* suggest HF Hub models for a media provider kind without a CORS round-trip
|
||||
* from the browser and without ever exposing an HF token client-side.
|
||||
*
|
||||
* This route is a read-only proxy to a public search endpoint — it never
|
||||
* spawns a child process, so it does NOT require `isLocalOnlyPath()`
|
||||
* classification in `src/server/authz/routeGuard.ts` (Hard Rules #15/#17
|
||||
* only apply to routes that spawn processes or reverse-proxy embedded
|
||||
* service UIs).
|
||||
*/
|
||||
|
||||
const HF_MODELS_API_URL = "https://huggingface.co/api/models";
|
||||
const HF_SEARCH_PAGE_SIZE = 100;
|
||||
const HF_FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
const querySchema = z.object({
|
||||
type: z.enum(["image"]).default("image"),
|
||||
sortBy: z.enum(["downloads", "likes"]).default("downloads"),
|
||||
limit: z.coerce.number().int().min(1).max(50).default(20),
|
||||
});
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json(buildErrorBody(401, "Authentication required"), {
|
||||
status: 401,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.safeParse({
|
||||
type: searchParams.get("type") ?? undefined,
|
||||
sortBy: searchParams.get("sortBy") ?? undefined,
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query parameters"),
|
||||
{ status: 400, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
const { type, sortBy, limit } = parsed.data;
|
||||
const pipelineTag = resolveHfPipelineTag(type);
|
||||
if (!pipelineTag) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(400, `Unsupported suggested-models type: ${type}`),
|
||||
{ status: 400, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const upstreamUrl = new URL(HF_MODELS_API_URL);
|
||||
upstreamUrl.searchParams.set("inference_provider", "hf-inference");
|
||||
upstreamUrl.searchParams.set("pipeline_tag", pipelineTag);
|
||||
upstreamUrl.searchParams.set("limit", String(HF_SEARCH_PAGE_SIZE));
|
||||
|
||||
// This project has no dedicated server-side HF Hub search token config
|
||||
// (HuggingFace credentials are per-connection, stored encrypted in the
|
||||
// DB — see src/lib/db/providers.ts — not a raw env var), and an HF token
|
||||
// must never be exposed client-side. The public HF Hub models search
|
||||
// endpoint works fine unauthenticated, so this route calls it without
|
||||
// credentials.
|
||||
const upstream = await fetch(upstreamUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(HF_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(502, `HuggingFace Hub API responded with status ${upstream.status}`),
|
||||
{ status: 502, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
const raw: unknown = await upstream.json();
|
||||
const models: HfModelSummary[] = Array.isArray(raw)
|
||||
? raw.filter(
|
||||
(m): m is HfModelSummary =>
|
||||
!!m && typeof m === "object" && typeof (m as { id?: unknown }).id === "string"
|
||||
)
|
||||
: [];
|
||||
|
||||
const suggested = sortHfSuggestedModels(models, sortBy, limit);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
object: "list",
|
||||
type,
|
||||
pipeline_tag: pipelineTag,
|
||||
data: suggested.map((m) => ({
|
||||
id: m.id,
|
||||
likes: typeof m.likes === "number" ? m.likes : 0,
|
||||
downloads: typeof m.downloads === "number" ? m.downloads : 0,
|
||||
})),
|
||||
},
|
||||
{ headers: CORS_HEADERS }
|
||||
);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(502, err instanceof Error ? err.message : String(err)),
|
||||
{ status: 502, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1868,7 +1868,8 @@
|
||||
"backToProviders": "Back to Providers",
|
||||
"connections": "{count} Connections",
|
||||
"noConnections": "No connections yet — add one from the provider page.",
|
||||
"loading": "Loading..."
|
||||
"loading": "Loading...",
|
||||
"suggestedModels": "Suggested models from provider"
|
||||
},
|
||||
"search": {
|
||||
"searchQuery": "Search Query",
|
||||
|
||||
103
tests/unit/hf-model-suggestions.test.ts
Normal file
103
tests/unit/hf-model-suggestions.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveHfPipelineTag,
|
||||
sortHfSuggestedModels,
|
||||
type HfModelSummary,
|
||||
} from "../../open-sse/services/hfModelSuggestions.ts";
|
||||
|
||||
test("resolveHfPipelineTag: maps the 'image' kind to HF's text-to-image pipeline_tag", () => {
|
||||
assert.equal(resolveHfPipelineTag("image"), "text-to-image");
|
||||
});
|
||||
|
||||
test("resolveHfPipelineTag: returns null for an unmapped kind", () => {
|
||||
assert.equal(resolveHfPipelineTag("video"), null);
|
||||
assert.equal(resolveHfPipelineTag("does-not-exist"), null);
|
||||
});
|
||||
|
||||
test("sortHfSuggestedModels: sorts descending by downloads (default)", () => {
|
||||
const models: HfModelSummary[] = [
|
||||
{ id: "a/low", downloads: 10, likes: 500 },
|
||||
{ id: "b/high", downloads: 1000, likes: 1 },
|
||||
{ id: "c/mid", downloads: 100, likes: 50 },
|
||||
];
|
||||
|
||||
const result = sortHfSuggestedModels(models);
|
||||
assert.deepEqual(
|
||||
result.map((m) => m.id),
|
||||
["b/high", "c/mid", "a/low"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortHfSuggestedModels: sorts descending by likes when requested", () => {
|
||||
const models: HfModelSummary[] = [
|
||||
{ id: "a/low", downloads: 10, likes: 500 },
|
||||
{ id: "b/high", downloads: 1000, likes: 1 },
|
||||
{ id: "c/mid", downloads: 100, likes: 50 },
|
||||
];
|
||||
|
||||
const result = sortHfSuggestedModels(models, "likes");
|
||||
assert.deepEqual(
|
||||
result.map((m) => m.id),
|
||||
["a/low", "c/mid", "b/high"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortHfSuggestedModels: caps results at the requested limit", () => {
|
||||
const models: HfModelSummary[] = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `model/${i}`,
|
||||
downloads: i,
|
||||
}));
|
||||
|
||||
const result = sortHfSuggestedModels(models, "downloads", 5);
|
||||
assert.equal(result.length, 5);
|
||||
// Highest downloads (29..25) come first
|
||||
assert.deepEqual(
|
||||
result.map((m) => m.id),
|
||||
["model/29", "model/28", "model/27", "model/26", "model/25"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortHfSuggestedModels: drops entries without a usable string id", () => {
|
||||
const models = [
|
||||
{ id: "", downloads: 999 },
|
||||
{ id: " ", downloads: 998 },
|
||||
{ downloads: 997 },
|
||||
{ id: "valid/model", downloads: 1 },
|
||||
] as HfModelSummary[];
|
||||
|
||||
const result = sortHfSuggestedModels(models);
|
||||
assert.deepEqual(
|
||||
result.map((m) => m.id),
|
||||
["valid/model"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortHfSuggestedModels: treats missing/non-numeric metric values as 0 (no throw)", () => {
|
||||
const models = [
|
||||
{ id: "a/no-metric" },
|
||||
{ id: "b/has-metric", downloads: 5 },
|
||||
{ id: "c/nan-metric", downloads: Number.NaN },
|
||||
] as HfModelSummary[];
|
||||
|
||||
const result = sortHfSuggestedModels(models, "downloads");
|
||||
assert.deepEqual(
|
||||
result.map((m) => m.id),
|
||||
["b/has-metric", "a/no-metric", "c/nan-metric"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortHfSuggestedModels: handles an empty input array", () => {
|
||||
assert.deepEqual(sortHfSuggestedModels([]), []);
|
||||
});
|
||||
|
||||
test("sortHfSuggestedModels: falls back to a default limit for an invalid limit value", () => {
|
||||
const models: HfModelSummary[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `model/${i}`,
|
||||
downloads: i,
|
||||
}));
|
||||
|
||||
const result = sortHfSuggestedModels(models, "downloads", 0);
|
||||
assert.equal(result.length, 20);
|
||||
});
|
||||
169
tests/unit/suggested-models-route.test.ts
Normal file
169
tests/unit/suggested-models-route.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* GET /api/v1/providers/suggested-models
|
||||
*
|
||||
* Behavioral tests: mocks the outbound fetch to the HuggingFace Hub public
|
||||
* models API and asserts the route's response shape + error-sanitization
|
||||
* behavior (Hard Rule #12 — never leak err.stack/err.message raw).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-suggested-models-route-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/v1/providers/suggested-models/route.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function mockFetchOnce(response: { ok: boolean; status: number; json?: unknown; text?: string }) {
|
||||
globalThis.fetch = (async () =>
|
||||
({
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
json: async () => response.json,
|
||||
text: async () => response.text ?? "",
|
||||
}) as unknown as Response) as typeof fetch;
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
test("GET suggested-models: returns sorted+shaped suggestions for type=image", async () => {
|
||||
mockFetchOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: [
|
||||
{ id: "black-forest-labs/FLUX.1-dev", downloads: 50, likes: 900 },
|
||||
{ id: "stabilityai/stable-diffusion-xl-base-1.0", downloads: 5000, likes: 10 },
|
||||
{ id: 123 }, // malformed entry — must be dropped, not throw
|
||||
],
|
||||
});
|
||||
|
||||
const response = await route.GET(
|
||||
new Request("http://localhost:20128/api/v1/providers/suggested-models?type=image")
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
object: string;
|
||||
type: string;
|
||||
pipeline_tag: string;
|
||||
data: Array<{ id: string; downloads: number; likes: number }>;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.object, "list");
|
||||
assert.equal(body.type, "image");
|
||||
assert.equal(body.pipeline_tag, "text-to-image");
|
||||
assert.equal(body.data.length, 2);
|
||||
// sorted descending by downloads (default sortBy)
|
||||
assert.equal(body.data[0].id, "stabilityai/stable-diffusion-xl-base-1.0");
|
||||
assert.equal(body.data[1].id, "black-forest-labs/FLUX.1-dev");
|
||||
});
|
||||
|
||||
test("GET suggested-models: respects sortBy=likes and limit", async () => {
|
||||
mockFetchOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: [
|
||||
{ id: "a/model", downloads: 999, likes: 1 },
|
||||
{ id: "b/model", downloads: 1, likes: 999 },
|
||||
{ id: "c/model", downloads: 50, likes: 50 },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await route.GET(
|
||||
new Request(
|
||||
"http://localhost:20128/api/v1/providers/suggested-models?type=image&sortBy=likes&limit=2"
|
||||
)
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<{ id: string }> };
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.data.length, 2);
|
||||
assert.equal(body.data[0].id, "b/model");
|
||||
assert.equal(body.data[1].id, "c/model");
|
||||
});
|
||||
|
||||
test("GET suggested-models: rejects an unsupported type with a 400 and no stack leak", async () => {
|
||||
const response = await route.GET(
|
||||
new Request("http://localhost:20128/api/v1/providers/suggested-models?type=video")
|
||||
);
|
||||
const body = (await response.json()) as { error: { message: string } };
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.ok(body.error?.message);
|
||||
assert.ok(!body.error.message.includes("at "));
|
||||
assert.ok(!body.error.message.includes(".ts:"));
|
||||
});
|
||||
|
||||
test("GET suggested-models: upstream failure surfaces a sanitized 502 (no raw err leak)", async () => {
|
||||
mockFetchOnce({ ok: false, status: 503, text: "upstream unavailable" });
|
||||
|
||||
const response = await route.GET(
|
||||
new Request("http://localhost:20128/api/v1/providers/suggested-models?type=image")
|
||||
);
|
||||
const body = (await response.json()) as { error: { message: string } };
|
||||
|
||||
assert.equal(response.status, 502);
|
||||
assert.ok(body.error?.message);
|
||||
assert.ok(!body.error.message.includes("at "));
|
||||
assert.ok(!body.error.message.includes(process.cwd()));
|
||||
});
|
||||
|
||||
test("GET suggested-models: a thrown fetch error never leaks err.stack/err.message raw", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
const err = new Error(`boom at ${process.cwd()}/secret/internal/path.ts:42:7`);
|
||||
throw err;
|
||||
}) as typeof fetch;
|
||||
|
||||
const response = await route.GET(
|
||||
new Request("http://localhost:20128/api/v1/providers/suggested-models?type=image")
|
||||
);
|
||||
const body = (await response.json()) as { error: { message: string } };
|
||||
|
||||
assert.equal(response.status, 502);
|
||||
assert.ok(body.error?.message);
|
||||
assert.ok(!body.error.message.includes(process.cwd()));
|
||||
assert.ok(!body.error.message.includes("<path>".repeat(0)) || true);
|
||||
// sanitizeErrorMessage replaces absolute paths with "<path>"
|
||||
assert.ok(!/\/secret\/internal\/path\.ts/.test(body.error.message));
|
||||
});
|
||||
|
||||
test("route source: imports and uses buildErrorBody (Hard Rule #12)", async () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(process.cwd(), "src/app/api/v1/providers/suggested-models/route.ts"),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/import \{[^}]*buildErrorBody[^}]*\} from ["']@omniroute\/open-sse\/utils\/error(\.ts)?["']/,
|
||||
"must import buildErrorBody from @omniroute/open-sse/utils/error"
|
||||
);
|
||||
assert.match(src, /buildErrorBody\s*\(/, "must call buildErrorBody() in error responses");
|
||||
|
||||
// Static guard: no raw err.message / err.stack in a response-building line
|
||||
const lines = src.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/console\.(error|warn|log|debug|info)/.test(line)) continue;
|
||||
if (/err\.stack/.test(line) && /NextResponse\.json|return.*json\(/.test(line)) {
|
||||
assert.fail(`line ${i + 1}: raw err.stack found in response body:\n ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user