diff --git a/src/generation/actions.ts b/src/generation/actions.ts index 7f2a97c..155eaa9 100644 --- a/src/generation/actions.ts +++ b/src/generation/actions.ts @@ -7,7 +7,7 @@ import { creditsFor } from "@/domain/pricing"; import { InsufficientCreditsError, refundCredits, reserveCredits } from "@/server/credits"; import { creditsOfRequest, insertRunningRows, runningRequestIds, settleRequest, type RunContext, type RunRecord } from "@/server/runs"; import { seal, unseal } from "@/server/seal"; -import { requireViewer, type Viewer } from "@/server/session"; +import { UnauthorizedError, requireViewer, type Viewer } from "@/server/session"; import { getModel, parseSettings } from "./catalog"; import type { GenerationPlane } from "./catalog/types"; @@ -18,7 +18,8 @@ import { platformBaseUrl, type KeyMode, } from "./credentials"; -import { createPlatformClient } from "./platform"; +import type { Failure } from "./outcome"; +import { PlatformError, createPlatformClient } from "./platform"; import type { GenerationStatus } from "./platform"; import { toPlatform } from "./to-platform"; @@ -35,23 +36,31 @@ export async function platformKeyStatus(): Promise { return { mode, byok, canEdit: viewer.role === "owner" }; } -export async function savePlatformCredentials(data: unknown): Promise { - const viewer = await requireOwner(); - const { apiKey } = parseCredentialInput(data); - await db - .update(schema.workspace) - .set({ platformKeySealed: seal(apiKey), updatedAt: new Date() }) - .where(eq(schema.workspace.id, viewer.workspace.id)); - return { mode: "byok", byok: true, canEdit: true }; +export async function savePlatformCredentials(data: unknown): Promise { + try { + const viewer = await requireOwner(); + const { apiKey } = parseCredentialInput(data); + await db + .update(schema.workspace) + .set({ platformKeySealed: seal(apiKey), updatedAt: new Date() }) + .where(eq(schema.workspace.id, viewer.workspace.id)); + return { mode: "byok", byok: true, canEdit: true }; + } catch (caught) { + return failureOf(caught); + } } -export async function clearPlatformCredentials(): Promise { - const viewer = await requireOwner(); - await db - .update(schema.workspace) - .set({ platformKeySealed: null, updatedAt: new Date() }) - .where(eq(schema.workspace.id, viewer.workspace.id)); - return { mode: operatorKey() ? "operator" : "none", byok: false, canEdit: true }; +export async function clearPlatformCredentials(): Promise { + try { + const viewer = await requireOwner(); + await db + .update(schema.workspace) + .set({ platformKeySealed: null, updatedAt: new Date() }) + .where(eq(schema.workspace.id, viewer.workspace.id)); + return { mode: operatorKey() ? "operator" : "none", byok: false, canEdit: true }; + } catch (caught) { + return failureOf(caught); + } } async function requireOwner(): Promise { @@ -93,8 +102,17 @@ export type SubmitResult = { /** Submit one generation. The credit debit happens before the platform is asked and is refunded if the ask itself fails; a run the platform accepts - and later fails is refunded when its status settles. */ -export async function submitGeneration(input: SubmitInput): Promise { + and later fails is refunded when its status settles. A refusal comes back + as a `Failure` rather than a throw, so its reason survives production. */ +export async function submitGeneration(input: SubmitInput): Promise { + try { + return await submit(input); + } catch (caught) { + return failureOf(caught); + } +} + +async function submit(input: SubmitInput): Promise { const viewer = await requireViewer(); const model = getModel(input.plane.model); const parsed: GenerationPlane = { ...input.plane, settings: parseSettings(model, input.plane.settings) }; @@ -192,7 +210,26 @@ async function refundForRequest(workspaceId: string, requestId: string, note?: s if (credits > 0) await refundCredits(workspaceId, credits, requestId, note); } -export { InsufficientCreditsError }; +/** Sorts a thrown reason into what the studio can say about it. Anything + unrecognised keeps its message but is logged, since it was not written + for the person on the other side. */ +function failureOf(caught: unknown): Failure { + const message = caught instanceof Error ? caught.message : String(caught); + if (caught instanceof UnauthorizedError) return { error: message, kind: "auth" }; + if (caught instanceof MissingCredentialsError) return { error: message, kind: "credentials" }; + if (caught instanceof InsufficientCreditsError) return { error: message, kind: "credits" }; + if (caught instanceof PlatformError) { + const kind = caught.status === 403 && /credit/i.test(message) ? "platform-credits" : "platform"; + return { error: message, kind }; + } + if (caught instanceof Error && !(caught instanceof TypeError) && !(caught instanceof RangeError)) { + /* Our own guards ("Write a prompt first", "API key must be id:secret") are + plain Errors written for the person; runtime faults are not. */ + return { error: message, kind: "input" }; + } + console.error("[actions] unexpected failure", caught); + return { error: message, kind: "unknown" }; +} function parseRequestIds(data: unknown): string[] { const payload = asObject(data, "Invalid status payload"); diff --git a/src/generation/outcome.ts b/src/generation/outcome.ts new file mode 100644 index 0000000..dd01b31 --- /dev/null +++ b/src/generation/outcome.ts @@ -0,0 +1,33 @@ +/** A server action's failure, carried as data. Next.js replaces the message + of an error thrown from a server action with a generic one in production, + so a reason the person should read has to travel in the return value. */ +export type FailureKind = + | "auth" + | "credentials" + | "credits" + | "platform-credits" + | "platform" + | "input" + | "unknown"; + +export type Failure = { error: string; kind: FailureKind }; + +export function isFailure(value: unknown): value is Failure { + return ( + value !== null && + typeof value === "object" && + typeof (value as { error?: unknown }).error === "string" && + typeof (value as { kind?: unknown }).kind === "string" + ); +} + +/** Thrown on the client once a failure comes back, so one catch path handles + both a transported failure and a network error. */ +export class ActionFailedError extends Error { + readonly kind: FailureKind; + constructor(failure: Failure) { + super(failure.error); + this.name = "ActionFailedError"; + this.kind = failure.kind; + } +} diff --git a/src/studio/key-modal.tsx b/src/studio/key-modal.tsx index 7956faf..efa33be 100644 --- a/src/studio/key-modal.tsx +++ b/src/studio/key-modal.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react"; import { clearPlatformCredentials, savePlatformCredentials, type KeyStatus } from "@/generation/actions"; +import { isFailure } from "@/generation/outcome"; import { CloseIcon } from "./icons"; @@ -31,7 +32,12 @@ export function KeyModal({ setBusy(true); setError(null); try { - onChange(await savePlatformCredentials({ api_key: apiKey })); + const result = await savePlatformCredentials({ api_key: apiKey }); + if (isFailure(result)) { + setError(result.error); + return; + } + onChange(result); onClose(); } catch (caught) { setError(caught instanceof Error ? caught.message : "Could not save the key"); @@ -44,7 +50,12 @@ export function KeyModal({ setBusy(true); setError(null); try { - onChange(await clearPlatformCredentials()); + const result = await clearPlatformCredentials(); + if (isFailure(result)) { + setError(result.error); + return; + } + onChange(result); setApiKey(""); } catch (caught) { setError(caught instanceof Error ? caught.message : "Could not remove the key"); diff --git a/src/studio/use-generation.ts b/src/studio/use-generation.ts index 2956350..a8e650c 100644 --- a/src/studio/use-generation.ts +++ b/src/studio/use-generation.ts @@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { InsufficientCreditsError, submitGeneration, type SubmitInput } from "@/generation/actions"; +import { submitGeneration, type SubmitInput } from "@/generation/actions"; import { getModel } from "@/generation/catalog"; import type { GenerationPlane, Surface } from "@/generation/catalog"; -import { MissingCredentialsError } from "@/generation/credentials"; +import { ActionFailedError, isFailure } from "@/generation/outcome"; import { POLL_DEADLINE_MS, stopWatching, watchRequest } from "@/generation/poll"; import { deleteRuns as deleteRunsAction, listRuns, restoreRuns, setFavorite, setReview } from "@/server/actions/runs"; import type { RunContext, RunRecord as ServerRunRecord } from "@/server/runs"; @@ -52,23 +52,26 @@ export type GenerationApi = { restore: (records: RunRecord[]) => void; }; -export class CreditsNeededError extends Error { - constructor(message: string) { - super(message); - this.name = "CreditsNeededError"; - } -} - export function describeError(caught: unknown): string { const message = caught instanceof Error ? caught.message : String(caught); - if (caught instanceof MissingCredentialsError || message.includes("Missing platform key")) { - return "No platform key is set. Add yours in Settings, or ask the operator to configure one."; - } - if (caught instanceof InsufficientCreditsError || message.startsWith("Not enough credits")) { - return `${message}. Upgrade the plan or top up in Settings.`; + const kind = caught instanceof ActionFailedError ? caught.kind : "unknown"; + switch (kind) { + case "auth": + return "Your session ended. Sign in again to keep generating."; + case "credentials": + return "No platform key is set. Add yours in Settings, or ask the operator to configure one."; + case "credits": + return `${message}. Upgrade the plan or top up in Settings.`; + case "platform-credits": + return "The platform account behind the key has no credits left. Top it up at Higgsfield, or bring your own key in Settings."; + case "platform": + return `The platform rejected the request — ${message}. Adjust the settings and retry; if it repeats, check the key in Settings.`; + case "input": + return message; + default: + if (message.includes("Sign in")) return "Your session ended. Sign in again to keep generating."; + return `Generation failed — ${message}. Try again; if it repeats, check the key in Settings.`; } - if (message.includes("Sign in")) return "Your session ended. Sign in again to keep generating."; - return `Generation failed — ${message}. Try again; if it repeats, check the key in Settings.`; } /** The generation lifecycle, shared by the studio and the campaign page: @@ -188,6 +191,7 @@ export function useGeneration(initial: ServerRunRecord[], initialCredits: number const input: SubmitInput = { plane, context: job.context, expected: slot.skeletons.length, view }; try { const result = await submitGeneration(input); + if (isFailure(result)) throw new ActionFailedError(result); if (!alive.current) return; const records = decorateAll(result.runs); setHistory((prev) => mergeHistory(prev, records)); diff --git a/src/ui/settings.tsx b/src/ui/settings.tsx index 44525f7..591f3a0 100644 --- a/src/ui/settings.tsx +++ b/src/ui/settings.tsx @@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react"; import { PLANS, formatPrice, getPlan } from "@/domain/plans"; import { clearPlatformCredentials, savePlatformCredentials, type KeyStatus } from "@/generation/actions"; +import { isFailure } from "@/generation/outcome"; import { openBillingPortal, startCheckout, type LedgerEntry, type WorkspaceSummary } from "@/server/actions/billing"; const REASONS: Record = { @@ -130,7 +131,12 @@ export function KeyPanel({ initial }: { initial: KeyStatus }) { setBusy(true); setError(null); try { - setStatus(await savePlatformCredentials({ api_key: apiKey })); + const result = await savePlatformCredentials({ api_key: apiKey }); + if (isFailure(result)) { + setError(result.error); + return; + } + setStatus(result); setApiKey(""); } catch (caught) { setError(caught instanceof Error ? caught.message : "Could not save the key"); @@ -143,7 +149,12 @@ export function KeyPanel({ initial }: { initial: KeyStatus }) { setBusy(true); setError(null); try { - setStatus(await clearPlatformCredentials()); + const result = await clearPlatformCredentials(); + if (isFailure(result)) { + setError(result.error); + return; + } + setStatus(result); } catch (caught) { setError(caught instanceof Error ? caught.message : "Could not remove the key"); } finally {