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
20 changes: 18 additions & 2 deletions apps/dashboard/src/components/workflow/WorkflowNodeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { Link } from "react-router-dom";
import { CheckCircle2, CloudUpload, Loader2, Mic, Square, Upload, X, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { NODE_KINDS, type ConfigField } from "@/lib/workflow/nodeKinds";
Expand Down Expand Up @@ -90,14 +91,29 @@ function Elapsed({ run }: { run: NodeRun }) {
function ReadinessBanner({ nodeId, readiness }: { nodeId: string; readiness: Readiness }) {
const { deploy } = useCanvas();
if (readiness.state === "ready" || readiness.state === "selectModel" || readiness.state === "deployed") return null;
if (readiness.state === "needsExternal") {
return (
<div className="space-y-1.5 border-b bg-amber-500/5 px-3 py-2 text-[11px]">
<span className="font-medium text-amber-600 dark:text-amber-400">Needs an external deployment</span>
<p className="text-muted-foreground">
Compute pools can't run this model type yet.{" "}
<Link to="/dashboard/deployments/new" className="nodrag text-ember-600 underline">
Add an external endpoint
</Link>
</p>
</div>
);
}
const { recommended } = readiness;
const retry = readiness.state === "waiting" || readiness.state === "failed";
return (
<div className="space-y-1.5 border-b bg-amber-500/5 px-3 py-2 text-[11px]">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-amber-600 dark:text-amber-400">
{readiness.state === "needsDeployment" && "Needs deployment"}
{readiness.state === "deploying" && "Deploying..."}
{readiness.state === "waiting" && "Waiting for compute"}
{readiness.state === "failed" && "Deploy failed"}
</span>
{readiness.state === "deploying" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-500" />
Expand All @@ -108,14 +124,14 @@ function ReadinessBanner({ nodeId, readiness }: { nodeId: string; readiness: Rea
className="nodrag flex items-center gap-1 rounded-md bg-ember-600 px-2 py-0.5 font-medium text-white hover:bg-ember-700"
>
<CloudUpload className="h-3 w-3" />
{readiness.state === "waiting" ? "Retry" : "Deploy"}
{retry ? "Retry" : "Deploy"}
</button>
)}
</div>
<div className="text-muted-foreground">
Recommended <span className="font-mono text-foreground">{recommended.modelId}</span> on {recommended.engine}
</div>
{readiness.state === "waiting" && <p className="break-words text-red-500">{readiness.reason}</p>}
{retry && <p className="break-words text-red-500">{readiness.reason}</p>}
</div>
);
}
Expand Down
29 changes: 27 additions & 2 deletions apps/dashboard/src/components/workflow/useNodeDeploys.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { computeApi } from "@/lib/api";
import { deployRequests, type CatalogModel, type PendingDeploy } from "@/lib/workflow/readiness";
import { listPools } from "@/services/poolService";

// A deploy that ends in one of these never comes up, so the node stops waiting on it.
const DEAD_STATES = new Set(["FAILED", "TERMINATED", "STOPPED"]);

interface PreflightResponse {
ready: boolean;
checks: Array<{ passed: boolean; message?: string | null; check: string }>;
Expand Down Expand Up @@ -43,12 +46,34 @@ export function useNodeDeploys() {
const failed = data.checks.filter((c) => !c.passed).map((c) => c.message || c.check);
return set(nodeId, { status: "waiting", reason: failed.join("; ") || "preflight failed" });
}
await computeApi.post("/deployment/deploy", req.deploy);
const { data: created } = await computeApi.post<{ deployment_id?: string }>("/deployment/deploy", req.deploy);
set(nodeId, { status: "deploying", deploymentName: entry.deploymentName, deploymentId: created?.deployment_id });
} catch (err) {
set(nodeId, { status: "waiting", reason: errorMessage(err) });
}
};

// The ready-deployments list never shows a failed deploy, so poll each one we started.
useEffect(() => {
const watched = Object.entries(pending).flatMap(([nodeId, p]) =>
p.status === "deploying" && p.deploymentId ? [[nodeId, p.deploymentId] as const] : [],
);
if (watched.length === 0) return;
const timer = setInterval(() => {
for (const [nodeId, id] of watched) {
computeApi
.get<{ state?: string; error_message?: string | null }>(`/deployment/status/${id}`)
.then(({ data }) => {
if (data.state && DEAD_STATES.has(data.state)) {
set(nodeId, { status: "failed", reason: data.error_message || `deployment ${data.state.toLowerCase()}` });
}
})
.catch(() => {});
}
}, 5000);
return () => clearInterval(timer);
}, [pending, set]);

const clear = useCallback((nodeId: string) => set(nodeId, null), [set]);
const reset = useCallback(() => setPending({}), []);
return { pending, deploy, clear, reset };
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/src/lib/workflow/__tests__/multimodal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ describe("vision model choice", () => {
it("recommends the catalog models when nothing matches", () => {
expect(nodeReadiness({ kind: "vision", config: {} }, [dep("qwen2-5-1-5b", "inference")])).toEqual({ state: "needsDeployment", recommended: MODEL_CATALOG.vision });
expect(MODEL_CATALOG.vision).toMatchObject({ modelId: "moondream", engine: "ollama", modelType: "inference", deploymentName: "moondream-vision" });
expect(MODEL_CATALOG.imageGeneration).toMatchObject({ modelId: "stabilityai/sd-turbo", engine: "generic", modelType: "image_generation", deploymentName: "sd-turbo-image" });
expect(MODEL_CATALOG.imageGeneration).toMatchObject({ modelId: "stabilityai/sd-turbo", engine: "inferia-diffusion", modelType: "image_generation", deploymentName: "sd-turbo-image" });
expect(MODEL_CATALOG.imageGeneration!.job).toMatchObject({ image: "docker.io/inferiaai/inferiadiffusion:latest", gpu: true });
});
});

Expand Down
55 changes: 38 additions & 17 deletions apps/dashboard/src/lib/workflow/__tests__/readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ function dep(modelName: string, model_type: string): Deployment {
}

const stt = { kind: "speechToText" as const, config: {} };
const whisperCatalog = MODEL_CATALOG.speechToText!;
const llm = { kind: "llm" as const, config: {} };
const qwenCatalog = MODEL_CATALOG.llm!;

describe("nodeReadiness", () => {
it("treats nodes without a model field, or with a model set, as ready", () => {
Expand All @@ -22,29 +23,47 @@ describe("nodeReadiness", () => {
});

it("needs deployment when only non-matching deployments exist", () => {
expect(nodeReadiness(stt, [dep("kokoro-tts", "audio_generation"), dep("bank-llm", "inference")])).toEqual({
expect(nodeReadiness(llm, [dep("whisper-stt", "audio_generation")])).toEqual({
state: "needsDeployment",
recommended: whisperCatalog,
recommended: qwenCatalog,
});
expect(nodeReadiness({ kind: "textToSpeech", config: {} }, [])).toEqual({
state: "needsDeployment",
recommended: MODEL_CATALOG.textToSpeech,
});

it("asks for an external deployment for speech, which no pool engine can run", () => {
expect(nodeReadiness(stt, [dep("kokoro-tts", "audio_generation"), dep("bank-llm", "inference")])).toEqual({
state: "needsExternal",
});
expect(nodeReadiness({ kind: "textToSpeech", config: {} }, [])).toEqual({ state: "needsExternal" });
});

it("follows the pending deploy through deploying, waiting and deployed", () => {
expect(nodeReadiness(stt, [], { status: "deploying", deploymentName: "whisper-tiny-stt" })).toEqual({
it("follows the pending deploy through deploying, waiting, failed and deployed", () => {
expect(nodeReadiness(llm, [], { status: "deploying", deploymentName: "qwen2-5-1-5b" })).toEqual({
state: "deploying",
recommended: whisperCatalog,
recommended: qwenCatalog,
});
expect(nodeReadiness(stt, [], { status: "waiting", reason: "no active compute pool" })).toEqual({
expect(nodeReadiness(llm, [], { status: "waiting", reason: "no active compute pool" })).toEqual({
state: "waiting",
recommended: whisperCatalog,
recommended: qwenCatalog,
reason: "no active compute pool",
});
expect(nodeReadiness(llm, [], { status: "failed", reason: "image pull failed" })).toEqual({
state: "failed",
recommended: qwenCatalog,
reason: "image pull failed",
});
expect(
nodeReadiness(stt, [dep("whisper-tiny-stt", "audio_generation")], { status: "deploying", deploymentName: "whisper-tiny-stt" }),
).toEqual({ state: "deployed", model: "whisper-tiny-stt" });
nodeReadiness(llm, [dep("qwen2-5-1-5b", "inference")], { status: "deploying", deploymentName: "qwen2-5-1-5b" }),
).toEqual({ state: "deployed", model: "qwen2-5-1-5b" });
});
});

describe("MODEL_CATALOG", () => {
it("only recommends engines that worker pools can run", () => {
const poolEngines = ["vllm", "vllm-omni", "ollama", "infinity", "triton", "inferia-diffusion"];
for (const entry of Object.values(MODEL_CATALOG)) {
expect(poolEngines).toContain(entry!.engine);
expect(entry!.job.engine).toBe(entry!.engine);
}
});
});

Expand All @@ -63,9 +82,12 @@ describe("runBlocker", () => {
});

it("names the first blocked node and its reason", () => {
expect(runBlocker(wf([{}, { model: "l" }]), [], {})).toBe("Speech to text: Needs deployment");
expect(runBlocker(wf([{}, { model: "l" }]), [], { s: { status: "waiting", reason: "no active compute pool" } })).toBe(
"Speech to text: Waiting for compute: no active compute pool",
expect(runBlocker(wf([{}, { model: "l" }]), [], {})).toBe("Speech to text: Needs an external deployment");
expect(runBlocker(wf([{ model: "w" }, {}]), [], { l: { status: "waiting", reason: "no active compute pool" } })).toBe(
"LLM: Waiting for compute: no active compute pool",
);
expect(runBlocker(wf([{ model: "w" }, {}]), [], { l: { status: "failed", reason: "image pull failed" } })).toBe(
"LLM: Deploy failed: image pull failed",
);
expect(runBlocker(wf([{ model: "w" }, {}]), [], { l: { status: "deploying", deploymentName: "qwen2-5-1-5b" } })).toBe(
"LLM: Deploying qwen2-5-1-5b",
Expand Down Expand Up @@ -97,6 +119,5 @@ describe("deployRequests", () => {
owner_id: "user-1",
org_id: "org-1",
});
expect(deployRequests(whisperCatalog, "p", { orgId: "o" }).deploy.gpu_per_replica).toBe(0);
});
});
45 changes: 18 additions & 27 deletions apps/dashboard/src/lib/workflow/readiness.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Deployment } from "@/hooks/useReadyDeployments";
import { NODE_KINDS, matchingDeployments } from "./nodeKinds";
import type { NodeKind, Workflow, WorkflowNode } from "./types";
import { buildDiffusionSpec } from "@/pages/newDeploymentSpec";

export interface CatalogModel {
modelId: string;
Expand All @@ -20,30 +21,9 @@ const ollama = (modelId: string) => ({
gpu: true,
});

const speaches = (modelId: string) => ({
model_id: modelId,
engine: "speaches",
image: "ghcr.io/speaches-ai/speaches:latest-cpu",
env: { PRELOAD_MODELS: JSON.stringify([modelId]) },
expose: [{ port: 8000, type: "http", health_checks: [{ path: "/health", type: "http", method: "GET", expected_status: 200 }] }],
gpu: false,
});

// Only engines that worker pools have a recipe for. Speech has none yet, so
// speech nodes ask for an external deployment instead of offering Deploy.
export const MODEL_CATALOG: Partial<Record<NodeKind, CatalogModel>> = {
speechToText: {
modelId: "Systran/faster-whisper-tiny.en",
engine: "speaches",
modelType: "audio_generation",
deploymentName: "whisper-tiny-stt",
job: speaches("Systran/faster-whisper-tiny.en"),
},
textToSpeech: {
modelId: "speaches-ai/piper-en_US-amy-medium",
engine: "speaches",
modelType: "audio_generation",
deploymentName: "piper-amy-tts",
job: speaches("speaches-ai/piper-en_US-amy-medium"),
},
llm: {
modelId: "qwen2.5:1.5b",
engine: "ollama",
Expand All @@ -60,22 +40,27 @@ export const MODEL_CATALOG: Partial<Record<NodeKind, CatalogModel>> = {
},
imageGeneration: {
modelId: "stabilityai/sd-turbo",
engine: "generic",
engine: "inferia-diffusion",
modelType: "image_generation",
deploymentName: "sd-turbo-image",
job: { model_id: "stabilityai/sd-turbo", engine: "generic", expose: [{ port: 8000, type: "http" }], gpu: true },
job: JSON.parse(buildDiffusionSpec({ modelId: "stabilityai/sd-turbo", modelType: "image_generation" })),
},
};

// Deploy attempt the canvas started for a node; lives in UI state, never in the saved workflow.
export type PendingDeploy = { status: "deploying"; deploymentName: string } | { status: "waiting"; reason: string };
export type PendingDeploy =
| { status: "deploying"; deploymentName: string; deploymentId?: string }
| { status: "waiting"; reason: string }
| { status: "failed"; reason: string };

export type Readiness =
| { state: "ready" }
| { state: "selectModel" }
| { state: "needsExternal" }
| { state: "needsDeployment"; recommended: CatalogModel }
| { state: "deploying"; recommended: CatalogModel }
| { state: "waiting"; recommended: CatalogModel; reason: string }
| { state: "failed"; recommended: CatalogModel; reason: string }
// The node's own deployment came up; the canvas selects it and the node becomes ready.
| { state: "deployed"; model: string };

Expand All @@ -90,9 +75,11 @@ export function nodeReadiness(
if (pending?.status === "deploying" && deployments.some((d) => d.modelName === pending.deploymentName)) {
return { state: "deployed", model: pending.deploymentName };
}
if (matchingDeployments(field, deployments).length > 0 || !recommended) return { state: "selectModel" };
if (matchingDeployments(field, deployments).length > 0) return { state: "selectModel" };
if (!recommended) return { state: "needsExternal" };
if (pending?.status === "deploying") return { state: "deploying", recommended };
if (pending?.status === "waiting") return { state: "waiting", recommended, reason: pending.reason };
if (pending?.status === "failed") return { state: "failed", recommended, reason: pending.reason };
return { state: "needsDeployment", recommended };
}

Expand All @@ -103,12 +90,16 @@ export function readinessMessage(r: Readiness): string | null {
return null;
case "selectModel":
return "Select a model";
case "needsExternal":
return "Needs an external deployment";
case "needsDeployment":
return "Needs deployment";
case "deploying":
return `Deploying ${r.recommended.deploymentName}`;
case "waiting":
return `Waiting for compute: ${r.reason}`;
case "failed":
return `Deploy failed: ${r.reason}`;
}
}

Expand Down
Loading