Skip to content
Open
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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,23 @@ 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

# 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

# Agent MCP SSO + mint (Scenario A). Unset secret/allowlist → mint route 404.
# MCP_INTERNAL_MINT_SECRET=
# MCP_INTERNAL_MINT_ALLOWLIST=https://agent.livepeer.org
# Optional explicit callback URLs; else {allowlist origin}/api/mcp/oauth/callback
# MCP_OAUTH_REDIRECT_ALLOWLIST=https://agent.livepeer.org/api/mcp/oauth/callback
# MCP_OAUTH_BRIDGE_SECRET=
# Non-prod mint requires PYMTHOUSE_PUBLIC_CLIENT_ID=app_98575870d7ae33589a3f0660

168 changes: 158 additions & 10 deletions app/(app)/apps/[id]/page.tsx → app/(app)/apps/[...id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,31 @@ import KeyBadge from "@/components/console/KeyBadge";
import CallsTable from "@/components/console/CallsTable";
import StatusDot from "@/components/console/StatusDot";
import {
getAppById,
effectiveVisibility,
setPipelineVisibility,
organizationSlug,
PIPELINE_APP_IDS,
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";
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";
Expand Down Expand Up @@ -103,6 +113,19 @@ function modelMatchesRow(catalogId: string, runModel: string): boolean {
// ─── Playground Tab ───

function PlaygroundTab({ model }: { model: App }) {
return (
<RunnerGatewayProvider model={model}>
<PlaygroundTabInner model={model} />
</RunnerGatewayProvider>
);
}

function PlaygroundTabInner({ model }: { model: App }) {
const {
canRunLive,
state: runnerGatewayState,
signerJwt,
} = useRunnerGatewayContext();
const [inputMode, setInputMode] = useState<
"form" | "json" | "python" | "node" | "http"
>("form");
Expand All @@ -113,12 +136,14 @@ function PlaygroundTab({ model }: { model: App }) {
string,
unknown
> | null>(null);
const [runError, setRunError] = useState<string | null>(null);

const handleRun = useCallback(
const runMock = useCallback(
(values: Record<string, unknown>) => {
setLastRunValues(values);
setIsRunning(true);
setResult(null);
setRunError(null);
const time = 0.3 + Math.random() * 1.5;
setTimeout(() => {
setIsRunning(false);
Expand Down Expand Up @@ -158,6 +183,87 @@ function PlaygroundTab({ model }: { model: App }) {
[model]
);

const runLive = useCallback(
async (values: Record<string, unknown>) => {
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<string, unknown>) => {
if (canRunLive) {
void runLive(values);
return;
}
runMock(values);
},
[canRunLive, runLive, runMock]
);

// Ctrl+Enter shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -233,6 +339,7 @@ function PlaygroundTab({ model }: { model: App }) {
config={model.playgroundConfig}
onRun={handleRun}
isRunning={isRunning}
signerJwt={signerJwt}
/>
)}
{inputMode === "json" && (
Expand All @@ -246,8 +353,20 @@ function PlaygroundTab({ model }: { model: App }) {
inputMode === "node" ||
inputMode === "http") && (
<div className="flex flex-col">
{signerJwt ? (
<input
type="hidden"
name="signer-jwt"
value={signerJwt}
readOnly
/>
) : null}
<div className="pb-4">
<CodeSnippets model={model} fixedLang={inputMode} />
<CodeSnippets
model={model}
fixedLang={inputMode}
runValues={lastRunValues ?? undefined}
/>
</div>
<div className="flex items-center gap-2 border-t border-hairline pt-4">
<button
Expand Down Expand Up @@ -283,7 +402,22 @@ function PlaygroundTab({ model }: { model: App }) {

{/* Right: Output */}
<div>
<h3 className="mb-4 text-sm font-medium text-fg-faint">Output</h3>
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium text-fg-faint">Output</h3>
{model.runnerAppId && runnerGatewayState.status === "ready" && (
<span className="rounded-full border border-green/30 bg-green/10 px-2 py-0.5 text-[10px] font-medium text-green-bright">
Live runner
</span>
)}
{model.runnerAppId && runnerGatewayState.status === "loading" && (
<span className="text-[10px] text-fg-label">Preparing signer…</span>
)}
</div>
{runError && (
<p className="mb-3 rounded-lg border border-red/30 bg-red/10 px-3 py-2 text-xs text-red-400">
{runError}
</p>
)}
{model.playgroundConfig.playgroundVariant === "transcoding" ? (
<TranscodingOutput
result={result}
Expand Down Expand Up @@ -605,14 +739,22 @@ function JobsTab({

// ─── Main Page ───

function capabilityIdFromParams(id: string | string[] | undefined): string {
if (Array.isArray(id)) {
return id.map((segment) => decodeURIComponent(segment)).join("/");
}
return id ? decodeURIComponent(id) : "";
}

export default function AppDetailPage() {
const { id } = useParams<{ id: string }>();
const params = useParams<{ id: string | string[] }>();
const id = capabilityIdFromParams(params.id);
const { isConnected } = useAuth();
const discovery = useDiscoveryModel(id || undefined);

// The unified app — its catalog face and (always, since unification) its
// deployment manifest under `app.deployment`. One id-based lookup resolves
// both the org's own apps and the third-party catalog models.
const app = getAppById(id);
// The app detail is powered by live Discovery Service data — capability ids
// may contain slashes (catch-all `[...id]`).
const app = discovery.status === "ready" ? discovery.model : undefined;
// Owner/operator chrome (Settings/manage tab, publish controls) lives in the
// stacked apps PR. In the consumer base the app detail is view-only for
// everyone, so owner mode is gated off here; the stacked PR's revert removes
Expand Down Expand Up @@ -671,11 +813,17 @@ export default function AppDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, isOwner]);

if (discovery.status === "loading") {
return <ConsolePageSkeleton withTabs kpiCount={0} withChart={false} />;
}

if (!app) {
return (
<main id="main-content" className="flex flex-1 flex-col bg-dark">
<div className="flex flex-1 flex-col items-center justify-center text-center">
<p className="text-sm text-fg-label">App not found</p>
<p className="text-sm text-fg-label">
{discovery.status === "error" ? discovery.message : "App not found"}
</p>
<Link
href="/explore"
className="mt-3 text-xs text-green-bright hover:underline focus:outline-none rounded"
Expand Down
49 changes: 47 additions & 2 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,57 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

import { auth0 } from "@/lib/auth0";
import LoginPage from "@/components/console/LoginPage";
import {
decodeMcpOauthPendingCookie,
MCP_OAUTH_COMPLETE_PATH,
MCP_OAUTH_PENDING_COOKIE,
parseMcpOauthLoginQuery,
} from "@/lib/console/mcp-oauth-login-bridge";

export default async function LoginRoute({
searchParams,
}: {
searchParams: Promise<{
mcp_oauth?: string;
mcp_bridge?: string;
state?: string;
redirect_uri?: string;
}>;
}) {
const params = await searchParams;
if (params.mcp_oauth === "1") {
const parsed = parseMcpOauthLoginQuery({
mcpOauth: params.mcp_oauth,
state: params.state,
redirectUri: params.redirect_uri,
});
if (!parsed.ok) {
redirect("/login");
}
const begin = new URLSearchParams({
state: parsed.pending.state,
redirect_uri: parsed.pending.redirectUri,
});
redirect(`/api/v1/auth/mcp/begin?${begin.toString()}`);
}

const jar = await cookies();
const pending = decodeMcpOauthPendingCookie(
jar.get(MCP_OAUTH_PENDING_COOKIE)?.value
);
const mcpBridge = params.mcp_bridge === "1" && pending !== null;

export default async function LoginRoute() {
const session = await auth0.getSession();
if (session) {
if (mcpBridge) {
redirect(MCP_OAUTH_COMPLETE_PATH);
}
redirect("/home");
}

return <LoginPage />;
return (
<LoginPage returnTo={mcpBridge ? MCP_OAUTH_COMPLETE_PATH : "/home"} />
);
}
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 });
}
}
Loading