diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc0c8ca39..2659810281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index c40d50cee6..544f51959b 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -575,6 +575,27 @@ export const IMAGE_PROVIDERS: Record = { 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"], + }, }; /** diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 1e63e105bd..852d4132b5 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -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, diff --git a/open-sse/handlers/imageGeneration/providers/huggingface.ts b/open-sse/handlers/imageGeneration/providers/huggingface.ts new file mode 100644 index 0000000000..7e5cad8de6 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/huggingface.ts @@ -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)}`, + }; + } +} diff --git a/open-sse/services/hfModelSuggestions.ts b/open-sse/services/hfModelSuggestions.ts new file mode 100644 index 0000000000..09a273a687 --- /dev/null +++ b/open-sse/services/hfModelSuggestions.ts @@ -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> = { + 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); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx index 2df7e2a28b..fea18d1207 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx @@ -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(""); @@ -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 ( + {/* Suggested models from HuggingFace Hub (image kind only) */} + {suggestedOnly.length > 0 && ( +
+ +
+ {suggestedOnly.map((m) => ( + + ))} +
+
+ )} {/* Size */}
diff --git a/src/app/api/v1/providers/suggested-models/route.ts b/src/app/api/v1/providers/suggested-models/route.ts new file mode 100644 index 0000000000..efe9bd1956 --- /dev/null +++ b/src/app/api/v1/providers/suggested-models/route.ts @@ -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 } + ); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1c033f4a48..1600125c84 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/tests/unit/hf-model-suggestions.test.ts b/tests/unit/hf-model-suggestions.test.ts new file mode 100644 index 0000000000..53b6491867 --- /dev/null +++ b/tests/unit/hf-model-suggestions.test.ts @@ -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); +}); diff --git a/tests/unit/suggested-models-route.test.ts b/tests/unit/suggested-models-route.test.ts new file mode 100644 index 0000000000..bca7401838 --- /dev/null +++ b/tests/unit/suggested-models-route.test.ts @@ -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("".repeat(0)) || true); + // sanitizeErrorMessage replaces absolute paths with "" + 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()}`); + } + } +});