diff --git a/frontend/src/components/settings/CloudGpuSelector.tsx b/frontend/src/components/settings/CloudGpuSelector.tsx new file mode 100644 index 000000000..7104116e6 --- /dev/null +++ b/frontend/src/components/settings/CloudGpuSelector.tsx @@ -0,0 +1,241 @@ +/** + * CloudGpuSelector — split button + dropdown for picking a cloud GPU. + * + * Replaces the on/off Switch in Settings → Daydream Account. The main button + * reflects the currently-selected (or last-used) GPU, and the caret opens a + * dropdown with the three GPU options + per-minute credit cost. Selecting an + * item persists the choice and immediately initiates a cloud connection. + * + * States (driven off `useCloudStatus()`): + * - Disconnected: "Run on {GPU}" + [caret] + * - Connecting: "Connecting to {GPU}…" + [X cancel], main button disabled + * - Connected: "Connected to {GPU}" + [caret] → "Switch GPU" / "Disconnect" + * + * Onboarding paths (CloudAuthStep, CloudConnectingStep) still call + * `connectToCloud()` with no argument, so first-time users always land on H100 + * regardless of what this component has stored. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { ChevronDown, Loader2, X } from "lucide-react"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { useCloudStatus } from "../../hooks/useCloudStatus"; +import { connectToCloud } from "../../lib/cloudApi"; +import { + CLOUD_GPUS, + cloudGpuLabel, + formatCreditsPerMin, + getStoredCloudGpu, + setStoredCloudGpu, + type CloudGpu, +} from "../../lib/cloudGpu"; + +interface CloudGpuSelectorProps { + /** Disable interaction (e.g. when not signed in or when streaming). */ + disabled?: boolean; + /** Called after a successful connect transition so the pipeline list refreshes. */ + onPipelinesRefresh?: () => Promise; +} + +export function CloudGpuSelector({ + disabled = false, + onPipelinesRefresh, +}: CloudGpuSelectorProps) { + const { status, refresh } = useCloudStatus(); + const [selectedGpu, setSelectedGpu] = useState(() => + getStoredCloudGpu() + ); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + /** When true, auto-open the dropdown as soon as we return to disconnected. */ + const pendingSwitchRef = useRef(false); + const prevConnectedRef = useRef(false); + + // Fire onPipelinesRefresh on the connecting → connected transition. + useEffect(() => { + if (!prevConnectedRef.current && status.connected) { + onPipelinesRefresh?.().catch(e => + console.error("[CloudGpuSelector] Failed to refresh pipelines:", e) + ); + } + prevConnectedRef.current = status.connected; + }, [status.connected, onPipelinesRefresh]); + + // After a "Switch GPU" click, auto-open the picker when disconnect lands. + useEffect(() => { + if (pendingSwitchRef.current && !status.connected && !status.connecting) { + pendingSwitchRef.current = false; + setOpen(true); + } + }, [status.connected, status.connecting]); + + const disconnect = useCallback(async (): Promise => { + try { + const res = await fetch("/api/v1/cloud/disconnect", { method: "POST" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || "Disconnect failed"); + } + await refresh(); + return true; + } catch (e) { + const msg = e instanceof Error ? e.message : "Disconnect failed"; + setError(msg); + console.error("[CloudGpuSelector] Disconnect failed:", e); + return false; + } + }, [refresh]); + + const handlePickGpu = useCallback( + async (gpu: CloudGpu) => { + setSelectedGpu(gpu); + setStoredCloudGpu(gpu); + setOpen(false); + setError(null); + setBusy(true); + try { + const res = await connectToCloud(gpu); + if (!res || !res.ok) { + const data = res ? await res.json().catch(() => ({})) : {}; + throw new Error(data.detail || "Connection failed"); + } + await refresh(); + } catch (e) { + const msg = e instanceof Error ? e.message : "Connection failed"; + setError(msg); + console.error("[CloudGpuSelector] Connect failed:", e); + } finally { + setBusy(false); + } + }, + [refresh] + ); + + const handleCancel = useCallback(async () => { + setBusy(true); + await disconnect(); + setBusy(false); + }, [disconnect]); + + const handleDisconnect = useCallback(async () => { + setOpen(false); + setBusy(true); + pendingSwitchRef.current = false; + await disconnect(); + setBusy(false); + }, [disconnect]); + + const handleSwitchGpu = useCallback(async () => { + setOpen(false); + setBusy(true); + pendingSwitchRef.current = true; + const ok = await disconnect(); + if (!ok) pendingSwitchRef.current = false; + setBusy(false); + }, [disconnect]); + + const { connected, connecting } = status; + const label = cloudGpuLabel(selectedGpu); + + // Main button text varies with state. + let mainText: string; + if (connecting) mainText = `Connecting to ${label}…`; + else if (connected) mainText = `Connected to ${label}`; + else mainText = `Run on ${label}`; + + const mainDisabled = disabled || connecting || busy; + const caretDisabled = disabled || busy; + + const handleMainClick = () => { + if (mainDisabled) return; + setOpen(true); + }; + + return ( +
+ +
+ {/* Main (left) button — opens dropdown in disconnected/connected states. */} + + + {/* Right (caret or cancel). */} + {connecting ? ( + + ) : ( + + + + )} +
+ + {/* Dropdown content differs between disconnected and connected. */} + {connected ? ( + + + Switch GPU + + + Disconnect + + + ) : ( + + {CLOUD_GPUS.map(g => ( + handlePickGpu(g.id)} + className="justify-between gap-6" + > + {g.label} + + {formatCreditsPerMin(g.creditsPerMin)} + + + ))} + + )} +
+ + {(error || status.error) && ( +

{error || status.error}

+ )} +
+ ); +} diff --git a/frontend/src/components/settings/DaydreamAccountSection.tsx b/frontend/src/components/settings/DaydreamAccountSection.tsx index 4885f738f..5adb64f0d 100644 --- a/frontend/src/components/settings/DaydreamAccountSection.tsx +++ b/frontend/src/components/settings/DaydreamAccountSection.tsx @@ -3,13 +3,12 @@ * * Displays: * - Not logged in: Sign in/Sign up buttons - * - Logged in: User info, Manage/Log out buttons, Cloud Mode toggle - * - Cloud connecting/connected states + * - Logged in: User info, Manage/Log out buttons, Cloud GPU selector + * - Cloud connecting/connected states (inside CloudGpuSelector) */ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { Button } from "../ui/button"; -import { Switch } from "../ui/switch"; import { Cloud, Copy, Check } from "lucide-react"; import { isAuthenticated, @@ -18,13 +17,13 @@ import { getDaydreamUserDisplayName, refreshUserProfile, } from "../../lib/auth"; -import { connectToCloud } from "../../lib/cloudApi"; import { useCloudStatus } from "../../hooks/useCloudStatus"; +import { CloudGpuSelector } from "./CloudGpuSelector"; interface DaydreamAccountSectionProps { /** Callback to refresh pipeline list after cloud mode toggle */ onPipelinesRefresh?: () => Promise; - /** Disable the toggle (e.g., when streaming) */ + /** Disable interactive cloud controls (e.g., when streaming) */ disabled?: boolean; } @@ -41,11 +40,7 @@ export function DaydreamAccountSection({ // Use shared cloud status hook - avoids redundant polling with Header const { status, refresh: refreshStatus } = useCloudStatus(); - // Local action state - const [isDisconnecting, setIsDisconnecting] = useState(false); - const [error, setError] = useState(null); const [copied, setCopied] = useState(false); - const prevConnectedRef = useRef(false); // Keep auth state in sync with storage changes and ensure display name is populated useEffect(() => { @@ -71,20 +66,6 @@ export function DaydreamAccountSection({ }; }, []); - // Detect connection completion (connecting → connected) to trigger pipeline refresh - useEffect(() => { - if (!prevConnectedRef.current && status.connected) { - // Just transitioned to connected - onPipelinesRefresh?.().catch(e => - console.error( - "[DaydreamAccountSection] Failed to refresh pipelines:", - e - ) - ); - } - prevConnectedRef.current = status.connected; - }, [status.connected, onPipelinesRefresh]); - const handleCopyConnectionId = async () => { if (status.connection_id) { try { @@ -97,78 +78,22 @@ export function DaydreamAccountSection({ } }; - const handleConnect = async () => { - setError(null); - - try { - const response = await connectToCloud(); - - if (!response || !response.ok) { - const data = response ? await response.json() : {}; - throw new Error(data.detail || "Connection failed"); - } - - // Backend returns immediately with connecting=true - await refreshStatus(); - } catch (e) { - const message = e instanceof Error ? e.message : "Connection failed"; - setError(message); - console.error("[DaydreamAccountSection] Connect failed:", e); - } - }; - - const handleDisconnect = async () => { - setIsDisconnecting(true); - setError(null); - - try { - const response = await fetch("/api/v1/cloud/disconnect", { - method: "POST", - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.detail || "Disconnect failed"); - } - - // Refresh status from shared hook - await refreshStatus(); - - if (onPipelinesRefresh) { - try { - await onPipelinesRefresh(); - } catch (refreshError) { - console.error( - "[DaydreamAccountSection] Failed to refresh pipelines:", - refreshError - ); - } - } - } catch (e) { - const message = e instanceof Error ? e.message : "Disconnect failed"; - setError(message); - console.error("[DaydreamAccountSection] Disconnect failed:", e); - } finally { - setIsDisconnecting(false); - } - }; - - const handleToggle = async (checked: boolean) => { - if (checked) { - await handleConnect(); - } else { - await handleDisconnect(); - } - }; - const handleSignIn = () => { redirectToSignIn(); }; const handleSignOut = async () => { // Disconnect from cloud if connected before signing out - if (status.connected) { - await handleDisconnect(); + if (status.connected || status.connecting) { + try { + await fetch("/api/v1/cloud/disconnect", { method: "POST" }); + await refreshStatus(); + } catch (e) { + console.error( + "[DaydreamAccountSection] Disconnect on sign-out failed:", + e + ); + } } clearDaydreamAuth(); setIsSignedIn(false); @@ -200,23 +125,14 @@ export function DaydreamAccountSection({
-
+
Remote Inference
-

@@ -249,10 +165,6 @@ export function DaydreamAccountSection({

)} - - {(error || status.error) && ( -

{error || status.error}

- )}
); diff --git a/frontend/src/lib/cloudApi.ts b/frontend/src/lib/cloudApi.ts index 7b1857c99..034c29bee 100644 --- a/frontend/src/lib/cloudApi.ts +++ b/frontend/src/lib/cloudApi.ts @@ -1,13 +1,19 @@ import { getDaydreamAPIKey, getDaydreamUserId } from "./auth"; +import type { CloudGpu } from "./cloudGpu"; /** * Connect to the cloud relay. Reads credentials from local auth storage * internally so callers don't need to pass them around. * + * @param gpu Optional GPU selector for Livepeer cloud app routing. When + * omitted, the backend falls back to the SCOPE_CLOUD_GPU env var (or H100). + * Onboarding paths should call with no argument so first-time users always + * land on H100. + * * Returns the fetch Response so callers can inspect status if needed, * or `null` if no user is signed in. */ -export async function connectToCloud(): Promise { +export async function connectToCloud(gpu?: CloudGpu): Promise { const userId = getDaydreamUserId(); if (!userId) return null; @@ -15,6 +21,6 @@ export async function connectToCloud(): Promise { return fetch("/api/v1/cloud/connect", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user_id: userId, api_key: apiKey }), + body: JSON.stringify({ user_id: userId, api_key: apiKey, gpu }), }); } diff --git a/frontend/src/lib/cloudGpu.ts b/frontend/src/lib/cloudGpu.ts new file mode 100644 index 000000000..5613a5cb0 --- /dev/null +++ b/frontend/src/lib/cloudGpu.ts @@ -0,0 +1,57 @@ +/** + * Cloud GPU catalog and local-storage helpers. + * + * The selected GPU is persisted per-browser so the settings selector comes + * back pre-populated with the user's last choice on return visits. + */ + +export type CloudGpu = "h100" | "rtx4090" | "rtx5090"; + +export interface CloudGpuOption { + id: CloudGpu; + label: string; + creditsPerMin: number; +} + +export const CLOUD_GPUS: readonly CloudGpuOption[] = [ + { id: "h100", label: "H100", creditsPerMin: 2.5 }, + { id: "rtx4090", label: "RTX 4090", creditsPerMin: 1.25 }, + { id: "rtx5090", label: "RTX 5090", creditsPerMin: 1.25 }, +]; + +export const DEFAULT_CLOUD_GPU: CloudGpu = "rtx5090"; +export const CLOUD_GPU_STORAGE_KEY = "daydream-cloud-gpu"; + +const VALID_IDS: ReadonlySet = new Set(CLOUD_GPUS.map(g => g.id)); + +function isCloudGpu(value: string | null): value is CloudGpu { + return value !== null && VALID_IDS.has(value); +} + +/** Read last-used GPU from localStorage, falling back to H100. */ +export function getStoredCloudGpu(): CloudGpu { + try { + const raw = window.localStorage.getItem(CLOUD_GPU_STORAGE_KEY); + if (isCloudGpu(raw)) return raw; + } catch { + // localStorage unavailable (private mode, SSR) — fall through + } + return DEFAULT_CLOUD_GPU; +} + +/** Persist the chosen GPU to localStorage. */ +export function setStoredCloudGpu(gpu: CloudGpu): void { + try { + window.localStorage.setItem(CLOUD_GPU_STORAGE_KEY, gpu); + } catch { + // noop + } +} + +export function cloudGpuLabel(gpu: CloudGpu): string { + return CLOUD_GPUS.find(g => g.id === gpu)?.label ?? gpu; +} + +export function formatCreditsPerMin(creditsPerMin: number): string { + return `${creditsPerMin} credits / min`; +} diff --git a/src/scope/server/app.py b/src/scope/server/app.py index ffdd48b93..bbd19dfbd 100644 --- a/src/scope/server/app.py +++ b/src/scope/server/app.py @@ -3124,9 +3124,11 @@ async def connect_to_cloud( ) logger.info( - f"Connecting to cloud (background): {app_id} (user_id: {request.user_id})" + f"Connecting to cloud (background): {app_id} (user_id: {request.user_id}, gpu: {request.gpu})" + ) + await cloud_manager.connect_background( + app_id, api_key, request.user_id, gpu=request.gpu ) - await cloud_manager.connect_background(app_id, api_key, request.user_id) # Invalidate cached pipeline schemas so that when the cloud connection # completes, subsequent requests either proxy to the cloud (returning diff --git a/src/scope/server/cloud_connection.py b/src/scope/server/cloud_connection.py index b918e5605..4c29ada48 100644 --- a/src/scope/server/cloud_connection.py +++ b/src/scope/server/cloud_connection.py @@ -123,7 +123,11 @@ def _publish_cloud_error( ) async def connect( - self, app_id: str, api_key: str, user_id: str | None = None + self, + app_id: str, + api_key: str, + user_id: str | None = None, + gpu: str | None = None, ) -> None: """Connect to cloud. @@ -131,10 +135,13 @@ async def connect( app_id: The cloud app ID (e.g., "username/scope-app") api_key: The cloud API key user_id: Optional user ID for log correlation + gpu: Ignored in dev-relay mode. Accepted to match the LivepeerConnection + signature so the two backends are polymorphic. Raises: RuntimeError: If connection fails or times out """ + del gpu # unused in dev-relay mode if self.is_connected: logger.info("Already connected to cloud, disconnecting first") await self.disconnect() @@ -227,7 +234,11 @@ async def connect( self._receive_task = asyncio.create_task(self._receive_loop()) async def connect_background( - self, app_id: str, api_key: str, user_id: str | None = None + self, + app_id: str, + api_key: str, + user_id: str | None = None, + gpu: str | None = None, ) -> None: """Start connecting to cloud in the background. @@ -238,7 +249,10 @@ async def connect_background( app_id: The cloud app ID api_key: The cloud API key user_id: Optional user ID for log correlation + gpu: Ignored in dev-relay mode. Accepted to match the LivepeerConnection + signature. """ + del gpu # unused in dev-relay mode # Cancel any existing connection task if self._connect_task is not None and not self._connect_task.done(): self._connect_task.cancel() diff --git a/src/scope/server/livepeer.py b/src/scope/server/livepeer.py index 2b0ba119d..fe889de36 100644 --- a/src/scope/server/livepeer.py +++ b/src/scope/server/livepeer.py @@ -87,12 +87,14 @@ async def connect( app_id: str | None = None, api_key: str | None = None, user_id: str | None = None, + gpu: str | None = None, ) -> None: """Create and connect a persistent Livepeer job.""" # Keep connect signature compatible with cloud-style connect requests. # app_id can be used as optional runner routing config (derived into a # fal ws_url in the client). api_key is forwarded so Livepeer startup can - # include Daydream signer metadata. + # include Daydream signer metadata. gpu selects a GPU-specific Fal app + # when app_id is not explicitly set. self._user_id = user_id if self.is_connected: @@ -119,6 +121,7 @@ async def connect( model_id=LIVEPEER_MODEL_ID, app_id=app_id, api_key=api_key, + gpu=gpu, ) try: @@ -146,6 +149,7 @@ async def connect_background( app_id: str | None = None, api_key: str | None = None, user_id: str | None = None, + gpu: str | None = None, ) -> None: """Start Livepeer connection in the background.""" logger.info("Cloud connect requested in Livepeer mode") @@ -161,7 +165,9 @@ async def connect_background( async def _do_connect(): try: - await self.connect(app_id=app_id, api_key=api_key, user_id=user_id) + await self.connect( + app_id=app_id, api_key=api_key, user_id=user_id, gpu=gpu + ) except Exception as e: self._connecting = False self._connect_error = str(e) diff --git a/src/scope/server/livepeer_client.py b/src/scope/server/livepeer_client.py index e3067a202..c51d9da48 100644 --- a/src/scope/server/livepeer_client.py +++ b/src/scope/server/livepeer_client.py @@ -70,17 +70,20 @@ def _normalize_optional_string(value: str | None) -> str | None: def _resolve_livepeer_app_id( app_id: str | None, + gpu: str | None = None, ) -> str | None: normalized_app_id = _normalize_optional_string(app_id) if normalized_app_id is not None: return normalized_app_id.strip("/") - gpu = _normalize_optional_string(os.getenv(SCOPE_CLOUD_GPU_ENV)) - if gpu not in {None, "h100", "rtx4090", "rtx5090"}: + resolved_gpu = _normalize_optional_string(gpu) or _normalize_optional_string( + os.getenv(SCOPE_CLOUD_GPU_ENV) + ) + if resolved_gpu not in {None, "h100", "rtx4090", "rtx5090"}: raise ValueError( - "Invalid SCOPE_CLOUD_GPU. Expected `h100`, `rtx4090`, `rtx5090`, or unset." + "Invalid GPU. Expected `h100`, `rtx4090`, `rtx5090`, or unset." ) - gpu_suffix = "" if gpu in {None, "h100"} else f"-{gpu}" + gpu_suffix = "" if resolved_gpu in {None, "h100"} else f"-{resolved_gpu}" return f"daydream/scope-livepeer{gpu_suffix}--prod/ws" @@ -143,6 +146,7 @@ def __init__( model_id: str, app_id: str | None = None, api_key: str | None = None, + gpu: str | None = None, fps: float = 30.0, ): self._token = token @@ -153,7 +157,7 @@ def __init__( os.getenv(LIVEPEER_ORCH_URL_ENV) ) env_ws_url = self._normalize_ws_url(os.getenv(LIVEPEER_WS_URL_ENV)) - ws_url = self._ws_url_from_app_id(app_id) + ws_url = self._ws_url_from_app_id(app_id, gpu) # Keep explicit ws URL support; app id support is a convenient fallback. self._ws_url = env_ws_url or ws_url @@ -336,8 +340,8 @@ def _normalize_ws_url(value: str | None) -> str | None: return trimmed @staticmethod - def _ws_url_from_app_id(value: str | None) -> str | None: - resolved_app_id = _resolve_livepeer_app_id(value) + def _ws_url_from_app_id(value: str | None, gpu: str | None = None) -> str | None: + resolved_app_id = _resolve_livepeer_app_id(value, gpu) if not resolved_app_id: return None try: diff --git a/src/scope/server/schema.py b/src/scope/server/schema.py index 883791df1..6b0ed4753 100644 --- a/src/scope/server/schema.py +++ b/src/scope/server/schema.py @@ -761,6 +761,14 @@ class CloudConnectRequest(BaseModel): default=None, description="The user ID for logging purposes.", ) + gpu: Literal["h100", "rtx4090", "rtx5090"] | None = Field( + default=None, + description=( + "GPU selector for Livepeer cloud app routing. When unset, the server " + "falls back to the SCOPE_CLOUD_GPU env var, or H100 if neither is set. " + "Ignored when an explicit app_id is provided." + ), + ) class CloudConnectionStats(BaseModel):