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:
Diego Rodrigues de Sa e Souza
2026-07-03 00:12:52 -03:00
committed by GitHub
parent c9ac3057f0
commit 9838acc741
10 changed files with 660 additions and 3 deletions

View File

@@ -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

View File

@@ -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"],
},
};
/**

View File

@@ -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,

View 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)}`,
};
}
}

View 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);
}

View File

@@ -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>

View 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 }
);
}
}

View File

@@ -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",

View 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);
});

View 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()}`);
}
}
});