From bbe9d2d2da59327b6625875194341538b21a92b4 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 24 Aug 2026 17:30:33 -0400 Subject: [PATCH] feat(discovery): live Explore catalog from Discovery Service Wire /explore to GET /api/discovery/explore, add catch-all model lookup, and widen next.config redirects so slash-y capability ids survive. streaming-playground helpers land here for map-to-model enrichment; the playground UI stays for the next PR. --- .env.example | 5 + app/api/discovery/explore/route.ts | 24 +++++ app/api/discovery/models/[...id]/route.ts | 56 ++++++++++ components/console/ExploreView.tsx | 112 +++++++++++--------- lib/console/mock-data.ts | 1 + lib/console/model-api-url.ts | 31 ++++++ lib/console/streaming-playground.ts | 108 +++++++++++++++++++ lib/console/types.ts | 8 ++ lib/console/useDiscoveryModel.ts | 69 ++++++++++++ lib/console/useExploreModels.ts | 86 +++++++++++++++ lib/discovery/client.ts | 102 ++++++++++++++++++ lib/discovery/config.ts | 43 ++++++++ lib/discovery/constants.ts | 3 + lib/discovery/map-to-model.ts | 122 ++++++++++++++++++++++ lib/discovery/types.ts | 54 ++++++++++ next.config.ts | 11 +- 16 files changed, 780 insertions(+), 55 deletions(-) create mode 100644 app/api/discovery/explore/route.ts create mode 100644 app/api/discovery/models/[...id]/route.ts create mode 100644 lib/console/model-api-url.ts create mode 100644 lib/console/streaming-playground.ts create mode 100644 lib/console/useDiscoveryModel.ts create mode 100644 lib/console/useExploreModels.ts create mode 100644 lib/discovery/client.ts create mode 100644 lib/discovery/config.ts create mode 100644 lib/discovery/constants.ts create mode 100644 lib/discovery/map-to-model.ts create mode 100644 lib/discovery/types.ts diff --git a/.env.example b/.env.example index 62c2d42..4568ede 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,8 @@ PYMTHOUSE_M2M_CLIENT_ID= PYMTHOUSE_M2M_CLIENT_SECRET= # Set to 1 for local http issuer only (not needed for https://localhost with mkcert) PYMTHOUSE_ALLOW_INSECURE_HTTP= + +# Discovery Service — full raw endpoint (as-is for gateway tokens). +# Explore uses the URL origin for `/v1/discovery/capabilities` etc. +# Aliases: DISCOVERY_URL, LIVEPEER_DISCOVERY_SERVICE_URL +DISCOVERY_SERVICE_URL=https://discovery-service-production-8955.up.railway.app/v1/discovery/raw diff --git a/app/api/discovery/explore/route.ts b/app/api/discovery/explore/route.ts new file mode 100644 index 0000000..fae5bd3 --- /dev/null +++ b/app/api/discovery/explore/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { + DEFAULT_DISCOVERY_SERVICE_TYPE, + fetchExploreModels, + type DiscoveryServiceType, +} from "@/lib/discovery/client"; + +function parseServiceType(value: string | null): DiscoveryServiceType { + if (value === "registry") return "registry"; + return DEFAULT_DISCOVERY_SERVICE_TYPE; +} + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const serviceType = parseServiceType(searchParams.get("serviceType")); + + try { + const payload = await fetchExploreModels(serviceType); + return NextResponse.json(payload); + } catch (error) { + const message = error instanceof Error ? error.message : "Discovery Service request failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/discovery/models/[...id]/route.ts b/app/api/discovery/models/[...id]/route.ts new file mode 100644 index 0000000..7231934 --- /dev/null +++ b/app/api/discovery/models/[...id]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { + DEFAULT_DISCOVERY_SERVICE_TYPE, + fetchDiscoveryCapabilities, + queryDiscoveryCapabilities, + type DiscoveryServiceType, +} from "@/lib/discovery/client"; +import { mapCapabilityToModel } from "@/lib/discovery/map-to-model"; + +function parseServiceType(value: string | null): DiscoveryServiceType { + if (value === "registry") return "registry"; + return DEFAULT_DISCOVERY_SERVICE_TYPE; +} + +function capabilityFromSegments(segments: string[]): string { + return segments.map((segment) => decodeURIComponent(segment)).join("/"); +} + +export async function GET( + request: Request, + context: { params: Promise<{ id: string[] }> }, +): Promise { + const { id: segments } = await context.params; + const capability = capabilityFromSegments(segments ?? []); + const { searchParams } = new URL(request.url); + const serviceType = parseServiceType(searchParams.get("serviceType")); + + if (!capability) { + return NextResponse.json({ error: "Capability not found" }, { status: 404 }); + } + + try { + const capabilitiesResponse = await fetchDiscoveryCapabilities(serviceType); + const entries = capabilitiesResponse.entries ?? []; + const known = + capabilitiesResponse.capabilities.includes(capability) || + entries.some((entry) => entry.capability === capability); + + if (!known) { + return NextResponse.json({ error: "Capability not found" }, { status: 404 }); + } + + const entry = entries.find((item) => item.capability === capability); + const queryResponse = await queryDiscoveryCapabilities([capability], serviceType); + const model = mapCapabilityToModel( + capability, + entry, + queryResponse.results[capability] ?? [], + ); + + return NextResponse.json({ model, serviceType }); + } catch (error) { + const message = error instanceof Error ? error.message : "Discovery Service request failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/components/console/ExploreView.tsx b/components/console/ExploreView.tsx index 4dce3a0..d547711 100644 --- a/components/console/ExploreView.tsx +++ b/components/console/ExploreView.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useState, useMemo, useEffect } from "react"; +import { Suspense, useState, useMemo } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { @@ -15,12 +15,7 @@ import { Star, Search, } from "lucide-react"; -import { - APPS, - publicPipelines, - SEED_PUBLIC_PIPELINE_APPS, - PIPELINE_APP_IDS, -} from "@/lib/console/mock-data"; +import { useExploreModels } from "@/lib/console/useExploreModels"; import Button from "@/components/design-system/Button"; import Drawer from "@/components/design-system/Drawer"; import { getAppIcon, formatRuns } from "@/lib/console/utils"; @@ -427,7 +422,29 @@ export default function ExploreView() { ); } +function ExploreLoadError({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
+

+ Could not load capabilities from Discovery Service. +

+

{message}

+ +
+ ); +} + function ExplorePageInner() { + const exploreState = useExploreModels(); + const { status, models, reload } = exploreState; const searchParams = useSearchParams(); const initialCategory = (() => { const qp = searchParams.get("category"); @@ -449,37 +466,13 @@ function ExplorePageInner() { const [priceMin, setPriceMin] = useState(0); const [priceMax, setPriceMax] = useState(100); - // The org's public deployed apps are listed in Explore alongside the - // third-party catalog models. Seeded SSR-safely, then refreshed from the - // localStorage-backed publish state after mount so toggling an app's - // visibility on its Settings tab is reflected here on next navigation. - const [pipelineModels, setPipelineModels] = useState( - SEED_PUBLIC_PIPELINE_APPS - ); - useEffect(() => { - setPipelineModels(publicPipelines()); - }, []); - - // APPS now carries the org's own apps too (public + private). Take the - // third-party catalog from APPS and re-attach only the *public* owned apps so - // private deployments never leak into Explore and nothing is duplicated. - const catalogModels = useMemo( - () => APPS.filter((m) => !PIPELINE_APP_IDS.has(m.id)), - [] - ); - - const allModels = useMemo( - () => [...catalogModels, ...pipelineModels], - [catalogModels, pipelineModels] - ); - const dataMaxPrice = useMemo( - () => Math.max(...allModels.map((m) => m.pricing.amount), 0.01), - [allModels] + () => Math.max(...models.map((m) => m.pricing.amount), 0.01), + [models] ); const filtered = useMemo(() => { - const result = allModels.filter((m) => { + const result = models.filter((m) => { if (availabilityFilter === "warm" && m.status !== "hot") return false; if (availabilityFilter === "cold" && m.status !== "cold") return false; if (favoritesOnly && !isStarred(m.id)) return false; @@ -509,7 +502,7 @@ function ExplorePageInner() { return result; }, [ - allModels, + models, search, category, availabilityFilter, @@ -520,6 +513,36 @@ function ExplorePageInner() { dataMaxPrice, ]); + if (status === "loading" && models.length === 0) { + return ( +
+ + +
+ ); + } + + if (status === "error") { + return ( +
+ + +
+ ); + } + const activeFilters = [ ...(category ? [{ label: category, onClear: () => setCategory(null) }] @@ -713,20 +736,9 @@ function ExplorePageInner() { ) : view === "grid" ? (
- {filtered.map((model) => { - const isPipeline = PIPELINE_APP_IDS.has(model.id); - // Pipeline cards open the consumer/playground face (/apps/[id]); - // owners reach the operator console from a "Manage app" affordance - // there. The catalog is a consume surface, so a card never drops a - // caller straight into someone's operator view. - return ( - - ); - })} + {filtered.map((model) => ( + + ))}
) : (
@@ -827,7 +839,7 @@ function ExplorePageInner() { setPriceMin(min); setPriceMax(max); }} - models={allModels} + models={models} />
diff --git a/lib/console/mock-data.ts b/lib/console/mock-data.ts index b9b4cde..96ed29e 100644 --- a/lib/console/mock-data.ts +++ b/lib/console/mock-data.ts @@ -1056,6 +1056,7 @@ Typical end-to-end: 20-40ms per frame on dedicated orchestrators.`, name: "Qwen3 32B", provider: "Qwen", category: "Language", + runnerAppId: "vllm/qwen2.5-0.5b-instruct", coverImage: "/images/console/explore/qwen3-32b.webp", description: "High-performance 32B parameter language model with strong reasoning and multilingual capabilities.", diff --git a/lib/console/model-api-url.ts b/lib/console/model-api-url.ts new file mode 100644 index 0000000..8a8d34d --- /dev/null +++ b/lib/console/model-api-url.ts @@ -0,0 +1,31 @@ +import type { App } from "@/lib/console/types"; + +const DEFAULT_GATEWAY_BASE = "https://gateway.livepeer.org/v1"; + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +/** Gateway base URL for snippets and docs (never a bare capability id). */ +export function getModelApiBaseUrl(model: App): string { + const candidate = model.apiEndpoint?.trim(); + if (candidate && isHttpUrl(candidate)) { + return candidate.replace(/\/$/, ""); + } + return DEFAULT_GATEWAY_BASE; +} + +/** POST target for the model's inference API. */ +export function getModelApiPostUrl(model: App): string { + const base = getModelApiBaseUrl(model); + if (model.category === "Language") { + return `${base}/chat/completions`; + } + const pipeline = encodeURIComponent(model.id); + return `${base}/${pipeline}`; +} + +/** Host header value for raw HTTP examples. */ +export function getModelApiHost(model: App): string { + return new URL(getModelApiBaseUrl(model)).host; +} diff --git a/lib/console/streaming-playground.ts b/lib/console/streaming-playground.ts new file mode 100644 index 0000000..05e71a0 --- /dev/null +++ b/lib/console/streaming-playground.ts @@ -0,0 +1,108 @@ +import type { App, PlaygroundConfig } from "@/lib/console/types"; + +/** Discovery capability ids that get the LV2V webcam / gateway playground. */ +export function isLv2vPlaygroundCapability(capability: string): boolean { + const id = capability.toLowerCase(); + return ( + id.includes("streamdiffusion") || + id === "live-video-to-video" || + id.startsWith("live-video") + ); +} + +/** + * Resolve the orchestrator pipeline model name for a capability. + * Discovery page id may differ from the orchestrator pipeline model name. + */ +export function resolveGatewayModelId(capability: string): string { + const id = capability.trim(); + const lower = id.toLowerCase(); + if (lower === "streamdiffusion") { + return "streamdiffusion"; + } + if (lower === "live-video-to-video") { + return "streamdiffusion-sdxl"; + } + return id; +} + +export function buildLv2vPlaygroundConfig(_capability: string): PlaygroundConfig { + return { + fields: [ + { + name: "prompt", + label: "Prompt", + type: "textarea", + placeholder: "Describe the look or style for the stream…", + description: "Optional pipeline prompt (passed when starting the LV2V job).", + }, + { + name: "style", + label: "Style preset", + type: "select", + options: ["none", "cinematic", "anime", "watercolor", "neon", "sketch"], + defaultValue: "none", + description: "Local preview label only until full pipeline params are wired.", + }, + { + name: "strength", + label: "Strength", + type: "range", + min: 0, + max: 1, + step: 0.05, + defaultValue: 0.6, + }, + ], + outputType: "video", + playgroundVariant: "webcam", + mockOutputUrl: "https://picsum.photos/seed/streamdiffusion/640/360", + }; +} + +/** Live-runner demo apps with a simple request/response playground. */ +export function isHelloWorldCapability(capability: string): boolean { + const id = capability.toLowerCase(); + return id === "livepeer-example/hello-world" || id.endsWith("/hello-world"); +} + +export function buildHelloWorldPlaygroundConfig(): PlaygroundConfig { + return { + fields: [ + { + name: "name", + label: "Name", + type: "text", + required: true, + defaultValue: "livepeer", + placeholder: "Who should we greet?", + description: "Passed as JSON { name } to POST /hello on the runner.", + }, + ], + outputType: "text", + mockOutputText: "Hello, livepeer!", + runnerPath: "hello", + }; +} + +export function enrichDiscoveryModelForStreaming(model: App): App { + if (isHelloWorldCapability(model.id)) { + return { + ...model, + playgroundConfig: model.playgroundConfig ?? buildHelloWorldPlaygroundConfig(), + }; + } + + if (!isLv2vPlaygroundCapability(model.id)) { + return model; + } + + return { + ...model, + realtime: true, + category: + model.category === "Language" ? "Video Generation" : model.category, + gatewayModelId: resolveGatewayModelId(model.id), + playgroundConfig: model.playgroundConfig ?? buildLv2vPlaygroundConfig(model.id), + }; +} diff --git a/lib/console/types.ts b/lib/console/types.ts index 4d9694c..05dea7b 100644 --- a/lib/console/types.ts +++ b/lib/console/types.ts @@ -147,6 +147,10 @@ export interface PlaygroundConfig { mockOutputJson?: unknown; /** Selects the playground UI. "webcam" mocks live video-in/video-out with the user's camera. "transcoding" shapes the output like a Livepeer HLS stream (playbackId, rendition ladder, copyable URLs). Defaults to "form". */ playgroundVariant?: "form" | "webcam" | "transcoding"; + /** Live-runner HTTP path under the reserved session app URL (e.g. "hello"). + * When set, playground posts form values as JSON to this path instead of + * OpenAI-style chat/completions. */ + runnerPath?: string; } export interface UsageDataPoint { @@ -184,6 +188,10 @@ export interface App { featured?: boolean; /** Supports streaming (WebRTC) inference in addition to request/response. The differentiator on the network — flagged as a capability pill and filterable on Explore. */ realtime?: boolean; + /** LV2V model_id for gateway sessions when different from discovery capability `id`. */ + gatewayModelId?: string; + /** Live-runner app id for gateway.py-style HTTP apps, e.g. vllm/qwen2.5-0.5b-instruct */ + runnerAppId?: string; /** ISO-8601 date the model was published on the network. Drives the "NEW" badge and Recently-added sort. */ releasedAt?: string; tags?: string[]; diff --git a/lib/console/useDiscoveryModel.ts b/lib/console/useDiscoveryModel.ts new file mode 100644 index 0000000..e4bbb32 --- /dev/null +++ b/lib/console/useDiscoveryModel.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { App } from "@/lib/console/types"; +import { DEFAULT_DISCOVERY_SERVICE_TYPE } from "@/lib/discovery/constants"; + +type ModelState = + | { status: "loading" } + | { status: "ready"; model: App } + | { status: "not_found" } + | { status: "error"; message: string }; + +export function useDiscoveryModel(capabilityId: string | undefined): ModelState { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + if (!capabilityId) { + setState({ status: "not_found" }); + return; + } + + let cancelled = false; + setState({ status: "loading" }); + + const params = new URLSearchParams({ serviceType: DEFAULT_DISCOVERY_SERVICE_TYPE }); + // Keep `/` as path separators so catch-all `[...id]` can rejoin slash-y + // capability ids (e.g. livepeer-example/hello-world). + const encodedId = capabilityId + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + const path = `/api/discovery/models/${encodedId}?${params}`; + + void (async () => { + try { + const response = await fetch(path); + const body = (await response.json()) as { model?: App; error?: string }; + + if (cancelled) return; + + if (response.status === 404) { + setState({ status: "not_found" }); + return; + } + if (!response.ok || !body.model) { + setState({ + status: "error", + message: body.error ?? `Failed to load capability (${response.status})`, + }); + return; + } + + setState({ status: "ready", model: body.model }); + } catch (error) { + if (cancelled) return; + setState({ + status: "error", + message: error instanceof Error ? error.message : "Failed to load capability", + }); + } + })(); + + return () => { + cancelled = true; + }; + }, [capabilityId]); + + return state; +} diff --git a/lib/console/useExploreModels.ts b/lib/console/useExploreModels.ts new file mode 100644 index 0000000..46e072c --- /dev/null +++ b/lib/console/useExploreModels.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { ExploreApiResponse } from "@/lib/discovery/types"; +import type { App } from "@/lib/console/types"; +import { + DEFAULT_DISCOVERY_SERVICE_TYPE, + type DiscoveryServiceType, +} from "@/lib/discovery/constants"; + +export type { DiscoveryServiceType } from "@/lib/discovery/constants"; + +type ExploreState = + | { status: "loading"; models: App[] } + | { status: "ready"; models: App[]; capabilityCount: number; serviceType: string } + | { status: "error"; models: App[]; error: string }; + +let exploreCache: { + key: string; + payload: ExploreApiResponse; + fetchedAt: number; +} | null = null; + +const CACHE_TTL_MS = 60_000; + +export function useExploreModels( + serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE, +): ExploreState & { reload: () => void } { + const [state, setState] = useState({ status: "loading", models: [] }); + const cacheKey = serviceType; + + const load = useCallback(async () => { + const cached = + exploreCache && + exploreCache.key === cacheKey && + Date.now() - exploreCache.fetchedAt < CACHE_TTL_MS + ? exploreCache.payload + : null; + + if (cached) { + setState({ + status: "ready", + models: cached.models, + capabilityCount: cached.capabilityCount, + serviceType: cached.serviceType, + }); + return; + } + + setState((prev) => ({ ...prev, status: "loading" })); + + try { + const params = new URLSearchParams({ serviceType }); + const response = await fetch(`/api/discovery/explore?${params}`); + const body = (await response.json()) as ExploreApiResponse & { error?: string }; + + if (!response.ok) { + throw new Error(body.error ?? `Explore fetch failed (${response.status})`); + } + + exploreCache = { key: cacheKey, payload: body, fetchedAt: Date.now() }; + setState({ + status: "ready", + models: body.models, + capabilityCount: body.capabilityCount, + serviceType: body.serviceType, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load capabilities"; + setState({ status: "error", models: [], error: message }); + } + }, [cacheKey, serviceType]); + + useEffect(() => { + void load(); + }, [load]); + + const reload = useCallback(() => { + if (exploreCache?.key === cacheKey) { + exploreCache = null; + } + void load(); + }, [cacheKey, load]); + + return { ...state, reload }; +} diff --git a/lib/discovery/client.ts b/lib/discovery/client.ts new file mode 100644 index 0000000..96871a6 --- /dev/null +++ b/lib/discovery/client.ts @@ -0,0 +1,102 @@ +import { readDiscoveryServiceUrl } from "./config"; +import { DEFAULT_DISCOVERY_SERVICE_TYPE, type DiscoveryServiceType } from "./constants"; +import { mapCapabilityToModel } from "./map-to-model"; +import type { + DiscoveryCapabilitiesResponse, + DiscoveryFreshnessResponse, + DiscoveryQueryResponse, + ExploreApiResponse, +} from "./types"; + +export { DEFAULT_DISCOVERY_SERVICE_TYPE, type DiscoveryServiceType } from "./constants"; + +async function discoveryFetch(path: string, init?: RequestInit): Promise { + const baseUrl = readDiscoveryServiceUrl(); + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + Accept: "application/json", + ...(init?.headers ?? {}), + }, + next: { revalidate: 60 }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Discovery Service ${response.status}: ${body || response.statusText}`); + } + + return response.json() as Promise; +} + +export async function fetchDiscoveryCapabilities( + serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE, +): Promise { + const params = new URLSearchParams({ serviceType }); + return discoveryFetch( + `/v1/discovery/capabilities?${params}`, + ); +} + +export async function fetchDiscoveryFreshness(): Promise { + return discoveryFetch("/v1/discovery/freshness"); +} + +export async function queryDiscoveryCapabilities( + capabilities: string[], + serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE, +): Promise { + if (capabilities.length === 0) { + return { results: {} }; + } + + return discoveryFetch("/v1/discovery/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + capabilities, + serviceTypes: [serviceType], + topN: 50, + sortBy: "avail", + }), + }); +} + +export async function fetchExploreModels( + serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE, +): Promise { + const [capabilitiesResponse, freshness] = await Promise.all([ + fetchDiscoveryCapabilities(serviceType), + fetchDiscoveryFreshness().catch(() => undefined), + ]); + + const entries = capabilitiesResponse.entries ?? []; + const capabilityNames = + capabilitiesResponse.capabilities.length > 0 + ? capabilitiesResponse.capabilities + : entries.map((entry) => entry.capability); + + const entryByCapability = new Map(entries.map((entry) => [entry.capability, entry])); + + const queryResponse = await queryDiscoveryCapabilities(capabilityNames, serviceType); + + const models = capabilityNames.map((capability) => + mapCapabilityToModel( + capability, + entryByCapability.get(capability), + queryResponse.results[capability] ?? [], + ), + ); + + models.sort((a, b) => { + if (a.status !== b.status) return a.status === "hot" ? -1 : 1; + return b.orchestrators - a.orchestrators; + }); + + return { + models, + capabilityCount: capabilityNames.length, + serviceType, + freshness, + }; +} diff --git a/lib/discovery/config.ts b/lib/discovery/config.ts new file mode 100644 index 0000000..563a3c4 --- /dev/null +++ b/lib/discovery/config.ts @@ -0,0 +1,43 @@ +/** + * Livepeer discovery-service URL. + * + * Configure the full raw endpoint, e.g. + * `https://discovery-service-production-8955.up.railway.app/v1/discovery/raw` + * Tokens embed that value as-is. Explore uses the URL origin for sibling + * `/v1/discovery/…` routes. + */ + +const ENV_KEYS = [ + "DISCOVERY_URL", + "DISCOVERY_SERVICE_URL", + "LIVEPEER_DISCOVERY_SERVICE_URL", +] as const; + +function readConfiguredDiscoveryUrl( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + for (const key of ENV_KEYS) { + const value = env[key]?.trim(); + if (value) return value; + } + return undefined; +} + +/** Full raw endpoint for python-gateway `--token` (as configured). */ +export function readDiscoveryRawUrl(): string | undefined { + return readConfiguredDiscoveryUrl(); +} + +/** + * Origin for Explore catalog fetches (`/v1/discovery/capabilities`, etc.). + * Env must be an absolute URL to the raw discovery endpoint. + */ +export function readDiscoveryServiceUrl(): string { + const configured = readConfiguredDiscoveryUrl(); + if (!configured) { + throw new Error( + "DISCOVERY_SERVICE_URL (or DISCOVERY_URL) is not configured", + ); + } + return new URL(configured).origin; +} diff --git a/lib/discovery/constants.ts b/lib/discovery/constants.ts new file mode 100644 index 0000000..b0abb0e --- /dev/null +++ b/lib/discovery/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_DISCOVERY_SERVICE_TYPE = "legacy" as const; + +export type DiscoveryServiceType = "legacy" | "registry"; diff --git a/lib/discovery/map-to-model.ts b/lib/discovery/map-to-model.ts new file mode 100644 index 0000000..5362cfb --- /dev/null +++ b/lib/discovery/map-to-model.ts @@ -0,0 +1,122 @@ +import type { App, AppCategory, AppStatus, PricingUnit } from "@/lib/console/types"; +import { enrichDiscoveryModelForStreaming } from "@/lib/console/streaming-playground"; +import type { DiscoveryCapabilityEntry, DiscoveryDatasetRow } from "./types"; + +function inferCategory(capability: string): AppCategory { + const c = capability.toLowerCase(); + + if (c.startsWith("video:transcode") || c === "video:live.rtmp") { + return "Live Transcoding"; + } + if ( + c.includes("streamdiffusion") || + c.includes("stable-video") || + c.includes("img2vid") || + c.startsWith("video:") + ) { + return "Video Generation"; + } + if ( + c.includes("whisper") || + c.startsWith("openai:audio") || + c.includes("tts") || + c.includes("parler") + ) { + return "Speech"; + } + if ( + c.startsWith("openai:images") || + c.includes("flux") || + c.includes("sdxl") || + c.includes("diffusion") || + c.includes("pix2pix") || + c.includes("upscaler") || + c.includes("realvis") || + c.includes("instruct-pix") + ) { + return "Image Generation"; + } + if (c.includes("sam2") || c.includes("vision")) { + return "Video Understanding"; + } + return "Language"; +} + +function inferPricingUnit(workUnit: string | undefined, capability: string): PricingUnit { + if (workUnit === "tokens") return "M Tokens"; + if (workUnit?.includes("second")) return "Second"; + if (capability.startsWith("video:")) return "Minute"; + return "Request"; +} + +function humanizeCapabilityName(capability: string): string { + const segment = capability.includes(":") + ? capability.split(":").slice(-1)[0]! + : capability; + return segment + .split(/[-_./]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function aggregateRows(rows: DiscoveryDatasetRow[]): { + orchestrators: number; + status: AppStatus; + latency: number; + price: number; + realtime: boolean; +} { + const orchUris = new Set(rows.map((row) => row.orchUri).filter(Boolean)); + const warm = rows.some((row) => row.avail > 0 || row.totalCap > 0); + const latencies = rows + .map((row) => row.avgLatMs ?? row.bestLatMs) + .filter((value): value is number => value != null && value > 0); + const prices = rows.map((row) => row.pricePerUnit).filter((value) => value > 0); + + return { + orchestrators: orchUris.size, + status: warm ? "hot" : "cold", + latency: + latencies.length > 0 + ? latencies.reduce((sum, value) => sum + value, 0) / latencies.length + : 0, + price: prices.length > 0 ? Math.min(...prices) : 0, + realtime: rows.some((row) => row.interactionMode?.includes("stream") ?? false), + }; +} + +export function mapCapabilityToModel( + capability: string, + entry: DiscoveryCapabilityEntry | undefined, + rows: DiscoveryDatasetRow[], +): App { + const stats = aggregateRows(rows); + const sample = rows[0]; + const provider = + entry?.offeringIds?.[0] ?? + (entry?.serviceType === "registry" ? "Registry" : "Livepeer network"); + + const runnerAppId = capability.includes("/") ? capability : undefined; + + return enrichDiscoveryModelForStreaming({ + id: capability, + runnerAppId, + name: humanizeCapabilityName(capability), + provider, + category: inferCategory(capability), + description: `${humanizeCapabilityName(capability)} on the Livepeer open GPU network (${stats.orchestrators} orchestrator${stats.orchestrators === 1 ? "" : "s"}).`, + status: stats.status, + pricing: { + amount: stats.price > 0 ? stats.price : 0.001, + unit: inferPricingUnit(sample?.workUnit, capability), + }, + latency: stats.latency, + orchestrators: stats.orchestrators, + runs7d: Math.max(stats.orchestrators * 8, stats.orchestrators > 0 ? 1 : 0), + uptime: stats.status === "hot" ? 99.2 : 0, + realtime: stats.realtime, + featured: stats.realtime && stats.status === "hot", + tags: entry?.serviceType ? [entry.serviceType] : undefined, + }); +} diff --git a/lib/discovery/types.ts b/lib/discovery/types.ts new file mode 100644 index 0000000..b4f8e13 --- /dev/null +++ b/lib/discovery/types.ts @@ -0,0 +1,54 @@ +/** Discovery Service API shapes (see discovery-service openapi). */ + +export interface DiscoveryCapabilityEntry { + serviceType: string; + capability: string; + offeringIds?: string[]; +} + +export interface DiscoveryCapabilitiesResponse { + capabilities: string[]; + entries?: DiscoveryCapabilityEntry[]; +} + +export interface DiscoveryDatasetRow { + serviceType?: string; + ethAddress?: string; + offeringId?: string; + interactionMode?: string; + workUnit?: string; + pricePerUnitWei?: string; + orchUri: string; + gpuName?: string; + gpuGb?: number; + avail: number; + totalCap: number; + pricePerUnit: number; + bestLatMs?: number | null; + avgLatMs?: number | null; + swapRatio?: number | null; + avgAvail?: number | null; + score?: number; + slaScore?: number | null; +} + +export interface DiscoveryQueryResponse { + results: Record; + datasetVersion?: number; + queryTimeMs?: number; +} + +export interface DiscoveryFreshnessResponse { + populated?: boolean; + refreshedAt?: number; + ageMs?: number; + capabilityCount?: number; + totalRows?: number; +} + +export interface ExploreApiResponse { + models: import("@/lib/console/types").App[]; + capabilityCount: number; + serviceType: string; + freshness?: DiscoveryFreshnessResponse; +} diff --git a/next.config.ts b/next.config.ts index 4f79207..967e28f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -24,16 +24,17 @@ const nextConfig: NextConfig = { // /models/[id] to /apps/[id] (one noun — "app" — for the object across // both the consumer catalog and the operator surfaces). { - source: "/models/:id", - destination: "/apps/:id", + source: "/models/:path*", + destination: "/apps/:path*", permanent: true, }, // The operator console folded into the app page as ownership-gated tabs, // so the separate /manage route is gone. Deep-link the console via - // /apps/[id]?tab=overview instead. + // /apps/[...id]?tab=overview instead. `:path*` preserves slash-y + // capability ids (e.g. livepeer-example/hello-world). { - source: "/apps/:id/manage", - destination: "/apps/:id?tab=overview", + source: "/apps/:path*/manage", + destination: "/apps/:path*?tab=overview", permanent: true, }, // Old livepeer.org routes → new site equivalents