Skip to content
Merged
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
77 changes: 57 additions & 20 deletions src/generation/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand All @@ -35,23 +36,31 @@ export async function platformKeyStatus(): Promise<KeyStatus> {
return { mode, byok, canEdit: viewer.role === "owner" };
}

export async function savePlatformCredentials(data: unknown): Promise<KeyStatus> {
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<KeyStatus | Failure> {
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<KeyStatus> {
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<KeyStatus | Failure> {
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<Viewer> {
Expand Down Expand Up @@ -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<SubmitResult> {
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<SubmitResult | Failure> {
try {
return await submit(input);
} catch (caught) {
return failureOf(caught);
}
}

async function submit(input: SubmitInput): Promise<SubmitResult> {
const viewer = await requireViewer();
const model = getModel(input.plane.model);
const parsed: GenerationPlane = { ...input.plane, settings: parseSettings(model, input.plane.settings) };
Expand Down Expand Up @@ -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");
Expand Down
33 changes: 33 additions & 0 deletions src/generation/outcome.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
15 changes: 13 additions & 2 deletions src/studio/key-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down
36 changes: 20 additions & 16 deletions src/studio/use-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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));
Expand Down
15 changes: 13 additions & 2 deletions src/ui/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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");
Expand All @@ -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 {
Expand Down
Loading