Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions app/api/discovery/explore/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 });
}
}
56 changes: 56 additions & 0 deletions app/api/discovery/models/[...id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 });
}
}
112 changes: 62 additions & 50 deletions components/console/ExploreView.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -427,7 +422,29 @@ export default function ExploreView() {
);
}

function ExploreLoadError({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-5 py-24 text-center">
<p className="text-sm text-fg-muted">
Could not load capabilities from Discovery Service.
</p>
<p className="mt-2 max-w-md font-mono text-xs text-fg-faint">{message}</p>
<Button className="mt-6" variant="secondary" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}

function ExplorePageInner() {
const exploreState = useExploreModels();
const { status, models, reload } = exploreState;
const searchParams = useSearchParams();
const initialCategory = (() => {
const qp = searchParams.get("category");
Expand All @@ -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<App[]>(
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;
Expand Down Expand Up @@ -509,7 +502,7 @@ function ExplorePageInner() {

return result;
}, [
allModels,
models,
search,
category,
availabilityFilter,
Expand All @@ -520,6 +513,36 @@ function ExplorePageInner() {
dataMaxPrice,
]);

if (status === "loading" && models.length === 0) {
return (
<main id="main-content" className="flex flex-1 flex-col bg-dark">
<ConsolePageHeader title="Explore" icon={LayoutGrid} />
<ConsolePageSkeleton
maxWidth="7xl"
withTabs
kpiCount={0}
withChart={false}
/>
</main>
);
}

if (status === "error") {
return (
<main id="main-content" className="flex flex-1 flex-col bg-dark">
<ConsolePageHeader title="Explore" icon={LayoutGrid} />
<ExploreLoadError
message={
exploreState.status === "error"
? exploreState.error
: "Unknown error"
}
onRetry={reload}
/>
</main>
);
}

const activeFilters = [
...(category
? [{ label: category, onClear: () => setCategory(null) }]
Expand Down Expand Up @@ -713,20 +736,9 @@ function ExplorePageInner() {
</div>
) : view === "grid" ? (
<div className="grid grid-cols-1 gap-3 px-5 pt-4 pb-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{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 (
<AppCard
key={model.id}
model={model}
tag={isPipeline ? "Pipeline" : undefined}
/>
);
})}
{filtered.map((model) => (
<AppCard key={model.id} model={model} />
))}
</div>
) : (
<div className="px-5 pb-8">
Expand Down Expand Up @@ -827,7 +839,7 @@ function ExplorePageInner() {
setPriceMin(min);
setPriceMax(max);
}}
models={allModels}
models={models}
/>
</div>

Expand Down
1 change: 1 addition & 0 deletions lib/console/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
31 changes: 31 additions & 0 deletions lib/console/model-api-url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading