Default Workflow
diff --git a/frontend/src/components/onboarding/InferenceModeStep.tsx b/frontend/src/components/onboarding/InferenceModeStep.tsx
index ffbb9be78..7aabf0101 100644
--- a/frontend/src/components/onboarding/InferenceModeStep.tsx
+++ b/frontend/src/components/onboarding/InferenceModeStep.tsx
@@ -20,7 +20,7 @@ const MODES: {
icon: Cloud,
title: "Use Daydream Cloud",
description: "Use cloud GPU provided by Daydream",
- detail: "Requires Pro account after free trial",
+ detail: "Requires credits — get started with free credits",
},
{
mode: "local",
diff --git a/frontend/src/components/settings/BillingTab.tsx b/frontend/src/components/settings/BillingTab.tsx
new file mode 100644
index 000000000..42db7fcea
--- /dev/null
+++ b/frontend/src/components/settings/BillingTab.tsx
@@ -0,0 +1,306 @@
+import { useState } from "react";
+import { useBilling } from "../../contexts/BillingContext";
+import { redeemCreditCode } from "../../lib/billing";
+import { getDaydreamAPIKey } from "../../lib/auth";
+import { Button } from "../ui/button";
+import { Switch } from "../ui/switch";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "../ui/alert-dialog";
+import { toast } from "sonner";
+
+function RedeemCodeSection({ onRedeemed }: { onRedeemed: () => void }) {
+ const [code, setCode] = useState("");
+ const [isRedeeming, setIsRedeeming] = useState(false);
+
+ const handleRedeem = async () => {
+ const trimmed = code.trim();
+ if (!trimmed) return;
+
+ setIsRedeeming(true);
+ try {
+ const apiKey = getDaydreamAPIKey();
+ if (!apiKey) {
+ toast.error("Please sign in to redeem a code");
+ return;
+ }
+ const result = await redeemCreditCode(apiKey, trimmed);
+ toast.success(
+ `${result.credits} credits added${result.label ? ` — ${result.label}` : ""}`
+ );
+ setCode("");
+ onRedeemed();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to redeem code");
+ } finally {
+ setIsRedeeming(false);
+ }
+ };
+
+ return (
+
+
Redeem Code
+
+ setCode(e.target.value.toUpperCase())}
+ onKeyDown={e => e.key === "Enter" && handleRedeem()}
+ placeholder="DD-XXXX-XXXX"
+ className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
+ disabled={isRedeeming}
+ />
+
+
+
+ Enter a credit code to add credits to your balance.
+
+
+ );
+}
+
+export function BillingTab() {
+ const {
+ tier,
+ credits,
+ subscription,
+ creditsPerMin,
+ toggleOverage,
+ refresh,
+ openCheckout,
+ } = useBilling();
+
+ const [showOverageConfirm, setShowOverageConfirm] = useState(false);
+
+ const handleSubscribe = () => {
+ openCheckout("pro");
+ };
+
+ const estimatedMinutes =
+ credits && creditsPerMin > 0 && credits.balance > 0
+ ? Math.round(credits.balance / creditsPerMin)
+ : null;
+
+ if (tier === "free") {
+ return (
+
+
+ Subscription & Billing
+
+
+ You're on the free plan. Subscribe to get credits for Daydream Cloud.
+
+ {credits && credits.balance > 0 && (
+
+
+ {Math.round(credits.balance)}
+ {" "}
+ welcome credits remaining
+ {estimatedMinutes !== null && (
+
+ {" "}
+ (~{estimatedMinutes} min)
+
+ )}
+
+ )}
+
+
+
+
+
+ Questions about billing?{" "}
+
+ Contact support
+
+
+
+ );
+ }
+
+ const tierLabel = tier === "pro" ? "Pro" : "Max";
+ const tierPrice = tier === "pro" ? "$10/mo" : "$30/mo";
+ const renewDate = subscription?.currentPeriodEnd
+ ? new Date(subscription.currentPeriodEnd).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ : "—";
+
+ return (
+
+
+ Subscription & Billing
+
+
+ {/* Plan info */}
+
+
+ {tierLabel}
+ — {tierPrice}
+ {subscription?.cancelAtPeriodEnd && (
+
+ Cancels {renewDate}
+
+ )}
+ {!subscription?.cancelAtPeriodEnd && (
+
+ Renews {renewDate}
+
+ )}
+
+
+
+ {/* Credits */}
+ {credits && (
+
+
+
+ {Math.round(credits.balance)}
+ {" "}
+ of {Math.round(credits.periodCredits)} credits
+ {estimatedMinutes !== null && (
+
+ {" "}
+ (~{estimatedMinutes} min remaining)
+
+ )}
+
+
+
credits.periodCredits * 0.2
+ ? "bg-green-500"
+ : credits.balance > credits.periodCredits * 0.05
+ ? "bg-amber-400"
+ : "bg-red-500"
+ }`}
+ style={{
+ width: `${Math.min(100, (credits.balance / credits.periodCredits) * 100)}%`,
+ }}
+ />
+
+ {creditsPerMin > 0 && (
+
+ Current rate: {creditsPerMin} credits/min
+
+ )}
+
+ )}
+
+ {/* Overage toggle */}
+
+
{
+ if (checked) {
+ setShowOverageConfirm(true);
+ } else {
+ toggleOverage(false);
+ }
+ }}
+ className="mt-0.5"
+ />
+
+
+ Overage billing
+
+
+ When your monthly credits run out, automatically add 500 credits for
+ $10 (up to 5 times per cycle, $50 max).
+
+
+
+
+ {/* Overage confirmation dialog */}
+
+
+
+ Enable overage billing?
+
+ When your monthly credits run out, you'll be automatically charged{" "}
+
+ $10 for 500 additional credits
+
+ . This can happen up to{" "}
+
+ 5 times per billing cycle ($50 max)
+
+ . You can disable this anytime in Settings.
+
+
+
+ Cancel
+ {
+ toggleOverage(true);
+ setShowOverageConfirm(false);
+ }}
+ >
+ Enable Overage
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+ {/* Redeem code */}
+
+
+
+
+ {/* Help & support */}
+
+ Questions about billing?{" "}
+
+ Contact support
+
+ {" · "}
+
+ How credits work
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/DaydreamAccountSection.tsx b/frontend/src/components/settings/DaydreamAccountSection.tsx
index 4885f738f..5b2232db8 100644
--- a/frontend/src/components/settings/DaydreamAccountSection.tsx
+++ b/frontend/src/components/settings/DaydreamAccountSection.tsx
@@ -220,7 +220,7 @@ export function DaydreamAccountSection({
/>
- Use remote inference for running pipelines.
+ Use Daydream Cloud inference for running workflows.
{!isSignedIn &&
!(status.connected || status.connecting) &&
" Log in required."}
diff --git a/frontend/src/components/ui/play-overlay.tsx b/frontend/src/components/ui/play-overlay.tsx
index e198c9dd9..5c6ced030 100644
--- a/frontend/src/components/ui/play-overlay.tsx
+++ b/frontend/src/components/ui/play-overlay.tsx
@@ -6,6 +6,7 @@ interface PlayOverlayProps {
size?: "sm" | "md" | "lg";
className?: string;
variant?: "default" | "themed";
+ costLabel?: string;
"data-testid"?: string;
"aria-label"?: string;
}
@@ -34,6 +35,7 @@ export function PlayOverlay({
size = "lg",
className = "",
variant = "default",
+ costLabel,
"data-testid": dataTestId,
"aria-label": ariaLabel,
}: PlayOverlayProps) {
@@ -46,15 +48,22 @@ export function PlayOverlay({
if (variant === "themed") {
return (
-
- {isPlaying ? (
-
- ) : (
-
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+ {costLabel && !isPlaying && (
+
+ {costLabel}
+
)}
);
diff --git a/frontend/src/contexts/BillingContext.tsx b/frontend/src/contexts/BillingContext.tsx
new file mode 100644
index 000000000..6c7a915f4
--- /dev/null
+++ b/frontend/src/contexts/BillingContext.tsx
@@ -0,0 +1,392 @@
+import {
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ useCallback,
+ useRef,
+ type ReactNode,
+} from "react";
+import {
+ fetchCreditsBalance,
+ createPortalSession,
+ setOverageEnabled,
+ requestInferenceToken,
+} from "../lib/billing";
+
+const SUBSCRIBE_URL = "https://app.daydream.live/dashboard/usage";
+import { getDaydreamAPIKey } from "../lib/auth";
+import { getDeviceId } from "../lib/deviceId";
+import { openExternalUrl } from "../lib/openExternal";
+import { useCloudStatus } from "../hooks/useCloudStatus";
+import { toast } from "sonner";
+
+// Default GPU type used for rate lookups when the cloud backend doesn't expose
+// one. Scope cloud streams currently default to h100, the highest tier; using
+// it keeps the displayed cost a safe upper bound.
+const DEFAULT_GPU_TYPE = "h100";
+
+export interface BillingState {
+ tier: "free" | "pro" | "max";
+ credits: { balance: number; periodCredits: number } | null;
+ subscription: {
+ status: string;
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+ overageEnabled: boolean;
+ } | null;
+ creditsPerMin: number;
+ allRates: Record
| null;
+ isLoading: boolean;
+ billingError: boolean;
+}
+
+interface BillingContextValue extends BillingState {
+ refresh: () => Promise;
+ openCheckout: (tier: "pro" | "max") => Promise;
+ openPortal: () => Promise;
+ toggleOverage: (enabled: boolean) => Promise;
+ showPaywall: boolean;
+ setShowPaywall: (show: boolean) => void;
+ paywallReason: "credits_exhausted" | "subscribe" | null;
+ setPaywallReason: (reason: "credits_exhausted" | "subscribe" | null) => void;
+ /** Get a valid inference token (requests new one if expired) */
+ getInferenceToken: () => Promise;
+}
+
+const defaultState: BillingContextValue = {
+ tier: "free",
+ credits: null,
+ subscription: null,
+ creditsPerMin: 7.5,
+ allRates: null,
+ isLoading: true,
+ billingError: false,
+ refresh: async () => {},
+ openCheckout: async () => {},
+ openPortal: async () => {},
+ toggleOverage: async () => {},
+ showPaywall: false,
+ setShowPaywall: () => {},
+ paywallReason: null,
+ setPaywallReason: () => {},
+ getInferenceToken: async () => null,
+};
+
+const BillingContext = createContext(defaultState);
+
+export function useBilling() {
+ return useContext(BillingContext);
+}
+
+export function BillingProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState({
+ tier: "free",
+ credits: null,
+ subscription: null,
+ creditsPerMin: 7.5,
+ allRates: null,
+ isLoading: true,
+ billingError: false,
+ });
+ const [showPaywall, setShowPaywall] = useState(false);
+ const [paywallReason, setPaywallReason] = useState<
+ "credits_exhausted" | "subscribe" | null
+ >(null);
+
+ const { isConnected } = useCloudStatus();
+ const pollRef = useRef | null>(null);
+ const bgPollRef = useRef | null>(null);
+
+ // Inference token cache — refresh before 5-min expiry
+ const inferenceTokenRef = useRef<{
+ token: string;
+ expiresAt: number;
+ } | null>(null);
+
+ // Warning thresholds (tracked so we only toast once per threshold)
+ const creditWarningShown = useRef<"none" | "low" | "critical" | "grace">(
+ "none"
+ );
+ const upsellShown = useRef(false);
+
+ const refresh = useCallback(async () => {
+ try {
+ const apiKey = getDaydreamAPIKey();
+ if (!apiKey) {
+ setState(prev => ({ ...prev, isLoading: false }));
+ return;
+ }
+
+ const deviceId = getDeviceId();
+ const data = await fetchCreditsBalance(apiKey, deviceId);
+ // creditsPerMin can be a number (old API) or Record (new API)
+ const rawRate = data.creditsPerMin;
+ const rateMap =
+ typeof rawRate === "object" && rawRate !== null
+ ? (rawRate as Record)
+ : null;
+ const scopeRate = rateMap
+ ? (rateMap[DEFAULT_GPU_TYPE] ?? rateMap.h100 ?? 7.5)
+ : (rawRate as number);
+
+ setState({
+ tier: data.tier,
+ credits: data.credits,
+ subscription: data.subscription,
+ creditsPerMin: scopeRate,
+ allRates: rateMap,
+ isLoading: false,
+ billingError: false,
+ });
+ } catch (err) {
+ console.error("[Billing] Failed to refresh:", err);
+ setState(prev => ({ ...prev, isLoading: false, billingError: true }));
+ }
+ }, []);
+
+ // Initial load + react to auth changes (sign-in / sign-out / token refresh).
+ // Independent of cloud status: a signed-in user should see their plan and
+ // credit balance even when they aren't actively streaming.
+ useEffect(() => {
+ refresh();
+ const handler = () => {
+ refresh();
+ };
+ window.addEventListener("daydream-auth-change", handler);
+ window.addEventListener("daydream-auth-success", handler);
+ window.addEventListener("daydream-auth-error", handler);
+ return () => {
+ window.removeEventListener("daydream-auth-change", handler);
+ window.removeEventListener("daydream-auth-success", handler);
+ window.removeEventListener("daydream-auth-error", handler);
+ };
+ }, [refresh]);
+
+ // Poll balance every 15s while cloud-connected (live credit drain updates).
+ useEffect(() => {
+ if (isConnected) {
+ refresh();
+ pollRef.current = setInterval(refresh, 15_000);
+ } else {
+ if (pollRef.current) clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ };
+ }, [isConnected, refresh]);
+
+ // Background poll every 30s when authenticated but not streaming, so top-ups,
+ // subscription changes, and credit deductions are reflected in the UI.
+ // Pauses when the window is hidden to save resources.
+ useEffect(() => {
+ if (isConnected) {
+ // Fast poll above handles this case — skip background poll.
+ if (bgPollRef.current) clearInterval(bgPollRef.current);
+ bgPollRef.current = null;
+ return;
+ }
+
+ const apiKey = getDaydreamAPIKey();
+ if (!apiKey) return;
+
+ const startBgPoll = () => {
+ if (bgPollRef.current) clearInterval(bgPollRef.current);
+ bgPollRef.current = setInterval(refresh, 30_000);
+ };
+
+ const stopBgPoll = () => {
+ if (bgPollRef.current) clearInterval(bgPollRef.current);
+ bgPollRef.current = null;
+ };
+
+ const onVisibility = () => {
+ if (document.hidden) {
+ stopBgPoll();
+ } else {
+ refresh(); // Immediately refresh when tab becomes visible
+ startBgPoll();
+ }
+ };
+
+ if (!document.hidden) startBgPoll();
+ document.addEventListener("visibilitychange", onVisibility);
+
+ return () => {
+ stopBgPoll();
+ document.removeEventListener("visibilitychange", onVisibility);
+ };
+ }, [isConnected, refresh]);
+
+ // Low credit warnings — toast once per threshold, with grace period warning
+ useEffect(() => {
+ if (!isConnected || !state.credits || state.tier === "free") {
+ creditWarningShown.current = "none";
+ upsellShown.current = false;
+ return;
+ }
+ const { balance, periodCredits } = state.credits;
+ const pct = periodCredits > 0 ? balance / periodCredits : 1;
+ const minutesLeft =
+ state.creditsPerMin > 0 ? Math.round(balance / state.creditsPerMin) : 0;
+
+ // Grace period: ~1 min of credits left — warn that stream will end soon
+ if (
+ minutesLeft <= 1 &&
+ balance > 0 &&
+ creditWarningShown.current !== "grace"
+ ) {
+ creditWarningShown.current = "grace";
+ toast.warning(
+ "Your stream will end in about 1 minute. Add credits to keep going.",
+ {
+ duration: 60000,
+ action: {
+ label: "Add Credits",
+ onClick: () => {
+ setPaywallReason("credits_exhausted");
+ setShowPaywall(true);
+ },
+ },
+ }
+ );
+ } else if (
+ pct <= 0.05 &&
+ creditWarningShown.current !== "critical" &&
+ creditWarningShown.current !== "grace"
+ ) {
+ creditWarningShown.current = "critical";
+ toast.warning(
+ `Credits critically low — ${Math.round(balance)} credits remaining (~${minutesLeft} min)`,
+ { duration: 10000 }
+ );
+ } else if (
+ pct <= 0.15 &&
+ pct > 0.05 &&
+ creditWarningShown.current === "none"
+ ) {
+ creditWarningShown.current = "low";
+ toast.warning(
+ `Credits running low — ${Math.round(balance)} credits remaining (~${minutesLeft} min)`
+ );
+ }
+
+ // Proactive upsell at 80% usage for Pro tier
+ if (
+ state.tier === "pro" &&
+ pct <= 0.2 &&
+ pct > 0.05 &&
+ !upsellShown.current
+ ) {
+ upsellShown.current = true;
+ toast.info(
+ "Running low on credits? Upgrade to Max for more credits per month.",
+ { duration: 8000 }
+ );
+ }
+ }, [isConnected, state.credits, state.tier, state.creditsPerMin]);
+
+ // Listen for credits-exhausted events from API error handling
+ useEffect(() => {
+ const handler = () => {
+ setPaywallReason("credits_exhausted");
+ setShowPaywall(true);
+ };
+ window.addEventListener("billing:credits-exhausted", handler);
+ return () =>
+ window.removeEventListener("billing:credits-exhausted", handler);
+ }, []);
+
+ const getInferenceToken = useCallback(async (): Promise => {
+ // Return cached token if still valid (with 60s buffer)
+ const cached = inferenceTokenRef.current;
+ if (cached && cached.expiresAt > Date.now() + 60_000) {
+ return cached.token;
+ }
+
+ try {
+ const apiKey = getDaydreamAPIKey();
+ if (!apiKey) return null;
+ const deviceId = getDeviceId();
+ const result = await requestInferenceToken(apiKey, deviceId);
+
+ if (!result.authorized || !result.token) {
+ inferenceTokenRef.current = null;
+ return null;
+ }
+
+ inferenceTokenRef.current = {
+ token: result.token,
+ expiresAt: new Date(result.expiresAt!).getTime(),
+ };
+ return result.token;
+ } catch (err) {
+ console.error("[Billing] Failed to get inference token:", err);
+ return null;
+ }
+ }, []);
+
+ const openPortal = useCallback(async () => {
+ try {
+ const apiKey = getDaydreamAPIKey();
+ if (!apiKey) {
+ toast.error("Please sign in first");
+ return;
+ }
+ const { portalUrl } = await createPortalSession(apiKey);
+ openExternalUrl(portalUrl);
+ toast.info("Opening subscription management in your browser...");
+ } catch (err) {
+ console.error("[Billing] Portal failed:", err);
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ toast.error(`Failed to open subscription management: ${msg}`, {
+ description: "If this persists, contact support@daydream.live",
+ });
+ }
+ }, []);
+
+ const openCheckout = useCallback(async (_tier: "pro" | "max") => {
+ openExternalUrl(SUBSCRIBE_URL);
+ }, []);
+
+ const toggleOverage = useCallback(
+ async (enabled: boolean) => {
+ try {
+ const apiKey = getDaydreamAPIKey();
+ if (!apiKey) return;
+ await setOverageEnabled(apiKey, enabled);
+ toast.success(
+ enabled ? "Overage billing enabled" : "Overage billing disabled"
+ );
+ await refresh();
+ } catch (err) {
+ console.error("[Billing] Overage toggle failed:", err);
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ toast.error(`Failed to update overage setting: ${msg}`, {
+ description: "If this persists, contact support@daydream.live",
+ });
+ }
+ },
+ [refresh]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx
index 2c6141bc8..290afe447 100644
--- a/frontend/src/contexts/OnboardingContext.tsx
+++ b/frontend/src/contexts/OnboardingContext.tsx
@@ -66,6 +66,7 @@ type OnboardingAction =
type: "LOADED";
completed: boolean;
onboardingStyle?: "teaching" | "simple" | null;
+ inferenceMode?: "local" | "cloud" | null;
};
// ---------------------------------------------------------------------------
@@ -184,6 +185,7 @@ function reducer(
...state,
phase: "idle",
onboardingStyle: action.onboardingStyle ?? null,
+ inferenceMode: action.inferenceMode ?? null,
};
// Check if we're resuming after an auth redirect (sessionStorage flag
// is set right before the redirect and consumed here exactly once)
@@ -268,6 +270,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
type: "LOADED",
completed: status.completed,
onboardingStyle: status.onboarding_style ?? null,
+ inferenceMode:
+ (status.inference_mode as "local" | "cloud" | null) ?? null,
});
});
}, []);
diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts
new file mode 100644
index 000000000..787565ccd
--- /dev/null
+++ b/frontend/src/lib/billing.ts
@@ -0,0 +1,134 @@
+/**
+ * Billing API client for communicating with the Daydream API credits endpoints.
+ */
+
+const DAYDREAM_API_BASE =
+ (import.meta.env.VITE_DAYDREAM_API_BASE as string | undefined) ||
+ "https://api.daydream.live";
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export interface CreditsBalance {
+ tier: "free" | "pro" | "max";
+ credits: {
+ balance: number;
+ periodCredits: number;
+ rolloverBalance?: number;
+ total?: number;
+ apiBalance?: number;
+ lastApiResetMonth?: string | null;
+ } | null;
+ subscription: {
+ status: string;
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+ overageEnabled: boolean;
+ } | null;
+ creditsPerMin: number | Record;
+}
+
+// ─── API functions ───────────────────────────────────────────────────────────
+
+function headers(apiKey: string | null): Record {
+ const h: Record = { "Content-Type": "application/json" };
+ if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
+ return h;
+}
+
+export async function fetchCreditsBalance(
+ apiKey: string,
+ deviceId?: string
+): Promise {
+ const url = deviceId
+ ? `${DAYDREAM_API_BASE}/credits/balance?deviceId=${encodeURIComponent(deviceId)}`
+ : `${DAYDREAM_API_BASE}/credits/balance`;
+ const res = await fetch(url, { headers: headers(apiKey) });
+ if (!res.ok)
+ throw new Error(`Failed to fetch credits balance: ${res.status}`);
+ return res.json();
+}
+
+export async function createCheckoutSession(
+ apiKey: string,
+ tier: "pro" | "max"
+): Promise<{ checkoutUrl: string }> {
+ const res = await fetch(`${DAYDREAM_API_BASE}/credits/checkout`, {
+ method: "POST",
+ headers: headers(apiKey),
+ body: JSON.stringify({ tier }),
+ });
+ if (!res.ok)
+ throw new Error(`Failed to create checkout session: ${res.status}`);
+ return res.json();
+}
+
+export async function createPortalSession(
+ apiKey: string
+): Promise<{ portalUrl: string }> {
+ const res = await fetch(`${DAYDREAM_API_BASE}/credits/portal`, {
+ method: "POST",
+ headers: headers(apiKey),
+ });
+ if (!res.ok)
+ throw new Error(`Failed to create portal session: ${res.status}`);
+ return res.json();
+}
+
+export async function setOverageEnabled(
+ apiKey: string,
+ enabled: boolean
+): Promise {
+ const res = await fetch(`${DAYDREAM_API_BASE}/credits/overage`, {
+ method: "POST",
+ headers: headers(apiKey),
+ body: JSON.stringify({ enabled }),
+ });
+ if (!res.ok) throw new Error(`Failed to set overage: ${res.status}`);
+}
+
+export interface RedeemCodeResponse {
+ credits: number;
+ label: string | null;
+ newBalance: number;
+}
+
+// ─── Inference token ──────────────────────────────────────────────────────
+
+export interface InferenceTokenResponse {
+ authorized: boolean;
+ token?: string;
+ expiresAt?: string;
+ reason?: string;
+}
+
+export async function requestInferenceToken(
+ apiKey: string,
+ deviceId?: string
+): Promise {
+ const res = await fetch(`${DAYDREAM_API_BASE}/credits/inference-token`, {
+ method: "POST",
+ headers: headers(apiKey),
+ body: JSON.stringify({ deviceId }),
+ });
+ // 403 is expected when unauthorized — parse the body, don't throw
+ if (!res.ok && res.status !== 403) {
+ throw new Error(`Failed to request inference token: ${res.status}`);
+ }
+ return res.json();
+}
+
+export async function redeemCreditCode(
+ apiKey: string,
+ code: string
+): Promise {
+ const res = await fetch(`${DAYDREAM_API_BASE}/credits/codes/redeem`, {
+ method: "POST",
+ headers: headers(apiKey),
+ body: JSON.stringify({ code }),
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => null);
+ throw new Error(body?.message ?? `Failed to redeem code: ${res.status}`);
+ }
+ return res.json();
+}
diff --git a/frontend/src/lib/cloudAdapter.ts b/frontend/src/lib/cloudAdapter.ts
index fce477e3b..850a5b198 100644
--- a/frontend/src/lib/cloudAdapter.ts
+++ b/frontend/src/lib/cloudAdapter.ts
@@ -58,6 +58,19 @@ interface PendingRequest {
timeout: ReturnType;
}
+function dispatchCreditsExhausted(source: string, detail?: unknown): void {
+ try {
+ console.warn("[CloudAdapter] credits exhausted detected:", source, detail);
+ window.dispatchEvent(
+ new CustomEvent("billing:credits-exhausted", {
+ detail: { source, ...(detail ? { info: detail } : {}) },
+ })
+ );
+ } catch (err) {
+ console.error("[CloudAdapter] failed to dispatch credits-exhausted:", err);
+ }
+}
+
export class CloudAdapter {
private ws: WebSocket | null = null;
private wsUrl: string;
@@ -144,6 +157,17 @@ export class CloudAdapter {
this.isReady = false;
this.ws = null;
+ if (
+ event.code === 4020 ||
+ (typeof event.reason === "string" &&
+ event.reason.toLowerCase().includes("credit"))
+ ) {
+ dispatchCreditsExhausted("ws_close", {
+ code: event.code,
+ reason: event.reason,
+ });
+ }
+
// Reject all pending requests
for (const [requestId, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
@@ -209,6 +233,20 @@ export class CloudAdapter {
}
private handleMessage(message: ApiResponse): void {
+ // Credit-exhaustion push messages from the cloud runner
+ if (
+ message.type === "credits_exhausted" ||
+ message.type === "stream_terminated"
+ ) {
+ const reason = (message as unknown as { reason?: string }).reason;
+ if (
+ message.type === "credits_exhausted" ||
+ (typeof reason === "string" && reason.toLowerCase().includes("credit"))
+ ) {
+ dispatchCreditsExhausted(message.type, { reason });
+ }
+ }
+
// Handle response to pending request
if (message.request_id && this.pendingRequests.has(message.request_id)) {
const pending = this.pendingRequests.get(message.request_id)!;
@@ -388,6 +426,12 @@ export class CloudAdapter {
);
if (response.status && response.status >= 400) {
+ if (response.status === 402) {
+ dispatchCreditsExhausted("http_402", {
+ path,
+ error: response.error,
+ });
+ }
throw new Error(
response.error || `API request failed with status ${response.status}`
);
diff --git a/frontend/src/lib/deviceId.ts b/frontend/src/lib/deviceId.ts
new file mode 100644
index 000000000..3e8ff8112
--- /dev/null
+++ b/frontend/src/lib/deviceId.ts
@@ -0,0 +1,24 @@
+const STORAGE_KEY = "daydream_device_id";
+
+/**
+ * Get or create a stable device identifier.
+ * Persisted in localStorage. Falls back to generating a new UUID on first launch.
+ */
+export function getDeviceId(): string {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) return stored;
+ } catch {
+ // localStorage not available
+ }
+
+ const id = crypto.randomUUID();
+
+ try {
+ localStorage.setItem(STORAGE_KEY, id);
+ } catch {
+ // localStorage not available — device ID is ephemeral this session
+ }
+
+ return id;
+}