From bbe9d2d2da59327b6625875194341538b21a92b4 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 24 Aug 2026 17:30:33 -0400 Subject: [PATCH 1/4] 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 From 63430e414e28684fcd37807a0de90f21d1d468a7 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 24 Aug 2026 17:48:58 -0400 Subject: [PATCH 2/4] feat(playground): session-bound runner gateway and live apps playground Catch-all /apps/[...id] runs discovery models through a BFF proxy that mints signer context from the Auth0 session. No client-supplied externalUserId. Insecure orch TLS is opt-in and refused in production. --- .env.example | 6 + app/(app)/apps/{[id] => [...id]}/page.tsx | 168 +++++- app/api/pymthouse/signer-session/route.ts | 48 ++ app/api/runner-gateway/v1/[...path]/route.ts | 94 +++ .../console/playground/CodeSnippets.tsx | 63 +- .../console/playground/PlaygroundForm.tsx | 6 + .../playground/RunnerGatewayContext.tsx | 139 +++++ lib/console/runner-gateway-client.ts | 123 ++++ lib/console/signer-session-bff.ts | 141 +++++ lib/runner-gateway/call-runner.ts | 549 ++++++++++++++++++ lib/runner-gateway/discovery.ts | 127 ++++ lib/runner-gateway/errors.ts | 36 ++ lib/runner-gateway/forward.ts | 107 ++++ lib/runner-gateway/index.ts | 24 + lib/runner-gateway/stop-session.ts | 61 ++ lib/runner-gateway/tls.ts | 18 + package.json | 2 + pnpm-lock.yaml | 53 ++ types/jmuxer.d.ts | 12 + types/muxjs.d.ts | 18 + 20 files changed, 1764 insertions(+), 31 deletions(-) rename app/(app)/apps/{[id] => [...id]}/page.tsx (86%) create mode 100644 app/api/pymthouse/signer-session/route.ts create mode 100644 app/api/runner-gateway/v1/[...path]/route.ts create mode 100644 components/console/playground/RunnerGatewayContext.tsx create mode 100644 lib/console/runner-gateway-client.ts create mode 100644 lib/console/signer-session-bff.ts create mode 100644 lib/runner-gateway/call-runner.ts create mode 100644 lib/runner-gateway/discovery.ts create mode 100644 lib/runner-gateway/errors.ts create mode 100644 lib/runner-gateway/forward.ts create mode 100644 lib/runner-gateway/index.ts create mode 100644 lib/runner-gateway/stop-session.ts create mode 100644 lib/runner-gateway/tls.ts create mode 100644 types/jmuxer.d.ts create mode 100644 types/muxjs.d.ts diff --git a/.env.example b/.env.example index 4568ede..bbcb9af 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,9 @@ PYMTHOUSE_ALLOW_INSECURE_HTTP= # 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 + +# Live-runner discovery (orchestrator /discovery endpoint). +# Local example-apps stack: http://localhost:8935/discovery +RUNNER_DISCOVERY_URL=http://localhost:8935/discovery +# Accept self-signed orchestrator TLS (local/dev only). Do not enable in production. +# RUNNER_GATEWAY_ALLOW_INSECURE_TLS=1 diff --git a/app/(app)/apps/[id]/page.tsx b/app/(app)/apps/[...id]/page.tsx similarity index 86% rename from app/(app)/apps/[id]/page.tsx rename to app/(app)/apps/[...id]/page.tsx index 8d75aaf..1f99797 100644 --- a/app/(app)/apps/[id]/page.tsx +++ b/app/(app)/apps/[...id]/page.tsx @@ -22,7 +22,6 @@ import KeyBadge from "@/components/console/KeyBadge"; import CallsTable from "@/components/console/CallsTable"; import StatusDot from "@/components/console/StatusDot"; import { - getAppById, effectiveVisibility, setPipelineVisibility, organizationSlug, @@ -30,6 +29,8 @@ import { SETTINGS_API_KEYS, MOCK_RECENT_REQUESTS, } from "@/lib/console/mock-data"; +import { useDiscoveryModel } from "@/lib/console/useDiscoveryModel"; +import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton"; import { getAppIcon } from "@/lib/console/utils"; import PlaygroundForm from "@/components/console/playground/PlaygroundForm"; import JsonInput from "@/components/console/playground/JsonInput"; @@ -37,6 +38,15 @@ import PlaygroundOutput from "@/components/console/playground/PlaygroundOutput"; import TranscodingOutput from "@/components/console/playground/TranscodingOutput"; import CodeSnippets from "@/components/console/playground/CodeSnippets"; import WebcamPlayground from "@/components/console/playground/WebcamPlayground"; +import { + RunnerGatewayProvider, + useRunnerGatewayContext, +} from "@/components/console/playground/RunnerGatewayContext"; +import { + buildLiveRunnerPayload, + extractRunnerResultText, + runnerGatewayPostUrl, +} from "@/lib/console/runner-gateway-client"; import AppAnalytics from "@/components/console/stats/AppAnalytics"; import { OverviewTab, SettingsTab } from "@/components/console/AppDetailView"; import type { App, Pipeline, PipelineVisibility } from "@/lib/console/types"; @@ -103,6 +113,19 @@ function modelMatchesRow(catalogId: string, runModel: string): boolean { // ─── Playground Tab ─── function PlaygroundTab({ model }: { model: App }) { + return ( + + + + ); +} + +function PlaygroundTabInner({ model }: { model: App }) { + const { + canRunLive, + state: runnerGatewayState, + signerJwt, + } = useRunnerGatewayContext(); const [inputMode, setInputMode] = useState< "form" | "json" | "python" | "node" | "http" >("form"); @@ -113,12 +136,14 @@ function PlaygroundTab({ model }: { model: App }) { string, unknown > | null>(null); + const [runError, setRunError] = useState(null); - const handleRun = useCallback( + const runMock = useCallback( (values: Record) => { setLastRunValues(values); setIsRunning(true); setResult(null); + setRunError(null); const time = 0.3 + Math.random() * 1.5; setTimeout(() => { setIsRunning(false); @@ -158,6 +183,87 @@ function PlaygroundTab({ model }: { model: App }) { [model] ); + const runLive = useCallback( + async (values: Record) => { + if (runnerGatewayState.status !== "ready") { + runMock(values); + return; + } + + setLastRunValues(values); + setIsRunning(true); + setResult(null); + setRunError(null); + const started = performance.now(); + + try { + const runnerPath = + model.playgroundConfig?.runnerPath?.trim() || "chat/completions"; + const payload = buildLiveRunnerPayload(model, values); + const url = runnerGatewayPostUrl( + runnerGatewayState.gatewayBaseUrl, + runnerGatewayState.runnerAppId, + runnerPath + ); + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const contentType = response.headers.get("content-type") ?? ""; + if (!response.ok) { + let message = `Gateway error (${response.status})`; + try { + const errBody = (await response.json()) as { error?: string }; + if (errBody.error) message = errBody.error; + } catch { + // ignore + } + throw new Error(message); + } + + if (contentType.includes("text/event-stream") && response.body) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let streamed = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + streamed += decoder.decode(value, { stream: true }); + setResult(streamed); + } + } else { + const data = await response.json(); + setResult(extractRunnerResultText(data)); + } + + setInferenceTime( + parseFloat(((performance.now() - started) / 1000).toFixed(1)) + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Run failed"; + setRunError(message); + setResult(null); + } finally { + setIsRunning(false); + } + }, + [model, runMock, runnerGatewayState] + ); + + const handleRun = useCallback( + (values: Record) => { + if (canRunLive) { + void runLive(values); + return; + } + runMock(values); + }, + [canRunLive, runLive, runMock] + ); + // Ctrl+Enter shortcut useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -233,6 +339,7 @@ function PlaygroundTab({ model }: { model: App }) { config={model.playgroundConfig} onRun={handleRun} isRunning={isRunning} + signerJwt={signerJwt} /> )} {inputMode === "json" && ( @@ -246,8 +353,20 @@ function PlaygroundTab({ model }: { model: App }) { inputMode === "node" || inputMode === "http") && (
+ {signerJwt ? ( + + ) : null}
- +
+
+ ); + } + + const { wallet, paymentMethods, invoices } = merchant + ? { + wallet: merchant.wallet, + paymentMethods: merchant.paymentMethods, + invoices: merchant.invoices, + } + : state.status === "ready" + ? state + : { wallet: null, paymentMethods: [], invoices: [] }; + + if (!wallet) { + return null; + } const usageUsd = formatWalletUsd(periodBillableUsdMicros); const billingState = wallet.billingState; const posture = spendPostureBadge(billingState.status); const runway = availableRunway(billingState); const included = includedUsageSummary(billingState); + const poolMeter = + included?.sharedWithApp && periodBillableUsdMicros + ? sharedPoolUsageMeter({ + state: billingState, + actorUsdMicros: periodBillableUsdMicros, + }) + : null; const limitNote = overageLimitNote(billingState); const defaultPm = paymentMethods.find((pm) => pm.isDefault) ?? paymentMethods[0] ?? null; @@ -209,7 +274,20 @@ export default function WalletPanel({

- {included ? ( + {poolMeter ? ( +

+ {poolMeter.label} + {included?.resetsAt + ? ` · resets ${new Date(included.resetsAt).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + } + )}` + : ""} +

+ ) : included ? (

{includedUsageRemainingLabel(included)} {included.resetsAt diff --git a/components/console/settings/BillingSection.tsx b/components/console/settings/BillingSection.tsx index 7bf1005..9cf7ffe 100644 --- a/components/console/settings/BillingSection.tsx +++ b/components/console/settings/BillingSection.tsx @@ -740,7 +740,8 @@ export default function BillingSection() { if ( isCurrent && included && - (included.planId === plan.id || !included.planId) + !included.sharedWithApp && + included.planId === plan.id ) { features.push( `$${included.remainingUsd} of $${included.totalUsd} included left` diff --git a/components/console/settings/EndUserMeBillingNote.tsx b/components/console/settings/EndUserMeBillingNote.tsx index d76ce10..e7084a6 100644 --- a/components/console/settings/EndUserMeBillingNote.tsx +++ b/components/console/settings/EndUserMeBillingNote.tsx @@ -1,10 +1,7 @@ "use client"; import { useEffect, useState } from "react"; - -type MeBillingResponse = - | { mode: "owner_rollup"; code: string } - | { mode: "merchant" }; +import type { MeBillingSurface } from "@/lib/console/pymthouse-me-billing-bff"; export default function EndUserMeBillingNote() { const [note, setNote] = useState(null); @@ -16,7 +13,7 @@ export default function EndUserMeBillingNote() { cache: "no-store", }); if (!response.ok || cancelled) return; - const body = (await response.json()) as MeBillingResponse; + const body = (await response.json()) as MeBillingSurface; if (cancelled) return; if (body.mode === "owner_rollup") { setNote( diff --git a/docs/SSO-MINT-OPERATOR.md b/docs/SSO-MINT-OPERATOR.md index 476860b..43250d6 100644 --- a/docs/SSO-MINT-OPERATOR.md +++ b/docs/SSO-MINT-OPERATOR.md @@ -33,6 +33,6 @@ MCP_OAUTH_BILLING_APP_ID=app_98575870d7ae33589a3f0660 Until `SSO_MINT_*` lands, equivalent names may be `NAAP_MCP_ORIGIN` / `NAAP_MCP_MINT_URL` / `MCP_INTERNAL_MINT_*`. -After mint, Agent may call PymtHouse `GET /api/v1/apps/{app}/me/billing/*` with the composite Bearer. On **owner_rollup** (RS-2 default) money routes return **403** `merchant_billing_required`. That is expected — usage is billed to the app owner, not a per-user prepaid wallet. Merchant-mode apps return 200 retail data. +After mint, Agent may call PymtHouse `GET /api/v1/apps/{app}/me/billing/*` with the composite Bearer. The minted JWT carries `billing_mode`. On **owner_rollup** (RS-2 default) skip money `/me/billing/*` — those routes return **403** `merchant_billing_required` because usage is billed to the app owner. Do not fall back to the M2M owner wallet (that discloses the shared pool). Merchant-mode JWTs may call the money routes. Do not retry those 403s via M2M `GET …/users/{id}/allowances` (that is the owner wallet). diff --git a/lib/console/billing-subscription-state.test.ts b/lib/console/billing-subscription-state.test.ts index b74a5b9..b08854f 100644 --- a/lib/console/billing-subscription-state.test.ts +++ b/lib/console/billing-subscription-state.test.ts @@ -10,6 +10,7 @@ import { formatIncludedUsdMicros, formatPendingCancelDate, isNothingToResumeError, + matchCatalogPlanId, paidCatalogPlanIds, resolveApplicablePendingCancel, resolveTimingPayload, @@ -374,3 +375,29 @@ test("formatBillingPlanPrice prefers Starter included usage over $0 fee", () => "Free included usage", ); }); + +test("matchCatalogPlanId prefers planId then sourcePlan then name", () => { + const catalog = [ + { id: "starter", name: "Starter" }, + { id: "ppu", name: "Pay per use" }, + ]; + assert.equal( + matchCatalogPlanId(catalog, { planId: "ppu", planName: "Pay per use" }), + "ppu" + ); + assert.equal( + matchCatalogPlanId(catalog, { planId: null, planName: null }, { + id: "ppu", + name: "Pay per use", + }), + "ppu" + ); + assert.equal( + matchCatalogPlanId(catalog, { planId: null, planName: "Pay per use" }), + "ppu" + ); + assert.equal( + matchCatalogPlanId(catalog, { planId: "missing", planName: null }), + null + ); +}); diff --git a/lib/console/billing-subscription-state.ts b/lib/console/billing-subscription-state.ts index 247acaf..f975216 100644 --- a/lib/console/billing-subscription-state.ts +++ b/lib/console/billing-subscription-state.ts @@ -108,6 +108,35 @@ export function deriveBillingSubscriptionUiState( return { kind: "none", planId: null }; } +/** + * Catalog row for the live subscription. Prefers `planId`, then included + * `sourcePlan.id`, then name — OpenMeter keys sometimes fail to resolve to + * Neon `plans.id`, which left every row as "Enable pay-per-use". + */ +export function matchCatalogPlanId( + plans: ReadonlyArray<{ id: string; name?: string | null }>, + subscription: { planId: string | null; planName?: string | null } | null, + sourcePlan?: { id: string | null; name: string | null } | null +): string | null { + const ids = [subscription?.planId, sourcePlan?.id] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + for (const id of ids) { + if (plans.some((plan) => plan.id === id)) return id; + } + + const names = [subscription?.planName, sourcePlan?.name] + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)); + for (const name of names) { + const hit = plans.find( + (plan) => (plan.name?.trim() || plan.id).toLowerCase() === name + ); + if (hit) return hit.id; + } + return null; +} + export function deriveBillingPlanAction( subscription: BillingSubscriptionUiState, planId: string diff --git a/lib/console/pymthouse-billing-bff.ts b/lib/console/pymthouse-billing-bff.ts index 8036c98..6c34eee 100644 --- a/lib/console/pymthouse-billing-bff.ts +++ b/lib/console/pymthouse-billing-bff.ts @@ -165,17 +165,21 @@ export async function changeDashboardBillingSubscription(input: { return readPymthouseResponse(response); } -export async function getDashboardUserSubscription( - externalUserId: string -): Promise { - const client = createPmtHouseClientForPublicApp(readPublicClientId()); - const result: UserSubscriptionResponse = - await client.getUserSubscription(externalUserId); +type UserSubscriptionWithLivePlan = UserSubscriptionResponse & { + livePlan?: { id?: string | null; name?: string | null } | null; +}; + +export function mapDashboardUserSubscription( + result: UserSubscriptionWithLivePlan +): DashboardUserSubscription { const sub = result.subscription; const pending = result.pendingCancel ?? null; + const livePlanId = result.livePlan?.id?.trim() || null; + const livePlanName = result.livePlan?.name?.trim() || null; return { - planId: sub?.planId?.trim() || pending?.planId?.trim() || null, - planName: sub?.planName?.trim() || pending?.planName?.trim() || null, + planId: sub?.planId?.trim() || livePlanId || pending?.planId?.trim() || null, + planName: + sub?.planName?.trim() || livePlanName || pending?.planName?.trim() || null, status: sub?.status?.trim() || (pending ? "canceled" : null), subscriptionId: sub?.id?.trim() || pending?.subscriptionId?.trim() || null, currentPeriodEnd: @@ -193,6 +197,16 @@ export async function getDashboardUserSubscription( }; } +export async function getDashboardUserSubscription( + externalUserId: string +): Promise { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + const result = (await client.getUserSubscription( + externalUserId + )) as UserSubscriptionWithLivePlan; + return mapDashboardUserSubscription(result); +} + export async function cancelDashboardUserSubscription( externalUserId: string, opts?: { timing?: string; effectiveAt?: string } diff --git a/lib/console/pymthouse-me-billing-bff.ts b/lib/console/pymthouse-me-billing-bff.ts index 7f7f776..b747f7f 100644 --- a/lib/console/pymthouse-me-billing-bff.ts +++ b/lib/console/pymthouse-me-billing-bff.ts @@ -1,44 +1,82 @@ import "server-only"; -import { mintEndUserAccessToken } from "@/lib/console/pymthouse-bff"; import { - pymthouseAppsOrigin, - readPublicClientId, -} from "@/lib/console/pymthouse-http"; + isMerchantBillingRequiredError, + readAccessTokenBillingMode, + type AppUserInvoice, + type BillingState, + type EndUserMeWallet, + type UserSubscriptionResponse, +} from "@pymthouse/builder-sdk"; + +import { mapDashboardUserSubscription } from "@/lib/console/pymthouse-billing-bff"; +import type { DashboardUserSubscription } from "@/lib/console/pymthouse-billing"; +import { + createPmtHouseClientForPublicApp, + mintEndUserAccessToken, +} from "@/lib/console/pymthouse-bff"; +import { readPublicClientId } from "@/lib/console/pymthouse-http"; +import type { + DashboardOwnerWallet, + DashboardWalletInvoice, + DashboardWalletPaymentMethod, +} from "@/lib/console/pymthouse-wallet"; + +export type MerchantMeBillingBundle = { + mode: "merchant"; + state: BillingState | null; + wallet: DashboardOwnerWallet | null; + subscription: DashboardUserSubscription | null; + paymentMethods: DashboardWalletPaymentMethod[]; + invoices: DashboardWalletInvoice[]; +}; export type MeBillingSurface = | { mode: "owner_rollup"; code: "merchant_billing_required"; } - | { - mode: "merchant"; - allowances: Record | null; - state: Record | null; - subscription: Record | null; - wallet: Record | null; - invoices: Record | null; - paymentMethods: Record | null; - }; + | MerchantMeBillingBundle; + +type UserSubscriptionWithLivePlan = UserSubscriptionResponse & { + livePlan?: { id?: string | null; name?: string | null } | null; +}; + +function asOwnerWallet(wallet: EndUserMeWallet): DashboardOwnerWallet { + return { + clientId: wallet.clientId, + balance: wallet.balance, + paymentMethod: wallet.paymentMethod, + billingState: wallet.billingState, + payPerUsePlans: wallet.payPerUsePlans, + }; +} -async function getMeBilling( - accessToken: string, - suffix: string -): Promise<{ status: number; body: Record | null }> { - const url = `${pymthouseAppsOrigin()}/api/v1/apps/${encodeURIComponent(readPublicClientId())}/me/billing/${suffix}`; - const response = await fetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - cache: "no-store", - }); - const body = (await response.json().catch(() => null)) as Record< - string, - unknown - > | null; - return { status: response.status, body }; +function mapInvoice(invoice: AppUserInvoice): DashboardWalletInvoice { + return { + id: invoice.id, + number: invoice.number, + status: invoice.status, + currency: invoice.currency, + totalAmount: invoice.totalAmount, + issuedAt: invoice.issuedAt, + periodStart: invoice.periodStart, + periodEnd: invoice.periodEnd, + invoiceType: invoice.invoiceType, + }; +} + +async function readMerchantPiece( + load: () => Promise +): Promise { + try { + return await load(); + } catch (error) { + if (isMerchantBillingRequiredError(error)) { + return "rollup"; + } + return null; + } } export async function readSessionMeBilling(input: { @@ -49,31 +87,44 @@ export async function readSessionMeBilling(input: { input.externalUserId, input.email ); - const allowances = await getMeBilling(accessToken, "allowances"); - if ( - allowances.status === 403 && - (allowances.body?.code === "merchant_billing_required" || - allowances.body?.code === "merchant_wallet_required") - ) { + const mintedMode = readAccessTokenBillingMode(accessToken); + if (mintedMode === "owner_rollup") { return { mode: "owner_rollup", code: "merchant_billing_required" }; } - const [billingState, subscription, wallet, invoices, paymentMethods] = + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + + const [stateResult, walletResult, subscriptionResult, pmResult, invoiceResult] = await Promise.all([ - getMeBilling(accessToken, "state"), - getMeBilling(accessToken, "subscription"), - getMeBilling(accessToken, "wallet"), - getMeBilling(accessToken, "invoices"), - getMeBilling(accessToken, "payment-methods"), + readMerchantPiece(() => client.getMeBillingState(accessToken)), + readMerchantPiece(() => client.getMeBillingWallet(accessToken)), + readMerchantPiece(() => client.getMeBillingSubscription(accessToken)), + readMerchantPiece(() => client.getMeBillingPaymentMethods(accessToken)), + readMerchantPiece(() => + client.getMeBillingInvoices(accessToken, { pageSize: 20 }) + ), ]); + if ( + stateResult === "rollup" || + walletResult === "rollup" || + subscriptionResult === "rollup" || + pmResult === "rollup" || + invoiceResult === "rollup" + ) { + return { mode: "owner_rollup", code: "merchant_billing_required" }; + } + return { mode: "merchant", - allowances: allowances.status === 200 ? allowances.body : null, - state: billingState.status === 200 ? billingState.body : null, - subscription: subscription.status === 200 ? subscription.body : null, - wallet: wallet.status === 200 ? wallet.body : null, - invoices: invoices.status === 200 ? invoices.body : null, - paymentMethods: paymentMethods.status === 200 ? paymentMethods.body : null, + state: stateResult, + wallet: walletResult ? asOwnerWallet(walletResult) : null, + subscription: subscriptionResult + ? mapDashboardUserSubscription( + subscriptionResult as UserSubscriptionWithLivePlan + ) + : null, + paymentMethods: pmResult?.paymentMethods ?? [], + invoices: (invoiceResult?.items ?? []).map(mapInvoice), }; } diff --git a/lib/console/useMeBillingSurface.ts b/lib/console/useMeBillingSurface.ts new file mode 100644 index 0000000..61c5a91 --- /dev/null +++ b/lib/console/useMeBillingSurface.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { MeBillingSurface } from "@/lib/console/pymthouse-me-billing-bff"; +import { readResponseJson } from "@/lib/console/read-response-json"; + +type MeBillingState = + | { status: "idle" } + | { status: "loading" } + | { status: "ready"; surface: MeBillingSurface } + | { status: "error"; message: string }; + +/** End-user `/me/billing` surface from the minted JWT's `billing_mode`. */ +export function useMeBillingSurface(enabled: boolean) { + const [state, setState] = useState({ status: "idle" }); + + const load = useCallback(async () => { + if (!enabled) { + setState({ status: "idle" }); + return; + } + + setState({ status: "loading" }); + try { + const response = await fetch("/api/pymthouse/me-billing", { + cache: "no-store", + }); + const body = await readResponseJson( + response + ); + if (!response.ok) { + throw new Error(body.error ?? `Me billing failed (${response.status})`); + } + setState({ status: "ready", surface: body }); + } catch (error) { + setState({ + status: "error", + message: + error instanceof Error ? error.message : "Failed to load me billing", + }); + } + }, [enabled]); + + useEffect(() => { + void load(); + }, [load]); + + return { state, reload: load }; +} diff --git a/lib/console/useOwnerWallet.ts b/lib/console/useOwnerWallet.ts index bf5ffac..7a03ece 100644 --- a/lib/console/useOwnerWallet.ts +++ b/lib/console/useOwnerWallet.ts @@ -37,6 +37,71 @@ type WalletBillingState = | { status: "ready"; wallet: DashboardOwnerWallet } | { status: "error"; message: string }; +/** Checkout actions against `/api/pymthouse/wallet*` (session `externalUserId`). */ +export function useWalletCheckoutActions() { + const startTopUp = useCallback(async (input: { amountUsd: string }) => { + const response = await fetch("/api/pymthouse/wallet/top-up", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amountUsd: input.amountUsd, + successUrl: `${window.location.origin}/usage?topup=succeeded`, + cancelUrl: `${window.location.origin}/usage?topup=canceled`, + }), + }); + const body = await readResponseJson<{ + checkoutUrl?: string; + error?: string; + }>(response); + if (!response.ok || !body.checkoutUrl) { + throw new Error(body.error ?? `Top-up failed (${response.status})`); + } + return { checkoutUrl: body.checkoutUrl }; + }, []); + + const startPaymentMethodCheckout = useCallback(async () => { + const response = await fetch("/api/pymthouse/wallet/payment-methods", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + successUrl: `${window.location.origin}/usage?topup=pm-saved`, + cancelUrl: `${window.location.origin}/usage?topup=canceled`, + }), + }); + const body = await readResponseJson<{ + checkoutUrl?: string; + error?: string; + }>(response); + if (!response.ok || !body.checkoutUrl) { + throw new Error( + body.error ?? `Payment method checkout failed (${response.status})` + ); + } + return { checkoutUrl: body.checkoutUrl }; + }, []); + + const ensureDefaultPaymentMethod = useCallback(async () => { + const response = await fetch("/api/pymthouse/wallet/payment-methods", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ensureDefault: true }), + }); + const body = await readResponseJson<{ error?: string }>(response); + if (!response.ok) { + throw new Error( + body.error ?? + `Ensure default payment method failed (${response.status})` + ); + } + }, []); + + return { + startTopUp, + startPaymentMethodCheckout, + ensureDefaultPaymentMethod, + }; +} + /** Wallet GET only — remaining included usage + plan, without PM/invoice lists. */ export function useWalletBillingState(enabled: boolean) { const [state, setState] = useState({ status: "idle" }); @@ -138,68 +203,18 @@ export function useOwnerWallet(enabled: boolean) { void load(); }, [load]); - const startTopUp = useCallback(async (input: { amountUsd: string }) => { - const response = await fetch("/api/pymthouse/wallet/top-up", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - amountUsd: input.amountUsd, - successUrl: `${window.location.origin}/usage?topup=succeeded`, - cancelUrl: `${window.location.origin}/usage?topup=canceled`, - }), - }); - const body = await readResponseJson<{ - checkoutUrl?: string; - error?: string; - }>(response); - if (!response.ok || !body.checkoutUrl) { - throw new Error(body.error ?? `Top-up failed (${response.status})`); - } - return { checkoutUrl: body.checkoutUrl }; - }, []); - - const startPaymentMethodCheckout = useCallback(async () => { - const response = await fetch("/api/pymthouse/wallet/payment-methods", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - successUrl: `${window.location.origin}/usage?topup=pm-saved`, - cancelUrl: `${window.location.origin}/usage?topup=canceled`, - }), - }); - const body = await readResponseJson<{ - checkoutUrl?: string; - error?: string; - }>(response); - if (!response.ok || !body.checkoutUrl) { - throw new Error( - body.error ?? `Payment method checkout failed (${response.status})` - ); - } - return { checkoutUrl: body.checkoutUrl }; - }, []); + const checkout = useWalletCheckoutActions(); const ensureDefaultPaymentMethod = useCallback(async () => { - const response = await fetch("/api/pymthouse/wallet/payment-methods", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ensureDefault: true }), - }); - const body = await readResponseJson<{ error?: string }>(response); - if (!response.ok) { - throw new Error( - body.error ?? - `Ensure default payment method failed (${response.status})` - ); - } + await checkout.ensureDefaultPaymentMethod(); await load(); - }, [load]); + }, [checkout, load]); return { state, reload: load, - startTopUp, - startPaymentMethodCheckout, + startTopUp: checkout.startTopUp, + startPaymentMethodCheckout: checkout.startPaymentMethodCheckout, ensureDefaultPaymentMethod, }; } diff --git a/lib/console/wallet-settlement-display.test.ts b/lib/console/wallet-settlement-display.test.ts index 7e588b0..22815d0 100644 --- a/lib/console/wallet-settlement-display.test.ts +++ b/lib/console/wallet-settlement-display.test.ts @@ -9,6 +9,7 @@ import { includedUsageRemainingLabel, includedUsageSummary, overageLimitNote, + sharedPoolUsageMeter, spendPostureBadge, } from "./wallet-settlement-display"; @@ -29,6 +30,7 @@ function makeState(overrides: { remaining?: { usdMicros: string; usd: string } | null; utilizationBps?: number | null; leadThreshold?: { usdMicros: string; usd: string }; + subjectType?: BillingState["subject"]["type"]; }): BillingStateWithIncluded { const prepaid = { ...money("0", "0.00"), @@ -48,7 +50,7 @@ function makeState(overrides: { return { asOf: "2026-08-08T00:00:00.000Z", subject: { - type: "owner", + type: overrides.subjectType ?? "owner", externalUserId: null, billingMode: "owner_rollup", }, @@ -243,6 +245,7 @@ describe("includedUsageSummary", () => { includedTotal: { usdMicros: "5000000", usd: "5.00" }, includedConsumed: { usdMicros: "18000", usd: "0.02" }, sourcePlan: { id: "plan_1", name: "Starter", type: "free" }, + subjectType: "end_user", }), ); assert.ok(summary); @@ -255,6 +258,49 @@ describe("includedUsageSummary", () => { "Starter · $4.98 of $5.00 included left", ); }); + + it("flags an owner_rollup pool as shared so actor counts are not its source", () => { + const owner = includedUsageSummary( + makeState({ + includedRemaining: { usdMicros: "2430000", usd: "2.43" }, + includedTotal: { usdMicros: "5000000", usd: "5.00" }, + includedConsumed: { usdMicros: "2570000", usd: "2.57" }, + subjectType: "owner", + }), + ); + assert.equal(owner?.sharedWithApp, true); + assert.equal( + includedUsageRemainingLabel(owner!), + "Plan · $2.43 available", + ); + + const endUser = includedUsageSummary( + makeState({ + includedRemaining: { usdMicros: "2430000", usd: "2.43" }, + includedTotal: { usdMicros: "5000000", usd: "5.00" }, + subjectType: "end_user", + }), + ); + assert.equal(endUser?.sharedWithApp, false); + }); + + it("meters this user's spend against remaining pool, not the $5 grant", () => { + const meter = sharedPoolUsageMeter({ + state: makeState({ + includedRemaining: { usdMicros: "2430000", usd: "2.43" }, + includedTotal: { usdMicros: "5000000", usd: "5.00" }, + includedConsumed: { usdMicros: "2570000", usd: "2.57" }, + prepaid: { usdMicros: "140000", usd: "0.14" }, + spendable: { usdMicros: "2570000", usd: "2.57" }, + subjectType: "owner", + }), + actorUsdMicros: "3000", + }); + assert.equal(meter.actorUsd, "0.003"); + assert.equal(meter.availableUsd, "2.57"); + assert.equal(meter.label, "$0.003 of $2.57 available"); + assert.ok(!meter.label.includes("5.00")); + }); }); describe("overageLimitNote", () => { diff --git a/lib/console/wallet-settlement-display.ts b/lib/console/wallet-settlement-display.ts index db95d08..497b499 100644 --- a/lib/console/wallet-settlement-display.ts +++ b/lib/console/wallet-settlement-display.ts @@ -30,6 +30,13 @@ export function formatWalletUsd(micros: string | null | undefined): string { return microsToUsd(micros).toFixed(2); } +function formatMeterActorUsd(micros: string): string { + const usd = microsToUsd(micros); + if (usd >= 0.01) return usd.toFixed(2); + const trimmed = usd.toFixed(4).replace(/0+$/, "").replace(/\.$/, ""); + return trimmed || "0"; +} + function parseUsdMicros(raw: string | null | undefined): bigint { const trimmed = raw?.trim(); if (!trimmed || !/^-?\d+$/.test(trimmed)) return BigInt(0); @@ -156,6 +163,12 @@ export type IncludedUsageSummary = { planId: string | null; planName: string | null; resetsAt: string | null; + /** + * True when the allowance is the app owner's rollup pool, which every end + * user of the app draws from. Actor-scoped counts (request history, jobs by + * capability) must not be presented as the source of `consumedUsdMicros`. + */ + sharedWithApp: boolean; }; /** @@ -188,6 +201,7 @@ export function includedUsageSummary( planId, planName, resetsAt, + sharedWithApp: state.subject.type === "owner", }; } @@ -195,9 +209,46 @@ export function includedUsageRemainingLabel( summary: IncludedUsageSummary, ): string { const plan = summary.planName ?? "Plan"; + if (summary.sharedWithApp) { + return `${plan} · $${summary.remainingUsd} available`; + } return `${plan} · $${summary.remainingUsd} of $${summary.totalUsd} included left`; } +export type SharedPoolUsageMeter = { + actorUsdMicros: string; + actorUsd: string; + availableUsdMicros: string; + availableUsd: string; + /** `$0.003 of $2.57 available` — actor spend vs remaining pool, not grant total. */ + label: string; +}; + +/** + * Owner-rollup meter: this user's period spend against remaining spendable + * on the shared owner pool. Never uses grant total (the `$5.00`) or pool + * consumed (other users' usage). + */ +export function sharedPoolUsageMeter(input: { + state: BillingState; + actorUsdMicros: string; +}): SharedPoolUsageMeter { + const runway = availableRunway(input.state); + const availableMicros = + parseUsdMicros(runway.usdMicros) > BigInt(0) + ? parseUsdMicros(runway.usdMicros) + : BigInt(0); + const actorUsd = formatMeterActorUsd(input.actorUsdMicros || "0"); + const availableUsd = formatWalletUsd(availableMicros.toString()); + return { + actorUsdMicros: input.actorUsdMicros || "0", + actorUsd, + availableUsdMicros: availableMicros.toString(), + availableUsd, + label: `$${actorUsd} of $${availableUsd} available`, + }; +} + /** When the next invoice goes out, in the customer's terms. */ export function collectionSchedule(state: BillingState): string { const lead = state.collection.leadThreshold; diff --git a/package.json b/package.json index 23d4081..4dfcc4a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@auth0/nextjs-auth0": "^4.27.0", - "@pymthouse/builder-sdk": "^0.6.5", + "@pymthouse/builder-sdk": "0.7.1-rc.0", "framer-motion": "^11.15.0", "geist": "^1.7.0", "jmuxer": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4acd9e..81ce89e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^4.27.0 version: 4.27.0(next@15.5.14(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@pymthouse/builder-sdk': - specifier: ^0.6.5 - version: 0.6.5 + specifier: 0.7.1-rc.0 + version: 0.7.1-rc.0 framer-motion: specifier: ^11.15.0 version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -418,8 +418,8 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} - '@pymthouse/builder-sdk@0.6.5': - resolution: {integrity: sha512-oWQC3y7vTqOKG+fEaZRc7TtbIMJaLZZoS6kCqBzF5M4489wGS31LlE2gwhYDEuVNAeyoJwaK/Uu+mfEe8TwGFw==} + '@pymthouse/builder-sdk@0.7.1-rc.0': + resolution: {integrity: sha512-H1nodCLM7UJsW/OwPW/p1KlwG0G4WRnoQJIgf79cJ9L2rM6kJPkp1vA4rKYLnmLDQ0ODbS9bnxQqaQKtL+b1HA==} engines: {node: '>=20'} '@reduxjs/toolkit@2.11.2': @@ -2379,7 +2379,7 @@ snapshots: '@panva/hkdf@1.2.1': {} - '@pymthouse/builder-sdk@0.6.5': + '@pymthouse/builder-sdk@0.7.1-rc.0': dependencies: oauth4webapi: 3.8.7